feat: entry editor at /content/:key/new and /content/:key/:id

This commit is contained in:
Florian Sylvain committed 2026-10-21 00:12:34 +02:00
1 parent 91d916fb74
commit fba20f83c5
94 files changed
+7116 -423

No files matched your search

+7 -1
View File
@@ -4,7 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique'
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique' | 'default'
compact?: boolean
}>()
@@ -34,6 +34,12 @@ const badges = {
},
class: 'border border-accented bg-transparent text-toned',
},
default: {
get label() {
return t('Default')
},
class: 'border border-accented bg-transparent text-toned',
},
unique: {
get label() {
return t('Unique')
+33 -16
View File
@@ -4,30 +4,43 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
const props = defineProps<{
status: BuilderStatus
problems: number
savedAt?: string
}>()
const props = withDefaults(
defineProps<{
status: BuilderStatus | 'new' | 'unsaved'
problems?: number
savedAt?: string
}>(),
{ problems: 0, savedAt: undefined },
)
const text = computed(() => {
switch (props.status) {
function chipText(
status: BuilderStatus | 'new' | 'unsaved',
problems: number,
savedAt: string | undefined,
): string {
switch (status) {
case 'saving':
return t('Saving…')
case 'offline':
return t('Waiting for connection')
case 'saved':
return props.savedAt ? t('Saved at {0}', [props.savedAt]) : t('Saved')
return savedAt ? t('Saved at {0}', [savedAt]) : t('Saved')
case 'failed':
return t('Save failed')
case 'new':
return t('New entry')
case 'unsaved':
return t('Not saved')
case 'problems':
return props.problems === 1
? t('1 problem to fix')
: t('{0} problems to fix', [props.problems])
default:
return problems === 1 ? t('1 problem to fix') : t('{0} problems to fix', [problems])
case 'dirty':
return t('Unsaved changes')
case 'none':
return ''
}
})
}
const text = computed(() => chipText(props.status, props.problems, props.savedAt))
</script>
<template>
@@ -38,8 +51,12 @@ const text = computed(() => {
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' || status === 'saved',
'text-error': status === 'failed' || status === 'problems' || status === 'unsaved',
'text-muted':
status === 'saving' ||
status === 'offline' ||
status === 'saved' ||
status === 'new',
}"
>
<UIcon
@@ -50,7 +67,7 @@ const text = computed(() => {
<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-if="status === 'saved'" name="i-lucide-check" class="size-3.5" />
<UIcon v-else name="i-lucide-circle-alert" class="size-3.5" />
<UIcon v-else-if="status !== 'new'" name="i-lucide-circle-alert" class="size-3.5" />
<span>{{ text }}</span>
</span>
</template>
+5 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { fieldIcon } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { COLUMN_CAP } from '@/lib/entryColumns'
import type { BuiltinField } from '@/lib/contentTypes'
@@ -43,7 +43,10 @@ const ROW = 'flex items-center gap-2.5 px-2'
@update:model-value="emit('toggle', field.key, $event === true)"
>
<template #label>
<UIcon :name="fieldIcon(field.type)" class="size-3.5 shrink-0 text-muted" />
<UIcon
:name="fieldTypeSpec(field.type).icon"
class="size-3.5 shrink-0 text-muted"
/>
<span class="min-w-0 flex-1 truncate">{{ field.label }}</span>
<span class="font-mono text-xs text-muted">{{ field.key }}</span>
</template>
+28 -85
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { computed } from 'vue'
import AppPaginator from '@/components/AppPaginator.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ContentEmpty from '@/components/ContentEmpty.vue'
@@ -14,94 +12,39 @@ import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import EntryCardList from '@/components/EntryCardList.vue'
import EntrySkeleton from '@/components/EntrySkeleton.vue'
import EntryTable from '@/components/EntryTable.vue'
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
import { useEntryColumns } from '@/composables/useEntryColumns'
import { useEntryList } from '@/composables/useEntryList'
import { resolveContentListState, type ContentListState } from '@/lib/contentListState'
import { entryCaption } from '@/lib/entryListQuery'
import { fieldCountLabel } from '@/lib/schemaRows'
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
import type { ContentListPage } from '@/composables/useContentListPage'
import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DESKTOP_SKELETON_ROWS = 8
const MOBILE_SKELETON_ROWS = 6
const props = defineProps<{ page: ContentListPage }>()
const props = defineProps<{
typeKey: string
type: ContentType | null
typeStatus: ContentTypeDetailStatus
canEdit: boolean
}>()
const typeKey = props.page.typeKey
const {
list,
columns,
type,
typeStatus,
isDesktop,
view,
centered,
failed,
name,
fields,
subtitle,
editTo,
caption,
showSkeleton,
skeletonRows,
showBody,
createEntry,
goHome,
retry,
} = props.page
const emit = defineEmits<{ view: [view: ContentListState]; 'retry-type': [] }>()
const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const list = useEntryList(props.typeKey)
const columns = useEntryColumns(props.typeKey, () => props.type?.fields ?? [])
const view = computed(() =>
resolveContentListState({
type: props.typeStatus,
entries: list.status.value,
total: list.total.value,
}),
)
const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound')
const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError')
const name = computed(() => props.type?.name ?? props.typeKey)
const fields = computed(() => props.type?.fields ?? [])
const subtitle = computed(() =>
props.type ? `${props.type.key} · ${fieldCountLabel(props.type.fields.length)}` : '',
)
const editTo = computed(() => (props.canEdit ? schemaPath(props.typeKey) : undefined))
const caption = computed(() => entryCaption(name.value, list.shown.value.sort))
const showSkeleton = computed(() =>
view.value === 'loading'
? list.showSkeleton.value || list.status.value === 'ready'
: view.value === 'list' && list.skeletonActive.value,
)
const skeletonRows = computed(() => {
if (view.value === 'list') {
return list.items.value.length
}
return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS
})
const showBody = computed(() => view.value === 'list' && !showSkeleton.value)
const singlePageLabel = computed(() =>
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
)
const typeErrorLine = computed(() =>
t('GET /v1/content-type/{0} failed. Check your connection and try again.', [props.typeKey]),
)
const entriesErrorLine = computed(() =>
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [
props.typeKey,
]),
)
function createEntry() {
router.push(entryPath(props.typeKey, 'new'))
}
function goHome() {
router.push(HOME_PATH)
}
function retry() {
if (view.value === 'typeError') {
emit('retry-type')
return
}
list.retry()
}
watch(view, (value) => emit('view', value), { immediate: true })
onMounted(() => {
list.start()
})
const typeErrorLine = computed(() => typeLoadErrorLine(typeKey))
const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
</script>
<template>
+25 -13
View File
@@ -3,11 +3,28 @@ import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>()
const props = defineProps<{ kind: 'forbidden' | 'notFound' | 'entryNotFound'; name: string }>()
const emit = defineEmits<{ home: [] }>()
const emit = defineEmits<{ home: []; back: [] }>()
const forbidden = computed(() => props.kind === 'forbidden')
const entryMissing = computed(() => props.kind === 'entryNotFound')
const title = computed(() => {
if (forbidden.value) {
return t("You don't have access to {0}", [props.name])
}
return entryMissing.value ? t('Entry not found') : t('Content type not found')
})
const description = computed(() => {
if (forbidden.value) {
return t(
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.",
)
}
return entryMissing.value
? t('It may have been deleted by someone else.')
: t('It may have been deleted, or the address may be mistyped.')
})
</script>
<template>
@@ -15,16 +32,8 @@ const forbidden = computed(() => props.kind === 'forbidden')
<UEmpty
variant="naked"
:icon="forbidden ? 'i-lucide-lock' : 'i-lucide-file-x'"
:title="
forbidden ? t('You don\'t have access to {0}', [name]) : t('Content type not found')
"
:description="
forbidden
? t(
'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.',
)
: t('It may have been deleted, or the address may be mistyped.')
"
:title="title"
:description="description"
:ui="{
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
@@ -33,7 +42,10 @@ const forbidden = computed(() => props.kind === 'forbidden')
}"
>
<template #actions>
<UButton class="h-8 px-4 max-md:h-11" @click="emit('home')">
<UButton v-if="entryMissing" class="h-8 px-4 max-md:h-11" @click="emit('back')">
{{ t('Back to {0}', [name]) }}
</UButton>
<UButton v-else class="h-8 px-4 max-md:h-11" @click="emit('home')">
{{ t('Go to Home') }}
</UButton>
</template>
+4
View File
@@ -51,12 +51,16 @@ const props = withDefaults(
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const COMPACT_STICKY_BODY = 'gap-0 px-4 pt-4 pb-0 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-0 lg:p-6'
const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
if (props.stickyFooter && props.compactPadding) {
return COMPACT_STICKY_BODY
}
if (props.stickyFooter) {
return STICKY_FOOTER_BODY
}
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
defineProps<{ page: EntryEditorPage; bar?: boolean }>()
</script>
<template>
<div
class="flex shrink-0 items-center"
:class="bar ? 'w-full gap-2' : 'gap-3'"
:data-testid="bar ? 'entry-bar' : 'entry-actions'"
>
<BuilderStatusChip
v-if="!bar"
:status="page.chip.value"
:saved-at="page.savedClock.value"
/>
<UButton
v-if="!bar && !page.creating.value"
type="button"
color="error"
variant="outline"
icon="i-lucide-trash-2"
:disabled="page.saving.value"
class="h-8 px-3"
@click="page.removal.open"
>
{{ t('Delete') }}
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
:disabled="page.saving.value"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:class="bar ? 'flex-1 justify-center' : ''"
@click="page.cancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
form="entry-form"
:disabled="!page.canSave.value"
:loading="page.saving.value"
class="h-11 px-4 md:h-8"
:class="[
bar ? 'flex-1 justify-center' : '',
page.saving.value ? 'disabled:opacity-100' : '',
]"
>
{{ page.saving.value ? t('Saving…') : t('Save') }}
</UButton>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import EntryDateField from '@/components/EntryDateField.vue'
import MarkdownField from '@/components/MarkdownField.vue'
import AppSwitch from '@/components/AppSwitch.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { asText, freeSentinel } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const INPUT_UI = {
base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm',
}
const NUMBER_UI = {
base: 'h-11 pe-20 text-left text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:pe-14 md:text-sm',
decrement: 'start-auto end-10 border-s border-default p-0 md:end-7',
increment: 'end-0 border-s border-default p-0',
}
const STEP_BUTTON = { class: 'size-10 justify-center rounded-none text-muted md:size-7' }
const NUMBER_FORMAT = { maximumFractionDigits: 20, useGrouping: false }
const props = defineProps<{
field: BuiltinField
value: unknown
invalid: boolean
disabled: boolean
}>()
const emit = defineEmits<{ update: [value: unknown] }>()
const control = computed(() => fieldTypeSpec(props.field.type).editor.control)
const color = computed(() => (props.invalid ? 'error' : undefined))
const required = computed(() => (props.field.required ? 'true' : undefined))
const optionLabels = computed(() => (props.field.options ?? []).map(String))
const noChoice = computed(() => freeSentinel(optionLabels.value))
const choices = computed(() => {
const items = optionLabels.value.map((option) => ({ label: option, value: option }))
return props.field.required ? items : [{ label: t('None'), value: noChoice.value }, ...items]
})
function onChoice(value: unknown) {
emit('update', value === noChoice.value || value === undefined ? '' : String(value))
}
function onNumber(value: number | null | undefined) {
emit('update', value === undefined || Number.isNaN(value) ? null : value)
}
</script>
<template>
<UInput
v-if="control === 'input'"
:model-value="asText(value)"
:color="color"
:disabled="disabled"
:aria-required="required"
class="w-full"
:ui="INPUT_UI"
@update:model-value="emit('update', String($event))"
/>
<MarkdownField
v-else-if="control === 'markdown'"
:model-value="asText(value)"
:invalid="invalid"
:disabled="disabled"
:required="field.required"
@update:model-value="emit('update', $event)"
/>
<UInputNumber
v-else-if="control === 'number'"
:model-value="typeof value === 'number' ? value : undefined"
:placeholder="t('Enter a number')"
:color="color"
:disabled="disabled"
:aria-required="required"
:format-options="NUMBER_FORMAT"
class="w-full"
:ui="NUMBER_UI"
:increment="STEP_BUTTON"
:decrement="STEP_BUTTON"
@update:model-value="onNumber"
/>
<div v-else-if="control === 'switch'" class="flex min-h-6 items-center gap-2.5">
<AppSwitch
:model-value="value === true"
:disabled="disabled"
:aria-required="required"
@update:model-value="emit('update', $event)"
/>
<span class="text-toned">{{ value === true ? t('On') : t('Off') }}</span>
</div>
<EntryDateField
v-else-if="control === 'date'"
:model-value="asText(value)"
:invalid="invalid"
:disabled="disabled"
:required="field.required"
@update:model-value="emit('update', $event)"
/>
<USelect
v-else
:model-value="value === '' || value === null ? undefined : String(value)"
:items="choices"
:placeholder="t('Choose an option')"
:color="color"
:disabled="disabled"
:aria-required="required"
class="w-full"
:ui="INPUT_UI"
@update:model-value="onChoice"
/>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { parseDate, type CalendarDate } from '@internationalized/date'
const props = defineProps<{
modelValue: string
disabled?: boolean
required?: boolean
invalid?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const focused = ref(false)
const calendarOpen = ref(false)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const date = computed(() => parse(props.modelValue))
const idle = computed(() => !props.modelValue && !focused.value)
function onDate(value: CalendarDate | null | undefined) {
emit('update:modelValue', value ? value.toString() : '')
}
function onCalendar(value: unknown) {
onDate(value as CalendarDate | null)
calendarOpen.value = false
}
</script>
<template>
<div class="relative w-full">
<UInputDate
:model-value="date"
:disabled="disabled"
:color="invalid ? 'error' : undefined"
:aria-required="required ? 'true' : undefined"
class="w-full"
:ui="{
base: 'h-11 w-full text-lg data-[disabled]:bg-elevated data-[disabled]:opacity-100 md:h-8 md:text-sm',
segment: idle ? 'text-transparent data-[placeholder]:text-transparent' : '',
}"
@update:model-value="onDate"
@focus="focused = true"
@blur="focused = false"
>
<template #trailing>
<UPopover v-model:open="calendarOpen" :content="{ align: 'end' }">
<UButton
type="button"
color="neutral"
variant="link"
icon="i-lucide-calendar"
:disabled="disabled"
:aria-label="t('Open calendar')"
class="size-8 justify-center text-muted max-md:size-11"
:ui="{ leadingIcon: 'size-3.5' }"
/>
<template #content>
<UCalendar
:model-value="date"
class="p-2"
@update:model-value="onCalendar"
/>
</template>
</UPopover>
</template>
</UInputDate>
<span
v-if="idle"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-lg text-muted md:text-sm"
>
{{ t('Pick a date') }}
</span>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
const props = defineProps<{
title: string
typeName: string
pending: boolean
failed: boolean
}>()
const emit = defineEmits<{ confirm: []; close: [] }>()
const notice = computed(() =>
props.failed
? {
title: t("Couldn't delete the entry"),
description: t(
'Something went wrong on our side. Nothing was deleted. Try again in a moment.',
),
}
: null,
)
const description = computed(() =>
t('"{0}" will be deleted from {1}. This can\'t be undone.', [props.title, props.typeName]),
)
</script>
<template>
<DirectoryDialog
:title="t('Delete this entry?')"
:description="description"
:confirm-label="t('Delete entry')"
:pending-label="t('Deleting…')"
:cancel-label="t('Cancel')"
tone="danger"
:pending="pending"
:notice="notice"
closable
close-gutter
@confirm="emit('confirm')"
@close="emit('close')"
/>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import ContentState from '@/components/ContentState.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import EntryActions from '@/components/EntryActions.vue'
import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue'
import EntryEditorSkeleton from '@/components/EntryEditorSkeleton.vue'
import EntryForm from '@/components/EntryForm.vue'
import EntryNotices from '@/components/EntryNotices.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { typeEndpoint } from '@/lib/contentEndpoints'
import { entryLoadErrorLine } from '@/lib/entryMessages'
import { HOME_PATH, contentPath } from '@/lib/routePaths'
import { useRouter } from 'vue-router'
const props = defineProps<{ page: EntryEditorPage }>()
const router = useRouter()
const { phase, isDesktop, typeKey } = props.page
function loadErrorLine() {
const id = props.page.entryId.value
const base = typeEndpoint(typeKey)
return entryLoadErrorLine(
phase.value === 'entryError' && id !== null ? `${base}/entry/${id}` : base,
)
}
function goHome() {
router.push(HOME_PATH)
}
function backToList() {
router.push(contentPath(typeKey))
}
</script>
<template>
<ContentState
v-if="phase === 'forbidden' || phase === 'typeNotFound'"
:kind="phase === 'forbidden' ? 'forbidden' : 'notFound'"
:name="page.typeName.value"
@home="goHome"
/>
<ContentState
v-else-if="phase === 'entryNotFound'"
kind="entryNotFound"
:name="page.typeName.value"
@back="backToList"
/>
<div v-else class="flex flex-col gap-4" data-testid="entry-editor">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<template v-if="phase === 'loading'">
<USkeleton class="h-7 w-40 rounded-none bg-accented max-lg:hidden" />
<USkeleton class="h-5 w-28 rounded-none bg-accented" />
</template>
<template v-else>
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
{{ page.title.value }}
</h1>
<p class="text-muted">{{ page.subtitle.value }}</p>
</template>
</div>
<EntryActions v-if="isDesktop && phase === 'ready'" :page="page" />
</div>
<div
v-if="!isDesktop && phase === 'ready'"
class="flex min-h-5 items-center justify-between"
>
<BuilderStatusChip :status="page.chip.value" :saved-at="page.savedClock.value" />
</div>
<DirectoryLoadError
v-if="phase === 'typeError' || phase === 'entryError'"
:title="t('Couldn\'t load the entry')"
:description="loadErrorLine()"
@retry="page.retryLoad"
/>
<EntryEditorSkeleton v-else-if="phase === 'loading'" />
<template v-else>
<EntryNotices :page="page" />
<EntryForm :page="page" />
</template>
</div>
<div
v-if="!isDesktop && phase === 'ready'"
class="sticky bottom-0 -mx-4 mt-auto flex border-t border-default bg-default px-4 py-3"
>
<EntryActions :page="page" bar />
</div>
<EntryDeleteDialog
v-if="page.removal.stage.value !== 'closed'"
:title="page.title.value"
:type-name="page.typeName.value"
:pending="page.removal.pending.value"
:failed="page.removal.stage.value === 'failed'"
@confirm="page.removal.confirm"
@close="page.removal.close"
/>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { t } from '@/i18n'
const ROWS = 5
</script>
<template>
<div
role="status"
aria-busy="true"
:aria-label="t('Loading')"
data-testid="entry-editor-skeleton"
class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 md:p-6"
>
<div v-for="row in ROWS" :key="row" class="flex flex-col gap-1.5">
<USkeleton class="h-4 w-24 rounded-none bg-accented" />
<USkeleton class="h-11 w-full rounded-none bg-accented md:h-8" />
</div>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import BuilderBadge from '@/components/BuilderBadge.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import EntryControl from '@/components/EntryControl.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import type { FieldIssue } from '@/lib/entryForm'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const props = defineProps<{
field: BuiltinField
value: unknown
issue?: FieldIssue
isDefault: boolean
disabled: boolean
}>()
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
const counter = computed(() => editor.value.counter(props.field, props.value))
const help = computed(() => editor.value.help(props.field))
</script>
<template>
<div :data-entry-field="field.key" class="min-w-0">
<DirectoryField
:label="field.label"
:name="field.key"
:required="field.required"
:error="issue?.message"
:help="help"
>
<template v-if="counter || isDefault" #hint>
<span class="flex items-center gap-2">
<span
v-if="counter"
data-testid="entry-counter"
:class="counter.over ? 'text-error' : ''"
>{{ counter.text }}</span
>
<BuilderBadge v-if="isDefault" kind="default" />
</span>
</template>
<EntryControl
:field="field"
:value="value"
:invalid="Boolean(issue)"
:disabled="disabled"
@update="emit('update', field.key, $event)"
/>
</DirectoryField>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, watch } from 'vue'
import EntryField from '@/components/EntryField.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { groupFields } from '@/lib/entryLayout'
const FOCUSABLE =
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
const props = defineProps<{ page: EntryEditorPage }>()
const { form, fields, locked, focusToken } = props.page
const rows = computed(() => groupFields(fields.value))
const dimmed = computed(() => locked.value)
async function focusFirstIssue() {
await nextTick()
const key = form.firstIssue.value
if (!key) {
return
}
const wrapper = [...document.querySelectorAll<HTMLElement>('[data-entry-field]')].find(
(candidate) => candidate.dataset.entryField === key,
)
const control = wrapper?.querySelector<HTMLElement>(FOCUSABLE)
control?.focus()
;(wrapper ?? control)?.scrollIntoView?.({ block: 'center' })
}
watch(focusToken, focusFirstIssue)
</script>
<template>
<form
id="entry-form"
novalidate
data-testid="entry-form"
class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 transition-opacity md:p-6"
:class="dimmed ? 'opacity-70' : ''"
:aria-busy="page.saving.value ? 'true' : undefined"
@submit.prevent="page.submit"
>
<div
v-for="row in rows"
:key="row.map((field) => field.key).join('+')"
class="grid gap-4"
:class="row.length > 1 ? 'md:grid-cols-2' : ''"
>
<EntryField
v-for="field in row"
:key="field.key"
:field="field"
:value="form.values.value[field.key]"
:issue="form.issues.value[field.key]"
:is-default="form.isDefault(field)"
:disabled="locked"
@update="page.change"
/>
</div>
<dl
data-testid="entry-meta"
class="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-default pt-4 text-xs/4.5 md:grid-cols-[auto_1fr]"
>
<dt class="text-muted">{{ t('Entry') }}</dt>
<dd :class="page.creating.value ? '' : 'font-mono'">{{ page.entryLine.value }}</dd>
<dt class="text-muted">{{ t('Created by') }}</dt>
<dd>{{ page.createdLine.value }}</dd>
<dt class="text-muted">{{ t('Updated by') }}</dt>
<dd>{{ page.updatedLine.value }}</dd>
</dl>
</form>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import RetryAlert from '@/components/RetryAlert.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
const props = defineProps<{ page: EntryEditorPage }>()
const { failure, schemaChanged, schemaField, form, fields, typeName } = props.page
const uniqueField = computed(() =>
fields.value.find((field) => form.issues.value[field.key]?.rule === 'unique'),
)
const alertUi = {
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'mt-0.5 text-toned',
}
</script>
<template>
<RetryAlert
v-if="failure === 'failed'"
:title="t('Couldn\'t save the entry')"
:description="
t(
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
"
@retry="page.submit"
/>
<UAlert
v-if="uniqueField"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Not saved')"
:description="uniqueAlert(uniqueField)"
:ui="alertUi"
/>
<UAlert
v-if="schemaChanged"
role="alert"
color="warning"
variant="soft"
orientation="vertical"
icon="i-lucide-triangle-alert"
:title="t('The fields of {0} changed', [typeName])"
:description="schemaChangedLine(schemaField)"
:ui="{ ...alertUi, actions: 'mt-1.5' }"
>
<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="page.reload"
>
{{ t('Reload form') }}
</UButton>
</template>
</UAlert>
</template>
+4 -3
View File
@@ -7,6 +7,7 @@ import FieldCell from '@/components/FieldCell.vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5'
@@ -15,7 +16,6 @@ const FIELD_WIDTH = 'w-33'
const CREATED_WIDTH = 'w-30.5'
const UPDATED_WIDTH = 'w-40.5'
const UPDATED_BY_WIDTH = 'w-37'
const NARROW_TYPES = ['boolean', 'number']
const FIELD_PREFIX = 'field:'
const props = defineProps<{
@@ -36,7 +36,7 @@ function widthOf(field: BuiltinField, index: number) {
if (index === 0) {
return ''
}
return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH
return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
}
function column(id: string, header: string, width: string, edge = 'px-1.5') {
@@ -62,7 +62,8 @@ function fieldOf(columnId: string) {
}
function isRight(columnId: string) {
return fieldOf(columnId)?.type === 'number'
const field = fieldOf(columnId)
return field ? fieldTypeSpec(field.type).alignEnd : false
}
function sortIcon(columnId: string) {
+2 -2
View File
@@ -6,7 +6,7 @@ import BuilderBadge from '@/components/BuilderBadge.vue'
import FieldActions from '@/components/FieldActions.vue'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import type { RowMarks } from '@/composables/useSchemaBuilder'
import { fieldIcon } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const props = defineProps<{
field: BuilderField
@@ -79,7 +79,7 @@ const rowClass = computed(() => [
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" />
<UIcon :name="fieldTypeSpec(field.type).icon" 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>
+4 -22
View File
@@ -2,8 +2,7 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
import { FIELD_TYPES, FIELD_TYPE_SPECS, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes'
const props = defineProps<{
modelValue: FieldType
@@ -14,27 +13,10 @@ const props = defineProps<{
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),
label: FIELD_TYPE_SPECS[type].label(),
disabled: props.locked && type !== props.modelValue,
})),
)
@@ -73,12 +55,12 @@ const items = computed(() =>
class="flex items-start gap-2.5 text-left"
:class="item.disabled ? 'opacity-40' : ''"
>
<UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" />
<UIcon :name="fieldTypeSpec(item.value).icon" 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
>{{ fieldTypeSpec(item.value).description() }}</span
>
</span>
</span>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { renderMarkdown } from '@/lib/markdown'
import { MARKDOWN_ACTIONS, applyMarkdownAction, type MarkdownAction } from '@/lib/markdownEdit'
type Mode = 'write' | 'preview'
const props = defineProps<{
modelValue: string
disabled?: boolean
invalid?: boolean
required?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const mode = ref<Mode>('write')
const root = ref<HTMLElement | null>(null)
const tabs = computed(() => [
{ label: t('Write'), value: 'write' },
{ label: t('Preview'), value: 'preview' },
])
const actions = computed(() => ({
bold: { label: t('Bold'), icon: 'i-lucide-bold' },
italic: { label: t('Italic'), icon: 'i-lucide-italic' },
heading: { label: t('Heading'), icon: 'i-lucide-heading' },
link: { label: t('Link'), icon: 'i-lucide-link' },
list: { label: t('List'), icon: 'i-lucide-list' },
}))
const rendered = computed(() => renderMarkdown(props.modelValue))
const empty = computed(() => props.modelValue.trim() === '')
function textarea() {
return root.value?.querySelector<HTMLTextAreaElement>('textarea') ?? null
}
async function run(action: MarkdownAction) {
const field = textarea()
if (!field) {
return
}
const edit = applyMarkdownAction(
action,
props.modelValue,
field.selectionStart,
field.selectionEnd,
)
emit('update:modelValue', edit.value)
await nextTick()
field.focus()
field.setSelectionRange(edit.start, edit.end)
}
async function onMode(value: string | number) {
mode.value = value === 'preview' ? 'preview' : 'write'
if (mode.value === 'write') {
await nextTick()
textarea()?.focus()
}
}
</script>
<template>
<div
ref="root"
data-testid="markdown-field"
class="flex min-w-0 flex-col border bg-default"
:class="[
invalid ? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]' : 'border-accented',
disabled ? 'bg-elevated' : '',
]"
>
<div class="flex items-center gap-0.5 overflow-hidden border-b border-default px-1.5">
<UTabs
:model-value="mode"
:items="tabs"
:content="false"
variant="link"
color="neutral"
:ui="{
root: 'mr-2 w-auto gap-0',
list: 'gap-3.5 px-1.5',
trigger: 'h-11 px-0 text-base md:h-8',
indicator: 'bg-primary',
}"
@update:model-value="onMode"
/>
<template v-if="mode === 'write'">
<UButton
v-for="action in MARKDOWN_ACTIONS"
:key="action"
type="button"
color="neutral"
variant="ghost"
:icon="actions[action].icon"
:aria-label="actions[action].label"
:disabled="disabled"
class="size-10 justify-center text-toned md:size-7"
:ui="{ leadingIcon: 'size-4' }"
@click="run(action)"
/>
</template>
</div>
<UTextarea
v-show="mode === 'write'"
:model-value="modelValue"
:placeholder="t('Write in Markdown')"
:disabled="disabled"
:aria-invalid="invalid ? 'true' : undefined"
:aria-required="required ? 'true' : undefined"
variant="none"
:rows="4"
autoresize
class="w-full"
:ui="{
base: 'min-h-30 px-2.5 py-2 font-mono text-lg/5.5 md:min-h-24 md:text-xs/4.75',
}"
@update:model-value="emit('update:modelValue', String($event))"
/>
<div
v-if="mode === 'preview'"
data-testid="markdown-preview"
class="flex min-h-30 flex-col gap-2 px-3 py-2.5 text-lg md:min-h-24 md:text-sm [&_a]:text-primary [&_a]:underline [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-[square] [&_ul]:ps-4.5"
>
<p v-if="empty" class="text-muted">{{ t('Nothing to preview yet.') }}</p>
<div v-else class="flex flex-col gap-2" v-html="rendered" />
</div>
</div>
</template>
+5 -4
View File
@@ -4,7 +4,8 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { fieldRules } from '@/lib/schemaFields'
import { SCHEMA_TABLE_QUERY } from '@/lib/schemaRows'
const NO_RULES = '–'
@@ -43,13 +44,13 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu
:style="{ gridTemplateColumns: COLUMNS }"
>
<span role="cell" class="flex">
<UIcon :name="fieldIcon(field.type)" class="size-4 text-toned" />
<UIcon :name="fieldTypeSpec(field.type).icon" class="size-4 text-toned" />
</span>
<span role="cell" class="truncate font-medium">{{ field.label }}</span>
<span role="cell" class="truncate font-mono text-xs text-muted">{{
field.key
}}</span>
<span role="cell">{{ fieldTypeLabel(field.type) }}</span>
<span role="cell">{{ fieldTypeSpec(field.type).label() }}</span>
<span role="cell" class="text-muted">{{ rules || NO_RULES }}</span>
</div>
<div
@@ -59,7 +60,7 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu
class="flex min-h-16 items-center gap-3 border-t border-default px-4 py-3"
>
<span class="flex size-7 shrink-0 items-center justify-center bg-elevated">
<UIcon :name="fieldIcon(field.type)" class="size-4" />
<UIcon :name="fieldTypeSpec(field.type).icon" class="size-4" />
</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ field.label }}</span>
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { CalendarDate } from '@internationalized/date'
import UCalendar from '@nuxt/ui/components/Calendar.vue'
import USelect from '@nuxt/ui/components/Select.vue'
import UInput from '@nuxt/ui/components/Input.vue'
import UInputDate from '@nuxt/ui/components/InputDate.vue'
import UInputNumber from '@nuxt/ui/components/InputNumber.vue'
import EntryField from '../EntryField.vue'
import EntryControl from '../EntryControl.vue'
import EntryDateField from '../EntryDateField.vue'
import MarkdownField from '../MarkdownField.vue'
import BuilderStatusChip from '../BuilderStatusChip.vue'
import type { BuiltinField } from '@/lib/contentTypes'
let mounted: VueWrapper | undefined
afterEach(() => {
mounted?.unmount()
mounted = undefined
document.body.innerHTML = ''
})
function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField {
return {
key: 'f',
label: 'Field',
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
function mountControl(type: string, props: { value?: unknown; invalid?: boolean } = {}) {
mounted = mount(EntryControl, {
props: { field: field(type), value: null, invalid: false, disabled: false, ...props },
global: { plugins: [uiPlugin] },
})
return mounted
}
describe('EntryControl number', () => {
it.each([
[NaN, null],
[undefined, null],
[0, 0],
[18, 18],
])('emits %s as %s', (typed, sent) => {
const wrapper = mountControl('number')
wrapper.findComponent(UInputNumber).vm.$emit('update:modelValue', typed)
expect(wrapper.emitted('update')).toEqual([[sent]])
})
})
describe('EntryControl select', () => {
function mountSelect(options: string[]) {
mounted = mount(EntryControl, {
props: {
field: field('select', { options }),
value: '',
invalid: false,
disabled: false,
},
global: { plugins: [uiPlugin] },
})
return mounted.findComponent(USelect)
}
it('keeps an option named like the old sentinel apart from None', () => {
const select = mountSelect(['__none__', 'Talk'])
const items = select.props('items') as { label: string; value: string }[]
const none = items.find((item) => item.label === 'None')!
expect(none.value).not.toBe('__none__')
select.vm.$emit('update:modelValue', '__none__')
select.vm.$emit('update:modelValue', none.value)
select.vm.$emit('update:modelValue', 'Talk')
expect(mounted!.emitted('update')).toEqual([['__none__'], [''], ['Talk']])
})
})
describe('EntryControl invalid state', () => {
it('colors the control as an error only when invalid', () => {
expect(mountControl('text', { invalid: true }).findComponent(UInput).props('color')).toBe(
'error',
)
mounted?.unmount()
expect(
mountControl('text', { invalid: false }).findComponent(UInput).props('color'),
).toBeUndefined()
})
})
describe('Markdown field accessibility', () => {
function mountField(required: boolean, issue?: { rule: 'required'; message: string }) {
mounted = mount(EntryField, {
props: {
field: field('richtext', { key: 'notes', label: 'Notes', required }),
value: '',
issue,
isDefault: false,
disabled: false,
},
global: { plugins: [uiPlugin] },
})
return mounted.find('textarea')
}
it('ties the textarea to its label and its message and marks it required', () => {
const area = mountField(true, { rule: 'required', message: 'Fill in Notes.' })
expect(mounted!.find('label').attributes('for')).toBe(area.attributes('id'))
expect(area.attributes('aria-describedby')).toBe(`${area.attributes('id')}-error`)
expect(area.attributes('aria-invalid')).toBe('true')
expect(area.attributes('aria-required')).toBe('true')
})
it('does not mark an optional textarea as required', () => {
expect(mountField(false).attributes('aria-required')).toBeUndefined()
})
})
describe('EntryDateField', () => {
function mountDate(modelValue: string) {
mounted = mount(EntryDateField, {
props: { modelValue },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return mounted
}
it('emits an ISO day for a picked date and an empty string when cleared', () => {
const wrapper = mountDate('2026-11-21')
const input = wrapper.findComponent(UInputDate)
input.vm.$emit('update:modelValue', new CalendarDate(2026, 12, 3))
input.vm.$emit('update:modelValue', null)
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-12-03'], ['']])
})
it('shows the placeholder only while empty and not focused', async () => {
const wrapper = mountDate('')
const input = wrapper.findComponent(UInputDate)
expect(wrapper.text()).toContain('Pick a date')
input.vm.$emit('focus')
await flushPromises()
expect(wrapper.text()).not.toContain('Pick a date')
input.vm.$emit('blur')
await flushPromises()
expect(wrapper.text()).toContain('Pick a date')
})
it('closes the calendar once a day is picked', async () => {
const wrapper = mountDate('2026-11-21')
await wrapper.find('button[aria-label="Open calendar"]').trigger('click')
await flushPromises()
const calendar = wrapper.findComponent(UCalendar)
expect(calendar.exists()).toBe(true)
calendar.vm.$emit('update:modelValue', new CalendarDate(2026, 11, 25))
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-11-25']])
expect(wrapper.findComponent(UCalendar).exists()).toBe(false)
})
it('hides the placeholder when a date is set', () => {
expect(mountDate('2026-11-21').text()).not.toContain('Pick a date')
})
})
describe('MarkdownField', () => {
function mountMarkdown(modelValue: string) {
mounted = mount(MarkdownField, {
props: { modelValue },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return mounted
}
async function openPreview(wrapper: VueWrapper) {
const tabs = wrapper.findAll('[role="tab"]')
const preview = tabs.find((candidate) => candidate.text() === 'Preview')!
await preview.trigger('mousedown', { button: 0 })
await preview.trigger('click')
await flushPromises()
}
it('treats whitespace as nothing to preview', async () => {
const wrapper = mountMarkdown(' \n ')
await openPreview(wrapper)
expect(wrapper.text()).toContain('Nothing to preview yet.')
})
it('selects the result of a toolbar action and keeps the focus in the textarea', async () => {
const wrapper = mountMarkdown('lime')
const area = wrapper.find('textarea').element
area.setSelectionRange(0, 4)
await wrapper.find('button[aria-label="Link"]').trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([['[lime](url)']])
expect(document.activeElement).toBe(area)
await wrapper.setProps({ modelValue: '[lime](url)' })
area.setSelectionRange(0, 0)
await wrapper.find('button[aria-label="Bold"]').trigger('click')
await flushPromises()
expect([area.selectionStart, area.selectionEnd]).toEqual([2, 2])
})
it('puts the focus back in the textarea when returning to Write', async () => {
const wrapper = mountMarkdown('lime')
await openPreview(wrapper)
;(document.activeElement as HTMLElement | null)?.blur()
const write = wrapper
.findAll('[role="tab"]')
.find((candidate) => candidate.text() === 'Write')!
await write.trigger('mousedown', { button: 0 })
await write.trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.find('textarea').element)
})
})
describe('BuilderStatusChip colors', () => {
function chipClasses(status: 'dirty' | 'unsaved' | 'saved') {
mounted = mount(BuilderStatusChip, {
props: { status },
global: { plugins: [uiPlugin] },
})
return mounted.find('[data-testid="builder-chip"]').classes()
}
it.each([
['dirty', 'text-warning'],
['unsaved', 'text-error'],
['saved', 'text-muted'],
] as const)('paints %s as %s', (status, color) => {
expect(chipClasses(status)).toContain(color)
mounted?.unmount()
})
})
@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
const getContentType = vi.fn()
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: () => ({ getContentType }) }))
beforeEach(() => {
getContentType.mockReset()
getContentType.mockResolvedValue({ key: 'events', fields: [] })
})
describe('useContentTypeDetail', () => {
it('requests the type at once by default', async () => {
const detail = useContentTypeDetail(() => 'events')
await flushPromises()
expect(getContentType).toHaveBeenCalledWith('events')
expect(detail.status.value).toBe('ready')
})
it('waits to be enabled before it requests anything', async () => {
const enabled = ref(false)
const detail = useContentTypeDetail(
() => 'events',
() => enabled.value,
)
await flushPromises()
expect(getContentType).not.toHaveBeenCalled()
expect(detail.status.value).toBe('loading')
enabled.value = true
await flushPromises()
expect(getContentType).toHaveBeenCalledTimes(1)
expect(detail.status.value).toBe('ready')
})
})
@@ -303,97 +303,3 @@ describe('getContentType', () => {
)
})
})
describe('listEntries', () => {
const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5, sort: 'id:asc' }
it('builds the query with explicit page, size and sort and returns the page', async () => {
answer(200, page)
const listed = await useContentTypes().listEntries('events', {
page: 2,
size: 5,
sort: 'id:asc',
})
expect(listed).toEqual(page)
expect(fetchMock.mock.calls[0]![0]).toBe(
'/v1/content-type/events/entry?page=2&size=5&sort=id:asc',
)
})
it.each([
[403, 'forbidden'],
[404, 'content_type_not_found'],
[500, 'boom'],
])('passes %i and its plain text body through', async (status, body) => {
answer(status, body)
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status, body })
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toBeInstanceOf(NetworkError)
})
it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])(
'rejects a malformed page: %j',
async (body) => {
answer(200, body)
await expect(
useContentTypes().listEntries('events', {
page: 1,
size: 25,
sort: 'updated_at:desc',
}),
).rejects.toThrow()
},
)
it('sends a 401 to the login page with the expired reason', async () => {
answer(401, '')
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status: 401 })
const { default: router } = await import('@/router')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query.reason).toBe('expired')
await router.push('/login')
})
})
describe('lookupUsers', () => {
it('sends the distinct ids once and returns the display names', async () => {
answer(200, [
{ id: 3, display_name: 'Marta Alves' },
{ id: 5, display_name: 'Jonas Pereira' },
])
const names = await useContentTypes().lookupUsers([3, 3, 5, 3])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5')
expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira'])
})
it('rejects with the status on a 500', async () => {
answer(500, 'boom')
await expect(useContentTypes().lookupUsers([3])).rejects.toMatchObject({ status: 500 })
})
it('rejects a body that is not a list', async () => {
answer(200, { id: 3 })
await expect(useContentTypes().lookupUsers([3])).rejects.toThrow()
})
})
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { NetworkError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries'
const fetchMock = vi.fn()
function answer(status: number, body: unknown) {
fetchMock.mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
}),
)
}
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('listEntries', () => {
const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5 }
it('builds the query with explicit page, size and sort and returns the page', async () => {
answer(200, page)
const listed = await useEntries().listEntries('events', {
page: 2,
size: 5,
sort: 'id:asc',
})
expect(listed).toEqual(page)
expect(fetchMock.mock.calls[0]![0]).toBe(
'/v1/content-type/events/entry?page=2&size=5&sort=id:asc',
)
})
it.each([
[403, 'forbidden'],
[404, 'content_type_not_found'],
[500, 'boom'],
])('passes %i and its plain text body through', async (status, body) => {
answer(status, body)
await expect(
useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status, body })
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(
useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toBeInstanceOf(NetworkError)
})
it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])(
'rejects a malformed page: %j',
async (body) => {
answer(200, body)
await expect(
useEntries().listEntries('events', {
page: 1,
size: 25,
sort: 'updated_at:desc',
}),
).rejects.toThrow()
},
)
it('sends a 401 to the login page with the expired reason', async () => {
answer(401, '')
await expect(
useEntries().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status: 401 })
const { default: router } = await import('@/router')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query.reason).toBe('expired')
await router.push('/login')
})
})
describe('entry writes', () => {
const entry = {
id: 43,
content_type: 'events',
data: { title: 'Open day' },
created_by: 1,
updated_by: 1,
created_at: '2026-10-19T11:02:00Z',
updated_at: '2026-10-19T11:02:00Z',
}
it('gets an entry by id', async () => {
answer(200, entry)
await expect(useEntries().getEntry('events', 43)).resolves.toEqual(entry)
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type/events/entry/43')
})
it('creates with the data wrapped in {data}', async () => {
answer(201, entry)
await useEntries().createEntry('events', { title: 'Open day', ends_on: null })
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/v1/content-type/events/entry')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body)).toEqual({ data: { title: 'Open day', ends_on: null } })
})
it('replaces an entry with PUT', async () => {
answer(200, entry)
await useEntries().saveEntry('events', 43, { title: 'Open day' })
const [url, init] = fetchMock.mock.calls[0]!
expect(url).toBe('/v1/content-type/events/entry/43')
expect(init.method).toBe('PUT')
expect(JSON.parse(init.body)).toEqual({ data: { title: 'Open day' } })
})
it('deletes with DELETE and resolves on 204', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 204 }))
await expect(useEntries().deleteEntry('events', 43)).resolves.toBeUndefined()
expect(fetchMock.mock.calls[0]![1].method).toBe('DELETE')
})
it.each([
[422, 'invalid_entry: title: required'],
[409, 'invalid_entry: title: unique'],
[404, 'entry_not_found'],
[404, 'content_type_not_found'],
[400, 'invalid id'],
[403, 'forbidden'],
])('passes %i "%s" through with its body', async (status, body) => {
answer(status, body)
await expect(useEntries().saveEntry('events', 43, {})).rejects.toMatchObject({
status,
body,
})
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(useEntries().createEntry('events', {})).rejects.toBeInstanceOf(NetworkError)
})
it.each([
['data is a string', { id: 1, data: 'x' }],
['data is null', { id: 1, data: null }],
['id is missing', { data: {} }],
])('rejects an entry whose %s', async (_name, body) => {
answer(200, body)
await expect(useEntries().getEntry('events', 1)).rejects.toThrow('Unexpected response')
})
it('rejects an answer that is not an entry', async () => {
answer(200, { items: [] })
await expect(useEntries().getEntry('events', 43)).rejects.toThrow('Unexpected response')
})
})
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/composables/useApi'
import { useEntryLoad } from '@/composables/useEntryLoad'
import type { ContentEntry } from '@/lib/contentTypes'
const getEntry = vi.fn()
vi.mock('@/composables/useEntries', () => ({ useEntries: () => ({ getEntry }) }))
function entry(id: number, title: string): ContentEntry {
return {
id,
content_type: 'events',
data: { title },
created_by: 1,
updated_by: 1,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-12T09:14:00',
}
}
function gate() {
let resolve!: (value: ContentEntry) => void
const promise = new Promise<ContentEntry>((done) => (resolve = done))
return { promise, resolve }
}
beforeEach(() => {
getEntry.mockReset()
})
describe('initial status', () => {
it.each([
['new', 'idle'],
['invalid', 'missing'],
[42, 'loading'],
] as const)('starts a %s target as %s', (target, status) => {
expect(useEntryLoad('events', target).status.value).toBe(status)
})
it('requests nothing for a new or invalid target', async () => {
await useEntryLoad('events', 'new').start()
await useEntryLoad('events', 'invalid').start()
expect(getEntry).not.toHaveBeenCalled()
})
})
describe('stale answers', () => {
it('keeps the answer of the latest request', async () => {
const first = gate()
const second = gate()
getEntry.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const load = useEntryLoad('events', 1)
const started = load.start()
const retried = load.retry(1)
second.resolve(entry(1, 'second'))
await retried
first.resolve(entry(1, 'first'))
await started
expect(load.entry.value?.data.title).toBe('second')
expect(load.status.value).toBe('ready')
})
it('drops a stale failure', async () => {
let fail!: (error: unknown) => void
const first = new Promise<ContentEntry>((_resolve, reject) => (fail = reject))
const second = gate()
getEntry.mockReturnValueOnce(first).mockReturnValueOnce(second.promise)
const load = useEntryLoad('events', 1)
const started = load.start()
const retried = load.retry(1)
second.resolve(entry(1, 'second'))
await retried
fail(new ApiError(500))
await started
expect(load.status.value).toBe('ready')
expect(load.entry.value?.data.title).toBe('second')
})
it('ignores an in-flight load once an entry is adopted', async () => {
const pendingLoad = gate()
getEntry.mockReturnValueOnce(pendingLoad.promise)
const load = useEntryLoad('events', 1)
const started = load.start()
load.adopt(entry(1, 'saved'))
pendingLoad.resolve(entry(1, 'loaded'))
await started
expect(load.entry.value?.data.title).toBe('saved')
})
it('ignores an in-flight load once the entry is marked gone', async () => {
const pendingLoad = gate()
getEntry.mockReturnValueOnce(pendingLoad.promise)
const load = useEntryLoad('events', 1)
const started = load.start()
load.markGone(new ApiError(404, undefined, 'entry_not_found'))
pendingLoad.resolve(entry(1, 'loaded'))
await started
expect(load.status.value).toBe('missing')
expect(load.entry.value).toBeNull()
})
})
describe('failures', () => {
it.each([
[403, undefined, 'forbidden'],
[404, 'entry_not_found', 'missing'],
[404, 'content_type_not_found', 'typeMissing'],
[500, undefined, 'failed'],
])('maps %i %s to %s', async (status, body, expected) => {
getEntry.mockRejectedValueOnce(new ApiError(status, undefined, body))
const load = useEntryLoad('events', 1)
await load.start()
expect(load.status.value).toBe(expected)
})
it('leaves the status alone on 401', async () => {
getEntry.mockRejectedValueOnce(new ApiError(401))
const load = useEntryLoad('events', 1)
await load.start()
expect(load.status.value).toBe('loading')
})
})
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ref } from 'vue'
import { useEntryNames } from '@/composables/useEntryNames'
import type { ContentEntry } from '@/lib/contentTypes'
const lookupUsers = vi.fn()
vi.mock('@/composables/useUserLookup', () => ({ useUserLookup: () => ({ lookupUsers }) }))
function entry(createdBy: number, updatedBy: number): ContentEntry {
return {
id: 1,
content_type: 'events',
data: {},
created_by: createdBy,
updated_by: updatedBy,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-12T09:14:00',
}
}
beforeEach(() => {
lookupUsers.mockReset()
})
describe('useEntryNames', () => {
it('maps the ids of the entry to display names', async () => {
lookupUsers.mockResolvedValue([
{ id: 4, display_name: 'Inès Duarte' },
{ id: 3, display_name: 'Marta Alves' },
])
const current = ref<ContentEntry | null>(entry(4, 3))
const names = useEntryNames(() => current.value)
await flushPromises()
expect(lookupUsers).toHaveBeenCalledWith([4, 3])
expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves' })
})
it('does not look up the unknown author id 0', async () => {
lookupUsers.mockResolvedValue([{ id: 3, display_name: 'Marta Alves' }])
const current = ref<ContentEntry | null>(entry(0, 3))
useEntryNames(() => current.value)
await flushPromises()
expect(lookupUsers).toHaveBeenCalledWith([3])
})
it('looks up nobody when both ids are 0 or the entry is missing', async () => {
const current = ref<ContentEntry | null>(entry(0, 0))
const names = useEntryNames(() => current.value)
current.value = null
await flushPromises()
expect(lookupUsers).not.toHaveBeenCalled()
expect(names.value).toEqual({})
})
it('keeps the names it knows and looks up only a new id', async () => {
lookupUsers
.mockResolvedValueOnce([
{ id: 4, display_name: 'Inès Duarte' },
{ id: 3, display_name: 'Marta Alves' },
])
.mockResolvedValueOnce([{ id: 5, display_name: 'Jonas Pereira' }])
const current = ref<ContentEntry | null>(entry(4, 3))
const names = useEntryNames(() => current.value)
await flushPromises()
current.value = entry(4, 5)
await flushPromises()
expect(lookupUsers).toHaveBeenCalledTimes(2)
expect(lookupUsers).toHaveBeenLastCalledWith([5])
expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves', 5: 'Jonas Pereira' })
})
it('does not look up again nor clear the names when the same ids come back', async () => {
lookupUsers.mockResolvedValue([
{ id: 4, display_name: 'Inès Duarte' },
{ id: 3, display_name: 'Marta Alves' },
])
const current = ref<ContentEntry | null>(entry(4, 3))
const names = useEntryNames(() => current.value)
await flushPromises()
current.value = { ...entry(4, 3), updated_at: '2026-10-19T11:02:00' }
await flushPromises()
expect(lookupUsers).toHaveBeenCalledTimes(1)
expect(names.value).toEqual({ 4: 'Inès Duarte', 3: 'Marta Alves' })
})
it('asks again for an id whose lookup failed once a new id shows up', async () => {
lookupUsers.mockRejectedValueOnce(new Error('down')).mockResolvedValueOnce([
{ id: 4, display_name: 'Inès Duarte' },
{ id: 5, display_name: 'Jonas Pereira' },
])
const current = ref<ContentEntry | null>(entry(4, 4))
const names = useEntryNames(() => current.value)
await flushPromises()
current.value = entry(4, 5)
await flushPromises()
expect(lookupUsers).toHaveBeenLastCalledWith([4, 5])
expect(names.value).toEqual({ 4: 'Inès Duarte', 5: 'Jonas Pereira' })
})
it('reads no names when the lookup fails', async () => {
lookupUsers.mockRejectedValue(new Error('down'))
const names = useEntryNames(() => entry(4, 3))
await flushPromises()
expect(names.value).toEqual({})
})
})
@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useEntryRemoval } from '@/composables/useEntryRemoval'
import type { EntryDraft } from '@/composables/useEntryDraft'
const deleteEntry = vi.fn()
const replace = vi.fn()
const add = vi.fn()
vi.mock('@/composables/useEntries', () => ({ useEntries: () => ({ deleteEntry }) }))
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRouter: () => ({ replace }),
}))
vi.mock('@/i18n/presentation', () => ({ useLocalizedToast: () => ({ add }) }))
function fakeEditor(overrides: { id?: number | null; saving?: boolean } = {}) {
const markGone = vi.fn()
const editor = {
typeKey: 'events',
entryId: ref(overrides.id === undefined ? 42 : overrides.id),
saving: ref(overrides.saving ?? false),
load: { markGone },
} as unknown as EntryDraft
return { editor, markGone }
}
function gate() {
let resolve!: () => void
const promise = new Promise<void>((done) => (resolve = done))
return { promise, resolve }
}
beforeEach(() => {
deleteEntry.mockReset()
replace.mockReset()
add.mockReset()
replace.mockResolvedValue(undefined)
})
describe('open and close', () => {
it('opens the confirmation for a saved entry', () => {
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
expect(removal.stage.value).toBe('confirm')
})
it.each([
['an unsaved entry', { id: null }],
['a form that is saving', { saving: true }],
])('does not open for %s', (_name, overrides) => {
const removal = useEntryRemoval(
fakeEditor(overrides).editor,
() => 'Events',
() => 'Lime',
)
removal.open()
expect(removal.stage.value).toBe('closed')
})
it('closes the confirmation when nothing is pending', () => {
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
removal.close()
expect(removal.stage.value).toBe('closed')
})
it('refuses to close while the deletion is pending', async () => {
const request = gate()
deleteEntry.mockReturnValue(request.promise)
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
const confirming = removal.confirm()
removal.close()
expect(removal.stage.value).toBe('confirm')
request.resolve()
await confirming
})
})
describe('confirm', () => {
it('sends a single request when confirmed twice', async () => {
const request = gate()
deleteEntry.mockReturnValue(request.promise)
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
const first = removal.confirm()
const second = removal.confirm()
request.resolve()
await Promise.all([first, second])
expect(deleteEntry).toHaveBeenCalledTimes(1)
expect(deleteEntry).toHaveBeenCalledWith('events', 42)
})
it('announces the title it had before the deletion and leaves for the list', async () => {
deleteEntry.mockResolvedValue(undefined)
let title = 'Lime'
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => title,
)
removal.open()
const confirming = removal.confirm()
title = 'changed meanwhile'
await confirming
expect(add).toHaveBeenCalledTimes(1)
const toast = add.mock.calls[0]![0]()
expect(toast.description).toBe('"Lime" was deleted from Events.')
expect(replace).toHaveBeenCalledWith('/content/events')
expect(removal.done.value).toBe(true)
expect(removal.stage.value).toBe('closed')
})
it('re-arms the unsaved changes prompt when the navigation is blocked', async () => {
deleteEntry.mockResolvedValue(undefined)
replace.mockResolvedValue(new Error('cancelled'))
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
await removal.confirm()
expect(removal.done.value).toBe(false)
})
it('re-arms the unsaved changes prompt when the navigation throws', async () => {
deleteEntry.mockResolvedValue(undefined)
replace.mockRejectedValue(new Error('boom'))
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
await removal.confirm()
expect(removal.done.value).toBe(false)
})
it.each([404, 403])('marks the entry gone on %i and closes the dialog', async (status) => {
const error = new ApiError(status)
deleteEntry.mockRejectedValue(error)
const { editor, markGone } = fakeEditor()
const removal = useEntryRemoval(
editor,
() => 'Events',
() => 'Lime',
)
removal.open()
await removal.confirm()
expect(markGone).toHaveBeenCalledWith(error)
expect(removal.stage.value).toBe('closed')
expect(removal.pending.value).toBe(false)
})
it('keeps the dialog open with a failure on 500', async () => {
deleteEntry.mockRejectedValue(new ApiError(500))
const { editor, markGone } = fakeEditor()
const removal = useEntryRemoval(
editor,
() => 'Events',
() => 'Lime',
)
removal.open()
await removal.confirm()
expect(removal.stage.value).toBe('failed')
expect(markGone).not.toHaveBeenCalled()
expect(removal.done.value).toBe(false)
})
it('stays silent on 401', async () => {
deleteEntry.mockRejectedValue(new ApiError(401))
const removal = useEntryRemoval(
fakeEditor().editor,
() => 'Events',
() => 'Lime',
)
removal.open()
await removal.confirm()
expect(removal.stage.value).toBe('confirm')
})
it('does nothing for an unsaved entry', async () => {
const removal = useEntryRemoval(
fakeEditor({ id: null }).editor,
() => 'Events',
() => 'Lime',
)
await removal.confirm()
expect(deleteEntry).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUserLookup } from '@/composables/useUserLookup'
const fetchMock = vi.fn()
function answer(status: number, body: unknown) {
fetchMock.mockResolvedValue(
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
}),
)
}
beforeEach(() => {
setActivePinia(createPinia())
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('lookupUsers', () => {
it('sends the distinct ids once and returns the display names', async () => {
answer(200, [
{ id: 3, display_name: 'Marta Alves' },
{ id: 5, display_name: 'Jonas Pereira' },
])
const names = await useUserLookup().lookupUsers([3, 3, 5, 3])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5')
expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira'])
})
it('rejects with the status on a 500', async () => {
answer(500, 'boom')
await expect(useUserLookup().lookupUsers([3])).rejects.toMatchObject({ status: 500 })
})
it('rejects a body that is not a list', async () => {
answer(200, { id: 3 })
await expect(useUserLookup().lookupUsers([3])).rejects.toThrow()
})
})
+2 -12
View File
@@ -1,19 +1,9 @@
import { ref } from 'vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError } from '@/lib/apiError'
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
constructor(status: number, retryAfter?: number, body?: string) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
}
}
export { ApiError }
export class NetworkError extends Error {
constructor() {
+98
View File
@@ -0,0 +1,98 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import { useEntryColumns } from '@/composables/useEntryColumns'
import { useEntryList } from '@/composables/useEntryList'
import { resolveContentListState } from '@/lib/contentListState'
import { entryCaption } from '@/lib/entryListQuery'
import { fieldCountLabel } from '@/lib/schemaRows'
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DESKTOP_SKELETON_ROWS = 8
const MOBILE_SKELETON_ROWS = 6
export function useContentListPage(
typeKey: string,
detail: ReturnType<typeof useContentTypeDetail>,
canEdit: () => boolean,
) {
const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const list = useEntryList(typeKey)
const columns = useEntryColumns(typeKey, () => detail.type.value?.fields ?? [])
const view = computed(() =>
resolveContentListState({
type: detail.status.value,
entries: list.status.value,
total: list.total.value,
}),
)
const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound')
const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError')
const name = computed(() => detail.type.value?.name ?? typeKey)
const fields = computed(() => detail.type.value?.fields ?? [])
const subtitle = computed(() =>
detail.type.value
? `${detail.type.value.key} · ${fieldCountLabel(detail.type.value.fields.length)}`
: '',
)
const editTo = computed(() => (canEdit() ? schemaPath(typeKey) : undefined))
const caption = computed(() => entryCaption(name.value, list.shown.value.sort))
const showSkeleton = computed(() =>
view.value === 'loading'
? list.showSkeleton.value || list.status.value === 'ready'
: view.value === 'list' && list.skeletonActive.value,
)
const skeletonRows = computed(() => {
if (view.value === 'list') {
return list.items.value.length
}
return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS
})
const showBody = computed(() => view.value === 'list' && !showSkeleton.value)
function createEntry() {
router.push(entryPath(typeKey, 'new'))
}
function goHome() {
router.push(HOME_PATH)
}
function retry() {
if (view.value === 'typeError') {
detail.retry()
return
}
list.retry()
}
return {
typeKey,
type: detail.type,
typeStatus: detail.status,
isDesktop,
list,
columns,
view,
centered,
failed,
name,
fields,
subtitle,
editTo,
caption,
showSkeleton,
skeletonRows,
showBody,
createEntry,
goHome,
retry,
}
}
export type ContentListPage = ReturnType<typeof useContentListPage>
+10 -2
View File
@@ -5,7 +5,7 @@ import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
const FAILURES: Record<number, ContentTypeDetailStatus> = { 403: 'forbidden', 404: 'missing' }
export function useContentTypeDetail(key: () => string) {
export function useContentTypeDetail(key: () => string, enabled: () => boolean = () => true) {
const { getContentType } = useContentTypes()
const status = ref<ContentTypeDetailStatus>('loading')
const type = ref<ContentType | null>(null)
@@ -29,7 +29,15 @@ export function useContentTypeDetail(key: () => string) {
}
}
watch(key, load, { immediate: true })
watch(
[key, enabled],
([, isEnabled]) => {
if (isEnabled) {
return load()
}
},
{ immediate: true },
)
return { status, type, retry: load }
}
+1 -28
View File
@@ -1,12 +1,6 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import type {
BuiltinType,
ContentType,
EntryPage,
EntryPageQuery,
UserName,
} from '@/lib/contentTypes'
import type { BuiltinType, ContentType } from '@/lib/contentTypes'
import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload'
export type {
@@ -75,29 +69,8 @@ export function useContentTypes() {
return request<ContentType>(`/v1/content-type/${encodeURIComponent(key)}`, options)
}
async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> {
const params = new URLSearchParams({
page: String(query.page),
size: String(query.size),
sort: query.sort,
})
const url = `/v1/content-type/${encodeURIComponent(key)}/entry?${params.toString().replace(/%3A/g, ':')}`
const page = await request<EntryPage>(url, options)
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
throw new Error(`Unexpected response from ${url}`)
}
return page
}
async function lookupUsers(ids: number[]): Promise<UserName[]> {
const distinct = [...new Set(ids)]
return requestList<UserName>(`/v1/user/lookup?ids=${distinct.join(',')}`)
}
return {
getContentType,
listEntries,
lookupUsers,
listContentTypes,
listBuiltinTypes,
createContentType,
+53
View File
@@ -0,0 +1,53 @@
import { useApi } from '@/composables/useApi'
import type { ContentEntry, EntryPage, EntryPageQuery } from '@/lib/contentTypes'
import { entryEndpoint } from '@/lib/contentEndpoints'
import { jsonInit } from '@/lib/directoryRequest'
export function useEntries() {
const { request } = useApi()
const options = { onForbidden: 'passthrough' } as const
async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> {
const params = new URLSearchParams({
page: String(query.page),
size: String(query.size),
sort: query.sort,
})
const url = `${entryEndpoint(key)}?${params.toString().replace(/%3A/g, ':')}`
const page = await request<EntryPage>(url, options)
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
throw new Error(`Unexpected response from ${url}`)
}
return page
}
async function requestEntry(url: string, init: RequestInit): Promise<ContentEntry> {
const entry = await request<ContentEntry>(url, { ...init, ...options })
if (
typeof entry?.id !== 'number' ||
typeof entry.data !== 'object' ||
entry.data === null
) {
throw new Error(`Unexpected response from ${url}`)
}
return entry
}
function getEntry(key: string, id: number) {
return requestEntry(entryEndpoint(key, id), {})
}
function createEntry(key: string, data: Record<string, unknown>) {
return requestEntry(entryEndpoint(key), jsonInit('POST', { data }))
}
function saveEntry(key: string, id: number, data: Record<string, unknown>) {
return requestEntry(entryEndpoint(key, id), jsonInit('PUT', { data }))
}
async function deleteEntry(key: string, id: number): Promise<void> {
await request<void>(entryEndpoint(key, id), { method: 'DELETE', ...options })
}
return { listEntries, getEntry, createEntry, saveEntry, deleteEntry }
}
+162
View File
@@ -0,0 +1,162 @@
import { computed, ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import { useEntries } from '@/composables/useEntries'
import { useEntryForm } from '@/composables/useEntryForm'
import { useEntryLoad } from '@/composables/useEntryLoad'
import type { ContentEntry } from '@/lib/contentTypes'
import { resolveEditorPhase, type EntryTarget } from '@/lib/entryEditorState'
import { buildData, issueFor, validateEntry } from '@/lib/entryForm'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
const UNAUTHORIZED = 401
export type SaveFailure = 'none' | 'failed'
export function useEntryDraft(typeKey: string, target: EntryTarget) {
const { createEntry, saveEntry } = useEntries()
const detail = useContentTypeDetail(() => typeKey)
const load = useEntryLoad(typeKey, target)
const fields = computed(() => detail.type.value?.fields ?? [])
const entryId = ref<number | null>(typeof target === 'number' ? target : null)
const creating = computed(() => entryId.value === null)
const form = useEntryForm(
() => fields.value,
() => creating.value,
)
const saving = ref(false)
const failure = ref<SaveFailure>('none')
const schemaChanged = ref(false)
const schemaField = ref('')
const savedAt = ref('')
const focusToken = ref(0)
let built = false
const phase = computed(() => resolveEditorPhase(detail.status.value, load.status.value))
const ready = computed(() => phase.value === 'ready')
const locked = computed(() => saving.value || schemaChanged.value)
const canSave = computed(
() => ready.value && !locked.value && (creating.value || form.dirty.value),
)
watch(
phase,
(next) => {
if (next === 'ready' && !built) {
form.reset(load.entry.value)
built = true
}
},
{ immediate: true },
)
function applyRefusal(error: unknown) {
const outcome = classifyRefusal(fields.value, parseEntryRefusal(error))
if (outcome.kind === 'schema') {
schemaChanged.value = true
schemaField.value = outcome.key
return true
}
if (outcome.kind === 'unknown') {
return false
}
form.rejectField(outcome.field.key, issueFor(outcome.field, outcome.rule))
focusToken.value++
return true
}
function settleFailure(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
return
}
if (isGoneError(error)) {
load.markGone(error)
return
}
if (applyRefusal(error)) {
return
}
failure.value = 'failed'
}
function request(data: Record<string, unknown>): Promise<ContentEntry> {
return entryId.value === null
? createEntry(typeKey, data)
: saveEntry(typeKey, entryId.value, data)
}
async function save(): Promise<ContentEntry | null> {
if (!ready.value || locked.value) {
return null
}
failure.value = 'none'
const issues = validateEntry(fields.value, form.values.value)
if (Object.keys(issues).length > 0) {
form.reject(issues)
focusToken.value++
return null
}
saving.value = true
try {
const answered = await request(buildData(fields.value, form.values.value))
load.adopt(answered)
entryId.value = answered.id
savedAt.value = answered.updated_at
form.reset(answered)
return answered
} catch (error) {
settleFailure(error)
return null
} finally {
saving.value = false
}
}
function change(key: string, value: unknown) {
form.setValue(key, value)
}
function reload() {
schemaChanged.value = false
failure.value = 'none'
built = false
detail.retry()
return load.retry(entryId.value)
}
function retryLoad() {
if (phase.value === 'typeError') {
return detail.retry()
}
return load.retry(entryId.value)
}
load.start()
return {
typeKey,
detail,
load,
fields,
form,
entryId,
saving,
failure,
schemaChanged,
schemaField,
savedAt,
focusToken,
phase,
ready,
creating,
locked,
canSave,
save,
change,
reload,
retryLoad,
}
}
export type EntryDraft = ReturnType<typeof useEntryDraft>
+150
View File
@@ -0,0 +1,150 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useEntryDraft } from '@/composables/useEntryDraft'
import { useEntryNames } from '@/composables/useEntryNames'
import { useEntryRemoval } from '@/composables/useEntryRemoval'
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
import { entryTitle } from '@/lib/entryForm'
import type { EntryTarget } from '@/lib/entryEditorState'
import { formatEntryStamp, formatSavedClock } from '@/lib/entryStamp'
import { contentPath, entryPath } from '@/lib/routePaths'
const DESKTOP_QUERY = '(min-width: 48rem)'
const SAVED_TOAST_ID = 'entry-saved'
const MISSING_NAME = '–'
export type EntryChipStatus = 'new' | 'saved' | 'dirty' | 'saving' | 'failed' | 'unsaved'
export function useEntryEditorPage(
typeKey: string,
target: EntryTarget,
adoptRoute: (id: number | null) => void,
) {
const router = useRouter()
const toast = useLocalizedToast()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const editor = useEntryDraft(typeKey, target)
const { detail, load, form } = editor
const names = useEntryNames(() => load.entry.value)
const typeName = computed(() => detail.type.value?.name ?? typeKey)
const title = computed(() => entryTitle(editor.fields.value, load.entry.value))
const subtitle = computed(() =>
editor.creating.value
? t('{0} · Fields marked * are required.', [typeName.value])
: t('{0} · Entry {1}', [typeName.value, editor.entryId.value!]),
)
const removal = useEntryRemoval(
editor,
() => typeName.value,
() => title.value,
)
const chip = computed<EntryChipStatus>(() => {
if (editor.saving.value) {
return 'saving'
}
if (editor.schemaChanged.value || form.rejected.value) {
return 'unsaved'
}
if (editor.failure.value === 'failed') {
return 'failed'
}
if (form.dirty.value) {
return 'dirty'
}
return editor.creating.value ? 'new' : 'saved'
})
const savedClock = computed(() =>
editor.savedAt.value && chip.value === 'saved'
? formatSavedClock(editor.savedAt.value)
: '',
)
const entryLine = computed(() =>
editor.creating.value ? t('Not saved yet') : `${typeKey} / ${editor.entryId.value}`,
)
function person(id: number | undefined, iso: string | undefined) {
const entry = load.entry.value
if (!entry || id === undefined || iso === undefined) {
return MISSING_NAME
}
return `${names.value[id] ?? MISSING_NAME} · ${formatEntryStamp(iso)}`
}
const createdLine = computed(() =>
person(load.entry.value?.created_by, load.entry.value?.created_at),
)
const updatedLine = computed(() =>
person(load.entry.value?.updated_by, load.entry.value?.updated_at),
)
useUnsavedChangesGuard({
isDirty: () => editor.ready.value && form.dirty.value && !removal.done.value,
title: () => title.value,
})
function announceSaved(savedTitle: string) {
toast.add(() => ({
id: SAVED_TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Entry saved'),
description: t('"{0}" is up to date.', [savedTitle]),
}))
}
async function submit() {
const wasCreating = editor.creating.value
const answered = await editor.save()
if (!answered) {
return
}
announceSaved(entryTitle(editor.fields.value, answered))
if (wasCreating) {
adoptRoute(answered.id)
const refused = await router.replace(entryPath(typeKey, answered.id)).then(
(failure) => Boolean(failure),
() => true,
)
if (refused) {
adoptRoute(null)
}
}
}
function cameFromList() {
const previous = (window.history.state as { back?: string | null } | null)?.back
return typeof previous === 'string' && previous.split('?')[0] === contentPath(typeKey)
}
function cancel() {
if (cameFromList()) {
router.back()
return
}
router.push(contentPath(typeKey))
}
return {
...editor,
isDesktop,
names,
typeName,
title,
subtitle,
chip,
savedClock,
entryLine,
createdLine,
updatedLine,
removal,
submit,
cancel,
}
}
export type EntryEditorPage = ReturnType<typeof useEntryEditorPage>
+69
View File
@@ -0,0 +1,69 @@
import { computed, ref } from 'vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import {
carriesDefault,
firstIssueKey,
initialValues,
valuesDiffer,
type EntryIssues,
type EntryValues,
type FieldIssue,
} from '@/lib/entryForm'
export function useEntryForm(fields: () => BuiltinField[], creating: () => boolean) {
const values = ref<EntryValues>({})
const saved = ref<EntryValues>({})
const issues = ref<EntryIssues>({})
const rejected = ref(false)
const dirty = computed(() => valuesDiffer(fields(), values.value, saved.value))
const firstIssue = computed(() => firstIssueKey(fields(), issues.value))
const hasIssues = computed(() => Object.keys(issues.value).length > 0)
function reset(entry: ContentEntry | null) {
const next = initialValues(fields(), entry)
values.value = { ...next }
saved.value = { ...next }
issues.value = {}
rejected.value = false
}
function setValue(key: string, value: unknown) {
values.value = { ...values.value, [key]: value }
rejected.value = false
if (issues.value[key]) {
const rest = { ...issues.value }
delete rest[key]
issues.value = rest
}
}
function reject(next: EntryIssues) {
issues.value = next
rejected.value = true
}
function rejectField(key: string, issue: FieldIssue) {
reject({ [key]: issue })
}
function isDefault(field: BuiltinField) {
return creating() && carriesDefault(field, values.value[field.key])
}
return {
values,
issues,
rejected,
dirty,
firstIssue,
hasIssues,
reset,
setValue,
reject,
rejectField,
isDefault,
}
}
export type EntryForm = ReturnType<typeof useEntryForm>
+4 -2
View File
@@ -1,7 +1,8 @@
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import { useEntries } from '@/composables/useEntries'
import { useUserLookup } from '@/composables/useUserLookup'
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
import {
@@ -20,7 +21,8 @@ const FAILURES: Record<number, EntryListStatus> = { 403: 'forbidden', 404: 'miss
export function useEntryList(typeKey: string) {
const route = useRoute()
const { listEntries, lookupUsers } = useContentTypes()
const { listEntries } = useEntries()
const { lookupUsers } = useUserLookup()
const items = ref<ContentEntry[]>([])
const status = ref<EntryListStatus>('loading')
+68
View File
@@ -0,0 +1,68 @@
import { ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries'
import type { ContentEntry } from '@/lib/contentTypes'
import { missingKind, type EntryLoadStatus, type EntryTarget } from '@/lib/entryEditorState'
const UNAUTHORIZED = 401
const FORBIDDEN = 403
const NOT_FOUND = 404
export function useEntryLoad(typeKey: string, target: EntryTarget) {
const { getEntry } = useEntries()
const entry = ref<ContentEntry | null>(null)
const status = ref<EntryLoadStatus>(
target === 'new' ? 'idle' : target === 'invalid' ? 'missing' : 'loading',
)
let generation = 0
function fail(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
return
}
if (error instanceof ApiError && error.status === FORBIDDEN) {
status.value = 'forbidden'
} else if (error instanceof ApiError && error.status === NOT_FOUND) {
status.value = missingKind(error.body)
} else {
status.value = 'failed'
}
}
async function load(id: number) {
const current = ++generation
status.value = 'loading'
try {
const loaded = await getEntry(typeKey, id)
if (current === generation) {
entry.value = loaded
status.value = 'ready'
}
} catch (error) {
if (current === generation) {
fail(error)
}
}
}
function start() {
return typeof target === 'number' ? load(target) : Promise.resolve()
}
function retry(id: number | null) {
return id === null ? Promise.resolve() : load(id)
}
function adopt(next: ContentEntry) {
generation++
entry.value = next
status.value = 'ready'
}
function markGone(error: unknown) {
generation++
fail(error)
}
return { entry, status, start, retry, adopt, markGone }
}
+34
View File
@@ -0,0 +1,34 @@
import { ref, watch } from 'vue'
import { useUserLookup } from '@/composables/useUserLookup'
import type { ContentEntry } from '@/lib/contentTypes'
function authorIds(entry: ContentEntry | null) {
return entry ? [entry.created_by, entry.updated_by].filter((id) => id > 0) : []
}
export function useEntryNames(entry: () => ContentEntry | null) {
const { lookupUsers } = useUserLookup()
const names = ref<Record<number, string>>({})
const requested = new Set<number>()
async function resolve() {
const wanted = [...new Set(authorIds(entry()))].filter((id) => !requested.has(id))
if (wanted.length === 0) {
return
}
wanted.forEach((id) => requested.add(id))
try {
const users = await lookupUsers(wanted)
names.value = {
...names.value,
...Object.fromEntries(users.map((user) => [user.id, user.display_name])),
}
} catch {
wanted.forEach((id) => requested.delete(id))
}
}
watch(() => authorIds(entry()).join(','), resolve, { immediate: true })
return names
}
+90
View File
@@ -0,0 +1,90 @@
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import { useEntries } from '@/composables/useEntries'
import type { EntryDraft } from '@/composables/useEntryDraft'
import { isGoneError } from '@/lib/entryServerError'
import { contentPath } from '@/lib/routePaths'
const UNAUTHORIZED = 401
const TOAST_ID = 'entry-deleted'
export type RemovalStage = 'closed' | 'confirm' | 'failed'
export function useEntryRemoval(editor: EntryDraft, typeName: () => string, title: () => string) {
const router = useRouter()
const toast = useLocalizedToast()
const { deleteEntry } = useEntries()
const stage = ref<RemovalStage>('closed')
const pending = ref(false)
const done = ref(false)
function open() {
if (!pending.value && !editor.saving.value && editor.entryId.value !== null) {
stage.value = 'confirm'
}
}
function close() {
if (!pending.value) {
stage.value = 'closed'
}
}
function announce(deletedTitle: string) {
toast.add(() => ({
id: TOAST_ID,
color: 'success',
icon: 'i-lucide-circle-check',
title: t('Entry deleted'),
description: t('"{0}" was deleted from {1}.', [deletedTitle, typeName()]),
}))
}
function refuse(error: unknown) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
return
}
if (isGoneError(error)) {
stage.value = 'closed'
editor.load.markGone(error)
return
}
stage.value = 'failed'
}
async function confirm() {
const id = editor.entryId.value
if (pending.value || id === null) {
return
}
const deletedTitle = title()
pending.value = true
stage.value = 'confirm'
try {
await deleteEntry(editor.typeKey, id)
} catch (error) {
pending.value = false
refuse(error)
return
}
pending.value = false
done.value = true
stage.value = 'closed'
announce(deletedTitle)
const arrived = await router.replace(contentPath(editor.typeKey)).then(
(failure) => !failure,
() => false,
)
if (!arrived) {
done.value = false
}
}
return { stage, pending, done, open, close, confirm }
}
export type EntryRemoval = ReturnType<typeof useEntryRemoval>
+18
View File
@@ -0,0 +1,18 @@
import { useApi } from '@/composables/useApi'
import type { UserName } from '@/lib/contentTypes'
export function useUserLookup() {
const { request } = useApi()
async function lookupUsers(ids: number[]): Promise<UserName[]> {
const distinct = [...new Set(ids)]
const url = `/v1/user/lookup?ids=${distinct.join(',')}`
const listed = await request<UserName[]>(url, { onForbidden: 'passthrough' })
if (!Array.isArray(listed)) {
throw new Error(`Unexpected response from ${url}`)
}
return listed
}
return { lookupUsers }
}
+1 -1
View File
@@ -17,7 +17,7 @@ const SOURCES = [
'components/SortSelect.vue',
'lib/entryCells.ts',
'lib/entryListQuery.ts',
'views/ContentListView.vue',
'views/ContentListPage.vue',
]
const SAME_IN_FRENCH = new Set(['ID'])
+58
View File
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/ContentState.vue',
'components/EntryActions.vue',
'components/EntryControl.vue',
'components/EntryDateField.vue',
'components/EntryDeleteDialog.vue',
'components/EntryEditor.vue',
'components/EntryEditorSkeleton.vue',
'components/EntryForm.vue',
'components/EntryNotices.vue',
'components/MarkdownField.vue',
'composables/useEntryEditorPage.ts',
'composables/useEntryRemoval.ts',
'lib/entryForm.ts',
'lib/entryMessages.ts',
'lib/entryEditors.ts',
'views/EntryEditorPage.vue',
]
const SAME_IN_FRENCH = new Set<string>([])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Entry editor messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the screen', () => {
expect(keys.length).toBeGreaterThan(60)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
})
+78
View File
@@ -2530,6 +2530,84 @@ export const messages = {
'{0}, sorted by last updated, newest first':
'{0}, trié par dernière modification, plus récent en premier',
'{0}, sorted by {1}, {2}': '{0}, trié par {1}, {2}',
'Entry saved': 'Entrée enregistrée',
'\u0022{0}\u0022 is up to date.': '« {0} » est à jour.',
'Entry deleted': 'Entrée supprimée',
'\u0022{0}\u0022 was deleted from {1}.': '« {0} » a été supprimée de {1}.',
'Delete this entry?': 'Supprimer cette entrée ?',
"\u0022{0}\u0022 will be deleted from {1}. This can't be undone.":
'« {0} » sera supprimée de {1}. Cette action est irréversible.',
'Delete entry': 'Supprimer l’entrée',
"Couldn't delete the entry": 'Impossible de supprimer l’entrée',
'Something went wrong on our side. Nothing was deleted. Try again in a moment.':
'Un problème est survenu de notre côté. Rien n’a été supprimé. Réessayez dans un instant.',
"Couldn't save the entry": 'Impossible d’enregistrer l’entrée',
"Couldn't load the entry": 'Impossible de charger l’entrée',
'GET {0} failed. Check your connection and try again.':
'GET {0} a échoué. Vérifiez votre connexion et réessayez.',
'Not saved': 'Non enregistrée',
'Not saved yet': 'Pas encore enregistrée',
Entry: 'Entrée',
'Entry {0}': 'Entrée {0}',
'Entry not found': 'Entrée introuvable',
'It may have been deleted by someone else.':
'Elle a peut-être été supprimée par quelqu’un d’autre.',
'Back to {0}': 'Retour à {0}',
'The fields of {0} changed': 'Les champs de {0} ont changé',
"A field was removed and a new required field, {0}, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.":
'Un champ a été supprimé et un nouveau champ obligatoire, {0}, a été ajouté après l’ouverture de ce formulaire. Rechargez le formulaire pour continuer. Ce que vous avez saisi ici ne peut pas être enregistré avec les anciens champs.',
'Reload form': 'Recharger le formulaire',
'Another entry already uses this {0}. Change it and save again.':
'Une autre entrée utilise déjà ce champ ({0}). Modifiez-le et enregistrez à nouveau.',
'Another entry already uses this choice. Change it and save again.':
'Une autre entrée utilise déjà ce choix. Modifiez-le et enregistrez à nouveau.',
'Another entry already uses this date. Change it and save again.':
'Une autre entrée utilise déjà cette date. Modifiez-la et enregistrez à nouveau.',
'Another entry already uses this {0}. Enter a different one.':
'Une autre entrée utilise déjà ce champ ({0}). Saisissez une autre valeur.',
'Another entry already uses this choice for {0}.':
'Une autre entrée utilise déjà ce choix pour {0}.',
'Another entry already uses this date for {0}.':
'Une autre entrée utilise déjà cette date pour {0}.',
'Each entry needs a different {0}.': 'Chaque entrée doit avoir une valeur différente ({0}).',
'{0} · Fields marked * are required.': '{0} · Les champs marqués * sont obligatoires.',
'{0} · Entry {1}': '{0} · Entrée {1}',
'Fill in {0}.': 'Renseignez {0}.',
'Pick a date for {0}.': 'Choisissez une date pour {0}.',
'Choose an option for {0}.': 'Choisissez une option pour {0}.',
'Choose yes or no for {0}.': 'Choisissez oui ou non pour {0}.',
'{0} must be text.': '{0} doit être du texte.',
'{0} must be a number.': '{0} doit être un nombre.',
'{0} must be yes or no.': '{0} doit être oui ou non.',
'{0} must be a date, like 2026-11-21.': '{0} doit être une date, par exemple 2026-11-21.',
'{0} must be one of the choices.': '{0} doit être l’un des choix.',
'{0} must be at least {1}.': '{0} doit être au moins {1}.',
'{0} must be at most {1}.': '{0} doit être au plus {1}.',
"{0} can't be before {1}.": '{0} ne peut pas être avant le {1}.',
"{0} can't be after {1}.": '{0} ne peut pas être après le {1}.',
'{0} needs at least {1} characters.': '{0} doit contenir au moins {1} caractères.',
'{0} can be up to {1} characters.': '{0} peut contenir jusqu’à {1} caractères.',
'Choose one of: {0}.': 'Choisissez parmi : {0}.',
'{0} or {1}': '{0} ou {1}',
'From {0} until {1}.': 'Du {0} au {1}.',
'From {0}.': 'À partir du {0}.',
'Until {0}.': 'Jusqu’au {0}.',
'Between {0} and {1}.': 'Entre {0} et {1}.',
'At least {0}.': 'Au moins {0}.',
'At most {0}.': 'Au plus {0}.',
'Markdown. Switch to Preview to check the result.':
'Markdown. Passez à l’aperçu pour vérifier le résultat.',
'Write in Markdown': 'Écrivez en Markdown',
'Nothing to preview yet.': 'Rien à prévisualiser pour l’instant.',
Write: 'Écrire',
'Pick a date': 'Choisir une date',
'Open calendar': 'Ouvrir le calendrier',
'Enter a number': 'Saisissez un nombre',
'Choose an option': 'Choisissez une option',
On: 'Activé',
Off: 'Désactivé',
Heading: 'Titre',
List: 'Liste',
id: 'identifiant',
} as const
+3
View File
@@ -65,6 +65,9 @@ describe('richTextFirstLine', () => {
['![alt text](https://example.test/a.png)', 'alt text'],
['`code` and ~~gone~~', 'code and gone'],
['snake_case stays', 'snake_case stays'],
['_lime_ plaster', 'lime plaster'],
['Two days of _lime putty_.', 'Two days of lime putty.'],
['(_lime_) and my_var_name', '(lime) and my_var_name'],
])('turns "%s" into "%s"', (markdown, expected) => {
expect(richTextFirstLine(markdown)).toBe(expected)
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { resolveEditorPhase, resolveEntryTarget } from '@/lib/entryEditorState'
describe('resolveEntryTarget', () => {
it.each([
['new', 'new'],
['42', 42],
['abc', 'invalid'],
['0', 0],
['4294967296', 'invalid'],
['4294967295', 4294967295],
['-1', 'invalid'],
['4.2', 'invalid'],
['', 'invalid'],
])('%s resolves to %s', (raw, target) => {
expect(resolveEntryTarget(raw)).toBe(target)
})
})
describe('resolveEditorPhase', () => {
it.each([
['ready', 'ready', 'ready'],
['ready', 'idle', 'ready'],
['loading', 'ready', 'loading'],
['ready', 'loading', 'loading'],
['missing', 'ready', 'typeNotFound'],
['ready', 'typeMissing', 'typeNotFound'],
['ready', 'missing', 'entryNotFound'],
['forbidden', 'ready', 'forbidden'],
['ready', 'forbidden', 'forbidden'],
['failed', 'loading', 'typeError'],
['ready', 'failed', 'entryError'],
] as const)('type %s and entry %s is %s', (type, entry, phase) => {
expect(resolveEditorPhase(type, entry)).toBe(phase)
})
})
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import {
buildData,
carriesDefault,
entryTitle,
firstIssueKey,
initialValues,
validateEntry,
valuesDiffer,
} from '@/lib/entryForm'
import { groupFields } from '@/lib/entryLayout'
import { apiField, eventsFields } from './schemaFixtures'
const fields = eventsFields as unknown as BuiltinField[]
function entry(data: Record<string, unknown>, id = 42): ContentEntry {
return {
id,
content_type: 'events',
data,
created_by: 1,
updated_by: 1,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-12T09:14:00',
}
}
describe('initialValues', () => {
it('pre-fills defaults on create', () => {
expect(initialValues(fields, null)).toEqual({
title: '',
starts_on: '',
ends_on: '',
online: false,
capacity: null,
category: '',
description: '',
})
})
it('uses the stored values on edit and no default', () => {
const values = initialValues(fields, entry({ title: 'Lime', online: null, capacity: 18 }))
expect(values).toMatchObject({ title: 'Lime', online: null, capacity: 18, ends_on: '' })
})
})
describe('buildData', () => {
it('sends one key per field with null for empty optional fields', () => {
const values = {
...initialValues(fields, null),
title: 'Open day',
starts_on: '2026-11-21',
}
expect(buildData(fields, values)).toEqual({
title: 'Open day',
starts_on: '2026-11-21',
ends_on: null,
online: false,
capacity: null,
category: null,
description: null,
})
})
it('never sends a key the type does not have', () => {
const stored = entry({ title: 'Lime', removed: 'x' })
expect(Object.keys(buildData(fields, initialValues(fields, stored)))).toEqual(
fields.map((field) => field.key),
)
})
it.each([
['optional, no default', {}, undefined, null],
['optional, loaded as null', { default: false }, null, null],
['required, no default', { required: true }, undefined, false],
['optional, default true', { default: true }, 'default', true],
])('a boolean that was never set: %s', (_name, rest, stored, sent) => {
const field = apiField('Flag', 'flag', 'boolean', rest) as unknown as BuiltinField
const loaded =
stored === undefined ? null : stored === 'default' ? null : entry({ flag: null })
const values = initialValues([field], stored === 'default' ? null : loaded)
expect(buildData([field], values).flag).toBe(sent)
})
})
describe('validateEntry', () => {
const base = initialValues(fields, null)
it('reports every empty required field at once, in schema order', () => {
const issues = validateEntry(fields, base)
expect(Object.keys(issues)).toEqual(['title', 'starts_on'])
expect(issues.title!.message).toBe('Fill in Title.')
expect(issues.starts_on!.message).toBe('Pick a date for Starts on.')
expect(firstIssueKey(fields, issues)).toBe('title')
})
it('reports one rule per field, with the message of the rule', () => {
const issues = validateEntry(fields, {
...base,
title: 'x'.repeat(91),
starts_on: '2026-11-21',
capacity: 6000,
category: 'Seminar',
})
expect(
Object.fromEntries(Object.entries(issues).map(([key, issue]) => [key, issue.message])),
).toEqual({
title: 'Title can be up to 80 characters.',
capacity: 'Capacity must be at most 5000.',
category: 'Choose one of: Talk, Workshop or Meetup.',
})
})
it.each([
['Title', 'text', { min: 3 }, 'ab', 'Title needs at least 3 characters.'],
['Capacity', 'number', { min: 1 }, 0, 'Capacity must be at least 1.'],
[
'Starts on',
'date',
{ min: '2026-01-01' },
'2025-12-31',
"Starts on can't be before 1 Jan 2026.",
],
[
'Starts on',
'date',
{ max: '2027-12-31' },
'2028-01-01',
"Starts on can't be after 31 Dec 2027.",
],
['Starts on', 'date', {}, '2026-02-30', 'Starts on must be a date, like 2026-11-21.'],
])('%s (%s %j) holding %j reads "%s"', (label, type, rest, value, message) => {
const field = apiField(label, 'f', type, rest) as unknown as BuiltinField
expect(validateEntry([field], { f: value }).f!.message).toBe(message)
})
it.each([
['text', 'Title', 'Fill in Title.'],
['richtext', 'Notes', 'Fill in Notes.'],
['number', 'Capacity', 'Fill in Capacity.'],
['date', 'Starts on', 'Pick a date for Starts on.'],
['select', 'Category', 'Choose an option for Category.'],
])('required %s %s reads "%s"', (type, label, message) => {
const field = apiField(label, 'f', type, { required: true }) as unknown as BuiltinField
expect(validateEntry([field], { f: type === 'number' ? null : '' }).f!.message).toBe(
message,
)
})
})
describe('entryTitle', () => {
it.each([
['Lime plaster workshop', 'text', 'Lime plaster workshop'],
[null, 'text', 'Entry 42'],
['First line\n\nSecond', 'richtext', 'First line'],
[18, 'number', '18'],
])('%j of a %s field reads %s', (value, type, title) => {
const first = apiField('First', 'first', type) as unknown as BuiltinField
expect(entryTitle([first], entry({ first: value }))).toBe(title)
})
it('reads New entry without an entry', () => {
expect(entryTitle(fields, null)).toBe('New entry')
})
})
describe('defaults and changes', () => {
it('tags a value equal to the default and drops the tag on change', () => {
const online = fields[3]!
expect(carriesDefault(online, false)).toBe(true)
expect(carriesDefault(online, true)).toBe(false)
expect(carriesDefault(fields[0]!, '')).toBe(false)
})
it('differs only when a value changed, and not when it is restored', () => {
const saved = initialValues(fields, entry({ capacity: 18 }))
expect(valuesDiffer(fields, { ...saved, capacity: 20 }, saved)).toBe(true)
expect(valuesDiffer(fields, { ...saved, capacity: 18 }, saved)).toBe(false)
})
})
describe('groupFields', () => {
it('pairs dates, numbers and booleans, never text, rich text or select', () => {
expect(groupFields(fields).map((row) => row.map((field) => field.key))).toEqual([
['title'],
['starts_on', 'ends_on'],
['online', 'capacity'],
['category'],
['description'],
])
})
it('keeps a pairable field alone when the next one cannot pair', () => {
const loose = [
{ ...fields.find((field) => field.key === 'capacity')! },
{ ...fields.find((field) => field.key === 'title')! },
{ ...fields.find((field) => field.key === 'online')! },
]
expect(groupFields(loose).map((row) => row.map((field) => field.key))).toEqual([
['capacity'],
['title'],
['online'],
])
})
})
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField } from '@/lib/contentTypes'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import { asNumber, freeSentinel, isIsoDay } from '@/lib/entryValue'
import { valuesDiffer } from '@/lib/entryForm'
function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField {
return {
key: 'f',
label: 'Field',
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
describe('length limits', () => {
const text = field('text', { min: 3, max: 5 })
it.each([
['ab', 'min'],
['abc', null],
['abcde', null],
['abcdef', 'max'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.text.validate(text, value)).toBe(rule)
})
it('flags the counter only once over the maximum', () => {
expect(ENTRY_EDITORS.text.counter(text, 'abcde')).toEqual({ text: '5 / 5', over: false })
expect(ENTRY_EDITORS.text.counter(text, 'abcdef')).toEqual({ text: '6 / 5', over: true })
})
})
describe('number limits', () => {
const number = field('number', { min: 2, max: 4 })
it.each([
[1, 'min'],
[2, null],
[4, null],
[5, 'max'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.number.validate(number, value)).toBe(rule)
})
it('sends an empty string as null', () => {
expect(ENTRY_EDITORS.number.payload(number, '')).toBeNull()
expect(ENTRY_EDITORS.number.payload(number, 0)).toBe(0)
})
})
describe('date limits', () => {
const date = field('date', { min: '2026-11-10', max: '2026-11-20' })
it.each([
['2026-11-09', 'min'],
['2026-11-10', null],
['2026-11-20', null],
['2026-11-21', 'max'],
['2026-02-30', 'type'],
])('%s is judged %s', (value, rule) => {
expect(ENTRY_EDITORS.date.validate(date, value)).toBe(rule)
})
})
describe('value helpers', () => {
it('rejects a day that does not exist', () => {
expect(isIsoDay('2026-02-28')).toBe(true)
expect(isIsoDay('2026-02-30')).toBe(false)
expect(isIsoDay('2026-13-01')).toBe(false)
})
it('reads blank strings as no number', () => {
expect(asNumber(' ')).toBeNull()
expect(asNumber(' 7 ')).toBe(7)
expect(asNumber('0')).toBe(0)
})
})
describe('freeSentinel', () => {
it('returns the base when free and extends it until it is not taken', () => {
expect(freeSentinel(['a'])).toBe('__none__')
expect(freeSentinel(['__none__'])).toBe('__none___')
expect(freeSentinel(['__none__', '__none___'])).toBe('__none____')
})
})
describe('valuesDiffer', () => {
it('compares each field by identity', () => {
const fields = [field('number')]
expect(valuesDiffer(fields, { f: 0 }, { f: 0 })).toBe(false)
expect(valuesDiffer(fields, { f: 0 }, { f: null })).toBe(true)
expect(valuesDiffer(fields, { f: '' }, { f: null })).toBe(true)
})
})
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/composables/useApi'
import type { BuiltinField } from '@/lib/contentTypes'
import { classifyRefusal, isGoneError, parseEntryRefusal } from '@/lib/entryServerError'
import { eventsFields } from './schemaFixtures'
const fields = eventsFields as unknown as BuiltinField[]
function refusal(status: number, body: string) {
return new ApiError(status, undefined, body)
}
describe('parseEntryRefusal', () => {
it.each([
[422, 'invalid_entry: title: required', { key: 'title', rule: 'required', status: 422 }],
[409, 'invalid_entry: title: unique', { key: 'title', rule: 'unique', status: 409 }],
[
422,
'invalid_entry: room: unknown_field',
{ key: 'room', rule: 'unknown_field', status: 422 },
],
])('%i %s', (status, body, parsed) => {
expect(parseEntryRefusal(refusal(status, body))).toEqual(parsed)
})
it.each([
[422, 'invalid_entry'],
[400, 'invalid_entry: title: required'],
[500, 'boom'],
[422, ''],
])('ignores %i "%s"', (status, body) => {
expect(parseEntryRefusal(refusal(status, body))).toBeNull()
})
it('ignores anything that is not an api error', () => {
expect(parseEntryRefusal(new Error('x'))).toBeNull()
})
})
describe('classifyRefusal', () => {
it.each([
['title', 'required', 'field'],
['capacity', 'max', 'field'],
['category', 'options', 'field'],
['room', 'required', 'schema'],
['room', 'unique', 'schema'],
['description', 'unknown_field', 'schema'],
['colour', 'unknown_field', 'schema'],
])('%s %s is %s', (key, rule, kind) => {
const outcome = classifyRefusal(fields, { key, rule, status: 422 })
expect(outcome.kind).toBe(kind)
})
it('carries the field of a field outcome and the key of a schema outcome', () => {
const title = fields.find((field) => field.key === 'title')!
expect(classifyRefusal(fields, { key: 'title', rule: 'min', status: 422 })).toEqual({
kind: 'field',
field: title,
rule: 'min',
status: 422,
})
expect(classifyRefusal(fields, { key: 'room', rule: 'required', status: 422 })).toEqual({
kind: 'schema',
key: 'room',
})
})
it('has no outcome without a refusal', () => {
expect(classifyRefusal(fields, null)).toEqual({ kind: 'unknown' })
})
})
describe('isGoneError', () => {
it.each([
[403, true],
[404, true],
[401, false],
[409, false],
[500, false],
])('reads status %i as %s', (status, gone) => {
expect(isGoneError(new ApiError(status))).toBe(gone)
})
it('ignores errors that are not API errors', () => {
expect(isGoneError(new Error('boom'))).toBe(false)
expect(isGoneError(undefined)).toBe(false)
})
})
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { FIELD_TYPES, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes'
import { apiField } from './schemaFixtures'
const CONTROLS: Record<FieldType, string> = {
text: 'input',
richtext: 'markdown',
number: 'number',
boolean: 'switch',
date: 'date',
select: 'select',
}
function editor(type: FieldType) {
return fieldTypeSpec(type).editor
}
describe('field type registry', () => {
it.each(FIELD_TYPES)('%s has an icon, a label, a description and a control', (type) => {
const spec = fieldTypeSpec(type)
expect(spec.icon).toMatch(/^i-lucide-/)
expect(spec.label()).not.toBe('')
expect(spec.description()).not.toBe('')
expect(spec.editor.control).toBe(CONTROLS[type])
})
it('falls back to text for an unknown type', () => {
expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text'))
})
it.each([
['text', ''],
['richtext', ''],
['number', null],
['boolean', null],
['date', ''],
['select', ''],
] as const)(
'%s starts empty on create without a default and from null on edit',
(type, empty) => {
const field = apiField('Field', 'field', type)
expect(editor(type).initial(field, undefined, true)).toBe(empty)
expect(editor(type).initial(field, null, false)).toBe(empty)
},
)
it.each([
['text', 'Untitled', 'Untitled'],
['richtext', '# Hello', '# Hello'],
['number', 100, 100],
['boolean', true, true],
['boolean', false, false],
['date', '2026-11-21', '2026-11-21'],
['select', 'Talk', 'Talk'],
] as const)('%s pre-fills its default %j on create only', (type, given, shown) => {
const field = apiField('Field', 'field', type, { default: given })
expect(editor(type).initial(field, undefined, true)).toBe(shown)
expect(editor(type).initial(field, null, false)).not.toBe(shown)
})
it.each([
['text', { unique: true, label: 'Title' }, 'Each entry needs a different title.'],
['text', {}, ''],
['number', { min: 1, max: 5000 }, 'Between 1 and 5000.'],
['number', { min: 1 }, 'At least 1.'],
['number', { max: 5000 }, 'At most 5000.'],
['date', { min: '2026-01-01', max: '2027-12-31' }, 'From 1 Jan 2026 until 31 Dec 2027.'],
['richtext', {}, 'Markdown. Switch to Preview to check the result.'],
['boolean', {}, ''],
['select', {}, ''],
] as const)('%s help line for %j reads "%s"', (type, rest, help) => {
const field = apiField(rest.label ?? 'Field', 'field', type, rest)
expect(editor(type).help(field)).toBe(help)
})
it.each(['text', 'richtext'] as const)('%s counts characters when it has a max', (type) => {
const field = apiField('Field', 'field', type, { max: 80 })
expect(editor(type).counter(field, 'a'.repeat(32))).toEqual({
text: '32 / 80',
over: false,
})
expect(editor(type).counter(field, 'a'.repeat(91))).toEqual({
text: '91 / 80',
over: true,
})
expect(editor(type).counter(apiField('F', 'f', type), 'abc')).toBeNull()
expect(editor(type).counter(field, '😀😀')?.text).toBe('2 / 80')
})
it.each([
['text', { required: true }, '', 'required'],
['text', { min: 3 }, 'ab', 'min'],
['text', { max: 3 }, 'abcd', 'max'],
['text', { min: 3 }, '', null],
['richtext', { required: true }, null, 'required'],
['number', { required: true }, null, 'required'],
['number', { min: 1 }, 0, 'min'],
['number', { max: 5000 }, 6000, 'max'],
['number', {}, 0, null],
['boolean', { required: true }, null, null],
['boolean', {}, 'yes', 'type'],
['date', { required: true }, '', 'required'],
['date', { min: '2026-01-01' }, '2025-12-31', 'min'],
['date', { max: '2027-12-31' }, '2028-01-01', 'max'],
['date', {}, '2026-02-30', 'type'],
['select', { required: true }, '', 'required'],
['select', { options: ['Talk'] }, 'Seminar', 'options'],
['select', { options: ['Talk'] }, 'Talk', null],
] as const)('%s %j holding %j gives %s', (type, rest, value, rule) => {
const field = apiField('Field', 'field', type, rest)
expect(editor(type).validate(field, value)).toBe(rule)
})
it.each([
['text', {}, '', null],
['text', {}, ' kept ', ' kept '],
['richtext', {}, '', null],
['number', {}, null, null],
['number', {}, 0, 0],
['boolean', {}, null, null],
['boolean', { required: true }, null, false],
['boolean', {}, true, true],
['date', {}, '', null],
['date', {}, '2026-11-21', '2026-11-21'],
['select', {}, '', null],
['select', {}, 'Talk', 'Talk'],
] as const)('%s %j holding %j sends %j', (type, rest, value, sent) => {
const field = apiField('Field', 'field', type, rest)
expect(editor(type).payload(field, value)).toBe(sent)
})
it.each([
['text', true],
['richtext', false],
['number', true],
['boolean', false],
['date', true],
['select', true],
] as const)('%s unique support is %s', (type, unique) => {
expect(fieldTypeSpec(type).supportsUnique).toBe(unique)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '@/lib/markdown'
import { applyMarkdownAction } from '@/lib/markdownEdit'
describe('renderMarkdown', () => {
it('renders bold text and a bulleted list', () => {
const html = renderMarkdown(
'Two days with **lime putty**.\n\n- Bring work clothes\n- Lunch',
)
expect(html).toContain('<strong>lime putty</strong>')
expect(html.match(/<li>/g)).toHaveLength(2)
})
it('shows raw HTML as text', () => {
const html = renderMarkdown('<script>alert(1)</script>')
expect(html).not.toContain('<script>')
expect(html).toContain('&lt;script&gt;')
})
it.each(['https://example.com', 'http://example.com', 'mailto:a@b.co'])(
'keeps the link %s active',
(url) => {
expect(renderMarkdown(`[x](${url})`)).toContain(`href="${url}"`)
},
)
it.each(['javascript:alert(1)', 'data:text/html,x', 'vbscript:x', '/relative', 'url'])(
'leaves the link %s inert',
(url) => {
expect(renderMarkdown(`[x](${url})`)).not.toContain('<a ')
},
)
it('opens links in a new tab without leaking the opener', () => {
const html = renderMarkdown('[x](https://example.com)')
expect(html).toContain('target="_blank"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('does not load images', () => {
expect(renderMarkdown('![x](https://example.com/a.png)')).not.toContain('<img')
})
})
describe('applyMarkdownAction', () => {
it.each([
['bold', '**lime**'],
['italic', '_lime_'],
['heading', '## lime'],
['link', '[lime](url)'],
['list', '- lime'],
] as const)('%s turns the selection into %s', (action, result) => {
expect(applyMarkdownAction(action, 'lime', 0, 4).value).toBe(result)
})
it('selects the url placeholder of a link', () => {
const edit = applyMarkdownAction('link', 'see lime now', 4, 8)
expect(edit.value.slice(edit.start, edit.end)).toBe('url')
})
it('prefixes every selected line of a list', () => {
expect(applyMarkdownAction('list', 'a\nb', 0, 3).value).toBe('- a\n- b')
})
it('puts the heading mark at the start of the line', () => {
expect(applyMarkdownAction('heading', 'one\ntwo lime', 8, 12).value).toBe(
'one\n## two lime',
)
})
it('moves the selection past the list marker of the first line', () => {
const edit = applyMarkdownAction('list', 'a\nb', 0, 3)
expect([edit.start, edit.end]).toEqual([2, 7])
})
it('prefixes only the current line when the cursor sits right after a line break', () => {
expect(applyMarkdownAction('list', 'one\ntwo', 4, 4).value).toBe('one\n- two')
expect(applyMarkdownAction('heading', 'one\ntwo', 4, 4).value).toBe('one\n## two')
})
it('prefixes the line the cursor ends, not the next one', () => {
expect(applyMarkdownAction('heading', 'one\ntwo', 3, 3).value).toBe('## one\ntwo')
})
it('prefixes the first line when it is empty and the caret sits at the start', () => {
expect(applyMarkdownAction('heading', '\nfoo', 0, 0).value).toBe('## \nfoo')
expect(applyMarkdownAction('list', '\nfoo', 0, 0).value).toBe('- \nfoo')
})
})
+4 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { fieldRules } from '@/lib/schemaFields'
function field(overrides: Partial<BuiltinField>): BuiltinField {
return {
@@ -26,8 +27,8 @@ describe('field types', () => {
['date', 'Date', 'i-lucide-calendar'],
['select', 'Choice', 'i-lucide-list'],
])('%s reads %s with %s', (type, label, icon) => {
expect(fieldTypeLabel(type)).toBe(label)
expect(fieldIcon(type)).toBe(icon)
expect(fieldTypeSpec(type).label()).toBe(label)
expect(fieldTypeSpec(type).icon).toBe(icon)
})
})
+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
status: number
retryAfter?: number
body?: string
constructor(status: number, retryAfter?: number, body?: string) {
super(`Request failed with status ${status}`)
this.status = status
this.retryAfter = retryAfter
this.body = body
}
}
+8
View File
@@ -0,0 +1,8 @@
export function typeEndpoint(key: string) {
return `/v1/content-type/${encodeURIComponent(key)}`
}
export function entryEndpoint(key: string, id?: number) {
const base = `${typeEndpoint(key)}/entry`
return id === undefined ? base : `${base}/${id}`
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ContentType } from '@/composables/useContentTypes'
import type { ContentType } from '@/lib/contentTypes'
export const COLLAPSED_LIMIT = 5
+3 -4
View File
@@ -5,8 +5,8 @@ export interface BuiltinField {
required: boolean
unique: boolean
default: unknown
min: number | null
max: number | null
min: number | string | null
max: number | string | null
options: unknown[] | null
}
@@ -22,7 +22,7 @@ export interface ContentType {
export interface ContentEntry {
id: number
content_type_id: number
content_type: string
data: Record<string, unknown>
created_by: number
updated_by: number
@@ -35,7 +35,6 @@ export interface EntryPage {
total: number
page: number
size: number
sort: string
}
export interface EntryPageQuery {
+17 -21
View File
@@ -1,8 +1,12 @@
import { formatNumber, t } from '@/i18n'
import type { BuiltinField } from '@/lib/contentTypes'
import { formatAbsoluteDate, formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
import { formatDayValue } from '@/lib/entryValue'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export type CellKind = 'empty' | 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
export { formatDayValue }
export type CellKind = 'empty' | FieldCellKind
export interface CellView {
kind: CellKind
@@ -13,10 +17,10 @@ export interface CellView {
export const EMPTY_TEXT = '–'
const EMPTY: CellView = { kind: 'empty', text: '' }
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/
const LINE_PREFIX = /^\s{0,3}(?:#{1,6}(?:\s+|$)|>\s?|[-*+]\s+|\d+[.)]\s+)+/
const IMAGE_OR_LINK = /!?\[([^\]]*)\]\([^)]*\)/g
const EMPHASIS = /(\*\*|__|\*|`|~~)/g
const UNDERSCORE_EMPHASIS = /(^|[\s(])_(?=\S)([^_]*?\S)_(?=$|[\s).,;:!?])/g
export function richTextFirstLine(markdown: string) {
for (const line of markdown.split(/\r?\n/)) {
@@ -24,6 +28,7 @@ export function richTextFirstLine(markdown: string) {
.replace(LINE_PREFIX, '')
.replace(IMAGE_OR_LINK, '$1')
.replace(EMPHASIS, '')
.replace(UNDERSCORE_EMPHASIS, '$1$2')
.trim()
if (plain) {
return plain
@@ -32,35 +37,26 @@ export function richTextFirstLine(markdown: string) {
return ''
}
export function formatDayValue(value: string) {
const match = ISO_DAY.exec(value)
if (!match) {
return value
}
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
}
export function describeCell(field: BuiltinField, value: unknown): CellView {
if (value === undefined || value === null || value === '') {
return EMPTY
}
switch (field.type) {
const kind = fieldTypeSpec(field.type).cell
switch (kind) {
case 'boolean':
return { kind: 'boolean', text: '', yes: value === true }
return { kind, text: '', yes: value === true }
case 'number':
return typeof value === 'number'
? { kind: 'number', text: formatNumber(value) }
: { kind: 'number', text: String(value) }
return { kind, text: typeof value === 'number' ? formatNumber(value) : String(value) }
case 'date':
return { kind: 'date', text: formatDayValue(String(value)) }
case 'select':
return { kind: 'choice', text: String(value) }
return { kind, text: formatDayValue(String(value)) }
case 'choice':
return { kind, text: String(value) }
case 'richtext': {
const line = richTextFirstLine(String(value))
return line ? { kind: 'richtext', text: line } : EMPTY
return line ? { kind, text: line } : EMPTY
}
default:
return { kind: 'text', text: String(value) }
return { kind, text: String(value) }
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ContentTypeDetailStatus } from '@/lib/contentTypes'
export type EntryLoadStatus =
| 'idle'
| 'loading'
| 'ready'
| 'failed'
| 'forbidden'
| 'missing'
| 'typeMissing'
export type EntryEditorPhase =
| 'loading'
| 'ready'
| 'typeNotFound'
| 'entryNotFound'
| 'forbidden'
| 'typeError'
| 'entryError'
const CONTENT_TYPE_MISSING_BODY = 'content_type_not_found'
export function missingKind(body: string | undefined): 'missing' | 'typeMissing' {
return body === CONTENT_TYPE_MISSING_BODY ? 'typeMissing' : 'missing'
}
export function resolveEditorPhase(
type: ContentTypeDetailStatus,
entry: EntryLoadStatus,
): EntryEditorPhase {
if (type === 'missing' || entry === 'typeMissing') {
return 'typeNotFound'
}
if (entry === 'missing') {
return 'entryNotFound'
}
if (type === 'forbidden' || entry === 'forbidden') {
return 'forbidden'
}
if (type === 'failed') {
return 'typeError'
}
if (entry === 'failed') {
return 'entryError'
}
if (type === 'loading' || entry === 'loading') {
return 'loading'
}
return 'ready'
}
export type EntryTarget = number | 'new' | 'invalid'
const DIGITS = /^\d+$/
const UINT32_MAX = 4294967295
export function resolveEntryTarget(raw: string): EntryTarget {
if (raw === 'new') {
return 'new'
}
if (!DIGITS.test(raw)) {
return 'invalid'
}
const id = Number(raw)
return id >= 0 && id <= UINT32_MAX ? id : 'invalid'
}
+172
View File
@@ -0,0 +1,172 @@
import { t } from '@/i18n'
import type { BuiltinField } from '@/lib/contentTypes'
import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
import type { FieldType } from '@/lib/fieldTypes'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select'
export interface FieldCounter {
text: string
over: boolean
}
export interface EntryEditorSpec {
control: FieldControl
initial: (field: BuiltinField, stored: unknown, creating: boolean) => unknown
validate: (field: BuiltinField, value: unknown) => EntryRule | null
payload: (field: BuiltinField, value: unknown) => unknown
help: (field: BuiltinField) => string
counter: (field: BuiltinField, value: unknown) => FieldCounter | null
}
type Limit = number | string | null
function textInitial(field: BuiltinField, stored: unknown, creating: boolean) {
return asText(creating ? field.default : stored)
}
function textPayload(_field: BuiltinField, value: unknown) {
return isBlank(value) ? null : String(value)
}
function lengthViolation(field: BuiltinField, value: unknown): EntryRule | null {
if (isBlank(value)) {
return field.required ? 'required' : null
}
const length = charCount(String(value))
if (typeof field.min === 'number' && length < field.min) {
return 'min'
}
return typeof field.max === 'number' && length > field.max ? 'max' : null
}
function lengthCounter(field: BuiltinField, value: unknown): FieldCounter | null {
if (typeof field.max !== 'number') {
return null
}
const length = charCount(asText(value))
return { text: `${length} / ${field.max}`, over: length > field.max }
}
function textHelp(field: BuiltinField) {
return field.unique ? uniqueHelp(field) : ''
}
function numberViolation(field: BuiltinField, value: unknown): EntryRule | null {
if (isBlank(value)) {
return field.required ? 'required' : null
}
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 'type'
}
if (typeof field.min === 'number' && value < field.min) {
return 'min'
}
return typeof field.max === 'number' && value > field.max ? 'max' : null
}
function dayBeyond(value: string, limit: Limit, side: 'min' | 'max') {
if (typeof limit !== 'string' || limit === '') {
return false
}
return side === 'min' ? value < limit : value > limit
}
function dateViolation(field: BuiltinField, value: unknown): EntryRule | null {
if (isBlank(value)) {
return field.required ? 'required' : null
}
if (!isIsoDay(value)) {
return 'type'
}
if (dayBeyond(value, field.min, 'min')) {
return 'min'
}
return dayBeyond(value, field.max, 'max') ? 'max' : null
}
function choiceViolation(field: BuiltinField, value: unknown): EntryRule | null {
if (isBlank(value)) {
return field.required ? 'required' : null
}
return (field.options ?? []).map(String).includes(String(value)) ? null : 'options'
}
function booleanViolation(_field: BuiltinField, value: unknown): EntryRule | null {
return isBlank(value) || typeof value === 'boolean' ? null : 'type'
}
function booleanInitial(field: BuiltinField, stored: unknown, creating: boolean) {
return asBoolean(creating ? field.default : stored)
}
function booleanPayload(field: BuiltinField, value: unknown) {
if (typeof value === 'boolean') {
return value
}
return field.required ? false : null
}
function noHelp() {
return ''
}
function noCounter() {
return null
}
function emptyAsText(value: unknown) {
return isBlank(value) ? '' : String(value)
}
export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
text: {
control: 'input',
initial: textInitial,
validate: lengthViolation,
payload: textPayload,
help: textHelp,
counter: lengthCounter,
},
richtext: {
control: 'markdown',
initial: textInitial,
validate: lengthViolation,
payload: textPayload,
help: () => t('Markdown. Switch to Preview to check the result.'),
counter: lengthCounter,
},
number: {
control: 'number',
initial: (field, stored, creating) => asNumber(creating ? field.default : stored),
validate: numberViolation,
payload: (_field, value) => (isBlank(value) ? null : value),
help: rangeHelp,
counter: noCounter,
},
boolean: {
control: 'switch',
initial: booleanInitial,
validate: booleanViolation,
payload: booleanPayload,
help: noHelp,
counter: noCounter,
},
date: {
control: 'date',
initial: (field, stored, creating) => asText(creating ? field.default : stored),
validate: dateViolation,
payload: (_field, value) => (isBlank(value) ? null : emptyAsText(value)),
help: rangeHelp,
counter: noCounter,
},
select: {
control: 'select',
initial: (field, stored, creating) => asText(creating ? field.default : stored),
validate: choiceViolation,
payload: textPayload,
help: noHelp,
counter: noCounter,
},
}
+77
View File
@@ -0,0 +1,77 @@
import { t } from '@/i18n'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
import { isBlank } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
export type EntryValues = Record<string, unknown>
export interface FieldIssue {
rule: EntryRule
message: string
}
export type EntryIssues = Record<string, FieldIssue>
export function initialValues(fields: BuiltinField[], stored: ContentEntry | null): EntryValues {
return Object.fromEntries(
fields.map((field) => [
field.key,
fieldTypeSpec(field.type).editor.initial(
field,
stored?.data[field.key],
stored === null,
),
]),
)
}
export function buildData(fields: BuiltinField[], values: EntryValues) {
return Object.fromEntries(
fields.map((field) => [
field.key,
fieldTypeSpec(field.type).editor.payload(field, values[field.key]),
]),
)
}
export function issueFor(field: BuiltinField, rule: EntryRule): FieldIssue {
return { rule, message: ruleMessage(rule, field) }
}
export function validateEntry(fields: BuiltinField[], values: EntryValues): EntryIssues {
const issues: EntryIssues = {}
for (const field of fields) {
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key])
if (rule) {
issues[field.key] = issueFor(field, rule)
}
}
return issues
}
export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) {
return fields.find((field) => issues[field.key])?.key ?? null
}
export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) {
return fields.some((field) => !Object.is(current[field.key], saved[field.key]))
}
export function carriesDefault(field: BuiltinField, value: unknown) {
if (isBlank(field.default)) {
return false
}
const initial = fieldTypeSpec(field.type).editor.initial(field, undefined, true)
return Object.is(initial, value)
}
export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
const first = fields[0]
if (!first || !entry) {
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
}
const cell = describeCell(first, entry.data[first.key])
return cell.kind === 'empty' ? t('Entry {0}', [entry.id]) : plainCellText(cell)
}
+15
View File
@@ -0,0 +1,15 @@
import type { BuiltinField } from '@/lib/contentTypes'
import { fieldTypeSpec } from '@/lib/fieldTypes'
export function groupFields(fields: BuiltinField[]) {
const rows: BuiltinField[][] = []
let index = 0
while (index < fields.length) {
const current = fields[index]!
const next = fields[index + 1]
const paired = next && fieldTypeSpec(current.type).pairs && fieldTypeSpec(next.type).pairs
rows.push(paired ? [current, next] : [current])
index += paired ? 2 : 1
}
return rows
}
+155
View File
@@ -0,0 +1,155 @@
import { t } from '@/i18n'
import type { BuiltinField } from '@/lib/contentTypes'
import { formatDayValue } from '@/lib/entryValue'
export type EntryRule = 'required' | 'type' | 'min' | 'max' | 'options' | 'unique'
function bound(field: BuiltinField, side: 'min' | 'max') {
return field[side] ?? ''
}
function choices(field: BuiltinField) {
const options = (field.options ?? []).map(String)
if (options.length < 2) {
return options.join('')
}
return t('{0} or {1}', [options.slice(0, -1).join(', '), options[options.length - 1]!])
}
function requiredMessage(field: BuiltinField) {
switch (field.type) {
case 'date':
return t('Pick a date for {0}.', [field.label])
case 'select':
return t('Choose an option for {0}.', [field.label])
case 'boolean':
return t('Choose yes or no for {0}.', [field.label])
default:
return t('Fill in {0}.', [field.label])
}
}
function typeMessage(field: BuiltinField) {
switch (field.type) {
case 'number':
return t('{0} must be a number.', [field.label])
case 'boolean':
return t('{0} must be yes or no.', [field.label])
case 'date':
return t('{0} must be a date, like 2026-11-21.', [field.label])
case 'select':
return t('{0} must be one of the choices.', [field.label])
default:
return t('{0} must be text.', [field.label])
}
}
function minMessage(field: BuiltinField) {
const limit = bound(field, 'min')
if (field.type === 'number') {
return t('{0} must be at least {1}.', [field.label, String(limit)])
}
if (field.type === 'date') {
return t("{0} can't be before {1}.", [field.label, formatDayValue(String(limit))])
}
return t('{0} needs at least {1} characters.', [field.label, String(limit)])
}
function maxMessage(field: BuiltinField) {
const limit = bound(field, 'max')
if (field.type === 'number') {
return t('{0} must be at most {1}.', [field.label, String(limit)])
}
if (field.type === 'date') {
return t("{0} can't be after {1}.", [field.label, formatDayValue(String(limit))])
}
return t('{0} can be up to {1} characters.', [field.label, String(limit)])
}
function uniqueMessage(field: BuiltinField) {
switch (field.type) {
case 'select':
return t('Another entry already uses this choice for {0}.', [field.label])
case 'date':
return t('Another entry already uses this date for {0}.', [field.label])
default:
return t('Another entry already uses this {0}. Enter a different one.', [field.label])
}
}
export function ruleMessage(rule: EntryRule, field: BuiltinField) {
switch (rule) {
case 'required':
return requiredMessage(field)
case 'type':
return typeMessage(field)
case 'min':
return minMessage(field)
case 'max':
return maxMessage(field)
case 'options':
return t('Choose one of: {0}.', [choices(field)])
default:
return uniqueMessage(field)
}
}
export function uniqueAlert(field: BuiltinField) {
switch (field.type) {
case 'select':
return t('Another entry already uses this choice. Change it and save again.')
case 'date':
return t('Another entry already uses this date. Change it and save again.')
default:
return t('Another entry already uses this {0}. Change it and save again.', [
field.label.toLowerCase(),
])
}
}
export function uniqueHelp(field: BuiltinField) {
return t('Each entry needs a different {0}.', [field.label.toLowerCase()])
}
export function rangeHelp(field: BuiltinField) {
const min = field.min
const max = field.max
if (field.type === 'date') {
const from = min === null || min === undefined ? '' : formatDayValue(String(min))
const until = max === null || max === undefined ? '' : formatDayValue(String(max))
if (from && until) {
return t('From {0} until {1}.', [from, until])
}
if (from) {
return t('From {0}.', [from])
}
return until ? t('Until {0}.', [until]) : ''
}
if (typeof min === 'number' && typeof max === 'number') {
return t('Between {0} and {1}.', [String(min), String(max)])
}
if (typeof min === 'number') {
return t('At least {0}.', [String(min)])
}
return typeof max === 'number' ? t('At most {0}.', [String(max)]) : ''
}
export function schemaChangedLine(key: string) {
const name = key.replace(/_/g, ' ')
return t(
"A field was removed and a new required field, {0}, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields.",
[name.charAt(0).toUpperCase() + name.slice(1)],
)
}
export function entryLoadErrorLine(path: string) {
return t('GET {0} failed. Check your connection and try again.', [path])
}
export function typeLoadErrorLine(key: string) {
return t('GET /v1/content-type/{0} failed. Check your connection and try again.', [key])
}
export function entriesLoadErrorLine(key: string) {
return t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [key])
}
+50
View File
@@ -0,0 +1,50 @@
import { ApiError } from '@/lib/apiError'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntryRule } from '@/lib/entryMessages'
const REFUSAL = /^invalid_entry:\s*([^:\s]+)\s*:\s*([a-z_]+)\s*$/
const FIELD_RULES: readonly string[] = ['required', 'type', 'min', 'max', 'options', 'unique']
const REFUSAL_STATUSES = [409, 422]
const GONE_STATUSES = [403, 404]
export interface EntryRefusal {
key: string
rule: string
status: number
}
export type RefusalOutcome =
| { kind: 'field'; field: BuiltinField; rule: EntryRule; status: number }
| { kind: 'schema'; key: string }
| { kind: 'unknown' }
export function isGoneError(error: unknown) {
return error instanceof ApiError && GONE_STATUSES.includes(error.status)
}
export function parseEntryRefusal(error: unknown): EntryRefusal | null {
if (!(error instanceof ApiError) || !REFUSAL_STATUSES.includes(error.status)) {
return null
}
const match = REFUSAL.exec(error.body ?? '')
return match ? { key: match[1]!, rule: match[2]!, status: error.status } : null
}
export function classifyRefusal(
fields: BuiltinField[],
refusal: EntryRefusal | null,
): RefusalOutcome {
if (!refusal) {
return { kind: 'unknown' }
}
const field = fields.find((candidate) => candidate.key === refusal.key)
if (!field || !FIELD_RULES.includes(refusal.rule)) {
return { kind: 'schema', key: refusal.key }
}
return {
kind: 'field',
field,
rule: refusal.rule as EntryRule,
status: refusal.status,
}
}
+10
View File
@@ -0,0 +1,10 @@
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatClock } from '@/lib/updateFormat'
export function formatEntryStamp(iso: string) {
return `${formatAbsoluteDateFromIso(iso)}, ${formatClock(new Date(iso))}`
}
export function formatSavedClock(iso: string) {
return formatClock(new Date(iso))
}
+59
View File
@@ -0,0 +1,59 @@
import { formatAbsoluteDate } from '@/lib/relativeDate'
const ISO_DAY_PREFIX = /^(\d{4})-(\d{2})-(\d{2})/
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
export function isBlank(value: unknown) {
return value === null || value === undefined || value === ''
}
export function charCount(text: string) {
return Array.from(text).length
}
export function isIsoDay(value: unknown): value is string {
if (typeof value !== 'string') {
return false
}
const match = ISO_DAY.exec(value)
if (!match) {
return false
}
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]
const date = new Date(year, month - 1, day)
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
}
export function asText(value: unknown) {
return value === null || value === undefined ? '' : String(value)
}
export function asNumber(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Number(value)
}
return null
}
export function asBoolean(value: unknown) {
return typeof value === 'boolean' ? value : null
}
export function formatDayValue(value: string) {
const match = ISO_DAY_PREFIX.exec(value)
if (!match) {
return value
}
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
}
export function freeSentinel(taken: string[], base = '__none__') {
let sentinel = base
while (taken.includes(sentinel)) {
sentinel += '_'
}
return sentinel
}
+106
View File
@@ -0,0 +1,106 @@
import { t } from '@/i18n'
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
export type FieldType = (typeof FIELD_TYPES)[number]
export type FieldCellKind = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
export interface FieldTypeSpec {
icon: string
label: () => string
description: () => string
cell: FieldCellKind
narrowColumn: boolean
alignEnd: boolean
supportsUnique: boolean
supportsLimits: boolean
pairs: boolean
editor: EntryEditorSpec
}
const FALLBACK_TYPE: FieldType = 'text'
export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
text: {
icon: 'i-lucide-type',
label: () => t('Text'),
description: () => t('Short text, like a title or a name'),
cell: 'text',
narrowColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
pairs: false,
editor: ENTRY_EDITORS.text,
},
richtext: {
icon: 'i-lucide-pilcrow',
label: () => t('Rich text'),
description: () => t('Long text with formatting, written in Markdown'),
cell: 'richtext',
narrowColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: true,
pairs: false,
editor: ENTRY_EDITORS.richtext,
},
number: {
icon: 'i-lucide-hash',
label: () => t('Number'),
description: () => t('Whole or decimal numbers'),
cell: 'number',
narrowColumn: true,
alignEnd: true,
supportsUnique: true,
supportsLimits: true,
pairs: true,
editor: ENTRY_EDITORS.number,
},
boolean: {
icon: 'i-lucide-toggle-left',
label: () => t('Yes or no'),
description: () => t('An on or off switch'),
cell: 'boolean',
narrowColumn: true,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
pairs: true,
editor: ENTRY_EDITORS.boolean,
},
date: {
icon: 'i-lucide-calendar',
label: () => t('Date'),
description: () => t('A calendar day, without a time'),
cell: 'date',
narrowColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
pairs: true,
editor: ENTRY_EDITORS.date,
},
select: {
icon: 'i-lucide-list',
label: () => t('Choice'),
description: () => t('One option from a list you define'),
cell: 'choice',
narrowColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: false,
pairs: false,
editor: ENTRY_EDITORS.select,
},
}
export function isFieldType(type: string): type is FieldType {
return (FIELD_TYPES as readonly string[]).includes(type)
}
export function fieldTypeSpec(type: string): FieldTypeSpec {
return FIELD_TYPE_SPECS[isFieldType(type) ? type : FALLBACK_TYPE]
}
+22
View File
@@ -0,0 +1,22 @@
import MarkdownIt from 'markdown-it'
const SAFE_LINK = /^(https?:|mailto:)/i
const renderer = new MarkdownIt({ html: false, linkify: false, breaks: false })
renderer.disable('image')
renderer.validateLink = (url) => SAFE_LINK.test(url.trim())
const defaultLinkOpen =
renderer.renderer.rules.link_open ??
((tokens, index, options, _env, self) => self.renderToken(tokens, index, options))
renderer.renderer.rules.link_open = (tokens, index, options, env, self) => {
const token = tokens[index]!
token.attrSet('target', '_blank')
token.attrSet('rel', 'noopener noreferrer')
return defaultLinkOpen(tokens, index, options, env, self)
}
export function renderMarkdown(source: string) {
return renderer.render(source)
}
+66
View File
@@ -0,0 +1,66 @@
export type MarkdownAction = 'bold' | 'italic' | 'heading' | 'link' | 'list'
export const MARKDOWN_ACTIONS: MarkdownAction[] = ['bold', 'italic', 'heading', 'link', 'list']
export interface MarkdownEdit {
value: string
start: number
end: number
}
const LINK_PLACEHOLDER = 'url'
function wrap(value: string, start: number, end: number, mark: string): MarkdownEdit {
const inner = value.slice(start, end)
return {
value: `${value.slice(0, start)}${mark}${inner}${mark}${value.slice(end)}`,
start: start + mark.length,
end: end + mark.length,
}
}
function lineStart(value: string, index: number) {
return index === 0 ? 0 : value.lastIndexOf('\n', index - 1) + 1
}
function prefixLines(value: string, start: number, end: number, prefix: string): MarkdownEdit {
const from = lineStart(value, start)
const block = value.slice(from, end)
const lines = block.split('\n')
const prefixed = lines.map((line) => `${prefix}${line}`).join('\n')
return {
value: `${value.slice(0, from)}${prefixed}${value.slice(end)}`,
start: start + prefix.length,
end: end + prefix.length * lines.length,
}
}
function link(value: string, start: number, end: number): MarkdownEdit {
const inner = value.slice(start, end)
const urlStart = start + inner.length + 3
return {
value: `${value.slice(0, start)}[${inner}](${LINK_PLACEHOLDER})${value.slice(end)}`,
start: urlStart,
end: urlStart + LINK_PLACEHOLDER.length,
}
}
export function applyMarkdownAction(
action: MarkdownAction,
value: string,
start: number,
end: number,
): MarkdownEdit {
switch (action) {
case 'bold':
return wrap(value, start, end, '**')
case 'italic':
return wrap(value, start, end, '_')
case 'heading':
return prefixLines(value, start, start, '## ')
case 'link':
return link(value, start, end)
default:
return prefixLines(value, start, end, '- ')
}
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { t } from '@/i18n'
import { fieldTypeLabel } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
@@ -151,7 +151,10 @@ function changeNotes(before: PayloadField, after: PayloadField) {
}
function addNote(field: PayloadField) {
const parts = [fieldTypeLabel(field.type), field.required ? t('required') : t('optional')]
const parts = [
fieldTypeSpec(field.type).label(),
field.required ? t('required') : t('optional'),
]
if (field.default !== null) {
parts.push(
field.type === 'boolean'
+1 -1
View File
@@ -1,4 +1,4 @@
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
import type { BuiltinField, ContentType } from '@/lib/contentTypes'
import {
FIELD_TYPES,
createField,
+4 -4
View File
@@ -1,6 +1,6 @@
export const FIELD_TYPES = ['text', 'richtext', 'number', 'boolean', 'date', 'select'] as const
import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes'
export type FieldType = (typeof FIELD_TYPES)[number]
export { FIELD_TYPES, type FieldType }
export type KeyMode = 'auto' | 'manual'
export type KeyDisplayMode = KeyMode | 'empty'
export type BuilderStatus =
@@ -104,11 +104,11 @@ export function changeFieldType(field: BuilderField, type: FieldType): BuilderFi
}
export function supportsUnique(type: FieldType) {
return type !== 'richtext' && type !== 'boolean'
return FIELD_TYPE_SPECS[type].supportsUnique
}
export function supportsLimits(type: FieldType) {
return type === 'text' || type === 'richtext' || type === 'number' || type === 'date'
return FIELD_TYPE_SPECS[type].supportsLimits
}
export function hasDefaultValue(field: BuilderField) {
+4 -33
View File
@@ -1,38 +1,9 @@
import { t, formatNumber } from '@/i18n'
import type { BuiltinField } from '@/composables/useContentTypes'
import type { BuiltinField } from '@/lib/contentTypes'
import { asNumber } from '@/lib/entryValue'
const RULE_SEPARATOR = ' · '
const FIELD_ICONS: Record<string, string> = {
text: 'i-lucide-type',
richtext: 'i-lucide-pilcrow',
number: 'i-lucide-hash',
boolean: 'i-lucide-toggle-left',
date: 'i-lucide-calendar',
select: 'i-lucide-list',
}
export function fieldIcon(type: string) {
return FIELD_ICONS[type] ?? FIELD_ICONS.text!
}
export function fieldTypeLabel(type: string) {
switch (type) {
case 'richtext':
return t('Rich text')
case 'number':
return t('Number')
case 'boolean':
return t('Yes or no')
case 'date':
return t('Date')
case 'select':
return t('Choice')
default:
return t('Text')
}
}
function present<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
@@ -78,10 +49,10 @@ function defaultRule(field: BuiltinField) {
function constraintRule(field: BuiltinField) {
if (field.type === 'text' || field.type === 'richtext') {
return lengthRule(field.min, field.max)
return lengthRule(asNumber(field.min), asNumber(field.max))
}
if (field.type === 'number') {
return rangeRule(field.min, field.max)
return rangeRule(asNumber(field.min), asNumber(field.max))
}
if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) {
return t('{0} choices', [field.options.length])
+1 -1
View File
@@ -1,5 +1,5 @@
import { t, formatNumber } from '@/i18n'
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
import type { BuiltinType, ContentType } from '@/lib/contentTypes'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export const SCHEMA_TABLE_QUERY = '(min-width: 40rem)'
+12
View File
@@ -107,6 +107,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/ContentListView.vue'),
meta: { auth: 'verified', title: 'Content' },
},
{
path: '/content/:key/new',
name: 'entry-new',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
},
{
path: '/content/:key/:id',
name: 'entry-edit',
component: () => import('@/views/EntryEditorView.vue'),
meta: { auth: 'verified', title: 'Content' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, watch } from 'vue'
import ContentEntries from '@/components/ContentEntries.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useContentListPage } from '@/composables/useContentListPage'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import { useSessionStore } from '@/stores/session'
const props = defineProps<{ typeKey: string }>()
const session = useSessionStore()
const detail = useContentTypeDetail(
() => props.typeKey,
() => session.status === 'ready',
)
const page = useContentListPage(props.typeKey, detail, () => session.canManageContent)
const notFound = computed(() => page.view.value === 'notFound')
const centered = computed(() => page.centered.value)
const displayName = computed(() =>
notFound.value ? undefined : (detail.type.value?.name ?? props.typeKey),
)
const mobileTitle = computed(() => displayName.value ?? t('Content'))
const breadcrumb = computed(() => [
{ label: t('Content') },
...(displayName.value ? [{ label: displayName.value }] : []),
])
watch(
() => session.status === 'ready',
(ready, wasReady) => {
if (ready && !wasReady) {
page.list.start()
}
},
{ immediate: true },
)
</script>
<template>
<DashboardShell
compact-padding
:full-bleed="centered"
:mobile-title="mobileTitle"
:no-current-nav="notFound"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<ContentEntries v-if="session.status === 'ready'" :page="page" />
</DashboardShell>
</template>
+3 -55
View File
@@ -1,64 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import ContentEntries from '@/components/ContentEntries.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import type { ContentListState } from '@/lib/contentListState'
import { useSessionStore } from '@/stores/session'
import ContentListPage from '@/views/ContentListPage.vue'
const route = useRoute()
const session = useSessionStore()
const key = computed(() => String(route.params.key))
const detail = useContentTypeDetail(() => key.value)
const listView = ref<ContentListState>('loading')
watch(key, () => {
listView.value = 'loading'
})
const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound')
const centered = computed(
() => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden',
)
const displayName = computed(() =>
notFound.value ? undefined : (detail.type.value?.name ?? key.value),
)
const mobileTitle = computed(() => displayName.value ?? t('Content'))
const breadcrumb = computed(() => [
{ label: t('Content') },
...(displayName.value ? [{ label: displayName.value }] : []),
])
</script>
<template>
<DashboardShell
compact-padding
:full-bleed="centered"
:mobile-title="mobileTitle"
:no-current-nav="notFound"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<ContentEntries
v-if="session.status === 'ready'"
:key="key"
:type-key="key"
:type="detail.type.value"
:type-status="detail.status.value"
:can-edit="session.canManageContent"
@view="listView = $event"
@retry-type="detail.retry"
/>
</DashboardShell>
<ContentListPage :key="key" :type-key="key" />
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EntryEditor from '@/components/EntryEditor.vue'
import { useEntryEditorPage } from '@/composables/useEntryEditorPage'
import type { EntryTarget } from '@/lib/entryEditorState'
import { contentPath } from '@/lib/routePaths'
const props = defineProps<{
typeKey: string
target: EntryTarget
adopt: (id: number | null) => void
}>()
const page = useEntryEditorPage(props.typeKey, props.target, props.adopt)
const listPath = computed(() => contentPath(props.typeKey))
const typeMissing = computed(() => page.phase.value === 'typeNotFound')
const centered = computed(() =>
['typeNotFound', 'entryNotFound', 'forbidden'].includes(page.phase.value),
)
const showsTitle = computed(() => page.phase.value === 'ready' && !centered.value)
const mobileTitle = computed(() => {
if (typeMissing.value || page.phase.value === 'forbidden') {
return t('Content')
}
return showsTitle.value ? page.title.value : page.typeName.value
})
const breadcrumb = computed(() => {
if (typeMissing.value) {
return [{ label: t('Content') }]
}
return [
{ label: t('Content') },
{ label: page.typeName.value, to: listPath.value },
...(showsTitle.value ? [{ label: page.title.value }] : []),
]
})
const showDelete = computed(() => !page.isDesktop.value && page.ready.value && !page.creating.value)
</script>
<template>
<DashboardShell
compact-padding
sticky-footer
:full-bleed="centered"
:mobile-title="mobileTitle"
:back-to="listPath"
:back-label="page.typeName.value"
:no-current-nav="typeMissing"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<template #navbar-right>
<UButton
v-if="showDelete"
type="button"
color="neutral"
variant="ghost"
icon="i-lucide-trash-2"
:disabled="page.saving.value"
:aria-label="t('Delete')"
class="size-11 justify-center text-muted"
:ui="{ leadingIcon: 'size-5' }"
@click="page.removal.open"
/>
</template>
<EntryEditor :page="page" />
</DashboardShell>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import { resolveEntryTarget } from '@/lib/entryEditorState'
import EntryEditorPage from '@/views/EntryEditorPage.vue'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const typeKey = computed(() => String(route.params.key))
const rawId = computed(() => (route.name === 'entry-new' ? 'new' : String(route.params.id)))
const target = computed(() => resolveEntryTarget(rawId.value))
const identity = computed(() => `${typeKey.value}/${rawId.value}`)
const instance = ref(0)
let adopted: string | null = null
function adopt(id: number | null) {
adopted = id === null ? null : `${typeKey.value}/${id}`
}
watch(identity, (next) => {
if (next !== adopted) {
instance.value++
}
adopted = null
})
</script>
<template>
<EntryEditorPage
v-if="session.status === 'ready'"
:key="instance"
:type-key="typeKey"
:target="target"
:adopt="adopt"
/>
<DashboardShell v-else compact-padding />
</template>
@@ -0,0 +1,226 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
MOBILE_WIDTH,
chip,
control,
entryWith,
fieldWrapper,
installSelectPolyfills,
mountEditor,
openChoices,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(unmountAll)
function preview() {
return document.body.querySelector('[data-testid="markdown-preview"]')
}
function tab(label: string) {
return [...document.body.querySelectorAll<HTMLElement>('[role="tab"]')].find(
(candidate) => text(candidate) === label,
)!
}
function toolbarButton(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)!
}
async function pressTab(label: string) {
const target = tab(label)
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
target.focus()
target.click()
await flushPromises()
}
describe('Form controls', () => {
it('uses one control per field type', async () => {
await mountEditor('/content/events/42')
expect(fieldWrapper('title').querySelector('input[type="text"]')).toBeTruthy()
expect(fieldWrapper('description').querySelector('textarea')).toBeTruthy()
expect(fieldWrapper('capacity').querySelector('input')).toBeTruthy()
expect(fieldWrapper('online').querySelector('[role="switch"]')).toBeTruthy()
expect(fieldWrapper('starts_on').querySelector('[role="spinbutton"]')).toBeTruthy()
expect(fieldWrapper('category').querySelector('[role="combobox"]')).toBeTruthy()
})
it('toggles the switch text between Off and On and marks the change', async () => {
await mountEditor('/content/events/42')
expect(text(fieldWrapper('online'))).toContain('Off')
fieldWrapper('online').querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
expect(text(fieldWrapper('online'))).toContain('On')
expect(text(chip())).toBe('Unsaved changes')
})
it('lists the choices in schema order with a None item for an optional select', async () => {
await mountEditor('/content/events/42')
const options = await openChoices('category')
expect(options.map((option) => text(option))).toEqual([
'None',
'Talk',
'Workshop',
'Meetup',
])
})
it('clears an optional select with None', async () => {
await mountEditor('/content/events/42')
const options = await openChoices('category')
options[0]!.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
options[0]!.click()
await flushPromises()
expect(text(fieldWrapper('category'))).toContain('Choose an option')
expect(text(chip())).toBe('Unsaved changes')
})
it('shows a counter while typing', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'Lime plaster workshop, two days')
expect(text(fieldWrapper('title'))).toContain('31 / 80')
expect(
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
).not.toContain('text-error')
})
it('has no counter for a text field without a max', async () => {
await mountEditor('/content/events/42', {
entry: () => entryWith({}),
type: () => ({
id: 1,
key: 'events',
name: 'Events',
icon: null,
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-09-01T09:30:00Z',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
],
}),
})
expect(fieldWrapper('title').querySelector('[data-testid="entry-counter"]')).toBeNull()
})
it('puts every field in one column below md', async () => {
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
const row = fieldWrapper('starts_on').parentElement!
expect(row.className).toContain('md:grid-cols-2')
expect(row.className).not.toMatch(/(^|\s)grid-cols-2/)
})
})
describe('4 Markdown preview', () => {
it('renders bold text and a list in the Preview tab and keeps the value', async () => {
await mountEditor('/content/events/42')
const value = control('description').value
await pressTab('Preview')
expect(preview()!.innerHTML).toContain('<strong>lime putty</strong>')
expect(preview()!.querySelectorAll('li')).toHaveLength(2)
expect(toolbarButton('Bold')).toBeNull()
expect(text(chip())).toBe('Saved')
await pressTab('Write')
expect(control('description').value).toBe(value)
expect(preview()).toBeNull()
expect(toolbarButton('Bold')).toBeTruthy()
})
it('shows raw HTML as text and keeps unsafe links inert', async () => {
await mountEditor('/content/events/42', {
entry: () =>
entryWith({
description: '<b>raw</b> [bad](javascript:alert(1)) [ok](https://example.com)',
}),
})
await pressTab('Preview')
expect(preview()!.querySelector('b')).toBeNull()
expect(text(preview())).toContain('<b>raw</b>')
const links = [...preview()!.querySelectorAll('a')]
expect(links.map((link) => link.getAttribute('href'))).toEqual(['https://example.com'])
})
it('reads Nothing to preview yet. when empty', async () => {
await mountEditor('/content/events/new')
await pressTab('Preview')
expect(text(preview())).toBe('Nothing to preview yet.')
})
it.each([
['Bold', '**lime**'],
['Italic', '_lime_'],
['Heading', '## lime'],
['Link', '[lime](url)'],
['List', '- lime'],
])('the %s button turns the selection into %s', async (label, result) => {
await mountEditor('/content/events/new')
await typeInto('description', 'lime')
const area = control('description') as HTMLTextAreaElement
area.focus()
area.setSelectionRange(0, 4)
toolbarButton(label).click()
await flushPromises()
expect(area.value).toBe(result)
expect(document.activeElement).toBe(area)
})
it('exposes the tabs as a tablist and names the toolbar buttons', async () => {
await mountEditor('/content/events/new')
expect(document.body.querySelector('[role="tablist"]')).toBeTruthy()
for (const label of ['Bold', 'Italic', 'Heading', 'Link', 'List']) {
expect(toolbarButton(label)).toBeTruthy()
}
})
})
describe('Accessibility', () => {
it('announces the chip politely and ties labels to controls', async () => {
await mountEditor('/content/events/42')
expect(chip()!.getAttribute('role')).toBe('status')
const label = fieldWrapper('title').querySelector('label')!
expect(label.getAttribute('for')).toBe(control('title').id)
})
})
@@ -0,0 +1,184 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
arrived,
button,
chip,
control,
entryWrites,
failWith,
failWithBody,
hasText,
main,
mountEditor,
networkFailure,
pending,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
async function openDialog() {
button('Delete')!.click()
await flushPromises()
}
function dialog() {
return document.body.querySelector('[role="alertdialog"]')
}
describe('13 Delete entry: confirm', () => {
it('opens the dialog with no request', async () => {
await mountEditor('/content/events/42')
await openDialog()
expect(text(dialog())).toContain('Delete this entry?')
expect(text(dialog())).toContain(
'"Lime plaster workshop" will be deleted from Events. This can\'t be undone.',
)
expect(button('Cancel')).toBeTruthy()
expect(button('Delete entry')).toBeTruthy()
expect(entryWrites).toEqual([])
})
it('closes on Cancel with nothing sent', async () => {
await mountEditor('/content/events/42')
await openDialog()
const cancel = [...dialog()!.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Cancel',
)!
cancel.click()
await flushPromises()
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(entryWrites).toEqual([])
})
it('returns focus to Delete after the dialog closes', async () => {
await mountEditor('/content/events/42')
const trigger = button('Delete')!
trigger.focus()
await openDialog()
expect(document.activeElement).not.toBe(button('Delete entry'))
const cancel = [...dialog()!.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Cancel',
)!
cancel.click()
await flushPromises()
await vi.waitFor(() => expect(document.activeElement).toBe(trigger))
})
it('locks the dialog while deleting and sends a single request', async () => {
const gate = pending()
await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
await openDialog()
button('Delete entry')!.click()
await flushPromises()
button('Deleting…')!.click()
await flushPromises()
expect(entryWrites).toEqual([
{ method: 'DELETE', url: '/v1/content-type/events/entry/42', body: {} },
])
expect(button('Deleting…')!.disabled).toBe(true)
expect(
[...dialog()!.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Cancel',
)!.disabled,
).toBe(true)
})
it('leaves for the list with a toast and no prompt on 204', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'Edited but not saved')
await openDialog()
expect(text(dialog())).toContain('"Lime plaster workshop" will be deleted')
button('Delete entry')!.click()
await arrived('/content/events')
expect(hasText('Entry deleted')).toBe(true)
expect(hasText('"Lime plaster workshop" was deleted from Events.')).toBe(true)
expect(hasText('Leave without saving?')).toBe(false)
})
it.each([
['entry_not_found', 'Entry not found'],
['content_type_not_found', 'Content type not found'],
])('shows the page for a 404 %s', async (body, shown) => {
await mountEditor('/content/events/42', { writeEntry: failWithBody(404, body) })
await openDialog()
button('Delete entry')!.click()
await flushPromises()
expect(dialog()).toBeNull()
expect(text(main())).toContain(shown)
})
it('shows the access page for a 403', async () => {
await mountEditor('/content/events/42', { writeEntry: failWith(403) })
await openDialog()
button('Delete entry')!.click()
await flushPromises()
expect(text(main())).toContain("You don't have access to Events")
})
it.each([
['500', failWith(500)],
['a network failure', networkFailure],
])('keeps the dialog with an alert on %s', async (_name, failure) => {
await mountEditor('/content/events/42', { writeEntry: failure })
await openDialog()
button('Delete entry')!.click()
await flushPromises()
expect(text(dialog())).toContain("Couldn't delete the entry")
expect(text(dialog())).toContain(
'Something went wrong on our side. Nothing was deleted. Try again in a moment.',
)
expect(button('Delete entry')!.disabled).toBe(false)
expect(
[...dialog()!.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Cancel',
)!.disabled,
).toBe(false)
})
it('shows the trash action on mobile that opens the same dialog', async () => {
await mountEditor('/content/events/42', { width: 390 })
document.body.querySelector<HTMLButtonElement>('button[aria-label="Delete"]')!.click()
await flushPromises()
expect(text(dialog())).toContain('Delete this entry?')
})
it('is disabled while an edit is saving', async () => {
const gate = pending()
const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
await setField(wrapper, 'capacity', 20)
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
expect(button('Delete')!.disabled).toBe(true)
expect(text(chip())).toBe('Saving…')
expect(control('title').disabled).toBe(true)
})
})
@@ -0,0 +1,197 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import {
answerWrite,
arrived,
button,
calls,
entryWrites,
failWith,
failWithBody,
messageUnder,
hasText,
main,
mountEditor,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
import { customTypes } from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(() => {
vi.restoreAllMocks()
return unmountAll()
})
describe('save refusals outside the form', () => {
it('shows the access page when the save answers 403', async () => {
const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(403) })
await setField(wrapper, 'capacity', 20)
await save()
expect(text(main())).toContain("You don't have access to Events")
})
it('shows nothing and keeps the form when the save answers 401', async () => {
const wrapper = await mountEditor('/content/events/42', { writeEntry: failWith(401) })
await setField(wrapper, 'capacity', 20)
await save()
expect(hasText("Couldn't save the entry")).toBe(false)
expect(hasText('Not saved')).toBe(false)
expect(document.body.querySelector('form')).toBeTruthy()
})
it('clears the duplicate alert once the next save goes through', async () => {
let attempts = 0
await mountEditor('/content/events/42', {
writeEntry: (write) => {
attempts++
if (attempts === 1) {
throw new ApiError(409, undefined, 'invalid_entry: title: unique')
}
return answerWrite(write)
},
})
await typeInto('title', 'Taken title')
await save()
expect(hasText('Change it and save again.')).toBe(true)
await save()
expect(entryWrites).toHaveLength(2)
expect(hasText('Change it and save again.')).toBe(false)
})
})
describe('required boolean', () => {
it('shows the required message only from a mapped server answer', async () => {
const wrapper = await mountEditor('/content/events/42', {
writeEntry: failWithBody(422, 'invalid_entry: online: required'),
})
await setField(wrapper, 'capacity', 20)
await save()
expect(entryWrites).toHaveLength(1)
expect(messageUnder('online')).toBe('Choose yes or no for Online.')
})
})
describe('schema changed notice', () => {
it.each([
['invalid_entry: room: required', 'Room'],
['invalid_entry: colour: unknown_field', 'Colour'],
])('names the field of %s as %s', async (body, name) => {
const wrapper = await mountEditor('/content/events/42', {
writeEntry: failWithBody(422, body),
})
await setField(wrapper, 'capacity', 20)
await save()
expect(text(document.body)).toContain(`a new required field, ${name}, was added`)
})
})
describe('author names across a save', () => {
it('keeps the names and looks nobody up again', async () => {
const wrapper = await mountEditor('/content/events/42')
const lookups = () => calls.filter((url) => url.startsWith('/v1/user/lookup')).length
expect(lookups()).toBe(1)
await setField(wrapper, 'capacity', 20)
await save()
expect(lookups()).toBe(1)
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Updated by Marta Alves',
)
})
})
describe('navigation after a save', () => {
it('replaces the URL after a create and never pushes', async () => {
const replace = vi.spyOn(router, 'replace')
const push = vi.spyOn(router, 'push')
const wrapper = await mountEditor('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
push.mockClear()
await save()
await arrived('/content/events/43')
expect(replace).toHaveBeenCalledWith('/content/events/43')
expect(push).not.toHaveBeenCalled()
})
it('releases the adopted route when the replace is cancelled', async () => {
let blocking = true
const remove = router.beforeEach((to) =>
blocking && to.path === '/content/events/43' ? false : true,
)
const wrapper = await mountEditor('/content/events/new')
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
await save()
expect(router.currentRoute.value.path).toBe('/content/events/new')
const before = calls.filter((url) => url.endsWith('/entry/43')).length
blocking = false
await router.push('/content/events/43')
await flushPromises()
remove()
expect(calls.filter((url) => url.endsWith('/entry/43')).length).toBe(before + 1)
})
it('leaves the URL alone after an edit', async () => {
const replace = vi.spyOn(router, 'replace')
const wrapper = await mountEditor('/content/events/42')
await setField(wrapper, 'capacity', 20)
await save()
expect(entryWrites).toHaveLength(1)
expect(replace).not.toHaveBeenCalled()
})
})
describe('content type load failure', () => {
it('names the type request and retries only the type', async () => {
let attempts = 0
await mountEditor('/content/events/42', {
type: (key) => {
attempts++
if (attempts === 1) {
throw new ApiError(500)
}
return customTypes.find((candidate) => candidate.key === key)
},
})
expect(text(main())).toContain("Couldn't load the entry")
expect(text(main())).toContain(
'GET /v1/content-type/events failed. Check your connection and try again.',
)
const entryRequests = calls.filter((url) => url.endsWith('/entry/42')).length
button('Try again')!.click()
await vi.waitFor(() => expect(document.body.querySelector('form')).toBeTruthy())
expect(attempts).toBe(2)
expect(calls.filter((url) => url.endsWith('/entry/42')).length).toBe(entryRequests)
})
})
@@ -0,0 +1,137 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import {
arrived,
button,
chip,
failWithBody,
hasText,
mountEditor,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
function breadcrumbLink(label: string) {
return [
...document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="breadcrumb"] a'),
].find((candidate) => text(candidate) === label)!
}
async function prompted() {
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
}
describe('Leaving with unsaved edits', () => {
it('navigates with no prompt when nothing changed', async () => {
await mountEditor('/content/events/42')
breadcrumbLink('Events').click()
await arrived('/content/events')
expect(hasText('Leave without saving?')).toBe(false)
})
it('prompts after an edit and stays on Keep editing', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'Changed')
breadcrumbLink('Events').click()
await prompted()
expect(hasText('Your changes to "Lime plaster workshop" haven\'t been saved.')).toBe(true)
button('Keep editing')!.click()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/content/events/42')
})
it('leaves on Discard changes', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'Changed')
breadcrumbLink('Events').click()
await prompted()
button('Discard changes')!.click()
await arrived('/content/events')
})
it('prompts for a typed value on a new entry but not for untouched defaults', async () => {
await mountEditor('/content/events/new')
breadcrumbLink('Events').click()
await arrived('/content/events')
expect(hasText('Leave without saving?')).toBe(false)
await router.push('/content/events/new')
await flushPromises()
await typeInto('title', 'x')
breadcrumbLink('Events').click()
await prompted()
})
it('prompts through Cancel too', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'Changed')
button('Cancel')!.click()
await prompted()
})
it('does not prompt after a save', async () => {
const wrapper = await mountEditor('/content/events/42')
await setField(wrapper, 'capacity', 20)
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
breadcrumbLink('Events').click()
await arrived('/content/events')
expect(hasText('Leave without saving?')).toBe(false)
})
it('prompts in the schema changed state with typed values', async () => {
const wrapper = await mountEditor('/content/events/new', {
writeEntry: failWithBody(422, 'invalid_entry: room: required'),
})
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
expect(text(chip())).toBe('Not saved')
breadcrumbLink('Events').click()
await prompted()
})
})
describe('Cancel history', () => {
it('goes back to the list with its query when it was the previous page', async () => {
await router.push('/content/events?sort=id:asc')
await mountEditor('/content/events/42')
await router.push('/content/events/42')
await flushPromises()
button('Cancel')!.click()
await arrived('/content/events?sort=id:asc')
})
it('navigates to the list when the previous page is something else', async () => {
await router.push('/home')
await mountEditor('/content/events/42')
button('Cancel')!.click()
await arrived('/content/events')
})
})
@@ -0,0 +1,363 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { flushPromises } from '@vue/test-utils'
import {
MOBILE_WIDTH,
arrived,
button,
calls,
chip,
control,
customTypes,
entry42,
entryWith,
eventsFields,
failWith,
failWithBody,
fieldWrapper,
hasText,
setField,
typeInto,
main,
mountEditor,
networkFailure,
pending,
text,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
function entryRequests() {
return calls.filter((url) => /\/entry\/\d+$/.test(url))
}
describe('1 New entry', () => {
it('shows the title, the subtitle, the chip and the actions', async () => {
await mountEditor('/content/events/new')
expect(text(main())).toContain('New entry Events · Fields marked * are required.')
expect(text(chip())).toBe('New entry')
expect(button('Cancel')).toBeTruthy()
expect(button('Save')!.disabled).toBe(false)
expect(button('Delete')).toBeUndefined()
expect(entryRequests()).toEqual([])
})
it('marks required labels and shows helps, placeholders and defaults', async () => {
await mountEditor('/content/events/new')
expect(text(fieldWrapper('title'))).toContain('Title *')
expect(text(fieldWrapper('starts_on'))).toContain('Starts on *')
expect(text(fieldWrapper('ends_on'))).not.toContain('*')
expect(text(fieldWrapper('title'))).toContain('Each entry needs a different title.')
expect(text(fieldWrapper('capacity'))).toContain('Between 1 and 5000.')
expect(control('capacity').getAttribute('placeholder')).toBe('Enter a number')
expect(text(fieldWrapper('category'))).toContain('Choose an option')
expect(text(fieldWrapper('starts_on'))).toContain('Pick a date')
expect(text(fieldWrapper('online'))).toContain('Off')
expect(text(fieldWrapper('online'))).toContain('Default')
expect(text(fieldWrapper('title'))).not.toContain('Default')
})
it('shows the footer of an unsaved entry', async () => {
await mountEditor('/content/events/new')
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
'Entry Not saved yet Created by – Updated by –',
)
})
it('reads Content, Events and New entry in the breadcrumb', async () => {
await mountEditor('/content/events/new')
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'Content Events New entry',
)
})
it('has no required marker for assistive technology', async () => {
await mountEditor('/content/events/new')
const star = fieldWrapper('title').querySelector('[aria-hidden="true"]')
expect(star?.textContent?.trim()).toBe('*')
expect(control('title').getAttribute('aria-required')).toBe('true')
})
})
describe('2 Editing', () => {
it('shows the entry, the chip, the actions and the footer', async () => {
await mountEditor('/content/events/42')
expect(text(main())).toContain('Lime plaster workshop Events · Entry 42')
expect(text(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'Content Events Lime plaster workshop',
)
expect(text(chip())).toBe('Saved')
expect(button('Delete')).toBeTruthy()
expect(button('Save')!.disabled).toBe(true)
expect(control('title').value).toBe('Lime plaster workshop')
expect(control('capacity').value).toBe('18')
expect(text(fieldWrapper('online'))).not.toContain('Default')
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toBe(
'Entry events / 42 Created by Inès Duarte · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
it('sends the entry request with the type request and the lookup once', async () => {
await mountEditor('/content/events/42')
expect(calls).toContain('/v1/content-type/events')
expect(calls).toContain('/v1/content-type/events/entry/42')
expect(calls.filter((url) => url.startsWith('/v1/user/lookup'))).toEqual([
'/v1/user/lookup?ids=4,3',
])
})
it('reads a dash and keeps the date when the lookup fails or lacks an id', async () => {
await mountEditor('/content/events/42', {
lookup: () => [{ id: 3, display_name: 'Marta Alves' }],
})
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Created by – · 12 Oct 2026, 09:14 Updated by Marta Alves · 15 Oct 2026, 11:02',
)
})
it('reads dashes when the lookup fails', async () => {
await mountEditor('/content/events/42', { lookup: failWith(500) })
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Updated by – · 15 Oct 2026, 11:02',
)
})
it('shows a trash action on mobile and no Delete button', async () => {
await mountEditor('/content/events/42', { width: MOBILE_WIDTH })
expect(button('Delete')).toBeUndefined()
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeTruthy()
expect(button('Cancel')).toBeTruthy()
expect(button('Save')!.disabled).toBe(true)
})
it('shows the new entry layout on mobile without an action', async () => {
await mountEditor('/content/events/new', { width: MOBILE_WIDTH })
expect(document.body.querySelector('button[aria-label="Delete"]')).toBeNull()
expect(button('Cancel')).toBeTruthy()
expect(text(chip())).toBe('New entry')
})
})
describe('The page title', () => {
it.each([
['text', { title: 'Lime plaster workshop' }, 'Lime plaster workshop'],
['null', { title: null }, 'Entry 42'],
['number', { title: 18 }, '18'],
])('reads %s', async (_name, data, title) => {
await mountEditor('/content/events/42', { entry: () => entryWith(data) })
expect(text(main().querySelector('h1'))).toBe(title)
})
it('reads the first line of rich text', async () => {
const types = customTypes.map((type) =>
type.key === 'events'
? { ...type, fields: [{ ...eventsFields[6]!, key: 'body', label: 'Body' }] }
: type,
)
await mountEditor('/content/events/42', {
type: () => types[0],
entry: () => entryWith({ body: 'First line\n\nSecond' }),
})
expect(text(main().querySelector('h1'))).toBe('First line')
})
})
describe('Defaults', () => {
it.each([
['Capacity', 'capacity', { default: 100 }, '100'],
['Title', 'title', { default: 'Untitled' }, 'Untitled'],
])('pre-fills and tags %s', async (_label, key, rest, shown) => {
const fields = eventsFields.map((field) =>
field.key === key ? { ...field, ...rest } : field,
)
await mountEditor('/content/events/new', {
type: () => ({ ...customTypes[0]!, fields }),
})
expect(control(key).value).toBe(shown)
expect(text(fieldWrapper(key))).toContain('Default')
})
})
describe('Create-only defaults', () => {
const withDefaults = eventsFields.map((field) =>
field.key === 'capacity'
? { ...field, default: 100 }
: field.key === 'title'
? { ...field, default: 'Untitled' }
: field,
)
const type = () => ({ ...customTypes[0]!, fields: withDefaults })
it('pre-fills and tags every default on a new entry', async () => {
await mountEditor('/content/events/new', { type })
expect(control('capacity').value).toBe('100')
expect(control('title').value).toBe('Untitled')
expect(text(fieldWrapper('capacity'))).toContain('Default')
expect(text(fieldWrapper('title'))).toContain('Default')
})
it('never pre-fills or tags a default on an existing entry', async () => {
await mountEditor('/content/events/42', {
type,
entry: () => entryWith({ capacity: null, title: 'Stored' }),
})
expect(control('capacity').value).toBe('')
expect(control('title').value).toBe('Stored')
expect(text(main())).not.toContain('Default')
})
})
describe('Session not ready', () => {
it('shows the shell and issues no request', async () => {
await mountEditor('/content/events/42', { status: 'checking' })
expect(calls.filter((url) => url.includes('/entry') || url.includes('lookup'))).toEqual([])
expect(document.body.querySelector('[data-entry-field]')).toBeNull()
})
})
describe('Navigating between entries', () => {
const entry = (_key: string, id: number) =>
entryWith({ title: `Entry title ${id}`, capacity: id }, { id })
it('carries no value, error or chip from /42 to /43', async () => {
const wrapper = await mountEditor('/content/events/42', {
entry,
writeEntry: failWith(500),
})
await setField(wrapper, 'capacity', 20)
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
await setField(wrapper, 'capacity', 42)
expect(hasText("Couldn't save the entry")).toBe(true)
await router.push('/content/events/43')
await flushPromises()
expect(control('title').value).toBe('Entry title 43')
expect(control('capacity').value).toBe('43')
expect(hasText("Couldn't save the entry")).toBe(false)
expect(text(chip())).toBe('Saved')
expect(text(main().querySelector('h1'))).toBe('Entry title 43')
})
it('carries nothing from an entry to another type new form', async () => {
await mountEditor('/content/events/42', { entry })
await typeInto('title', 'Typed here')
void router.push('/content/recipes/new')
await vi.waitFor(() => expect(hasText('Leave without saving?')).toBe(true))
button('Discard changes')!.click()
await arrived('/content/recipes/new')
await flushPromises()
expect(control('title').value).toBe('')
expect(text(chip())).toBe('New entry')
expect(text(main().querySelector('h1'))).toBe('New entry')
expect(hasText('Typed here')).toBe(false)
})
})
describe('Entry not found and load failures', () => {
it.each(['abc', '4294967296', '-1'])(
'shows Entry not found for /content/events/%s with no entry request',
async (id) => {
await mountEditor(`/content/events/${id}`)
expect(text(main())).toContain('Entry not found')
expect(text(main())).toContain('It may have been deleted by someone else.')
expect(entryRequests()).toEqual([])
expect(button('Back to Events')).toBeTruthy()
},
)
it('sends id 0 as is and shows Entry not found when the API answers 404', async () => {
await mountEditor('/content/events/0', { entry: failWithBody(404, 'entry_not_found') })
expect(entryRequests()).toEqual(['/v1/content-type/events/entry/0'])
expect(text(main())).toContain('Entry not found')
})
it('shows Entry not found on 404 entry_not_found and goes back to the list', async () => {
await mountEditor('/content/events/999', { entry: failWithBody(404, 'entry_not_found') })
expect(text(main())).toContain('Entry not found')
button('Back to Events')!.click()
await arrived('/content/events')
})
it('shows the content type page on 404 content_type_not_found', async () => {
await mountEditor('/content/events/42', {
type: failWithBody(404, 'content_type_not_found'),
entry: failWithBody(404, 'content_type_not_found'),
})
expect(text(main())).toContain('Content type not found')
})
it('shows the access page on 403', async () => {
await mountEditor('/content/events/42', { entry: failWith(403) })
expect(text(main())).toContain("You don't have access to Events")
})
it.each([
['500', failWith(500)],
['a network failure', networkFailure],
])('shows the load error on %s and retries only the entry', async (_name, failure) => {
let attempts = 0
await mountEditor('/content/events/42', {
entry: () => {
attempts++
if (attempts === 1) {
return failure()
}
return entry42
},
})
expect(text(main())).toContain("Couldn't load the entry")
expect(text(main())).toContain(
'GET /v1/content-type/events/entry/42 failed. Check your connection and try again.',
)
expect(document.body.querySelector('form')).toBeNull()
const typeRequests = calls.filter((url) => url === '/v1/content-type/events').length
button('Try again')!.click()
await vi.waitFor(() => expect(control('title').value).toBe('Lime plaster workshop'))
expect(calls.filter((url) => url === '/v1/content-type/events').length).toBe(typeRequests)
})
it('shows a skeleton with aria-busy while loading', async () => {
const gate = pending()
await mountEditor('/content/events/42', { entry: () => gate.promise })
const skeleton = document.body.querySelector('[data-testid="entry-editor-skeleton"]')
expect(skeleton?.getAttribute('aria-busy')).toBe('true')
expect(button('Save')).toBeUndefined()
expect(button('Delete')).toBeUndefined()
})
})
@@ -0,0 +1,506 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { formatClock } from '@/lib/updateFormat'
import {
answerWrite,
arrived,
button,
calls,
chip,
control,
customTypes,
entryWith,
eventsFields,
entryWrites,
failWith,
failWithBody,
fieldWrapper,
hasText,
messageUnder,
mountEditor,
networkFailure,
pending,
save,
setField,
text,
typeInto,
unmountAll,
} from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountAll)
const SAVED_CLOCK = formatClock(new Date('2026-10-19T11:02:00'))
async function fillRequired(wrapper: Awaited<ReturnType<typeof mountEditor>>) {
await typeInto('title', 'Open day')
await setField(wrapper, 'starts_on', '2026-11-21')
}
describe('Payloads', () => {
it('sends every field of a create, with explicit nulls', async () => {
const wrapper = await mountEditor('/content/events/new')
await fillRequired(wrapper)
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]).toMatchObject({
method: 'POST',
url: '/v1/content-type/events/entry',
body: {
data: {
title: 'Open day',
starts_on: '2026-11-21',
ends_on: null,
online: false,
capacity: null,
category: null,
description: null,
},
},
})
})
it('sends every field of an edit with the unchanged loaded values', async () => {
const wrapper = await mountEditor('/content/events/42', {
entry: () => entryWith({ removed: 'x' }),
})
await setField(wrapper, 'capacity', 20)
await save()
expect(entryWrites).toHaveLength(1)
expect(entryWrites[0]).toMatchObject({
method: 'PUT',
url: '/v1/content-type/events/entry/42',
})
const data = entryWrites[0]!.body.data as Record<string, unknown>
expect(data.capacity).toBe(20)
expect(data.title).toBe('Lime plaster workshop')
expect(Object.keys(data)).toEqual([
'title',
'starts_on',
'ends_on',
'online',
'capacity',
'category',
'description',
])
})
it('keeps leading and trailing spaces of a text', async () => {
const wrapper = await mountEditor('/content/events/new')
await fillRequired(wrapper)
await typeInto('title', ' spaced ')
await save()
expect((entryWrites[0]!.body.data as Record<string, unknown>).title).toBe(' spaced ')
})
})
describe('Untouched optional boolean', () => {
const noDefault = [
...eventsFields.map((field) =>
field.key === 'online' ? { ...field, default: null } : field,
),
]
it('sends a create with a null boolean and shows no type message', async () => {
const wrapper = await mountEditor('/content/events/new', {
type: () => ({ ...customTypes[0]!, fields: noDefault }),
})
await fillRequired(wrapper)
await save()
expect(entryWrites).toHaveLength(1)
expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull()
expect(messageUnder('online')).toBe('')
expect(hasText('Choose yes or no')).toBe(false)
})
it('sends an edit with a stored null boolean and shows no type message', async () => {
const wrapper = await mountEditor('/content/events/42', {
entry: () => entryWith({ online: null }),
})
await setField(wrapper, 'capacity', 20)
await save()
expect(entryWrites).toHaveLength(1)
expect((entryWrites[0]!.body.data as Record<string, unknown>).online).toBeNull()
expect(messageUnder('online')).toBe('')
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
})
})
describe('The heading while typing', () => {
function headings() {
return [
text(document.body.querySelector('#dashboard-content h1')),
text(document.body.querySelector('nav[aria-label="breadcrumb"]')),
]
}
it('keeps the h1 and the breadcrumb until a save answers', async () => {
await mountEditor('/content/events/42')
const before = headings()
expect(before[0]).toBe('Lime plaster workshop')
await typeInto('title', 'Brand new name')
expect(headings()).toEqual(before)
await save()
expect(headings()[0]).toBe('Brand new name')
expect(headings()[1]).toContain('Brand new name')
})
})
describe('5 Saving', () => {
it('dims the form, disables the controls and sends one request', async () => {
const gate = pending()
const wrapper = await mountEditor('/content/events/42', { writeEntry: () => gate.promise })
await setField(wrapper, 'capacity', 20)
await save()
await save()
control('title')
.closest('form')!
.dispatchEvent(new Event('submit', { cancelable: true }))
await flushPromises()
expect(entryWrites).toHaveLength(1)
expect(text(chip())).toBe('Saving…')
expect(button('Saving…')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(true)
expect(button('Delete')!.disabled).toBe(true)
expect(control('title').disabled).toBe(true)
expect(document.body.querySelector('form')!.className).toContain('opacity-70')
expect(document.body.querySelector('form')!.getAttribute('aria-busy')).toBe('true')
})
})
describe('6 Saved', () => {
it('shows the toast, the chip and the answered values after an edit', async () => {
const wrapper = await mountEditor('/content/events/42')
await typeInto('title', 'Lime plaster workshop, two days')
await save()
expect(hasText('Entry saved')).toBe(true)
expect(hasText('"Lime plaster workshop, two days" is up to date.')).toBe(true)
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
expect(button('Save')!.disabled).toBe(true)
expect(text(document.body.querySelector('h1'))).toBe('Lime plaster workshop, two days')
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'Updated by Marta Alves · 19 Oct 2026, 11:02',
)
await setField(wrapper, 'capacity', 21)
expect(text(chip())).toBe('Unsaved changes')
})
it('replaces the URL after a create without a new entry request', async () => {
const wrapper = await mountEditor('/content/events/new')
await fillRequired(wrapper)
const before = calls.length
await save()
await arrived('/content/events/43')
expect(calls.slice(before).filter((url) => /\/entry\/\d+$/.test(url))).toEqual([])
expect(calls.slice(before).filter((url) => url === '/v1/content-type/events')).toEqual([])
expect(text(chip())).toBe(`Saved at ${SAVED_CLOCK}`)
expect(button('Delete')).toBeTruthy()
expect(text(document.body.querySelector('[data-testid="entry-meta"]'))).toContain(
'events / 43',
)
})
it('reverting an edit returns to Saved and disables Save', async () => {
await mountEditor('/content/events/42')
await typeInto('title', 'x')
expect(text(chip())).toBe('Unsaved changes')
expect(button('Save')!.disabled).toBe(false)
await typeInto('title', 'Lime plaster workshop')
expect(text(chip())).toBe('Saved')
expect(button('Save')!.disabled).toBe(true)
})
})
describe('7 Save failed', () => {
it.each([
['500', failWith(500)],
['a network failure', networkFailure],
['400', failWith(400)],
['422 with no field', failWithBody(422, 'invalid_entry')],
])('shows the alert on %s and sends the same body again', async (_name, failure) => {
let attempts = 0
const wrapper = await mountEditor('/content/events/42', {
writeEntry: (write) => {
attempts++
return attempts === 1 ? failure() : answerWrite(write)
},
})
await setField(wrapper, 'capacity', 20)
await save()
expect(text(document.body)).toContain("Couldn't save the entry")
expect(text(document.body)).toContain(
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
expect(text(chip())).toBe('Save failed')
expect(button('Save')!.disabled).toBe(false)
expect(document.body.querySelector('form')!.className).not.toContain('opacity-70')
button('Try again')!.click()
await flushPromises()
expect(entryWrites).toHaveLength(2)
expect(entryWrites[1]!.body).toEqual(entryWrites[0]!.body)
expect(text(document.body)).not.toContain("Couldn't save the entry")
})
})
describe('8 Required fields empty', () => {
it('sends nothing, shows every message and focuses the first field', async () => {
await mountEditor('/content/events/new')
await save()
expect(entryWrites).toEqual([])
expect(messageUnder('title')).toBe('Fill in Title.')
expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.')
expect(text(chip())).toBe('Not saved')
expect(document.activeElement).toBe(control('title'))
expect(control('title').getAttribute('aria-invalid')).toBe('true')
expect(control('title').getAttribute('aria-describedby')).toBeTruthy()
})
it('clears a message when its field is edited and validates nothing on blur', async () => {
await mountEditor('/content/events/new')
await save()
await typeInto('title', 'Open day')
expect(messageUnder('title')).toBe('')
expect(messageUnder('starts_on')).toBe('Pick a date for Starts on.')
expect(text(chip())).toBe('Unsaved changes')
control('title').dispatchEvent(new Event('blur'))
await flushPromises()
expect(messageUnder('title')).toBe('')
})
})
describe('9 Rule errors', () => {
it('shows one message per field with the counter', async () => {
const wrapper = await mountEditor('/content/events/42')
await typeInto('title', 'x'.repeat(91))
await setField(wrapper, 'capacity', 6000)
await setField(wrapper, 'category', 'Seminar')
await save()
expect(entryWrites).toEqual([])
expect(messageUnder('title')).toBe('Title can be up to 80 characters.')
expect(messageUnder('capacity')).toBe('Capacity must be at most 5000.')
expect(messageUnder('category')).toBe('Choose one of: Talk, Workshop or Meetup.')
expect(text(fieldWrapper('title'))).toContain('91 / 80')
expect(
fieldWrapper('title').querySelector('[data-testid="entry-counter"]')!.className,
).toContain('text-error')
expect(text(chip())).toBe('Not saved')
expect(document.activeElement).toBe(control('title'))
})
it.each([
['title', 'min', 'Title needs at least 3 characters.'],
['capacity', 'max', 'Capacity must be at most 5000.'],
['ends_on', 'type', 'Ends on must be a date, like 2026-11-21.'],
['category', 'options', 'Choose one of: Talk, Workshop or Meetup.'],
['title', 'required', 'Fill in Title.'],
])('maps a server answer %s %s onto its field', async (key, rule, message) => {
const wrapper = await mountEditor('/content/events/42', {
writeEntry: failWithBody(422, `invalid_entry: ${key}: ${rule}`),
type: () => ({
id: 1,
key: 'events',
name: 'Events',
icon: null,
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-09-01T09:30:00Z',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: false,
default: null,
min: 3,
max: 80,
options: [],
},
{
key: 'ends_on',
label: 'Ends on',
type: 'date',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
{
key: 'capacity',
label: 'Capacity',
type: 'number',
required: false,
unique: false,
default: null,
min: 1,
max: 5000,
options: [],
},
{
key: 'category',
label: 'Category',
type: 'select',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
},
],
}),
entry: () => entryWith({}),
})
await setField(wrapper, 'title', 'Open day')
await save()
expect(entryWrites).toHaveLength(1)
expect(messageUnder(key)).toBe(message)
expect(text(chip())).toBe('Not saved')
})
})
describe('10 Duplicate unique value (409)', () => {
it('shows the alert and the field message, and clears the alert on edit', async () => {
const wrapper = await mountEditor('/content/events/new', {
writeEntry: failWithBody(409, 'invalid_entry: title: unique'),
})
await fillRequired(wrapper)
await typeInto('title', 'Heat pumps: what the numbers say')
await save()
expect(text(document.body)).toContain(
'Another entry already uses this title. Change it and save again.',
)
expect(messageUnder('title')).toBe(
'Another entry already uses this Title. Enter a different one.',
)
expect(text(chip())).toBe('Not saved')
expect(document.activeElement).toBe(control('title'))
expect(control('title').value).toBe('Heat pumps: what the numbers say')
await typeInto('title', 'Heat pumps')
expect(text(document.body)).not.toContain('Change it and save again.')
})
})
describe('11 Entry not found on save', () => {
it('shows Entry not found when the PUT answers 404 entry_not_found', async () => {
const wrapper = await mountEditor('/content/events/42', {
writeEntry: failWithBody(404, 'entry_not_found'),
})
await setField(wrapper, 'capacity', 20)
await save()
expect(text(document.body)).toContain('Entry not found')
button('Back to Events')!.click()
await arrived('/content/events')
})
})
describe('12 Schema changed since load', () => {
it.each([
['invalid_entry: room: required', 422],
['invalid_entry: description: unknown_field', 422],
['invalid_entry: colour: unknown_field', 422],
['invalid_entry: room: unique', 409],
])('shows the read-only form on %s', async (body, status) => {
const wrapper = await mountEditor('/content/events/new', {
writeEntry: failWithBody(status, body),
})
await fillRequired(wrapper)
await save()
expect(text(document.body)).toContain('The fields of Events changed')
expect(text(chip())).toBe('Not saved')
expect(button('Save')!.disabled).toBe(true)
expect(button('Cancel')!.disabled).toBe(false)
expect(control('title').disabled).toBe(true)
expect(control('title').value).toBe('Open day')
})
it('rebuilds the form from the new type when Reload form is pressed', async () => {
let reloads = 0
const wrapper = await mountEditor('/content/events/new', {
writeEntry: failWithBody(422, 'invalid_entry: room: required'),
type: (key) => {
reloads++
return {
id: 1,
key,
name: 'Events',
icon: null,
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-09-01T09:30:00Z',
fields: [
{
key: 'title',
label: 'Title',
type: 'text',
required: true,
unique: false,
default: null,
min: null,
max: null,
options: [],
},
],
}
},
})
await typeInto('title', 'Open day')
await save()
expect(reloads).toBe(1)
button('Reload form')!.click()
await flushPromises()
expect(reloads).toBe(2)
expect(control('title').value).toBe('')
expect(text(chip())).toBe('New entry')
expect(text(document.body)).not.toContain('The fields of Events changed')
expect(wrapper.exists()).toBe(true)
})
})
+1 -2
View File
@@ -150,7 +150,7 @@ export function entry(row: Row) {
] = row
return {
id,
content_type_id: 1,
content_type: 'events',
data: {
title,
starts_on: startsOn,
@@ -181,7 +181,6 @@ export function listPage(items: unknown[], total: number, query: URLSearchParams
total,
page: Number(query.get('page')),
size: Number(query.get('size')),
sort: query.get('sort'),
}
}
+157
View File
@@ -0,0 +1,157 @@
import router from '@/router'
import { expect, vi } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import EntryField from '@/components/EntryField.vue'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { mountAt, text, type EntryWrite, type SchemaMount } from './schemaTestKit'
export {
button,
calls,
customTypes,
entryWrites,
failWith,
failWithBody,
networkFailure,
pending,
text,
unmountAll,
} from './schemaTestKit'
export { MOBILE_WIDTH } from '@/components/__tests__/viewport'
export { eventsFields } from '@/lib/__tests__/schemaFixtures'
addCollection(lucide)
export const MARTA = 3
export const INES = 4
export const JONAS = 5
export const entry42 = {
id: 42,
content_type: 'events',
data: {
title: 'Lime plaster workshop',
starts_on: '2026-11-21',
ends_on: '2026-11-22',
online: false,
capacity: 18,
category: 'Workshop',
description:
'Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included',
},
created_by: INES,
updated_by: MARTA,
created_at: '2026-10-12T09:14:00',
updated_at: '2026-10-15T11:02:00',
}
export const userNames = [
{ id: INES, display_name: 'Inès Duarte' },
{ id: MARTA, display_name: 'Marta Alves' },
{ id: JONAS, display_name: 'Jonas Pereira' },
]
export function lookupNames(ids: number[]) {
return userNames.filter((user) => ids.includes(user.id))
}
export function entryWith(data: Record<string, unknown>, rest: Record<string, unknown> = {}) {
return { ...entry42, ...rest, data: { ...entry42.data, ...data } }
}
export function answerWrite(write: EntryWrite) {
const data = write.body.data as Record<string, unknown>
const base = write.method === 'POST' ? { ...entry42, id: 43 } : entry42
return {
...base,
data,
updated_by: MARTA,
updated_at: '2026-10-19T11:02:00',
}
}
export function mountEditor(path: string, options: SchemaMount = {}) {
return mountAt(path, {
entry: () => entry42,
writeEntry: answerWrite,
lookup: lookupNames,
...options,
})
}
export async function settle() {
await flushPromises()
}
export function fieldWrapper(key: string) {
return document.body.querySelector<HTMLElement>(`[data-entry-field="${key}"]`)!
}
export function control(key: string) {
return fieldWrapper(key).querySelector<HTMLInputElement | HTMLTextAreaElement>(
'input, textarea',
)!
}
export async function typeInto(key: string, value: string) {
const element = control(key)
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export function chip() {
return document.body.querySelector('[data-testid="builder-chip"]')
}
export async function save() {
document.body.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
await flushPromises()
}
export function setField(wrapper: VueWrapper, key: string, value: unknown) {
const owner = wrapper
.findAllComponents(EntryField)
.find((candidate) => candidate.props('field').key === key)!
owner.vm.$emit('update', key, value)
return flushPromises()
}
export function main() {
return document.body.querySelector('#dashboard-content')!
}
export function toast() {
return text(document.body.querySelector('[data-part="root"], [role="status"][data-state]'))
}
export function hasText(value: string) {
return text(document.body).includes(value)
}
export function messageUnder(key: string) {
return text(fieldWrapper(key).querySelector('[role="alert"]'))
}
export async function arrived(path: string) {
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path))
}
export function installSelectPolyfills() {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
}
export async function openChoices(key: string) {
const trigger = fieldWrapper(key).querySelector('[role="combobox"]')!
trigger.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
await flushPromises()
return [...document.body.querySelectorAll<HTMLElement>('[role="option"]')]
}
+30 -2
View File
@@ -134,7 +134,15 @@ function defaultType(key: string) {
return found
}
export interface EntryWrite {
method: string
url: string
body: Record<string, unknown>
}
export interface SchemaMount {
entry?: (key: string, id: number) => unknown
writeEntry?: (write: EntryWrite) => unknown
role?: string
create?: (body: PostedRequest['body']) => unknown
update?: (body: PostedRequest['body'], key: string) => unknown
@@ -144,6 +152,7 @@ export interface SchemaMount {
content?: Responder
builtin?: Responder
verified?: boolean
status?: 'checking' | 'ready'
actions?: string[]
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
@@ -156,6 +165,8 @@ export const calls: string[] = []
export const posts: PostedRequest[] = []
export const puts: PostedRequest[] = []
export const deletes: string[] = []
export const entryWrites: EntryWrite[] = []
const ENTRY_URL = /^\/v1\/content-type\/[^/?]+\/entry(\/\d+)?$/
export function updatedType(key: string, body: Record<string, unknown>) {
const base = customTypes.find((type) => type.key === key)!
@@ -167,6 +178,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
posts.length = 0
puts.length = 0
deletes.length = 0
entryWrites.length = 0
localStorage.clear()
options.before?.()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
@@ -187,8 +199,17 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
session.status = options.status ?? 'ready'
const request = vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method && ENTRY_URL.test(url)) {
const write = {
method: init.method,
url,
body: init.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
}
entryWrites.push(write)
return options.writeEntry?.(write)
}
if (init?.method === 'DELETE') {
deletes.push(url)
return (options.remove ?? (() => undefined))(url.split('/').pop()!)
@@ -223,6 +244,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
if (looked) {
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
}
const single = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\/(\d+)$/)
if (single) {
return options.entry?.(single[1]!, Number(single[2]))
}
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
if (detailed) {
return (options.type ?? defaultType)(detailed[1]!)
@@ -233,7 +258,10 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
return undefined
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
getMe:
options.status === 'checking'
? vi.fn(() => new Promise(() => undefined))
: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)