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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user