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

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

No files matched your search

+7 -1
View File
@@ -4,7 +4,7 @@ import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique'
kind: 'new' | 'changed' | 'cause' | 'required' | 'unique' | 'default'
compact?: boolean
}>()
@@ -34,6 +34,12 @@ const badges = {
},
class: 'border border-accented bg-transparent text-toned',
},
default: {
get label() {
return t('Default')
},
class: 'border border-accented bg-transparent text-toned',
},
unique: {
get label() {
return t('Unique')
+33 -16
View File
@@ -4,30 +4,43 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuilderStatus } from '@/lib/schemaBuilderModel'
const props = defineProps<{
status: BuilderStatus
problems: number
savedAt?: string
}>()
const props = withDefaults(
defineProps<{
status: BuilderStatus | 'new' | 'unsaved'
problems?: number
savedAt?: string
}>(),
{ problems: 0, savedAt: undefined },
)
const text = computed(() => {
switch (props.status) {
function chipText(
status: BuilderStatus | 'new' | 'unsaved',
problems: number,
savedAt: string | undefined,
): string {
switch (status) {
case 'saving':
return t('Saving…')
case 'offline':
return t('Waiting for connection')
case 'saved':
return props.savedAt ? t('Saved at {0}', [props.savedAt]) : t('Saved')
return savedAt ? t('Saved at {0}', [savedAt]) : t('Saved')
case 'failed':
return t('Save failed')
case 'new':
return t('New entry')
case 'unsaved':
return t('Not saved')
case 'problems':
return props.problems === 1
? t('1 problem to fix')
: t('{0} problems to fix', [props.problems])
default:
return problems === 1 ? t('1 problem to fix') : t('{0} problems to fix', [problems])
case 'dirty':
return t('Unsaved changes')
case 'none':
return ''
}
})
}
const text = computed(() => chipText(props.status, props.problems, props.savedAt))
</script>
<template>
@@ -38,8 +51,12 @@ const text = computed(() => {
class="inline-flex shrink-0 items-center gap-1.5 text-xs whitespace-nowrap"
:class="{
'text-warning': status === 'dirty',
'text-error': status === 'failed' || status === 'problems',
'text-muted': status === 'saving' || status === 'offline' || status === 'saved',
'text-error': status === 'failed' || status === 'problems' || status === 'unsaved',
'text-muted':
status === 'saving' ||
status === 'offline' ||
status === 'saved' ||
status === 'new',
}"
>
<UIcon
@@ -50,7 +67,7 @@ const text = computed(() => {
<UIcon v-else-if="status === 'offline'" name="i-lucide-wifi-off" class="size-3.5" />
<UIcon v-else-if="status === 'dirty'" name="i-lucide-circle-dot" class="size-3.5" />
<UIcon v-else-if="status === 'saved'" name="i-lucide-check" class="size-3.5" />
<UIcon v-else name="i-lucide-circle-alert" class="size-3.5" />
<UIcon v-else-if="status !== 'new'" name="i-lucide-circle-alert" class="size-3.5" />
<span>{{ text }}</span>
</span>
</template>
+5 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { fieldIcon } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { COLUMN_CAP } from '@/lib/entryColumns'
import type { BuiltinField } from '@/lib/contentTypes'
@@ -43,7 +43,10 @@ const ROW = 'flex items-center gap-2.5 px-2'
@update:model-value="emit('toggle', field.key, $event === true)"
>
<template #label>
<UIcon :name="fieldIcon(field.type)" class="size-3.5 shrink-0 text-muted" />
<UIcon
:name="fieldTypeSpec(field.type).icon"
class="size-3.5 shrink-0 text-muted"
/>
<span class="min-w-0 flex-1 truncate">{{ field.label }}</span>
<span class="font-mono text-xs text-muted">{{ field.key }}</span>
</template>
+28 -85
View File
@@ -1,9 +1,7 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { computed } from 'vue'
import AppPaginator from '@/components/AppPaginator.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ContentEmpty from '@/components/ContentEmpty.vue'
@@ -14,94 +12,39 @@ import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import EntryCardList from '@/components/EntryCardList.vue'
import EntrySkeleton from '@/components/EntrySkeleton.vue'
import EntryTable from '@/components/EntryTable.vue'
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
import { useEntryColumns } from '@/composables/useEntryColumns'
import { useEntryList } from '@/composables/useEntryList'
import { resolveContentListState, type ContentListState } from '@/lib/contentListState'
import { entryCaption } from '@/lib/entryListQuery'
import { fieldCountLabel } from '@/lib/schemaRows'
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
import type { ContentListPage } from '@/composables/useContentListPage'
import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DESKTOP_SKELETON_ROWS = 8
const MOBILE_SKELETON_ROWS = 6
const props = defineProps<{ page: ContentListPage }>()
const props = defineProps<{
typeKey: string
type: ContentType | null
typeStatus: ContentTypeDetailStatus
canEdit: boolean
}>()
const typeKey = props.page.typeKey
const {
list,
columns,
type,
typeStatus,
isDesktop,
view,
centered,
failed,
name,
fields,
subtitle,
editTo,
caption,
showSkeleton,
skeletonRows,
showBody,
createEntry,
goHome,
retry,
} = props.page
const emit = defineEmits<{ view: [view: ContentListState]; 'retry-type': [] }>()
const router = useRouter()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const list = useEntryList(props.typeKey)
const columns = useEntryColumns(props.typeKey, () => props.type?.fields ?? [])
const view = computed(() =>
resolveContentListState({
type: props.typeStatus,
entries: list.status.value,
total: list.total.value,
}),
)
const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound')
const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError')
const name = computed(() => props.type?.name ?? props.typeKey)
const fields = computed(() => props.type?.fields ?? [])
const subtitle = computed(() =>
props.type ? `${props.type.key} · ${fieldCountLabel(props.type.fields.length)}` : '',
)
const editTo = computed(() => (props.canEdit ? schemaPath(props.typeKey) : undefined))
const caption = computed(() => entryCaption(name.value, list.shown.value.sort))
const showSkeleton = computed(() =>
view.value === 'loading'
? list.showSkeleton.value || list.status.value === 'ready'
: view.value === 'list' && list.skeletonActive.value,
)
const skeletonRows = computed(() => {
if (view.value === 'list') {
return list.items.value.length
}
return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS
})
const showBody = computed(() => view.value === 'list' && !showSkeleton.value)
const singlePageLabel = computed(() =>
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
)
const typeErrorLine = computed(() =>
t('GET /v1/content-type/{0} failed. Check your connection and try again.', [props.typeKey]),
)
const entriesErrorLine = computed(() =>
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [
props.typeKey,
]),
)
function createEntry() {
router.push(entryPath(props.typeKey, 'new'))
}
function goHome() {
router.push(HOME_PATH)
}
function retry() {
if (view.value === 'typeError') {
emit('retry-type')
return
}
list.retry()
}
watch(view, (value) => emit('view', value), { immediate: true })
onMounted(() => {
list.start()
})
const typeErrorLine = computed(() => typeLoadErrorLine(typeKey))
const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
</script>
<template>
+25 -13
View File
@@ -3,11 +3,28 @@ import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>()
const props = defineProps<{ kind: 'forbidden' | 'notFound' | 'entryNotFound'; name: string }>()
const emit = defineEmits<{ home: [] }>()
const emit = defineEmits<{ home: []; back: [] }>()
const forbidden = computed(() => props.kind === 'forbidden')
const entryMissing = computed(() => props.kind === 'entryNotFound')
const title = computed(() => {
if (forbidden.value) {
return t("You don't have access to {0}", [props.name])
}
return entryMissing.value ? t('Entry not found') : t('Content type not found')
})
const description = computed(() => {
if (forbidden.value) {
return t(
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.",
)
}
return entryMissing.value
? t('It may have been deleted by someone else.')
: t('It may have been deleted, or the address may be mistyped.')
})
</script>
<template>
@@ -15,16 +32,8 @@ const forbidden = computed(() => props.kind === 'forbidden')
<UEmpty
variant="naked"
:icon="forbidden ? 'i-lucide-lock' : 'i-lucide-file-x'"
:title="
forbidden ? t('You don\'t have access to {0}', [name]) : t('Content type not found')
"
:description="
forbidden
? t(
'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.',
)
: t('It may have been deleted, or the address may be mistyped.')
"
:title="title"
:description="description"
:ui="{
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
@@ -33,7 +42,10 @@ const forbidden = computed(() => props.kind === 'forbidden')
}"
>
<template #actions>
<UButton class="h-8 px-4 max-md:h-11" @click="emit('home')">
<UButton v-if="entryMissing" class="h-8 px-4 max-md:h-11" @click="emit('back')">
{{ t('Back to {0}', [name]) }}
</UButton>
<UButton v-else class="h-8 px-4 max-md:h-11" @click="emit('home')">
{{ t('Go to Home') }}
</UButton>
</template>
+4
View File
@@ -51,12 +51,16 @@ const props = withDefaults(
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const COMPACT_STICKY_BODY = 'gap-0 px-4 pt-4 pb-0 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-0 lg:p-6'
const STICKY_FOOTER_BODY = 'gap-0 px-4 pt-5 pb-0 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-0 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
if (props.stickyFooter && props.compactPadding) {
return COMPACT_STICKY_BODY
}
if (props.stickyFooter) {
return STICKY_FOOTER_BODY
}
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
defineProps<{ page: EntryEditorPage; bar?: boolean }>()
</script>
<template>
<div
class="flex shrink-0 items-center"
:class="bar ? 'w-full gap-2' : 'gap-3'"
:data-testid="bar ? 'entry-bar' : 'entry-actions'"
>
<BuilderStatusChip
v-if="!bar"
:status="page.chip.value"
:saved-at="page.savedClock.value"
/>
<UButton
v-if="!bar && !page.creating.value"
type="button"
color="error"
variant="outline"
icon="i-lucide-trash-2"
:disabled="page.saving.value"
class="h-8 px-3"
@click="page.removal.open"
>
{{ t('Delete') }}
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
:disabled="page.saving.value"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:class="bar ? 'flex-1 justify-center' : ''"
@click="page.cancel"
>
{{ t('Cancel') }}
</UButton>
<UButton
type="submit"
form="entry-form"
:disabled="!page.canSave.value"
:loading="page.saving.value"
class="h-11 px-4 md:h-8"
:class="[
bar ? 'flex-1 justify-center' : '',
page.saving.value ? 'disabled:opacity-100' : '',
]"
>
{{ page.saving.value ? t('Saving…') : t('Save') }}
</UButton>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import EntryDateField from '@/components/EntryDateField.vue'
import MarkdownField from '@/components/MarkdownField.vue'
import AppSwitch from '@/components/AppSwitch.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { asText, freeSentinel } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const INPUT_UI = {
base: 'h-11 text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:text-sm',
}
const NUMBER_UI = {
base: 'h-11 pe-20 text-left text-lg disabled:bg-elevated disabled:opacity-100 md:h-8 md:pe-14 md:text-sm',
decrement: 'start-auto end-10 border-s border-default p-0 md:end-7',
increment: 'end-0 border-s border-default p-0',
}
const STEP_BUTTON = { class: 'size-10 justify-center rounded-none text-muted md:size-7' }
const NUMBER_FORMAT = { maximumFractionDigits: 20, useGrouping: false }
const props = defineProps<{
field: BuiltinField
value: unknown
invalid: boolean
disabled: boolean
}>()
const emit = defineEmits<{ update: [value: unknown] }>()
const control = computed(() => fieldTypeSpec(props.field.type).editor.control)
const color = computed(() => (props.invalid ? 'error' : undefined))
const required = computed(() => (props.field.required ? 'true' : undefined))
const optionLabels = computed(() => (props.field.options ?? []).map(String))
const noChoice = computed(() => freeSentinel(optionLabels.value))
const choices = computed(() => {
const items = optionLabels.value.map((option) => ({ label: option, value: option }))
return props.field.required ? items : [{ label: t('None'), value: noChoice.value }, ...items]
})
function onChoice(value: unknown) {
emit('update', value === noChoice.value || value === undefined ? '' : String(value))
}
function onNumber(value: number | null | undefined) {
emit('update', value === undefined || Number.isNaN(value) ? null : value)
}
</script>
<template>
<UInput
v-if="control === 'input'"
:model-value="asText(value)"
:color="color"
:disabled="disabled"
:aria-required="required"
class="w-full"
:ui="INPUT_UI"
@update:model-value="emit('update', String($event))"
/>
<MarkdownField
v-else-if="control === 'markdown'"
:model-value="asText(value)"
:invalid="invalid"
:disabled="disabled"
:required="field.required"
@update:model-value="emit('update', $event)"
/>
<UInputNumber
v-else-if="control === 'number'"
:model-value="typeof value === 'number' ? value : undefined"
:placeholder="t('Enter a number')"
:color="color"
:disabled="disabled"
:aria-required="required"
:format-options="NUMBER_FORMAT"
class="w-full"
:ui="NUMBER_UI"
:increment="STEP_BUTTON"
:decrement="STEP_BUTTON"
@update:model-value="onNumber"
/>
<div v-else-if="control === 'switch'" class="flex min-h-6 items-center gap-2.5">
<AppSwitch
:model-value="value === true"
:disabled="disabled"
:aria-required="required"
@update:model-value="emit('update', $event)"
/>
<span class="text-toned">{{ value === true ? t('On') : t('Off') }}</span>
</div>
<EntryDateField
v-else-if="control === 'date'"
:model-value="asText(value)"
:invalid="invalid"
:disabled="disabled"
:required="field.required"
@update:model-value="emit('update', $event)"
/>
<USelect
v-else
:model-value="value === '' || value === null ? undefined : String(value)"
:items="choices"
:placeholder="t('Choose an option')"
:color="color"
:disabled="disabled"
:aria-required="required"
class="w-full"
:ui="INPUT_UI"
@update:model-value="onChoice"
/>
</template>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { parseDate, type CalendarDate } from '@internationalized/date'
const props = defineProps<{
modelValue: string
disabled?: boolean
required?: boolean
invalid?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const focused = ref(false)
const calendarOpen = ref(false)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const date = computed(() => parse(props.modelValue))
const idle = computed(() => !props.modelValue && !focused.value)
function onDate(value: CalendarDate | null | undefined) {
emit('update:modelValue', value ? value.toString() : '')
}
function onCalendar(value: unknown) {
onDate(value as CalendarDate | null)
calendarOpen.value = false
}
</script>
<template>
<div class="relative w-full">
<UInputDate
:model-value="date"
:disabled="disabled"
:color="invalid ? 'error' : undefined"
:aria-required="required ? 'true' : undefined"
class="w-full"
:ui="{
base: 'h-11 w-full text-lg data-[disabled]:bg-elevated data-[disabled]:opacity-100 md:h-8 md:text-sm',
segment: idle ? 'text-transparent data-[placeholder]:text-transparent' : '',
}"
@update:model-value="onDate"
@focus="focused = true"
@blur="focused = false"
>
<template #trailing>
<UPopover v-model:open="calendarOpen" :content="{ align: 'end' }">
<UButton
type="button"
color="neutral"
variant="link"
icon="i-lucide-calendar"
:disabled="disabled"
:aria-label="t('Open calendar')"
class="size-8 justify-center text-muted max-md:size-11"
:ui="{ leadingIcon: 'size-3.5' }"
/>
<template #content>
<UCalendar
:model-value="date"
class="p-2"
@update:model-value="onCalendar"
/>
</template>
</UPopover>
</template>
</UInputDate>
<span
v-if="idle"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-2.5 flex items-center text-lg text-muted md:text-sm"
>
{{ t('Pick a date') }}
</span>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
const props = defineProps<{
title: string
typeName: string
pending: boolean
failed: boolean
}>()
const emit = defineEmits<{ confirm: []; close: [] }>()
const notice = computed(() =>
props.failed
? {
title: t("Couldn't delete the entry"),
description: t(
'Something went wrong on our side. Nothing was deleted. Try again in a moment.',
),
}
: null,
)
const description = computed(() =>
t('"{0}" will be deleted from {1}. This can\'t be undone.', [props.title, props.typeName]),
)
</script>
<template>
<DirectoryDialog
:title="t('Delete this entry?')"
:description="description"
:confirm-label="t('Delete entry')"
:pending-label="t('Deleting…')"
:cancel-label="t('Cancel')"
tone="danger"
:pending="pending"
:notice="notice"
closable
close-gutter
@confirm="emit('confirm')"
@close="emit('close')"
/>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { t } from '@/i18n'
import BuilderStatusChip from '@/components/BuilderStatusChip.vue'
import ContentState from '@/components/ContentState.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import EntryActions from '@/components/EntryActions.vue'
import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue'
import EntryEditorSkeleton from '@/components/EntryEditorSkeleton.vue'
import EntryForm from '@/components/EntryForm.vue'
import EntryNotices from '@/components/EntryNotices.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { typeEndpoint } from '@/lib/contentEndpoints'
import { entryLoadErrorLine } from '@/lib/entryMessages'
import { HOME_PATH, contentPath } from '@/lib/routePaths'
import { useRouter } from 'vue-router'
const props = defineProps<{ page: EntryEditorPage }>()
const router = useRouter()
const { phase, isDesktop, typeKey } = props.page
function loadErrorLine() {
const id = props.page.entryId.value
const base = typeEndpoint(typeKey)
return entryLoadErrorLine(
phase.value === 'entryError' && id !== null ? `${base}/entry/${id}` : base,
)
}
function goHome() {
router.push(HOME_PATH)
}
function backToList() {
router.push(contentPath(typeKey))
}
</script>
<template>
<ContentState
v-if="phase === 'forbidden' || phase === 'typeNotFound'"
:kind="phase === 'forbidden' ? 'forbidden' : 'notFound'"
:name="page.typeName.value"
@home="goHome"
/>
<ContentState
v-else-if="phase === 'entryNotFound'"
kind="entryNotFound"
:name="page.typeName.value"
@back="backToList"
/>
<div v-else class="flex flex-col gap-4" data-testid="entry-editor">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<template v-if="phase === 'loading'">
<USkeleton class="h-7 w-40 rounded-none bg-accented max-lg:hidden" />
<USkeleton class="h-5 w-28 rounded-none bg-accented" />
</template>
<template v-else>
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
{{ page.title.value }}
</h1>
<p class="text-muted">{{ page.subtitle.value }}</p>
</template>
</div>
<EntryActions v-if="isDesktop && phase === 'ready'" :page="page" />
</div>
<div
v-if="!isDesktop && phase === 'ready'"
class="flex min-h-5 items-center justify-between"
>
<BuilderStatusChip :status="page.chip.value" :saved-at="page.savedClock.value" />
</div>
<DirectoryLoadError
v-if="phase === 'typeError' || phase === 'entryError'"
:title="t('Couldn\'t load the entry')"
:description="loadErrorLine()"
@retry="page.retryLoad"
/>
<EntryEditorSkeleton v-else-if="phase === 'loading'" />
<template v-else>
<EntryNotices :page="page" />
<EntryForm :page="page" />
</template>
</div>
<div
v-if="!isDesktop && phase === 'ready'"
class="sticky bottom-0 -mx-4 mt-auto flex border-t border-default bg-default px-4 py-3"
>
<EntryActions :page="page" bar />
</div>
<EntryDeleteDialog
v-if="page.removal.stage.value !== 'closed'"
:title="page.title.value"
:type-name="page.typeName.value"
:pending="page.removal.pending.value"
:failed="page.removal.stage.value === 'failed'"
@confirm="page.removal.confirm"
@close="page.removal.close"
/>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { t } from '@/i18n'
const ROWS = 5
</script>
<template>
<div
role="status"
aria-busy="true"
:aria-label="t('Loading')"
data-testid="entry-editor-skeleton"
class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 md:p-6"
>
<div v-for="row in ROWS" :key="row" class="flex flex-col gap-1.5">
<USkeleton class="h-4 w-24 rounded-none bg-accented" />
<USkeleton class="h-11 w-full rounded-none bg-accented md:h-8" />
</div>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
import BuilderBadge from '@/components/BuilderBadge.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import EntryControl from '@/components/EntryControl.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import type { FieldIssue } from '@/lib/entryForm'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const props = defineProps<{
field: BuiltinField
value: unknown
issue?: FieldIssue
isDefault: boolean
disabled: boolean
}>()
const emit = defineEmits<{ update: [key: string, value: unknown] }>()
const editor = computed(() => fieldTypeSpec(props.field.type).editor)
const counter = computed(() => editor.value.counter(props.field, props.value))
const help = computed(() => editor.value.help(props.field))
</script>
<template>
<div :data-entry-field="field.key" class="min-w-0">
<DirectoryField
:label="field.label"
:name="field.key"
:required="field.required"
:error="issue?.message"
:help="help"
>
<template v-if="counter || isDefault" #hint>
<span class="flex items-center gap-2">
<span
v-if="counter"
data-testid="entry-counter"
:class="counter.over ? 'text-error' : ''"
>{{ counter.text }}</span
>
<BuilderBadge v-if="isDefault" kind="default" />
</span>
</template>
<EntryControl
:field="field"
:value="value"
:invalid="Boolean(issue)"
:disabled="disabled"
@update="emit('update', field.key, $event)"
/>
</DirectoryField>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, watch } from 'vue'
import EntryField from '@/components/EntryField.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { groupFields } from '@/lib/entryLayout'
const FOCUSABLE =
'input:not([type="hidden"]), textarea, [role="spinbutton"], [role="switch"], [role="combobox"]'
const props = defineProps<{ page: EntryEditorPage }>()
const { form, fields, locked, focusToken } = props.page
const rows = computed(() => groupFields(fields.value))
const dimmed = computed(() => locked.value)
async function focusFirstIssue() {
await nextTick()
const key = form.firstIssue.value
if (!key) {
return
}
const wrapper = [...document.querySelectorAll<HTMLElement>('[data-entry-field]')].find(
(candidate) => candidate.dataset.entryField === key,
)
const control = wrapper?.querySelector<HTMLElement>(FOCUSABLE)
control?.focus()
;(wrapper ?? control)?.scrollIntoView?.({ block: 'center' })
}
watch(focusToken, focusFirstIssue)
</script>
<template>
<form
id="entry-form"
novalidate
data-testid="entry-form"
class="flex max-w-190 flex-col gap-5 border border-default bg-default p-4 transition-opacity md:p-6"
:class="dimmed ? 'opacity-70' : ''"
:aria-busy="page.saving.value ? 'true' : undefined"
@submit.prevent="page.submit"
>
<div
v-for="row in rows"
:key="row.map((field) => field.key).join('+')"
class="grid gap-4"
:class="row.length > 1 ? 'md:grid-cols-2' : ''"
>
<EntryField
v-for="field in row"
:key="field.key"
:field="field"
:value="form.values.value[field.key]"
:issue="form.issues.value[field.key]"
:is-default="form.isDefault(field)"
:disabled="locked"
@update="page.change"
/>
</div>
<dl
data-testid="entry-meta"
class="grid grid-cols-1 gap-x-4 gap-y-1.5 border-t border-default pt-4 text-xs/4.5 md:grid-cols-[auto_1fr]"
>
<dt class="text-muted">{{ t('Entry') }}</dt>
<dd :class="page.creating.value ? '' : 'font-mono'">{{ page.entryLine.value }}</dd>
<dt class="text-muted">{{ t('Created by') }}</dt>
<dd>{{ page.createdLine.value }}</dd>
<dt class="text-muted">{{ t('Updated by') }}</dt>
<dd>{{ page.updatedLine.value }}</dd>
</dl>
</form>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import RetryAlert from '@/components/RetryAlert.vue'
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
import { schemaChangedLine, uniqueAlert } from '@/lib/entryMessages'
const props = defineProps<{ page: EntryEditorPage }>()
const { failure, schemaChanged, schemaField, form, fields, typeName } = props.page
const uniqueField = computed(() =>
fields.value.find((field) => form.issues.value[field.key]?.rule === 'unique'),
)
const alertUi = {
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'mt-0.5 text-toned',
}
</script>
<template>
<RetryAlert
v-if="failure === 'failed'"
:title="t('Couldn\'t save the entry')"
:description="
t(
'Something went wrong on our side. Your changes are still here. Try again in a moment.',
)
"
@retry="page.submit"
/>
<UAlert
v-if="uniqueField"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Not saved')"
:description="uniqueAlert(uniqueField)"
:ui="alertUi"
/>
<UAlert
v-if="schemaChanged"
role="alert"
color="warning"
variant="soft"
orientation="vertical"
icon="i-lucide-triangle-alert"
:title="t('The fields of {0} changed', [typeName])"
:description="schemaChangedLine(schemaField)"
:ui="{ ...alertUi, actions: 'mt-1.5' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="page.reload"
>
{{ t('Reload form') }}
</UButton>
</template>
</UAlert>
</template>
+4 -3
View File
@@ -7,6 +7,7 @@ import FieldCell from '@/components/FieldCell.vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5'
@@ -15,7 +16,6 @@ const FIELD_WIDTH = 'w-33'
const CREATED_WIDTH = 'w-30.5'
const UPDATED_WIDTH = 'w-40.5'
const UPDATED_BY_WIDTH = 'w-37'
const NARROW_TYPES = ['boolean', 'number']
const FIELD_PREFIX = 'field:'
const props = defineProps<{
@@ -36,7 +36,7 @@ function widthOf(field: BuiltinField, index: number) {
if (index === 0) {
return ''
}
return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH
return fieldTypeSpec(field.type).narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
}
function column(id: string, header: string, width: string, edge = 'px-1.5') {
@@ -62,7 +62,8 @@ function fieldOf(columnId: string) {
}
function isRight(columnId: string) {
return fieldOf(columnId)?.type === 'number'
const field = fieldOf(columnId)
return field ? fieldTypeSpec(field.type).alignEnd : false
}
function sortIcon(columnId: string) {
+2 -2
View File
@@ -6,7 +6,7 @@ import BuilderBadge from '@/components/BuilderBadge.vue'
import FieldActions from '@/components/FieldActions.vue'
import type { BuilderField } from '@/lib/schemaBuilderModel'
import type { RowMarks } from '@/composables/useSchemaBuilder'
import { fieldIcon } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
const props = defineProps<{
field: BuilderField
@@ -79,7 +79,7 @@ const rowClass = computed(() => [
class="flex size-7 shrink-0 items-center justify-center text-highlighted"
:class="selected ? 'bg-default' : 'bg-elevated'"
>
<UIcon :name="fieldIcon(field.type)" class="size-4" />
<UIcon :name="fieldTypeSpec(field.type).icon" class="size-4" />
</span>
<span class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-medium" data-testid="field-row-label">{{ name }}</span>
+4 -22
View File
@@ -2,8 +2,7 @@
import { t } from '@/i18n'
import { computed } from 'vue'
import { FIELD_TYPES, type FieldType } from '@/lib/schemaBuilderModel'
import { fieldIcon, fieldTypeLabel } from '@/lib/schemaFields'
import { FIELD_TYPES, FIELD_TYPE_SPECS, fieldTypeSpec, type FieldType } from '@/lib/fieldTypes'
const props = defineProps<{
modelValue: FieldType
@@ -14,27 +13,10 @@ const props = defineProps<{
defineEmits<{ 'update:modelValue': [type: FieldType] }>()
function describe(type: FieldType) {
switch (type) {
case 'richtext':
return t('Long text with formatting, written in Markdown')
case 'number':
return t('Whole or decimal numbers')
case 'boolean':
return t('An on or off switch')
case 'date':
return t('A calendar day, without a time')
case 'select':
return t('One option from a list you define')
default:
return t('Short text, like a title or a name')
}
}
const items = computed(() =>
FIELD_TYPES.map((type) => ({
value: type,
label: fieldTypeLabel(type),
label: FIELD_TYPE_SPECS[type].label(),
disabled: props.locked && type !== props.modelValue,
})),
)
@@ -73,12 +55,12 @@ const items = computed(() =>
class="flex items-start gap-2.5 text-left"
:class="item.disabled ? 'opacity-40' : ''"
>
<UIcon :name="fieldIcon(item.value)" class="mt-0.5 size-4 shrink-0" />
<UIcon :name="fieldTypeSpec(item.value).icon" class="mt-0.5 size-4 shrink-0" />
<span class="flex min-w-0 flex-col">
<span class="font-medium">{{ item.label }}</span>
<span
class="text-xs/4 font-normal text-pretty text-muted group-has-data-[state=checked]:text-(--rc-on-sand)"
>{{ describe(item.value) }}</span
>{{ fieldTypeSpec(item.value).description() }}</span
>
</span>
</span>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { renderMarkdown } from '@/lib/markdown'
import { MARKDOWN_ACTIONS, applyMarkdownAction, type MarkdownAction } from '@/lib/markdownEdit'
type Mode = 'write' | 'preview'
const props = defineProps<{
modelValue: string
disabled?: boolean
invalid?: boolean
required?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const mode = ref<Mode>('write')
const root = ref<HTMLElement | null>(null)
const tabs = computed(() => [
{ label: t('Write'), value: 'write' },
{ label: t('Preview'), value: 'preview' },
])
const actions = computed(() => ({
bold: { label: t('Bold'), icon: 'i-lucide-bold' },
italic: { label: t('Italic'), icon: 'i-lucide-italic' },
heading: { label: t('Heading'), icon: 'i-lucide-heading' },
link: { label: t('Link'), icon: 'i-lucide-link' },
list: { label: t('List'), icon: 'i-lucide-list' },
}))
const rendered = computed(() => renderMarkdown(props.modelValue))
const empty = computed(() => props.modelValue.trim() === '')
function textarea() {
return root.value?.querySelector<HTMLTextAreaElement>('textarea') ?? null
}
async function run(action: MarkdownAction) {
const field = textarea()
if (!field) {
return
}
const edit = applyMarkdownAction(
action,
props.modelValue,
field.selectionStart,
field.selectionEnd,
)
emit('update:modelValue', edit.value)
await nextTick()
field.focus()
field.setSelectionRange(edit.start, edit.end)
}
async function onMode(value: string | number) {
mode.value = value === 'preview' ? 'preview' : 'write'
if (mode.value === 'write') {
await nextTick()
textarea()?.focus()
}
}
</script>
<template>
<div
ref="root"
data-testid="markdown-field"
class="flex min-w-0 flex-col border bg-default"
:class="[
invalid ? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]' : 'border-accented',
disabled ? 'bg-elevated' : '',
]"
>
<div class="flex items-center gap-0.5 overflow-hidden border-b border-default px-1.5">
<UTabs
:model-value="mode"
:items="tabs"
:content="false"
variant="link"
color="neutral"
:ui="{
root: 'mr-2 w-auto gap-0',
list: 'gap-3.5 px-1.5',
trigger: 'h-11 px-0 text-base md:h-8',
indicator: 'bg-primary',
}"
@update:model-value="onMode"
/>
<template v-if="mode === 'write'">
<UButton
v-for="action in MARKDOWN_ACTIONS"
:key="action"
type="button"
color="neutral"
variant="ghost"
:icon="actions[action].icon"
:aria-label="actions[action].label"
:disabled="disabled"
class="size-10 justify-center text-toned md:size-7"
:ui="{ leadingIcon: 'size-4' }"
@click="run(action)"
/>
</template>
</div>
<UTextarea
v-show="mode === 'write'"
:model-value="modelValue"
:placeholder="t('Write in Markdown')"
:disabled="disabled"
:aria-invalid="invalid ? 'true' : undefined"
:aria-required="required ? 'true' : undefined"
variant="none"
:rows="4"
autoresize
class="w-full"
:ui="{
base: 'min-h-30 px-2.5 py-2 font-mono text-lg/5.5 md:min-h-24 md:text-xs/4.75',
}"
@update:model-value="emit('update:modelValue', String($event))"
/>
<div
v-if="mode === 'preview'"
data-testid="markdown-preview"
class="flex min-h-30 flex-col gap-2 px-3 py-2.5 text-lg md:min-h-24 md:text-sm [&_a]:text-primary [&_a]:underline [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-[square] [&_ul]:ps-4.5"
>
<p v-if="empty" class="text-muted">{{ t('Nothing to preview yet.') }}</p>
<div v-else class="flex flex-col gap-2" v-html="rendered" />
</div>
</div>
</template>
+5 -4
View File
@@ -4,7 +4,8 @@ import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { fieldRules } from '@/lib/schemaFields'
import { SCHEMA_TABLE_QUERY } from '@/lib/schemaRows'
const NO_RULES = '–'
@@ -43,13 +44,13 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu
:style="{ gridTemplateColumns: COLUMNS }"
>
<span role="cell" class="flex">
<UIcon :name="fieldIcon(field.type)" class="size-4 text-toned" />
<UIcon :name="fieldTypeSpec(field.type).icon" class="size-4 text-toned" />
</span>
<span role="cell" class="truncate font-medium">{{ field.label }}</span>
<span role="cell" class="truncate font-mono text-xs text-muted">{{
field.key
}}</span>
<span role="cell">{{ fieldTypeLabel(field.type) }}</span>
<span role="cell">{{ fieldTypeSpec(field.type).label() }}</span>
<span role="cell" class="text-muted">{{ rules || NO_RULES }}</span>
</div>
<div
@@ -59,7 +60,7 @@ const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRu
class="flex min-h-16 items-center gap-3 border-t border-default px-4 py-3"
>
<span class="flex size-7 shrink-0 items-center justify-center bg-elevated">
<UIcon :name="fieldIcon(field.type)" class="size-4" />
<UIcon :name="fieldTypeSpec(field.type).icon" class="size-4" />
</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ field.label }}</span>
@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { CalendarDate } from '@internationalized/date'
import UCalendar from '@nuxt/ui/components/Calendar.vue'
import USelect from '@nuxt/ui/components/Select.vue'
import UInput from '@nuxt/ui/components/Input.vue'
import UInputDate from '@nuxt/ui/components/InputDate.vue'
import UInputNumber from '@nuxt/ui/components/InputNumber.vue'
import EntryField from '../EntryField.vue'
import EntryControl from '../EntryControl.vue'
import EntryDateField from '../EntryDateField.vue'
import MarkdownField from '../MarkdownField.vue'
import BuilderStatusChip from '../BuilderStatusChip.vue'
import type { BuiltinField } from '@/lib/contentTypes'
let mounted: VueWrapper | undefined
afterEach(() => {
mounted?.unmount()
mounted = undefined
document.body.innerHTML = ''
})
function field(type: string, rest: Partial<BuiltinField> = {}): BuiltinField {
return {
key: 'f',
label: 'Field',
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
function mountControl(type: string, props: { value?: unknown; invalid?: boolean } = {}) {
mounted = mount(EntryControl, {
props: { field: field(type), value: null, invalid: false, disabled: false, ...props },
global: { plugins: [uiPlugin] },
})
return mounted
}
describe('EntryControl number', () => {
it.each([
[NaN, null],
[undefined, null],
[0, 0],
[18, 18],
])('emits %s as %s', (typed, sent) => {
const wrapper = mountControl('number')
wrapper.findComponent(UInputNumber).vm.$emit('update:modelValue', typed)
expect(wrapper.emitted('update')).toEqual([[sent]])
})
})
describe('EntryControl select', () => {
function mountSelect(options: string[]) {
mounted = mount(EntryControl, {
props: {
field: field('select', { options }),
value: '',
invalid: false,
disabled: false,
},
global: { plugins: [uiPlugin] },
})
return mounted.findComponent(USelect)
}
it('keeps an option named like the old sentinel apart from None', () => {
const select = mountSelect(['__none__', 'Talk'])
const items = select.props('items') as { label: string; value: string }[]
const none = items.find((item) => item.label === 'None')!
expect(none.value).not.toBe('__none__')
select.vm.$emit('update:modelValue', '__none__')
select.vm.$emit('update:modelValue', none.value)
select.vm.$emit('update:modelValue', 'Talk')
expect(mounted!.emitted('update')).toEqual([['__none__'], [''], ['Talk']])
})
})
describe('EntryControl invalid state', () => {
it('colors the control as an error only when invalid', () => {
expect(mountControl('text', { invalid: true }).findComponent(UInput).props('color')).toBe(
'error',
)
mounted?.unmount()
expect(
mountControl('text', { invalid: false }).findComponent(UInput).props('color'),
).toBeUndefined()
})
})
describe('Markdown field accessibility', () => {
function mountField(required: boolean, issue?: { rule: 'required'; message: string }) {
mounted = mount(EntryField, {
props: {
field: field('richtext', { key: 'notes', label: 'Notes', required }),
value: '',
issue,
isDefault: false,
disabled: false,
},
global: { plugins: [uiPlugin] },
})
return mounted.find('textarea')
}
it('ties the textarea to its label and its message and marks it required', () => {
const area = mountField(true, { rule: 'required', message: 'Fill in Notes.' })
expect(mounted!.find('label').attributes('for')).toBe(area.attributes('id'))
expect(area.attributes('aria-describedby')).toBe(`${area.attributes('id')}-error`)
expect(area.attributes('aria-invalid')).toBe('true')
expect(area.attributes('aria-required')).toBe('true')
})
it('does not mark an optional textarea as required', () => {
expect(mountField(false).attributes('aria-required')).toBeUndefined()
})
})
describe('EntryDateField', () => {
function mountDate(modelValue: string) {
mounted = mount(EntryDateField, {
props: { modelValue },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return mounted
}
it('emits an ISO day for a picked date and an empty string when cleared', () => {
const wrapper = mountDate('2026-11-21')
const input = wrapper.findComponent(UInputDate)
input.vm.$emit('update:modelValue', new CalendarDate(2026, 12, 3))
input.vm.$emit('update:modelValue', null)
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-12-03'], ['']])
})
it('shows the placeholder only while empty and not focused', async () => {
const wrapper = mountDate('')
const input = wrapper.findComponent(UInputDate)
expect(wrapper.text()).toContain('Pick a date')
input.vm.$emit('focus')
await flushPromises()
expect(wrapper.text()).not.toContain('Pick a date')
input.vm.$emit('blur')
await flushPromises()
expect(wrapper.text()).toContain('Pick a date')
})
it('closes the calendar once a day is picked', async () => {
const wrapper = mountDate('2026-11-21')
await wrapper.find('button[aria-label="Open calendar"]').trigger('click')
await flushPromises()
const calendar = wrapper.findComponent(UCalendar)
expect(calendar.exists()).toBe(true)
calendar.vm.$emit('update:modelValue', new CalendarDate(2026, 11, 25))
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-11-25']])
expect(wrapper.findComponent(UCalendar).exists()).toBe(false)
})
it('hides the placeholder when a date is set', () => {
expect(mountDate('2026-11-21').text()).not.toContain('Pick a date')
})
})
describe('MarkdownField', () => {
function mountMarkdown(modelValue: string) {
mounted = mount(MarkdownField, {
props: { modelValue },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return mounted
}
async function openPreview(wrapper: VueWrapper) {
const tabs = wrapper.findAll('[role="tab"]')
const preview = tabs.find((candidate) => candidate.text() === 'Preview')!
await preview.trigger('mousedown', { button: 0 })
await preview.trigger('click')
await flushPromises()
}
it('treats whitespace as nothing to preview', async () => {
const wrapper = mountMarkdown(' \n ')
await openPreview(wrapper)
expect(wrapper.text()).toContain('Nothing to preview yet.')
})
it('selects the result of a toolbar action and keeps the focus in the textarea', async () => {
const wrapper = mountMarkdown('lime')
const area = wrapper.find('textarea').element
area.setSelectionRange(0, 4)
await wrapper.find('button[aria-label="Link"]').trigger('click')
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([['[lime](url)']])
expect(document.activeElement).toBe(area)
await wrapper.setProps({ modelValue: '[lime](url)' })
area.setSelectionRange(0, 0)
await wrapper.find('button[aria-label="Bold"]').trigger('click')
await flushPromises()
expect([area.selectionStart, area.selectionEnd]).toEqual([2, 2])
})
it('puts the focus back in the textarea when returning to Write', async () => {
const wrapper = mountMarkdown('lime')
await openPreview(wrapper)
;(document.activeElement as HTMLElement | null)?.blur()
const write = wrapper
.findAll('[role="tab"]')
.find((candidate) => candidate.text() === 'Write')!
await write.trigger('mousedown', { button: 0 })
await write.trigger('click')
await flushPromises()
expect(document.activeElement).toBe(wrapper.find('textarea').element)
})
})
describe('BuilderStatusChip colors', () => {
function chipClasses(status: 'dirty' | 'unsaved' | 'saved') {
mounted = mount(BuilderStatusChip, {
props: { status },
global: { plugins: [uiPlugin] },
})
return mounted.find('[data-testid="builder-chip"]').classes()
}
it.each([
['dirty', 'text-warning'],
['unsaved', 'text-error'],
['saved', 'text-muted'],
] as const)('paints %s as %s', (status, color) => {
expect(chipClasses(status)).toContain(color)
mounted?.unmount()
})
})