feat: entry list at /content/:key

This commit is contained in:
Florian Sylvain committed 2026-10-20 21:05:03 +02:00
1 parent b4cbcb0395
commit 91d916fb74
44 files changed
+4984 -32

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import ColumnPickerList from '@/components/ColumnPickerList.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { COLUMN_CAP } from '@/lib/entryColumns'
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
const DESKTOP_QUERY = '(min-width: 48rem)'
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const TRIGGER = `h-11 w-11 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:h-8 md:w-auto md:px-3 ${FOCUS_RING}`
defineProps<{ fields: BuiltinField[]; keys: string[]; atCap: boolean; disabled?: boolean }>()
const emit = defineEmits<{ toggle: [key: string, on: boolean]; reset: [] }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
</script>
<template>
<UPopover
v-if="isDesktop"
v-model:open="open"
:content="{ align: 'start', side: 'bottom', sideOffset: 4 }"
:ui="{
content:
'w-75 rounded-none bg-default p-2 shadow-(--shadow-popover) ring-0 border border-default',
}"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-columns-3"
:disabled="disabled"
:class="TRIGGER"
>
{{ t('Columns') }}
</UButton>
<template #content>
<div class="flex flex-col gap-2" data-testid="column-picker">
<div class="flex items-center justify-between px-2 py-1">
<span class="font-semibold text-highlighted">{{ t('Columns') }}</span>
<span class="text-xs text-muted">{{
t('{0} of {1}', [keys.length, COLUMN_CAP])
}}</span>
</div>
<ColumnPickerList
:fields="fields"
:keys="keys"
:at-cap="atCap"
@toggle="(key, on) => emit('toggle', key, on)"
/>
<div class="flex justify-end px-2 py-1">
<UButton
color="neutral"
variant="outline"
class="h-7 rounded-none border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
@click="emit('reset')"
>
{{ t('Reset') }}
</UButton>
</div>
</div>
</template>
</UPopover>
<USlideover
v-else
v-model:open="open"
side="bottom"
:title="t('Columns')"
:description="t('Columns')"
:ui="BOTTOM_SHEET_UI"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-columns-3"
:aria-label="t('Columns')"
:disabled="disabled"
:class="TRIGGER"
/>
<template #title>
<span>{{ t('Columns') }}</span>
<span class="ms-2 text-xs font-normal text-muted">{{
t('{0} of {1}', [keys.length, COLUMN_CAP])
}}</span>
</template>
<template #body>
<p class="text-muted">
{{ t('Choose up to {0} fields to show. {1} selected.', [COLUMN_CAP, keys.length]) }}
</p>
<ColumnPickerList
touch
:fields="fields"
:keys="keys"
:at-cap="atCap"
@toggle="(key, on) => emit('toggle', key, on)"
/>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center rounded-none border border-accented bg-default text-highlighted ring-0"
@click="emit('reset')"
>
{{ t('Reset') }}
</UButton>
<UButton class="h-11 flex-1 justify-center" @click="open = false">
{{ t('Done') }}
</UButton>
</template>
</USlideover>
</template>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { fieldIcon } from '@/lib/schemaFields'
import { COLUMN_CAP } from '@/lib/entryColumns'
import type { BuiltinField } from '@/lib/contentTypes'
const props = defineProps<{
fields: BuiltinField[]
keys: string[]
atCap: boolean
touch?: boolean
}>()
const emit = defineEmits<{ toggle: [key: string, on: boolean] }>()
function isLocked(key: string) {
const checked = props.keys.includes(key)
return checked ? props.keys.length === 1 : props.atCap
}
const ROW = 'flex items-center gap-2.5 px-2'
</script>
<template>
<div class="flex flex-col">
<div
v-for="field in fields"
:key="field.key"
data-testid="column-row"
:class="[ROW, touch ? 'min-h-12' : 'min-h-9']"
>
<UCheckbox
:model-value="keys.includes(field.key)"
:disabled="isLocked(field.key)"
:ui="{
root: 'w-full items-center',
base: 'rounded-none',
container: 'h-auto',
wrapper: 'ms-2.5 min-w-0 flex-1',
label: 'flex w-full items-center gap-2.5 font-normal text-default',
}"
@update:model-value="emit('toggle', field.key, $event === true)"
>
<template #label>
<UIcon :name="fieldIcon(field.type)" 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>
</UCheckbox>
</div>
<div data-testid="column-fixed-row" :class="[ROW, touch ? 'min-h-12' : 'min-h-9']">
<UCheckbox
:model-value="false"
disabled
:ui="{
root: 'w-full items-center',
base: 'rounded-none',
container: 'h-auto',
wrapper: 'ms-2.5 min-w-0 flex-1',
label: 'flex w-full items-center gap-2.5 font-normal text-muted',
}"
>
<template #label>
<UIcon name="i-lucide-clock" class="size-3.5 shrink-0" />
<span>{{ t('Created and updated dates always show') }}</span>
</template>
</UCheckbox>
</div>
<p v-if="atCap" role="status" class="px-2 pt-1 text-xs text-muted">
{{ t('You can show up to {0} fields.', [COLUMN_CAP]) }}
</p>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ create: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-file-plus"
:title="t('No entries yet')"
:description="
t(
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg:py-10',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/7 font-semibold',
description: 'max-w-90 text-muted text-pretty',
}"
>
<template #actions>
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('create')">
{{ t('New entry') }}
</UButton>
</template>
</UEmpty>
</template>
+218
View File
@@ -0,0 +1,218 @@
<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 AppPaginator from '@/components/AppPaginator.vue'
import ArticleListNotice from '@/components/ArticleListNotice.vue'
import ContentEmpty from '@/components/ContentEmpty.vue'
import ContentState from '@/components/ContentState.vue'
import ContentToolbar from '@/components/ContentToolbar.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
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'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DESKTOP_SKELETON_ROWS = 8
const MOBILE_SKELETON_ROWS = 6
const props = defineProps<{
typeKey: string
type: ContentType | null
typeStatus: ContentTypeDetailStatus
canEdit: boolean
}>()
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()
})
</script>
<template>
<ContentState
v-if="centered"
:kind="view === 'forbidden' ? 'forbidden' : 'notFound'"
:name="name"
@home="goHome"
/>
<div v-else class="flex flex-col gap-4">
<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="typeStatus === '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">
{{ name }}
</h1>
<p v-if="type" class="text-muted">{{ subtitle }}</p>
</template>
</div>
<UButton
v-if="isDesktop"
icon="i-lucide-plus"
class="h-8 px-3.5"
:disabled="view === 'loading'"
@click="createEntry"
>
{{ t('New entry') }}
</UButton>
</div>
<ContentToolbar
v-if="!failed"
:sort="list.desired.value.sort"
:fields="fields"
:keys="columns.keys.value"
:at-cap="columns.atCap.value"
:picker-disabled="type === null"
:edit-to="editTo"
@field="list.changeSortField"
@direction="list.toggleDirection"
@toggle="columns.toggle"
@reset="columns.reset"
/>
<DirectoryLoadError
v-if="failed"
:title="t('Couldn\'t load entries')"
:description="view === 'typeError' ? typeErrorLine : entriesErrorLine"
@retry="retry"
/>
<ArticleListNotice
v-if="list.notice.value"
:kind="list.notice.value.kind"
:failed-page="
list.notice.value.kind === 'error'
? list.notice.value.query.page
: list.shown.value.page
"
:shown-page="list.shown.value.page"
@retry="list.retryNotice"
/>
<EntrySkeleton v-if="showSkeleton" :rows="skeletonRows" :cards="!isDesktop" />
<ContentEmpty v-else-if="view === 'empty'" @create="createEntry" />
<template v-else-if="showBody">
<EntryTable
v-if="isDesktop"
:type-key="typeKey"
:entries="list.items.value"
:fields="columns.columns.value"
:sort="list.shown.value.sort"
:names="list.names.value"
:caption="caption"
/>
<EntryCardList
v-else
:type-key="typeKey"
:entries="list.items.value"
:fields="fields"
:names="list.names.value"
:caption="caption"
/>
</template>
<AppPaginator
v-if="view === 'list'"
flush
:page="list.shown.value.page"
:page-count="list.pageCount.value"
:total="list.total.value"
:size="list.shown.value.size"
:loading-page="list.pendingPage.value"
:single-page-label="singlePageLabel"
@update:page="list.changePage"
@update:size="list.changeSize"
/>
</div>
<DirectoryAddButton
v-if="!isDesktop && !centered && !failed && view !== 'loading'"
pinned
@click="createEntry"
>
{{ t('New entry') }}
</DirectoryAddButton>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>()
const emit = defineEmits<{ home: [] }>()
const forbidden = computed(() => props.kind === 'forbidden')
</script>
<template>
<div class="flex min-h-0 flex-1 items-center justify-center px-6 py-6">
<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.')
"
: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',
title: 'text-title font-semibold text-wrap',
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
}"
>
<template #actions>
<UButton class="h-8 px-4 max-md:h-11" @click="emit('home')">
{{ t('Go to Home') }}
</UButton>
</template>
</UEmpty>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { useMediaQuery } from '@vueuse/core'
import ColumnPicker from '@/components/ColumnPicker.vue'
import SortSelect from '@/components/SortSelect.vue'
import type { BuiltinField } from '@/lib/contentTypes'
import type { EntrySortField } from '@/lib/entryListQuery'
const DESKTOP_QUERY = '(min-width: 48rem)'
defineProps<{
sort: string
fields: BuiltinField[]
keys: string[]
atCap: boolean
pickerDisabled: boolean
editTo?: string
}>()
const emit = defineEmits<{
field: [field: EntrySortField]
direction: []
toggle: [key: string, on: boolean]
reset: []
}>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
</script>
<template>
<div class="flex shrink-0 items-center gap-2" data-testid="entry-toolbar">
<SortSelect :sort="sort" @field="emit('field', $event)" @direction="emit('direction')" />
<ColumnPicker
:fields="fields"
:keys="keys"
:at-cap="atCap"
:disabled="pickerDisabled"
@toggle="(key, on) => emit('toggle', key, on)"
@reset="emit('reset')"
/>
<template v-if="isDesktop">
<span class="flex-1" />
<UButton
v-if="editTo"
:to="editTo"
color="neutral"
variant="ghost"
icon="i-lucide-settings-2"
class="h-8 rounded-none px-3 text-highlighted"
>
{{ t('Edit content type') }}
</UButton>
</template>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import FieldCell from '@/components/FieldCell.vue'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
import { entryPath } from '@/lib/routePaths'
import { computed } from 'vue'
const props = defineProps<{
typeKey: string
entries: ContentEntry[]
fields: BuiltinField[]
names: Record<number, string>
caption: string
}>()
const shown = computed(() => cardFields(props.fields))
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
}
const secondIsChoice = computed(
() => shown.value.second !== undefined && shown.value.second === shown.value.choice,
)
function hasChoice(entry: ContentEntry) {
const { choice } = shown.value
return choice !== undefined && !isEmptyCell(describeCell(choice, entry.data[choice.key]))
}
function nameOf(entry: ContentEntry) {
return props.names[entry.updated_by]
}
</script>
<template>
<ul :aria-label="caption" class="border border-default bg-default">
<li
v-for="(entry, index) in entries"
:key="entry.id"
:class="index > 0 ? 'border-t border-default' : ''"
>
<RouterLink
:to="entryPath(typeKey, entry.id)"
data-testid="entry-card"
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
>
<span class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
textOf(shown.first, entry)
}}</span>
<span class="flex items-center gap-2 text-xs text-muted">
<template v-if="shown.second && !secondIsChoice">
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
</template>
<FieldCell
v-if="shown.choice && hasChoice(entry)"
:field="shown.choice"
:value="entry.data[shown.choice.key]"
class="shrink-0"
/>
<span class="truncate">· {{ nameOf(entry) ?? EMPTY_TEXT }}</span>
</span>
</span>
<span class="flex h-11 w-9 shrink-0 items-center justify-center text-muted">
<UIcon name="i-lucide-chevron-right" class="size-4.5" />
</span>
</RouterLink>
</li>
</ul>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ rows: number; cards?: boolean }>()
</script>
<template>
<div
role="status"
:aria-label="t('Loading')"
aria-busy="true"
class="border border-default bg-default"
>
<div
v-for="n in rows"
:key="n"
data-testid="entry-skeleton-row"
class="flex items-center gap-5 px-4"
:class="[cards ? 'h-17' : 'h-12', n > 1 ? 'border-t border-default' : '']"
>
<USkeleton v-if="!cards" class="h-3 w-8 rounded-none bg-accented" />
<USkeleton class="h-3.5 rounded-none bg-accented" :class="n % 2 ? 'w-1/4' : 'w-3/10'" />
<template v-if="!cards">
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<USkeleton class="h-3 w-12.5 rounded-none bg-accented" />
</template>
</div>
</div>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchPostEffect } from 'vue'
import { RouterLink } from 'vue-router'
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 { entryPath } from '@/lib/routePaths'
const ID_WIDTH = 'w-16.5'
const NARROW_WIDTH = 'w-23'
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<{
typeKey: string
entries: ContentEntry[]
fields: BuiltinField[]
sort: string
names: Record<number, string>
caption: string
}>()
const root = ref<HTMLElement | null>(null)
const sorted = computed(() => sortParts(props.sort))
const HEADER = 'h-9 py-0 font-medium text-muted'
function widthOf(field: BuiltinField, index: number) {
if (index === 0) {
return ''
}
return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH
}
function column(id: string, header: string, width: string, edge = 'px-1.5') {
return {
id,
header,
meta: { class: { th: `${width} ${edge} ${HEADER}`, td: `${width} ${edge}` } },
}
}
const columns = computed(() => [
column('id', t('ID'), ID_WIDTH, 'pr-1.5 pl-3'),
...props.fields.map((field, index) =>
column(`${FIELD_PREFIX}${field.key}`, field.label, widthOf(field, index)),
),
column('created_at', t('Created'), CREATED_WIDTH),
column('updated_at', t('Updated'), UPDATED_WIDTH),
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
])
function fieldOf(columnId: string) {
return props.fields.find((field) => `${FIELD_PREFIX}${field.key}` === columnId)
}
function isRight(columnId: string) {
return fieldOf(columnId)?.type === 'number'
}
function sortIcon(columnId: string) {
if (sorted.value.field !== columnId) {
return undefined
}
return sorted.value.direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function nameOf(entry: ContentEntry) {
return props.names[entry.updated_by]
}
watchPostEffect(() => {
const headers = root.value?.querySelectorAll('thead th') ?? []
headers.forEach((header, index) => {
const id = columns.value[index]?.id
if (
id &&
(ENTRY_SORT_FIELDS as readonly string[]).includes(id) &&
id === sorted.value.field
) {
header.setAttribute(
'aria-sort',
sorted.value.direction === 'desc' ? 'descending' : 'ascending',
)
} else {
header.removeAttribute('aria-sort')
}
})
})
</script>
<template>
<div ref="root" class="border border-default bg-default">
<UTable
:data="entries"
:columns="columns"
:ui="{
base: 'table-fixed',
thead: 'bg-elevated',
separator: 'bg-(--ui-border)',
td: 'h-12 px-1.5 py-0 text-default',
}"
>
<template #caption>
<caption class="sr-only">
{{
caption
}}
</caption>
</template>
<template v-for="column in columns" :key="column.id" #[`${column.id}-header`]>
<span
class="flex items-center gap-1"
:class="isRight(column.id) ? 'justify-end' : ''"
>
{{ column.header }}
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="sorted.direction"
class="size-3"
/>
</span>
</template>
<template #id-cell="{ row }">
<span class="font-mono text-xs text-muted">{{ row.original.id }}</span>
</template>
<template
v-for="(field, index) in fields"
:key="field.key"
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
>
<RouterLink
v-if="index === 0"
:to="entryPath(typeKey, row.original.id)"
data-testid="entry-link"
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<FieldCell
:field="field"
:value="row.original.data[field.key]"
class="min-w-0"
/>
</RouterLink>
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{ formatEntryDate(row.original.created_at) }}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
</template>
<template #updated_by-cell="{ row }">
<span v-if="nameOf(row.original)" class="block truncate">{{
nameOf(row.original)
}}</span>
<span v-else class="text-muted">{{ EMPTY_TEXT }}</span>
</template>
</UTable>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { BuiltinField } from '@/lib/contentTypes'
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
const props = defineProps<{ field: BuiltinField; value: unknown }>()
const cell = computed(() => describeCell(props.field, props.value))
</script>
<template>
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
<UIcon
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
class="size-3.5 shrink-0 text-muted"
/>
{{ cell.yes ? t('Yes') : t('No') }}
</span>
<UBadge
v-else-if="cell.kind === 'choice'"
color="neutral"
variant="outline"
:label="cell.text"
class="max-w-full border border-accented bg-transparent text-toned ring-0"
:ui="{ base: 'h-5 rounded-none px-1.5 py-0 text-xs font-normal', label: 'truncate' }"
/>
<span v-else-if="cell.kind === 'number'" class="block text-right tabular-nums">{{
cell.text
}}</span>
<span
v-else
class="block truncate"
:class="cell.kind === 'richtext' ? 'text-toned' : ''"
:title="cell.text"
>{{ cell.text }}</span
>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import {
ENTRY_SORT_FIELDS,
sortDirectionLabel,
sortFieldLabel,
sortParts,
type EntrySortField,
} from '@/lib/entryListQuery'
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const props = defineProps<{ sort: string }>()
const emit = defineEmits<{ field: [field: EntrySortField]; direction: [] }>()
const parts = computed(() => sortParts(props.sort))
const items = computed(() =>
ENTRY_SORT_FIELDS.map((field) => ({ label: sortFieldLabel(field), value: field })),
)
const directionIcon = computed(() =>
parts.value.direction === 'asc'
? 'i-lucide-arrow-up-narrow-wide'
: 'i-lucide-arrow-down-wide-narrow',
)
</script>
<template>
<div class="flex min-w-0 flex-1 items-center gap-2 md:flex-none">
<USelect
:model-value="parts.field"
:items="items"
value-key="value"
color="neutral"
variant="outline"
:aria-label="t('Sort by')"
class="min-w-0 flex-1 md:w-55 md:flex-none"
:ui="{
base: `h-11 gap-2 rounded-none border border-accented bg-default px-2.5 text-sm text-highlighted ring-0 focus-visible:ring-0 md:h-8 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
item: 'min-h-11 items-center rounded-none px-2 py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand) md:min-h-8',
itemTrailingIcon: 'hidden',
}"
@update:model-value="emit('field', $event as EntrySortField)"
>
<template #default>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="shrink-0 text-muted">{{ t('Sort by') }}</span>
<span class="min-w-0 flex-1 truncate text-left font-medium">{{
sortFieldLabel(parts.field)
}}</span>
</span>
</template>
</USelect>
<UButton
color="neutral"
variant="outline"
:icon="directionIcon"
:aria-label="sortDirectionLabel(sort)"
:data-sort-direction="parts.direction"
:class="`size-11 shrink-0 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:size-8 ${FOCUS_RING}`"
@click="emit('direction')"
/>
</div>
</template>
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { apiField } from '@/lib/__tests__/schemaFixtures'
import { setLocale } from '@/i18n'
import FieldCell from '../FieldCell.vue'
import { mountInApp, text, unmountApp } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(async () => {
setLocale('en')
unmountApp()
})
const LONG = 'A very long title that would never fit on a single line of the table'
function iconPath(name: string) {
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
}
function cell() {
return document.body.firstElementChild!.firstElementChild!
}
async function show(type: string, value: unknown) {
await mountInApp(FieldCell, { field: apiField('Field', 'field', type), value })
return cell()
}
describe('FieldCell', () => {
it('truncates text on one line with the full text as tooltip', async () => {
const element = await show('text', LONG)
expect(text(element)).toBe(LONG)
expect(element.className).toContain('truncate')
expect(element.getAttribute('title')).toBe(LONG)
})
it('shows the first line of a rich text in plain text, truncated, in the toned colour', async () => {
const element = await show('richtext', '## **Two** days\n\nSecond paragraph')
expect(text(element)).toBe('Two days')
expect(element.className).toContain('truncate')
expect(element.className).toContain('text-toned')
})
it('right-aligns a number with tabular figures', async () => {
const element = await show('number', 500)
expect(text(element)).toBe('500')
expect(element.className).toContain('text-right')
expect(element.className).toContain('tabular-nums')
})
it.each([
[true, 'Yes'],
[false, 'No'],
])('shows %s as an icon and %s', async (value, label) => {
const element = await show('boolean', value)
expect(text(element)).toBe(label)
expect(element.querySelector('svg')).not.toBeNull()
})
it('draws a check for yes and a minus for no', async () => {
const yes = (await show('boolean', true)).querySelector('svg path')!.getAttribute('d')
unmountApp()
const no = (await show('boolean', false)).querySelector('svg path')!.getAttribute('d')
expect(yes).toBe(iconPath('check'))
expect(no).toBe(iconPath('minus'))
})
it.each([
['2026-11-21', '21 Nov 2026'],
['2027-01-01', '1 Jan 2027'],
])('shows the day %s as %s', async (value, label) => {
expect(text(await show('date', value))).toBe(label)
})
it('shows the day in French', async () => {
setLocale('fr')
expect(text(await show('date', '2026-11-21'))).toMatch(/^21 nov\.? 2026$/)
})
it('shows a choice as a small outline tag', async () => {
const element = await show('select', 'Workshop')
expect(text(element)).toBe('Workshop')
expect(element.className).toContain('border-accented')
expect(element.className).toContain('ring-0')
})
it.each([
['text', ''],
['richtext', null],
['number', undefined],
['date', ''],
['select', null],
])('shows a dash for an empty %s', async (type, value) => {
const element = await show(type, value)
expect(text(element)).toBe('–')
expect(element.className).toContain('text-muted')
})
})