feat: entry editor at /content/:key/new and /content/:key/:id
This commit is contained in:
1 parent
91d916fb74
commit
fba20f83c5
94 files changed
+7116
-423
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ describe('richTextFirstLine', () => {
|
||||
['', '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)
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('<script>')
|
||||
})
|
||||
|
||||
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('')).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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { ContentType } from '@/composables/useContentTypes'
|
||||
import type { ContentType } from '@/lib/contentTypes'
|
||||
|
||||
export const COLLAPSED_LIMIT = 5
|
||||
|
||||
|
||||
@@ -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
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, '- ')
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { BuiltinField, ContentType } from '@/composables/useContentTypes'
|
||||
import type { BuiltinField, ContentType } from '@/lib/contentTypes'
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
createField,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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)'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]')]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user