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')
})
})
@@ -271,3 +271,129 @@ describe('countEntries', () => {
await expect(useContentTypes().countEntries('events')).rejects.toBeInstanceOf(NetworkError)
})
})
describe('getContentType', () => {
it('reads one type by key', async () => {
answer(200, recipes)
await expect(useContentTypes().getContentType('recipes')).resolves.toMatchObject({
key: 'recipes',
})
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type/recipes')
})
it.each([
[403, 'forbidden'],
[404, 'content_type_not_found'],
[500, 'boom'],
])('passes %i and its plain text body through', async (status, body) => {
answer(status, body)
await expect(useContentTypes().getContentType('recipes')).rejects.toMatchObject({
status,
body,
})
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(useContentTypes().getContentType('recipes')).rejects.toBeInstanceOf(
NetworkError,
)
})
})
describe('listEntries', () => {
const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5, sort: 'id:asc' }
it('builds the query with explicit page, size and sort and returns the page', async () => {
answer(200, page)
const listed = await useContentTypes().listEntries('events', {
page: 2,
size: 5,
sort: 'id:asc',
})
expect(listed).toEqual(page)
expect(fetchMock.mock.calls[0]![0]).toBe(
'/v1/content-type/events/entry?page=2&size=5&sort=id:asc',
)
})
it.each([
[403, 'forbidden'],
[404, 'content_type_not_found'],
[500, 'boom'],
])('passes %i and its plain text body through', async (status, body) => {
answer(status, body)
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status, body })
})
it('lets a network failure through', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toBeInstanceOf(NetworkError)
})
it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])(
'rejects a malformed page: %j',
async (body) => {
answer(200, body)
await expect(
useContentTypes().listEntries('events', {
page: 1,
size: 25,
sort: 'updated_at:desc',
}),
).rejects.toThrow()
},
)
it('sends a 401 to the login page with the expired reason', async () => {
answer(401, '')
await expect(
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
).rejects.toMatchObject({ status: 401 })
const { default: router } = await import('@/router')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(router.currentRoute.value.query.reason).toBe('expired')
await router.push('/login')
})
})
describe('lookupUsers', () => {
it('sends the distinct ids once and returns the display names', async () => {
answer(200, [
{ id: 3, display_name: 'Marta Alves' },
{ id: 5, display_name: 'Jonas Pereira' },
])
const names = await useContentTypes().lookupUsers([3, 3, 5, 3])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5')
expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira'])
})
it('rejects with the status on a 500', async () => {
answer(500, 'boom')
await expect(useContentTypes().lookupUsers([3])).rejects.toMatchObject({ status: 500 })
})
it('rejects a body that is not a list', async () => {
answer(200, { id: 3 })
await expect(useContentTypes().lookupUsers([3])).rejects.toThrow()
})
})
@@ -0,0 +1,35 @@
import { ref, watch } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
const FAILURES: Record<number, ContentTypeDetailStatus> = { 403: 'forbidden', 404: 'missing' }
export function useContentTypeDetail(key: () => string) {
const { getContentType } = useContentTypes()
const status = ref<ContentTypeDetailStatus>('loading')
const type = ref<ContentType | null>(null)
let generation = 0
async function load() {
const current = ++generation
status.value = 'loading'
type.value = null
try {
const loaded = await getContentType(key())
if (current === generation) {
type.value = loaded
status.value = 'ready'
}
} catch (error) {
if (current === generation) {
type.value = null
status.value = (error instanceof ApiError && FAILURES[error.status]) || 'failed'
}
}
}
watch(key, load, { immediate: true })
return { status, type, retry: load }
}
+42 -27
View File
@@ -1,34 +1,23 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import type {
BuiltinType,
ContentType,
EntryPage,
EntryPageQuery,
UserName,
} from '@/lib/contentTypes'
import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload'
export interface BuiltinField {
key: string
label: string
type: string
required: boolean
unique: boolean
default: unknown
min: number | null
max: number | null
options: unknown[] | null
}
export interface ContentType {
id: number
key: string
name: string
icon: string | null
fields: BuiltinField[]
created_at: string
updated_at: string
}
export interface BuiltinType {
key: string
name: string
fields: BuiltinField[]
}
export type {
BuiltinField,
BuiltinType,
ContentEntry,
ContentType,
EntryPage,
EntryPageQuery,
UserName,
} from '@/lib/contentTypes'
export function useContentTypes() {
const { request } = useApi()
@@ -82,7 +71,33 @@ export function useContentTypes() {
return page.total
}
function getContentType(key: string): Promise<ContentType> {
return request<ContentType>(`/v1/content-type/${encodeURIComponent(key)}`, options)
}
async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> {
const params = new URLSearchParams({
page: String(query.page),
size: String(query.size),
sort: query.sort,
})
const url = `/v1/content-type/${encodeURIComponent(key)}/entry?${params.toString().replace(/%3A/g, ':')}`
const page = await request<EntryPage>(url, options)
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
throw new Error(`Unexpected response from ${url}`)
}
return page
}
async function lookupUsers(ids: number[]): Promise<UserName[]> {
const distinct = [...new Set(ids)]
return requestList<UserName>(`/v1/user/lookup?ids=${distinct.join(',')}`)
}
return {
getContentType,
listEntries,
lookupUsers,
listContentTypes,
listBuiltinTypes,
createContentType,
+33
View File
@@ -0,0 +1,33 @@
import { computed, ref } from 'vue'
import type { BuiltinField } from '@/composables/useContentTypes'
import {
COLUMN_CAP,
clearStoredColumns,
defaultColumnKeys,
readStoredColumns,
resolveColumnKeys,
storeColumns,
toggleColumnKeys,
} from '@/lib/entryColumns'
export function useEntryColumns(typeKey: string, fields: () => BuiltinField[]) {
const stored = readStoredColumns(typeKey)
const chosen = ref<string[] | null>(null)
const keys = computed(() => chosen.value ?? resolveColumnKeys(fields(), stored))
const columns = computed(() => fields().filter((field) => keys.value.includes(field.key)))
const atCap = computed(() => keys.value.length >= COLUMN_CAP)
function toggle(key: string, on: boolean) {
const next = toggleColumnKeys(fields(), keys.value, key, on)
chosen.value = next
storeColumns(typeKey, next)
}
function reset() {
chosen.value = defaultColumnKeys(fields())
clearStoredColumns(typeKey)
}
return { keys, columns, atCap, toggle, reset }
}
+126
View File
@@ -0,0 +1,126 @@
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { ApiError } from '@/composables/useApi'
import { useContentTypes } from '@/composables/useContentTypes'
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
import {
parseEntryQuery,
sameEntryQuery,
toEntryRouteQuery,
withOppositeDirection,
withSortField,
type EntryListQuery,
type EntrySortField,
} from '@/lib/entryListQuery'
import { contentPath } from '@/lib/routePaths'
const UNAUTHORIZED = 401
const FAILURES: Record<number, EntryListStatus> = { 403: 'forbidden', 404: 'missing' }
export function useEntryList(typeKey: string) {
const route = useRoute()
const { listEntries, lookupUsers } = useContentTypes()
const items = ref<ContentEntry[]>([])
const status = ref<EntryListStatus>('loading')
const names = ref<Record<number, string>>({})
let lookupToken = 0
async function resolveNames(entries: ContentEntry[]) {
const current = ++lookupToken
const ids = entries.map((entry) => entry.updated_by).filter((id) => id > 0)
if (ids.length === 0) {
return
}
const found = await lookupUsers(ids).catch(() => [])
if (current === lookupToken) {
names.value = {
...names.value,
...Object.fromEntries(found.map((user) => [user.id, user.display_name])),
}
}
}
async function request(query: EntryListQuery, context: { loaded: boolean }) {
try {
const response = await listEntries(typeKey, query)
return {
kind: 'loaded',
total: response.total,
count: response.items.length,
apply: () => {
items.value = response.items
status.value = 'ready'
resolveNames(response.items)
},
} satisfies PagedOutcome
} catch (error) {
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
return { kind: 'stale' } satisfies PagedOutcome
}
if (!context.loaded) {
items.value = []
status.value = (error instanceof ApiError && FAILURES[error.status]) || 'failed'
}
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
}
}
const paged = usePagedQuery<EntryListQuery>({
path: contentPath(typeKey),
initial: parseEntryQuery(route.query),
initialLoading: true,
request,
parseRoute: parseEntryQuery,
toRoute: toEntryRouteQuery,
same: sameEntryQuery,
})
const { desired } = paged
function start() {
return paged.load(paged.shown.value, 'replace')
}
function retry() {
status.value = 'loading'
return paged.load(desired.value, 'replace')
}
function changeSortField(field: EntrySortField) {
return paged.load(
{ ...desired.value, sort: withSortField(desired.value.sort, field), page: 1 },
'push',
)
}
function toggleDirection() {
return paged.load(
{ ...desired.value, sort: withOppositeDirection(desired.value.sort), page: 1 },
'push',
)
}
return {
items,
status,
names,
shown: paged.shown,
desired,
total: paged.total,
hasLoaded: paged.hasLoaded,
loading: paged.loading,
pendingPage: paged.pendingPage,
notice: paged.notice,
showSkeleton: paged.showSkeleton,
skeletonActive: paged.skeletonActive,
pageCount: paged.pageCount,
start,
retry,
changePage: paged.changePage,
changeSize: paged.changeSize,
changeSortField,
toggleDirection,
retryNotice: paged.retryNotice,
}
}
+56
View File
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/ColumnPicker.vue',
'components/ColumnPickerList.vue',
'components/ContentEmpty.vue',
'components/ContentEntries.vue',
'components/ContentState.vue',
'components/ContentToolbar.vue',
'components/EntryCardList.vue',
'components/EntrySkeleton.vue',
'components/EntryTable.vue',
'components/FieldCell.vue',
'components/SortSelect.vue',
'lib/entryCells.ts',
'lib/entryListQuery.ts',
'views/ContentListView.vue',
]
const SAME_IN_FRENCH = new Set(['ID'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Entry list messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the screen', () => {
expect(keys.length).toBeGreaterThan(30)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
})
+28
View File
@@ -2503,6 +2503,34 @@ export const messages = {
'View entries': 'Voir les entrées',
'Content type deleted': 'Type de contenu supprimé',
'{0} was deleted.': '{0} a été supprimé.',
Columns: 'Colonnes',
'Choose up to {0} fields to show. {1} selected.':
'Choisissez jusqu’à {0} champs à afficher. {1} sélectionné(s).',
'Created and updated dates always show':
'Les dates de création et de modification sont toujours affichées',
'You can show up to {0} fields.': 'Vous pouvez afficher jusqu’à {0} champs.',
'No entries yet': 'Aucune entrée pour le moment',
'Use New entry to add the first entry. Each entry follows the fields of this content type.':
'Utilisez Nouvelle entrée pour ajouter la première entrée. Chaque entrée suit les champs de ce type de contenu.',
'New entry': 'Nouvelle entrée',
'1 entry': '1 entrée',
'{0} entries': '{0} entrées',
'GET /v1/content-type/{0} failed. Check your connection and try again.':
'GET /v1/content-type/{0} a échoué. Vérifiez votre connexion et réessayez.',
"Couldn't load entries": 'Impossible de charger les entrées',
"You don't have access to {0}": 'Vous n’avez pas accès à {0}',
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.":
'Votre compte ne peut pas ouvrir ce contenu pour le moment. Si vous pensez qu’il s’agit d’une erreur, contactez un propriétaire ou un administrateur.',
'Edit content type': 'Modifier le type de contenu',
ID: 'ID',
'Updated by': 'Modifié par',
'Sort by': 'Trier par',
'Sort descending': 'Trier par ordre décroissant',
'Sort ascending': 'Trier par ordre croissant',
'{0}, sorted by last updated, newest first':
'{0}, trié par dernière modification, plus récent en premier',
'{0}, sorted by {1}, {2}': '{0}, trié par {1}, {2}',
id: 'identifiant',
} as const
export type MessageKey = keyof typeof messages
@@ -34,9 +34,12 @@ describe('parseArticleQuery', () => {
})
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
expect(parseArticleQuery({ page }).page).toBe(1)
})
it.each(['abc', '0', '-2', '2.5', '', '1e2', '99999999999999999999999'])(
'treats page "%s" as 1',
(page) => {
expect(parseArticleQuery({ page }).page).toBe(1)
},
)
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
expect(parseArticleQuery({ size }).size).toBe(25)
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { apiField } from '@/lib/__tests__/schemaFixtures'
import {
cardFields,
describeCell,
formatDayValue,
plainCellText,
richTextFirstLine,
} from '@/lib/entryCells'
import { resolveContentListState } from '@/lib/contentListState'
const text = apiField('Title', 'title', 'text')
const rich = apiField('Description', 'description', 'richtext')
const number = apiField('Capacity', 'capacity', 'number')
const flag = apiField('Online', 'online', 'boolean')
const day = apiField('Starts on', 'starts_on', 'date')
const choice = apiField('Category', 'category', 'select')
describe('describeCell', () => {
it.each([
['text', text, 'Lime plaster', { kind: 'text', text: 'Lime plaster' }],
['number', number, 1500, { kind: 'number', text: '1,500' }],
['number zero', number, 0, { kind: 'number', text: '0' }],
['boolean true', flag, true, { kind: 'boolean', text: '', yes: true }],
['boolean false', flag, false, { kind: 'boolean', text: '', yes: false }],
['date', day, '2026-11-21', { kind: 'date', text: '21 Nov 2026' }],
['choice', choice, 'Workshop', { kind: 'choice', text: 'Workshop' }],
['rich text', rich, 'First line\nSecond', { kind: 'richtext', text: 'First line' }],
])('describes %s', (_, field, value, expected) => {
expect(describeCell(field, value)).toEqual(expected)
})
it.each([undefined, null, ''])('treats %s as empty for every type', (value) => {
for (const field of [text, rich, number, flag, day, choice]) {
expect(describeCell(field, value).kind).toBe('empty')
}
})
it('treats a rich text without a visible line as empty', () => {
expect(describeCell(rich, '\n \n').kind).toBe('empty')
expect(describeCell(rich, '##').kind).toBe('empty')
})
})
describe('formatDayValue', () => {
it.each([
['2026-11-21', '21 Nov 2026'],
['2027-01-01', '1 Jan 2027'],
['2026-12-31', '31 Dec 2026'],
['2026-11-21T23:30:00Z', '21 Nov 2026'],
['soon', 'soon'],
])('reads %s as %s without shifting the day', (value, expected) => {
expect(formatDayValue(value)).toBe(expected)
})
})
describe('richTextFirstLine', () => {
it.each([
['Two days of **lime putty** and stone.', 'Two days of lime putty and stone.'],
['# A heading\nBody', 'A heading'],
['\n\n- First item\n- Second', 'First item'],
['1. Ordered', 'Ordered'],
['> Quote', 'Quote'],
['See [the guide](https://example.test/guide)', 'See the guide'],
['![alt text](https://example.test/a.png)', 'alt text'],
['`code` and ~~gone~~', 'code and gone'],
['snake_case stays', 'snake_case stays'],
])('turns "%s" into "%s"', (markdown, expected) => {
expect(richTextFirstLine(markdown)).toBe(expected)
})
})
describe('plainCellText', () => {
it('spells empty, yes and no and passes text through', () => {
expect(plainCellText(describeCell(text, ''))).toBe('–')
expect(plainCellText(describeCell(flag, true))).toBe('Yes')
expect(plainCellText(describeCell(flag, false))).toBe('No')
expect(plainCellText(describeCell(day, '2026-11-21'))).toBe('21 Nov 2026')
})
})
describe('cardFields', () => {
it('picks the first two fields and the first choice', () => {
expect(cardFields([text, day, flag, choice])).toEqual({
first: text,
second: day,
choice,
})
})
it('has no choice when the type has none, and never repeats the first field', () => {
expect(cardFields([text, day]).choice).toBeUndefined()
expect(cardFields([choice, day]).choice).toBeUndefined()
})
it('copes with an empty type', () => {
expect(cardFields([])).toEqual({ first: undefined, second: undefined, choice: undefined })
})
})
describe('resolveContentListState', () => {
it.each([
['ready', 'ready', 7, 'list'],
['ready', 'ready', 0, 'empty'],
['loading', 'ready', 7, 'loading'],
['ready', 'loading', 0, 'loading'],
['ready', 'failed', 0, 'entriesError'],
['failed', 'ready', 7, 'typeError'],
['failed', 'failed', 0, 'typeError'],
['forbidden', 'loading', 0, 'forbidden'],
['ready', 'forbidden', 0, 'forbidden'],
['missing', 'ready', 0, 'notFound'],
['ready', 'missing', 0, 'notFound'],
['forbidden', 'missing', 0, 'notFound'],
] as const)('type %s, entries %s, total %s is %s', (type, entries, total, expected) => {
expect(resolveContentListState({ type, entries, total })).toBe(expected)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
import {
clearStoredColumns,
columnsStorageKey,
defaultColumnKeys,
readStoredColumns,
resolveColumnKeys,
storeColumns,
toggleColumnKeys,
} from '@/lib/entryColumns'
const KEY = 'renewcms-content-columns-events'
const ALL = eventsFields.map((field) => field.key)
afterEach(() => {
vi.unstubAllGlobals()
localStorage.clear()
})
describe('defaultColumnKeys', () => {
it('takes the first four fields', () => {
expect(defaultColumnKeys(eventsFields)).toEqual(['title', 'starts_on', 'ends_on', 'online'])
})
it('takes fewer when the type has fewer', () => {
expect(defaultColumnKeys(eventsFields.slice(0, 2))).toEqual(['title', 'starts_on'])
})
})
describe('resolveColumnKeys', () => {
it.each([
['nothing stored', null, ['title', 'starts_on', 'ends_on', 'online']],
['not an array', 'title', ['title', 'starts_on', 'ends_on', 'online']],
['an empty array', [], ['title', 'starts_on', 'ends_on', 'online']],
['only unknown keys', ['gone'], ['title', 'starts_on', 'ends_on', 'online']],
['unknown keys dropped', ['title', 'gone', 'capacity'], ['title', 'capacity']],
['schema order, not stored order', ['capacity', 'title'], ['title', 'capacity']],
['non-string items ignored', [1, null, 'title'], ['title']],
['more than six, first six in schema order', [...ALL].reverse(), ALL.slice(0, 6)],
['duplicates counted once', ['title', 'title', 'online'], ['title', 'online']],
])('%s', (_, stored, expected) => {
expect(resolveColumnKeys(eventsFields, stored)).toEqual(expected)
})
})
describe('toggleColumnKeys', () => {
it('adds a key in schema order', () => {
expect(toggleColumnKeys(eventsFields, ['title', 'capacity'], 'online', true)).toEqual([
'title',
'online',
'capacity',
])
})
it('removes a key', () => {
expect(toggleColumnKeys(eventsFields, ['title', 'online'], 'online', false)).toEqual([
'title',
])
})
it('never removes the last field column', () => {
const one = ['capacity']
expect(toggleColumnKeys(eventsFields, one, 'capacity', false)).toEqual(['capacity'])
})
it('refuses a seventh column', () => {
const six = ALL.slice(0, 6)
expect(toggleColumnKeys(eventsFields, six, 'description', true)).toBe(six)
})
})
describe('storage', () => {
it('uses one key per type', () => {
expect(columnsStorageKey('events')).toBe(KEY)
expect(columnsStorageKey('recipes')).toBe('renewcms-content-columns-recipes')
})
it('writes, reads and clears', () => {
storeColumns('events', ['title', 'capacity'])
expect(localStorage.getItem(KEY)).toBe('["title","capacity"]')
expect(readStoredColumns('events')).toEqual(['title', 'capacity'])
clearStoredColumns('events')
expect(localStorage.getItem(KEY)).toBeNull()
})
it.each(['not json', '{', ''])('reads "%s" as nothing usable', (raw) => {
localStorage.setItem(KEY, raw)
expect(resolveColumnKeys(eventsFields, readStoredColumns('events'))).toEqual(
defaultColumnKeys(eventsFields),
)
})
it('swallows a throwing or missing localStorage', () => {
vi.stubGlobal('localStorage', undefined)
expect(readStoredColumns('events')).toBeNull()
expect(() => storeColumns('events', ['title'])).not.toThrow()
expect(() => clearStoredColumns('events')).not.toThrow()
})
})
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
entryCaption,
parseEntryQuery,
sameEntryQuery,
sortDirectionLabel,
toEntryRouteQuery,
withOppositeDirection,
withSortField,
} from '@/lib/entryListQuery'
describe('parseEntryQuery', () => {
it('falls back to page 1, size 25 and updated_at:desc', () => {
expect(parseEntryQuery({})).toEqual({
page: 1,
size: 25,
q: '',
sort: 'updated_at:desc',
})
})
it('reads valid values', () => {
expect(parseEntryQuery({ page: '2', size: '50', sort: 'created_at:asc' })).toEqual({
page: 2,
size: 50,
q: '',
sort: 'created_at:asc',
})
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
expect(parseEntryQuery({ page }).page).toBe(1)
})
it.each(['10', '0', 'abc', '', '500'])('treats size "%s" as 25', (size) => {
expect(parseEntryQuery({ size }).size).toBe(25)
})
it.each([
'title:asc',
'data:asc',
'id',
'id:up',
'ID:asc',
':asc',
'updated_at:',
'created_at:asc:x',
])('treats sort "%s" as the default', (sort) => {
expect(parseEntryQuery({ sort }).sort).toBe('updated_at:desc')
})
it.each(['99999999999999999999999', '9007199254740993', ' 5', '5e0', '0x5', '5 ', '+5', '05'])(
'rejects the unsafe or non canonical number "%s"',
(value) => {
expect(parseEntryQuery({ page: value, size: value })).toMatchObject({
page: 1,
size: 25,
})
},
)
it('keeps the largest safe page', () => {
expect(parseEntryQuery({ page: '9007199254740991' }).page).toBe(9007199254740991)
})
it('takes the first of repeated values', () => {
expect(parseEntryQuery({ page: ['3', '4'], size: ['5', '50'] })).toMatchObject({
page: 3,
size: 5,
})
})
})
describe('toEntryRouteQuery', () => {
it('omits the defaults', () => {
expect(toEntryRouteQuery({ page: 1, size: 25, q: '', sort: 'updated_at:desc' })).toEqual({})
})
it('writes size, then sort, then page', () => {
const route = toEntryRouteQuery({ page: 2, size: 5, q: '', sort: 'id:asc' })
expect(Object.keys(route)).toEqual(['size', 'sort', 'page'])
expect(route).toEqual({ size: '5', sort: 'id:asc', page: '2' })
})
it('keeps the sort of an ascending default field', () => {
expect(toEntryRouteQuery({ page: 1, size: 25, q: '', sort: 'updated_at:asc' })).toEqual({
sort: 'updated_at:asc',
})
})
})
describe('sameEntryQuery', () => {
const base = { page: 1, size: 25, q: '', sort: 'updated_at:desc' }
it('compares page, size and sort', () => {
expect(sameEntryQuery(base, { ...base })).toBe(true)
expect(sameEntryQuery(base, { ...base, page: 2 })).toBe(false)
expect(sameEntryQuery(base, { ...base, size: 5 })).toBe(false)
expect(sameEntryQuery(base, { ...base, sort: 'id:desc' })).toBe(false)
})
})
describe('sort helpers', () => {
it.each([
['updated_at:desc', 'id', 'id:desc'],
['updated_at:asc', 'created_at', 'created_at:asc'],
['id:asc', 'updated_at', 'updated_at:asc'],
] as const)('moves %s to field %s as %s', (sort, field, expected) => {
expect(withSortField(sort, field)).toBe(expected)
})
it.each([
['updated_at:desc', 'updated_at:asc'],
['updated_at:asc', 'updated_at:desc'],
['id:desc', 'id:asc'],
])('flips %s to %s', (sort, expected) => {
expect(withOppositeDirection(sort)).toBe(expected)
})
it('names the next action on the direction button', () => {
expect(sortDirectionLabel('updated_at:desc')).toBe('Sort ascending')
expect(sortDirectionLabel('id:asc')).toBe('Sort descending')
})
})
describe('entryCaption', () => {
it.each([
['updated_at:desc', 'Events, sorted by last updated, newest first'],
['updated_at:asc', 'Events, sorted by updated, ascending'],
['id:desc', 'Events, sorted by id, descending'],
['created_at:asc', 'Events, sorted by created, ascending'],
])('reads %s as "%s"', (sort, caption) => {
expect(entryCaption('Events', sort)).toBe(caption)
})
})
+39
View File
@@ -0,0 +1,39 @@
import type { ContentTypeDetailStatus, EntryListStatus } from '@/lib/contentTypes'
export type ContentListState =
| 'notFound'
| 'forbidden'
| 'typeError'
| 'entriesError'
| 'loading'
| 'empty'
| 'list'
interface ContentListInputs {
type: ContentTypeDetailStatus
entries: EntryListStatus
total: number
}
export function resolveContentListState({
type,
entries,
total,
}: ContentListInputs): ContentListState {
if (type === 'missing' || entries === 'missing') {
return 'notFound'
}
if (type === 'forbidden' || entries === 'forbidden') {
return 'forbidden'
}
if (type === 'failed') {
return 'typeError'
}
if (entries === 'failed') {
return 'entriesError'
}
if (type === 'loading' || entries === 'loading') {
return 'loading'
}
return total === 0 ? 'empty' : 'list'
}
+60
View File
@@ -0,0 +1,60 @@
export interface BuiltinField {
key: string
label: string
type: string
required: boolean
unique: boolean
default: unknown
min: number | null
max: number | null
options: unknown[] | null
}
export interface ContentType {
id: number
key: string
name: string
icon: string | null
fields: BuiltinField[]
created_at: string
updated_at: string
}
export interface ContentEntry {
id: number
content_type_id: number
data: Record<string, unknown>
created_by: number
updated_by: number
created_at: string
updated_at: string
}
export interface EntryPage {
items: ContentEntry[]
total: number
page: number
size: number
sort: string
}
export interface EntryPageQuery {
page: number
size: number
sort: string
}
export interface UserName {
id: number
display_name: string
}
export interface BuiltinType {
key: string
name: string
fields: BuiltinField[]
}
export type ContentTypeDetailStatus = 'loading' | 'ready' | 'failed' | 'forbidden' | 'missing'
export type EntryListStatus = 'loading' | 'ready' | 'failed' | 'forbidden' | 'missing'
+95
View File
@@ -0,0 +1,95 @@
import { formatNumber, t } from '@/i18n'
import type { BuiltinField } from '@/lib/contentTypes'
import { formatAbsoluteDate, formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export type CellKind = 'empty' | 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
export interface CellView {
kind: CellKind
text: string
yes?: boolean
}
export const EMPTY_TEXT = '–'
const EMPTY: CellView = { kind: 'empty', text: '' }
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/
const LINE_PREFIX = /^\s{0,3}(?:#{1,6}(?:\s+|$)|>\s?|[-*+]\s+|\d+[.)]\s+)+/
const IMAGE_OR_LINK = /!?\[([^\]]*)\]\([^)]*\)/g
const EMPHASIS = /(\*\*|__|\*|`|~~)/g
export function richTextFirstLine(markdown: string) {
for (const line of markdown.split(/\r?\n/)) {
const plain = line
.replace(LINE_PREFIX, '')
.replace(IMAGE_OR_LINK, '$1')
.replace(EMPHASIS, '')
.trim()
if (plain) {
return plain
}
}
return ''
}
export function formatDayValue(value: string) {
const match = ISO_DAY.exec(value)
if (!match) {
return value
}
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
}
export function describeCell(field: BuiltinField, value: unknown): CellView {
if (value === undefined || value === null || value === '') {
return EMPTY
}
switch (field.type) {
case 'boolean':
return { kind: 'boolean', text: '', yes: value === true }
case 'number':
return typeof value === 'number'
? { kind: 'number', text: formatNumber(value) }
: { kind: 'number', text: String(value) }
case 'date':
return { kind: 'date', text: formatDayValue(String(value)) }
case 'select':
return { kind: 'choice', text: String(value) }
case 'richtext': {
const line = richTextFirstLine(String(value))
return line ? { kind: 'richtext', text: line } : EMPTY
}
default:
return { kind: 'text', text: String(value) }
}
}
export function formatEntryDate(iso: string) {
return formatAbsoluteDateFromIso(iso)
}
export function isEmptyCell(cell: CellView) {
return cell.kind === 'empty'
}
export function plainCellText(cell: CellView) {
if (cell.kind === 'empty') {
return EMPTY_TEXT
}
if (cell.kind === 'boolean') {
return cell.yes ? t('Yes') : t('No')
}
return cell.text
}
export interface CardFields {
first?: BuiltinField
second?: BuiltinField
choice?: BuiltinField
}
export function cardFields(fields: BuiltinField[]): CardFields {
const [first, second] = fields
const choice = fields.find((field) => field.type === 'select')
return { first, second, choice: choice === first ? undefined : choice }
}
+69
View File
@@ -0,0 +1,69 @@
import type { BuiltinField } from '@/lib/contentTypes'
export const COLUMN_CAP = 6
export const DEFAULT_COLUMN_COUNT = 4
const STORAGE_PREFIX = 'renewcms-content-columns-'
export function columnsStorageKey(typeKey: string) {
return `${STORAGE_PREFIX}${typeKey}`
}
export function defaultColumnKeys(fields: BuiltinField[]) {
return fields.slice(0, DEFAULT_COLUMN_COUNT).map((field) => field.key)
}
export function resolveColumnKeys(fields: BuiltinField[], stored: unknown) {
if (!Array.isArray(stored)) {
return defaultColumnKeys(fields)
}
const wanted = new Set(stored.filter((key): key is string => typeof key === 'string'))
const kept = fields
.map((field) => field.key)
.filter((key) => wanted.has(key))
.slice(0, COLUMN_CAP)
return kept.length > 0 ? kept : defaultColumnKeys(fields)
}
export function readStoredColumns(typeKey: string): unknown {
try {
const raw = localStorage.getItem(columnsStorageKey(typeKey))
return raw === null ? null : JSON.parse(raw)
} catch {
return null
}
}
export function storeColumns(typeKey: string, keys: string[]) {
try {
localStorage.setItem(columnsStorageKey(typeKey), JSON.stringify(keys))
} catch {
return
}
}
export function clearStoredColumns(typeKey: string) {
try {
localStorage.removeItem(columnsStorageKey(typeKey))
} catch {
return
}
}
export function toggleColumnKeys(
fields: BuiltinField[],
current: string[],
key: string,
on: boolean,
) {
const wanted = new Set(current)
if (on) {
if (wanted.size >= COLUMN_CAP) {
return current
}
wanted.add(key)
} else if (wanted.size > 1) {
wanted.delete(key)
}
return fields.map((field) => field.key).filter((fieldKey) => wanted.has(fieldKey))
}
+90
View File
@@ -0,0 +1,90 @@
import type { LocationQuery } from 'vue-router'
import { t } from '@/i18n'
import {
DEFAULT_PAGE_SIZE,
PAGE_SIZES,
firstQueryValue,
parsePositiveInteger,
} from '@/lib/pageQuery'
export const ENTRY_SORT_FIELDS = ['id', 'created_at', 'updated_at'] as const
export type EntrySortField = (typeof ENTRY_SORT_FIELDS)[number]
export type EntrySortDirection = 'asc' | 'desc'
export const DEFAULT_ENTRY_SORT = 'updated_at:desc'
export interface EntryListQuery {
page: number
size: number
q: string
sort: string
}
const SORT_PATTERN = new RegExp(`^(${ENTRY_SORT_FIELDS.join('|')}):(asc|desc)$`)
export function parseEntryQuery(query: LocationQuery): EntryListQuery {
const size = parsePositiveInteger(firstQueryValue(query.size))
const sort = firstQueryValue(query.sort)
return {
page: parsePositiveInteger(firstQueryValue(query.page)) ?? 1,
size: size !== null && PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
q: '',
sort: SORT_PATTERN.test(sort) ? sort : DEFAULT_ENTRY_SORT,
}
}
export function toEntryRouteQuery(query: EntryListQuery) {
const route: Record<string, string> = {}
if (query.size !== DEFAULT_PAGE_SIZE) {
route.size = String(query.size)
}
if (query.sort !== DEFAULT_ENTRY_SORT) {
route.sort = query.sort
}
if (query.page !== 1) {
route.page = String(query.page)
}
return route
}
export function sameEntryQuery(left: EntryListQuery, right: EntryListQuery) {
return left.page === right.page && left.size === right.size && left.sort === right.sort
}
export function sortParts(sort: string) {
const [field, direction] = sort.split(':') as [EntrySortField, EntrySortDirection]
return { field, direction }
}
export function withSortField(sort: string, field: EntrySortField) {
return `${field}:${sortParts(sort).direction}`
}
export function withOppositeDirection(sort: string) {
const { field, direction } = sortParts(sort)
return `${field}:${direction === 'asc' ? 'desc' : 'asc'}`
}
export function sortFieldLabel(field: EntrySortField) {
if (field === 'id') {
return t('ID')
}
return field === 'created_at' ? t('Created') : t('Last updated')
}
export function sortDirectionLabel(sort: string) {
return sortParts(sort).direction === 'asc' ? t('Sort descending') : t('Sort ascending')
}
export function entryCaption(name: string, sort: string) {
const { field, direction } = sortParts(sort)
if (sort === DEFAULT_ENTRY_SORT) {
return t('{0}, sorted by last updated, newest first', [name])
}
const column = { id: t('id'), created_at: t('created'), updated_at: t('updated') }[field]
return t('{0}, sorted by {1}, {2}', [
name,
column,
direction === 'desc' ? t('descending') : t('ascending'),
])
}
+5 -1
View File
@@ -12,7 +12,11 @@ export function firstQueryValue(value: LocationQuery[string] | undefined) {
}
export function parsePositiveInteger(value: string) {
return POSITIVE_INTEGER.test(value) ? Number(value) : null
if (!POSITIVE_INTEGER.test(value)) {
return null
}
const parsed = Number(value)
return Number.isSafeInteger(parsed) ? parsed : null
}
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
+1 -1
View File
@@ -19,7 +19,7 @@ const MONTH_NAMES = [
'Dec',
]
function formatAbsoluteDate(date: Date) {
export function formatAbsoluteDate(date: Date) {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', {
day: 'numeric',
+12
View File
@@ -1,2 +1,14 @@
export const HOME_PATH = '/home'
export const SCHEMA_PATH = '/schema'
export function contentPath(key: string) {
return `/content/${encodeURIComponent(key)}`
}
export function entryPath(typeKey: string, id: number | 'new') {
return `${contentPath(typeKey)}/${id}`
}
export function schemaPath(key: string) {
return `${SCHEMA_PATH}/${encodeURIComponent(key)}`
}
+5
View File
@@ -102,6 +102,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
},
{
path: '/content/:key',
component: () => import('@/views/ContentListView.vue'),
meta: { auth: 'verified', title: 'Content' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ContentEntries from '@/components/ContentEntries.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
import type { ContentListState } from '@/lib/contentListState'
import { useSessionStore } from '@/stores/session'
const route = useRoute()
const session = useSessionStore()
const key = computed(() => String(route.params.key))
const detail = useContentTypeDetail(() => key.value)
const listView = ref<ContentListState>('loading')
watch(key, () => {
listView.value = 'loading'
})
const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound')
const centered = computed(
() => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden',
)
const displayName = computed(() =>
notFound.value ? undefined : (detail.type.value?.name ?? key.value),
)
const mobileTitle = computed(() => displayName.value ?? t('Content'))
const breadcrumb = computed(() => [
{ label: t('Content') },
...(displayName.value ? [{ label: displayName.value }] : []),
])
</script>
<template>
<DashboardShell
compact-padding
:full-bleed="centered"
:mobile-title="mobileTitle"
:no-current-nav="notFound"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<ContentEntries
v-if="session.status === 'ready'"
:key="key"
:type-key="key"
:type="detail.type.value"
:type-status="detail.status.value"
:can-edit="session.canManageContent"
@view="listView = $event"
@retry-type="detail.retry"
/>
</DashboardShell>
</template>
+640
View File
@@ -0,0 +1,640 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
SKELETON_DELAY_MS,
button,
calls,
customTypes,
eventEntries,
failWith,
failWithBody,
listPage,
listRequests,
lookupNames,
lookupRequests,
manyTypes,
mountList,
networkFailure,
pending,
populated,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(unmountAll)
const NOT_FOUND = failWithBody(404, 'content_type_not_found')
const LONG_NAME = 'Frequently asked questions and answers'
function main() {
return document.body.querySelector('#dashboard-content')!
}
function headers() {
return [...main().querySelectorAll('thead th')].map(text)
}
function rows() {
return [...main().querySelectorAll('tbody tr')]
}
function cells(row: Element) {
return [...row.querySelectorAll('td')].map(text)
}
function skeletonRows() {
return main().querySelectorAll('[data-testid="entry-skeleton-row"]')
}
function topBarTitle() {
return text(document.body.querySelector('h1[data-slot="title"]'))
}
function breadcrumb() {
return text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
}
function currentSidebarRows() {
return document.body.querySelectorAll('nav[aria-label="Content"] a[aria-current="page"]')
}
async function afterDelay() {
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
}
describe('1 Populated, one page', () => {
it('shows the breadcrumb, the title and the subtitle', async () => {
await mountList('/content/events')
expect(breadcrumb()).toBe('Content Events')
expect(main().querySelector('h1')?.textContent).toBe('Events')
expect(text(main())).toContain('events · 7 fields')
})
it('puts New entry in the title row and the toolbar below it', async () => {
await mountList('/content/events')
expect(button('New entry')?.hasAttribute('disabled')).toBe(false)
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
expect(text(toolbar)).toBe('Sort by Last updated Columns Edit content type')
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
})
it('reads the headers with the arrow on Updated', async () => {
await mountList('/content/events')
expect(headers()).toEqual([
'ID',
'Title',
'Starts on',
'Ends on',
'Online',
'Created',
'Updated',
'Updated by',
])
const updated = [...main().querySelectorAll('thead th')][6]!
expect(updated.querySelector('[data-sort-direction="desc"]')).not.toBeNull()
expect(updated.getAttribute('aria-sort')).toBe('descending')
expect(main().querySelectorAll('thead th [data-sort-direction]')).toHaveLength(1)
})
it('leaves the first field column to take the free width and fixes the others', async () => {
await mountList('/content/events')
const [, first, second] = [...main().querySelectorAll('thead th')]
expect(first!.className).not.toMatch(/\bw-\d/)
expect(second!.className).toMatch(/\bw-\d/)
})
it('shows the seven rows, the first with its values', async () => {
await mountList('/content/events')
expect(rows()).toHaveLength(7)
expect(cells(rows()[0]!)).toEqual([
'42',
'Lime plaster workshop',
'21 Nov 2026',
'22 Nov 2026',
'No',
'12 Oct 2026',
'15 Oct 2026',
'Marta Alves',
])
expect(cells(rows()[1]!)).toContain('–')
})
it('shows only the count, no pager and no page size select, and leaves the URL bare', async () => {
await mountList('/content/events')
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entries')
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(document.body.querySelector('[aria-label="Rows per page"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/content/events')
expect(
document.body.querySelector('nav[aria-label="Content"] a[aria-current="page"]')
?.textContent,
).toContain('Events')
})
it('reads "1 entry" for a single entry', async () => {
await mountList('/content/events', {
list: (_, query) => listPage(eventEntries.slice(0, 1), 1, query),
})
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('1 entry')
})
it('reads "1 field" for a one-field type', async () => {
await mountList('/content/events', {
type: () => ({ ...customTypes[0]!, fields: customTypes[0]!.fields.slice(0, 1) }),
})
expect(text(main())).toContain('events · 1 field')
})
it('titles the top bar with the type name', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(topBarTitle()).toBe('Events')
})
it('requests the type and the entries with explicit page, size and sort', async () => {
await mountList('/content/events')
expect(calls).toContain('/v1/content-type/events')
expect(listRequests(calls)).toEqual([
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
])
})
})
describe('Updated by lookup', () => {
it('sends one lookup with the distinct ids after the entries answer', async () => {
await mountList('/content/events', {
list: (_, query) =>
listPage(
[
{ ...eventEntries[0]!, updated_by: 3 },
{ ...eventEntries[1]!, updated_by: 3 },
{ ...eventEntries[2]!, updated_by: 5 },
],
3,
query,
),
})
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3,5'])
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
expect(cells(rows()[2]!).at(-1)).toBe('Jonas Pereira')
})
it('sends no lookup before the entries answer', async () => {
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
expect(lookupRequests(calls)).toEqual([])
})
it('shows a muted dash until the lookup resolves', async () => {
const gate = pending()
await mountList('/content/events', { lookup: () => gate.promise })
expect(cells(rows()[0]!).at(-1)).toBe('–')
gate.resolve(lookupNames([3, 4, 5]))
await flushPromises()
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
})
it('shows dashes and no error when the lookup fails', async () => {
await mountList('/content/events', { lookup: failWith(500) })
expect(rows().map((row) => cells(row).at(-1))).toEqual(new Array(7).fill('–'))
expect(main().querySelector('[role="alert"]')).toBeNull()
})
it('shows a dash for an id the lookup does not know', async () => {
await mountList('/content/events', { lookup: () => lookupNames([3]) })
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
expect(cells(rows()[1]!).at(-1)).toBe('–')
})
it('leaves entries without an author out of the lookup', async () => {
await mountList('/content/events', {
list: (_, query) =>
listPage(
[
{ ...eventEntries[0]!, updated_by: 0 },
{ ...eventEntries[1]!, updated_by: 3 },
],
2,
query,
),
})
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3'])
})
it('sends no lookup when no entry has an author', async () => {
await mountList('/content/events', {
list: (_, query) => listPage([{ ...eventEntries[0]!, updated_by: 0 }], 1, query),
})
expect(lookupRequests(calls)).toEqual([])
})
it('ignores a lookup answer that a later page replaced', async () => {
const slow = pending()
await mountList('/content/events?size=5', {
list: (_, query) =>
query.get('page') === '2'
? listPage([{ ...eventEntries[0]!, updated_by: 4 }], 37, query)
: listPage([{ ...eventEntries[0]!, updated_by: 3 }], 37, query),
lookup: (ids) => (ids[0] === 3 ? slow.promise : lookupNames([4])),
})
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
await flushPromises()
slow.resolve([{ id: 4, display_name: 'Stale Name' }])
await flushPromises()
expect(cells(rows()[0]!).at(-1)).toBe('Inès Duarte')
})
it('sends no lookup for an empty list', async () => {
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
expect(lookupRequests(calls)).toEqual([])
})
})
describe('4 Many types in the sidebar', () => {
const types = () => [
customTypes[0]!,
...manyTypes(13).map((type, index) => ({
...type,
name: index === 2 ? LONG_NAME : type.name,
})),
]
function contentLinks() {
return [...document.body.querySelectorAll('nav[aria-label="Content"] a')].map(text)
}
it('shows the first five types and Show all 14 on desktop, behaving as before', async () => {
await mountList('/content/events', { content: types })
expect(contentLinks()).toEqual(['Events', 'Type 1', 'Type 2', LONG_NAME, 'Type 4'])
expect(text(document.body.querySelector('nav[aria-label="Content"]'))).toContain(
'Show all 14',
)
expect(currentSidebarRows()).toHaveLength(1)
})
it('shows the collapsed All content types 14 row in the mobile drawer', async () => {
await mountList('/content/events', { content: types, width: MOBILE_WIDTH })
document.body.querySelector<HTMLElement>('[data-slot="toggle"]')!.click()
await flushPromises()
const group = document.body.querySelector('nav[aria-label="Content"]')!
expect(text(group)).toContain('All content types')
expect(text(group)).toContain('14')
expect(contentLinks()).toEqual([])
})
})
describe('5 Loading', () => {
it('shows the title once the type has resolved, a disabled New entry and the toolbar', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
await afterDelay()
expect(main().querySelector('h1')?.textContent).toBe('Events')
expect(button('New entry')?.hasAttribute('disabled')).toBe(true)
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
expect(main().querySelector('table')).toBeNull()
})
it('shows no skeleton for 150 ms, then eight rows with aria-busy', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
expect(skeletonRows()).toHaveLength(0)
await afterDelay()
expect(skeletonRows()).toHaveLength(8)
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
})
it('shows six rows on mobile', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { list: () => gate.promise, width: MOBILE_WIDTH })
await afterDelay()
expect(skeletonRows()).toHaveLength(6)
})
it('shows a skeleton for the title until the type resolves', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { type: () => gate.promise })
expect(main().querySelector('h1')).toBeNull()
expect(main().querySelector('[data-slot="base"].bg-accented, .bg-accented')).not.toBeNull()
})
it('replaces the skeleton with the rows when both resolve', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { list: () => gate.promise })
await afterDelay()
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
await flushPromises()
expect(skeletonRows()).toHaveLength(0)
expect(rows()).toHaveLength(7)
})
it('keeps the skeleton while the type is slower than the entries', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { type: () => gate.promise })
expect(skeletonRows()).toHaveLength(8)
expect(rows()).toHaveLength(0)
})
})
describe('6 Empty for this type', () => {
const empty = (_: string, query: URLSearchParams) => listPage([], 0, query)
it('shows the empty state with its copy and action, and keeps the toolbar', async () => {
await mountList('/content/events', { list: empty })
expect(text(main())).toContain('No entries yet')
expect(text(main())).toContain(
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
)
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(text(main())).not.toContain('0 entries')
})
it('sends the empty state New entry action to /content/events/new', async () => {
await mountList('/content/events', { list: empty })
const action = [...main().querySelectorAll('button')].filter(
(candidate) => text(candidate) === 'New entry',
)
expect(action).toHaveLength(2)
action.at(-1)!.click()
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/content/events/new'),
)
})
it('shows the empty state for an out-of-range page of an empty type and cleans the URL', async () => {
await mountList('/content/events?page=3', { list: empty })
expect(text(main())).toContain('No entries yet')
expect(router.currentRoute.value.fullPath).toBe('/content/events')
})
})
describe('7 Error loading entries', () => {
it.each([
['a 500 response', failWith(500)],
['a network failure', networkFailure],
])('shows the alert for %s, without toolbar, list or pager', async (_, failure) => {
await mountList('/content/events', { list: failure })
expect(text(main())).toContain("Couldn't load entries")
expect(text(main())).toContain(
'GET /v1/content-type/events/entry failed. Check your connection and try again.',
)
expect(button('Try again')).toBeDefined()
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
expect(main().querySelector('table')).toBeNull()
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(main().querySelector('h1')?.textContent).toBe('Events')
})
it('shows the skeleton on Try again and sends the failed request again, not the type', async () => {
vi.useFakeTimers()
let attempt = 0
const gate = pending()
await mountList('/content/events', {
list: (key, query) => {
attempt++
if (attempt === 1) {
throw new Error('x')
}
return attempt === 2 ? gate.promise : populated(key, query)
},
})
button('Try again')!.click()
await afterDelay()
expect(skeletonRows()).toHaveLength(8)
expect(listRequests(calls)).toHaveLength(2)
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(1)
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
await flushPromises()
expect(rows()).toHaveLength(7)
})
it('shows no raw error body', async () => {
await mountList('/content/events', { list: failWithBody(500, 'sql: secret detail') })
expect(text(main())).not.toContain('secret detail')
})
})
describe('Error loading the type', () => {
it('shows the alert naming the type request and retries only that request', async () => {
let attempt = 0
await mountList('/content/events', {
type: (key) => {
attempt++
if (attempt === 1) {
throw new Error('x')
}
return customTypes.find((type) => type.key === key)
},
})
expect(text(main())).toContain("Couldn't load entries")
expect(text(main())).toContain(
'GET /v1/content-type/events failed. Check your connection and try again.',
)
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
button('Try again')!.click()
await flushPromises()
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(2)
expect(listRequests(calls)).toHaveLength(1)
expect(rows()).toHaveLength(7)
})
})
describe('8 Forbidden (403)', () => {
it.each([
['the entries request', { list: failWith(403) }, 'Events'],
['the type request', { type: failWith(403) }, 'events'],
])('shows the lock state when %s answers 403, titled %s', async (_, options, name) => {
await mountList('/content/events', options)
expect(text(main())).toContain(`You don't have access to ${name}`)
expect(text(main())).toContain(
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.",
)
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
expect(main().querySelector('table')).toBeNull()
expect(button('New entry')).toBeUndefined()
})
it('goes to Home from Go to Home', async () => {
await mountList('/content/events', { list: failWith(403) })
button('Go to Home')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
})
describe('9 Type not found', () => {
it.each([
['the type request', { type: NOT_FOUND }],
['the entries request', { list: NOT_FOUND }],
])('shows the file-x state when %s answers 404', async (_, options) => {
await mountList('/content/unknown', options)
expect(text(main())).toContain('Content type not found')
expect(text(main())).toContain('It may have been deleted, or the address may be mistyped.')
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
expect(breadcrumb()).toBe('Content')
expect(currentSidebarRows()).toHaveLength(0)
})
it('titles the mobile top bar Content', async () => {
await mountList('/content/unknown', {
type: NOT_FOUND,
list: NOT_FOUND,
width: MOBILE_WIDTH,
})
expect(topBarTitle()).toBe('Content')
})
it('goes to Home from Go to Home', async () => {
await mountList('/content/unknown', { type: NOT_FOUND, list: NOT_FOUND })
button('Go to Home')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('sends no lookup for a type that was deleted behind the sidebar', async () => {
await mountList('/content/recipes', { type: NOT_FOUND, list: NOT_FOUND })
expect(text(main())).toContain('Content type not found')
expect(lookupRequests(calls)).toEqual([])
})
})
describe('Session expired and stale answers', () => {
it('shows no error alert while a 401 sends the user to the login page', async () => {
await mountList('/content/events', { list: failWith(401) })
expect(text(main())).not.toContain("Couldn't load entries")
})
it('drops the answer of a request a later sort change replaced', async () => {
const stale = pending()
await mountList('/content/events?size=5', {
list: (_, query) =>
query.get('page') === '2'
? stale.promise
: listPage(eventEntries.slice(0, 5), 37, query),
})
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
await flushPromises()
document.body.querySelector<HTMLElement>('[aria-label="Sort ascending"]')!.click()
await flushPromises()
stale.resolve(listPage(eventEntries.slice(2, 4), 37, new URLSearchParams('page=2&size=5')))
await flushPromises()
expect(rows()).toHaveLength(5)
expect(router.currentRoute.value.fullPath).toBe(
'/content/events?size=5&sort=updated_at:asc',
)
})
})
describe('Switching to another type resets the page', () => {
it('ignores the type answer of the type the user left', async () => {
const gate = pending()
await mountList('/content/events', {
type: (key) =>
key === 'events' ? gate.promise : customTypes.find((t) => t.key === key),
})
await router.push('/content/recipes')
await flushPromises()
gate.resolve(customTypes.find((type) => type.key === 'events'))
await flushPromises()
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
expect(text(main())).toContain('recipes')
})
it('requests Recipes with page 1, size 25 and the default sort, and shows no Events row', async () => {
await mountList('/content/events?page=3&size=50&sort=id:asc')
expect(listRequests(calls).at(-1)).toContain('page=3&size=50&sort=id:asc')
await router.push('/content/recipes')
await flushPromises()
expect(listRequests(calls).at(-1)).toBe(
'/v1/content-type/recipes/entry?page=1&size=25&sort=updated_at:desc',
)
expect(text(main())).not.toContain('Lime plaster workshop')
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
expect(router.currentRoute.value.fullPath).toBe('/content/recipes')
})
it('shows no Events row while Recipes is loading', async () => {
await mountList('/content/events')
const gate = pending()
await router.push('/content/recipes')
expect(text(main())).not.toContain('Lime plaster workshop')
expect(headers()).toEqual([])
gate.resolve(undefined)
})
})
@@ -0,0 +1,454 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
calls,
customTypes,
eventEntries,
installSelectPolyfills,
listPage,
listRequests,
mountList,
text,
unmountAll,
} from './contentTestKit'
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
await unmountAll()
})
const KEY = 'renewcms-content-columns-events'
const DEFAULT_HEADERS = ['ID', 'Title', 'Starts on', 'Ends on', 'Online']
const TAIL_HEADERS = ['Created', 'Updated', 'Updated by']
const ALL_KEYS = eventsFields.map((field) => field.key)
function seed(value: string, key = KEY) {
return () => localStorage.setItem(key, value)
}
function headers() {
return [...document.body.querySelectorAll('thead th')].map(text)
}
function trigger() {
return [...document.body.querySelectorAll('button')].find(
(candidate) =>
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
) as HTMLElement
}
async function openPicker() {
trigger().click()
await flushPromises()
}
function picker() {
return document.body.querySelector('[data-testid="column-picker"], [role="dialog"]')
}
function pickerRows() {
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="column-row"]')]
}
function checkbox(label: string) {
const row = pickerRows().find((candidate) => text(candidate).startsWith(label))!
return row.querySelector<HTMLElement>('[role="checkbox"]')!
}
function checkedLabels() {
return pickerRows()
.filter(
(row) =>
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') === 'true',
)
.map((row) =>
text(row).split(' ')[0] === 'Starts' ? 'Starts on' : text(row).split(' ')[0]!,
)
}
function isDisabled(label: string) {
return (
checkbox(label).hasAttribute('disabled') ||
checkbox(label).getAttribute('data-disabled') !== null
)
}
async function toggle(label: string) {
checkbox(label).click()
await flushPromises()
}
function stored() {
return localStorage.getItem(KEY)
}
function button(label: string) {
return [...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === label,
)
}
describe('3 Columns menu', () => {
it('opens a popover titled Columns with 4 of 6, a Reset and no Done', async () => {
await mountList('/content/events')
await openPicker()
const popover = picker()!
expect(popover.getAttribute('role')).toBe('dialog')
expect(text(popover)).toContain('Columns')
expect(text(popover)).toContain('4 of 6')
expect(button('Reset')).toBeDefined()
expect(button('Done')).toBeUndefined()
expect(text(popover)).not.toContain('Choose up to 6 fields to show')
})
it('lists the seven fields in schema order with icon, label and key, four checked', async () => {
await mountList('/content/events')
await openPicker()
expect(pickerRows().map(text)).toEqual([
'Title title',
'Starts on starts_on',
'Ends on ends_on',
'Online online',
'Capacity capacity',
'Category category',
'Description description',
])
expect(
pickerRows().map((row) =>
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked'),
),
).toEqual(['true', 'true', 'true', 'true', 'false', 'false', 'false'])
expect(pickerRows().every((row) => row.querySelector('svg') !== null)).toBe(true)
})
it('ends with a disabled, unchecked clock row about the dates', async () => {
await mountList('/content/events')
await openPicker()
const row = document.body.querySelector('[data-testid="column-fixed-row"]')!
expect(text(row)).toBe('Created and updated dates always show')
const box = row.querySelector('[role="checkbox"]')!
expect(box.getAttribute('aria-checked')).toBe('false')
expect(box.hasAttribute('disabled')).toBe(true)
})
it('adds a right-aligned Capacity column at once, with no request, and counts 5 of 6', async () => {
await mountList('/content/events')
const before = listRequests(calls).length
await openPicker()
await toggle('Capacity')
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
const capacity = [...document.body.querySelectorAll('thead th')][5]!
expect(capacity.querySelector('span')?.className).toContain('justify-end')
expect(document.body.querySelector('tbody tr td:nth-child(6) span')?.className).toContain(
'text-right',
)
expect(text(picker())).toContain('5 of 6')
expect(listRequests(calls)).toHaveLength(before)
})
it('removes a column when it is unchecked', async () => {
await mountList('/content/events')
await openPicker()
await toggle('Online')
expect(headers()).toEqual(['ID', 'Title', 'Starts on', 'Ends on', ...TAIL_HEADERS])
expect(text(picker())).toContain('3 of 6')
})
it('closes on Escape and returns focus to Columns', async () => {
await mountList('/content/events')
await openPicker()
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
picker()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
expect(document.activeElement).toBe(trigger())
})
it('closes on a click outside', async () => {
await mountList('/content/events')
await openPicker()
const outside = document.querySelector('h1')!
outside.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
outside.click()
await flushPromises()
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
})
})
describe('3 Columns menu on mobile', () => {
it('opens a sheet with the sentence, Reset and Done, and Done closes it', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
await openPicker()
const sheet = document.body.querySelector('[role="dialog"]')!
expect(text(sheet)).toContain('Columns')
expect(text(sheet)).toContain('4 of 6')
expect(text(sheet)).toContain('Choose up to 6 fields to show. 4 selected.')
expect(button('Reset')).toBeDefined()
expect(button('Done')).toBeDefined()
expect(pickerRows()).toHaveLength(7)
button('Done')!.click()
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
expect(document.activeElement).toBe(trigger())
})
it('counts the selection in the sentence', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
await openPicker()
await toggle('Capacity')
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
'Choose up to 6 fields to show. 5 selected.',
)
})
it('keeps the cards unchanged when the selection changes', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
const before = [...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)
await openPicker()
await toggle('Capacity')
await toggle('Title')
expect([...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)).toEqual(
before,
)
})
})
describe('The column cap', () => {
const SIX = JSON.stringify(ALL_KEYS.slice(0, 6))
it('disables the unchecked fields and explains the cap at six', async () => {
await mountList('/content/events', { before: seed(SIX) })
await openPicker()
expect(isDisabled('Description')).toBe(true)
expect(isDisabled('Title')).toBe(false)
expect(text(picker())).toContain('You can show up to 6 fields.')
expect(text(picker())).toContain('6 of 6')
})
it('shows no cap message below six', async () => {
await mountList('/content/events')
await openPicker()
expect(text(picker())).not.toContain('You can show up to 6 fields.')
expect(isDisabled('Description')).toBe(false)
})
it('checks a sixth and disables the others', async () => {
await mountList('/content/events', { before: seed(JSON.stringify(ALL_KEYS.slice(0, 5))) })
await openPicker()
await toggle('Description')
expect(checkedLabels()).toHaveLength(6)
expect(isDisabled('Category')).toBe(true)
expect(isDisabled('Title')).toBe(false)
expect(text(picker())).toContain('6 of 6')
})
it('enables the others again when one is unchecked', async () => {
await mountList('/content/events', { before: seed(SIX) })
await openPicker()
await toggle('Title')
expect(isDisabled('Description')).toBe(false)
expect(text(picker())).not.toContain('You can show up to 6 fields.')
})
it('shows both field columns and still reads the cap for a type with two fields', async () => {
await mountList('/content/events', {
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 2) }),
})
expect(headers()).toEqual(['ID', 'Title', 'Starts on', ...TAIL_HEADERS])
await openPicker()
expect(text(picker())).toContain('2 of 6')
})
})
describe('The last field column cannot be unchecked', () => {
it('disables the only checked field and keeps its column', async () => {
await mountList('/content/events', { before: seed('["capacity"]') })
await openPicker()
expect(isDisabled('Capacity')).toBe(true)
expect(isDisabled('Title')).toBe(false)
await toggle('Capacity')
expect(headers()).toEqual(['ID', 'Capacity', ...TAIL_HEADERS])
expect(text(picker())).toContain('1 of 6')
expect(document.body.querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
})
it('unlocks it again once a second field is checked', async () => {
await mountList('/content/events', { before: seed('["capacity"]') })
await openPicker()
await toggle('Title')
expect(isDisabled('Capacity')).toBe(false)
})
})
describe('The stored choice', () => {
it('writes the default four plus Capacity when Capacity is checked and nothing was stored', async () => {
await mountList('/content/events')
expect(stored()).toBeNull()
await openPicker()
await toggle('Capacity')
expect(stored()).toBe('["title","starts_on","ends_on","online","capacity"]')
})
it('removes the stored key and applies the default four on Reset', async () => {
await mountList('/content/events', { before: seed('["title","capacity"]') })
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
await openPicker()
button('Reset')!.click()
await flushPromises()
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
expect(stored()).toBeNull()
})
it('drops unknown keys on load and writes nothing', async () => {
let setItem!: ReturnType<typeof vi.spyOn>
let removeItem!: ReturnType<typeof vi.spyOn>
await mountList('/content/events', {
before: () => {
seed('["title","gone","capacity"]')()
setItem = vi.spyOn(Storage.prototype, 'setItem')
removeItem = vi.spyOn(Storage.prototype, 'removeItem')
},
})
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
expect(stored()).toBe('["title","gone","capacity"]')
expect(setItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
expect(removeItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
})
it('shows the first six in schema order for more stored keys than the cap', async () => {
await mountList('/content/events', {
before: seed(JSON.stringify([...ALL_KEYS, 'title'])),
})
expect(headers()).toEqual([
'ID',
'Title',
'Starts on',
'Ends on',
'Online',
'Capacity',
'Category',
...TAIL_HEADERS,
])
})
it.each(['not json', '[]', '{"title":true}', '"title"', 'null'])(
'shows the default four for the stored value %s',
async (raw) => {
await mountList('/content/events', { before: seed(raw) })
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
expect(stored()).toBe(raw)
},
)
it('changes the columns for this visit when localStorage throws, with no error', async () => {
await mountList('/content/events')
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await openPicker()
await toggle('Capacity')
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('reads and changes columns when localStorage is undefined', async () => {
await mountList('/content/events', {
before: () => vi.stubGlobal('localStorage', undefined),
})
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
await openPicker()
await toggle('Capacity')
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
})
it('gives Recipes its own key and its default when only Events has a stored choice', async () => {
await mountList('/content/recipes', {
before: seed('["title","capacity"]'),
list: (_, query) => listPage(eventEntries, 7, query),
})
expect(headers()).toEqual([
'ID',
'Title',
'Servings',
'Vegetarian',
'Method',
...TAIL_HEADERS,
])
})
it('applies the Events choice again after switching from Recipes to Events', async () => {
await mountList('/content/recipes', {
before: seed('["title","capacity"]'),
list: (_, query) => listPage(eventEntries, 7, query),
})
await router.push('/content/events')
await flushPromises()
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
})
})
@@ -0,0 +1,314 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { setLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
import {
SKELETON_DELAY_MS,
button,
customTypes,
eventEntries,
failWith,
installSelectPolyfills,
listPage,
mountList,
pending,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(unmountAll)
function main() {
return document.body.querySelector('#dashboard-content')!
}
function link(label: string) {
return [...main().querySelectorAll<HTMLAnchorElement>('a')].find(
(candidate) => text(candidate) === label,
)
}
function cards() {
return [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-card"]')]
}
function floatingButton() {
return [...main().querySelectorAll('button')].find(
(candidate) => text(candidate) === 'New entry' && candidate.className.includes('fixed'),
)
}
async function arrived(path: string) {
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path))
}
describe('Navigation actions', () => {
it('sends New entry to /content/events/new', async () => {
await mountList('/content/events')
button('New entry')!.click()
await arrived('/content/events/new')
})
it('links the first cell of every row to /content/events/:id and navigates through the router', async () => {
await mountList('/content/events')
const links = [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-link"]')]
expect(links.map((anchor) => anchor.getAttribute('href'))).toEqual(
eventEntries.map((entry) => `/content/events/${entry.id}`),
)
expect(text(links[0])).toBe('Lime plaster workshop')
links[0]!.click()
await arrived('/content/events/42')
})
it('links the ID cell when no field column is chosen', async () => {
await mountList('/content/events', {
before: () => localStorage.setItem('renewcms-content-columns-events', '["gone"]'),
})
expect(main().querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
})
it.each(['owner', 'admin'])(
'sends Edit content type to /schema/events for a %s',
async (role) => {
await mountList('/content/events', { role })
const edit = link('Edit content type')!
expect(edit.getAttribute('href')).toBe('/schema/events')
edit.click()
await arrived('/schema/events')
},
)
it.each(['user', 'editor', 'unknown'])(
'does not render Edit content type for the role %s',
async (role) => {
await mountList('/content/events', { role })
expect(link('Edit content type')).toBeUndefined()
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
},
)
it('does not render Edit content type below md', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(link('Edit content type')).toBeUndefined()
})
it('puts the toolbar in the order select, direction, Columns, Edit', async () => {
await mountList('/content/events')
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
const order = [...toolbar.querySelectorAll('button, a')].map(
(element) => element.getAttribute('aria-label') ?? text(element),
)
expect(order).toEqual(['Sort by', 'Sort ascending', 'Columns', 'Edit content type'])
})
})
describe('Mobile cards', () => {
it('shows a card per entry with the first field, the second field, the tag and the author', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(main().querySelector('table')).toBeNull()
expect(cards()).toHaveLength(7)
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · Marta Alves')
expect(cards()[0]!.querySelector('svg')).not.toBeNull()
})
it('links the whole card to /content/events/:id through the router', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(cards()[1]!.getAttribute('href')).toBe('/content/events/41')
cards()[1]!.click()
await arrived('/content/events/41')
})
it('shows a muted dash for the author until the lookup resolves', async () => {
const gate = pending()
await mountList('/content/events', { width: MOBILE_WIDTH, lookup: () => gate.promise })
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · –')
})
it('shows no tag for a type without a choice field and no tag when the choice is empty', async () => {
await mountList('/content/events', {
width: MOBILE_WIDTH,
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 3) }),
})
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 · Marta Alves')
})
it('shows the choice once, as the tag, when the second field is the choice', async () => {
const [title, , , , , category] = eventsFields
await mountList('/content/events', {
width: MOBILE_WIDTH,
type: () => ({ ...customTypes[0]!, fields: [title, category] }),
})
expect(text(cards()[0])).toBe('Lime plaster workshop Workshop · Marta Alves')
})
it('shows a floating New entry button that goes to /content/events/new', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(button('New entry')).toBe(floatingButton())
floatingButton()!.click()
await arrived('/content/events/new')
})
it('shows the floating button on the empty state', async () => {
await mountList('/content/events', {
width: MOBILE_WIDTH,
list: (_, query) => listPage([], 0, query),
})
expect(floatingButton()).toBeDefined()
})
it('hides the floating button while loading', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events', { width: MOBILE_WIDTH, list: () => gate.promise })
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(floatingButton()).toBeUndefined()
})
it('hides the floating button in the error state', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH, list: failWith(500) })
expect(floatingButton()).toBeUndefined()
})
it('shows no New entry in the title row on mobile besides the floating one', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(
[...main().querySelectorAll('button')].filter(
(candidate) => text(candidate) === 'New entry',
),
).toHaveLength(1)
})
})
describe('Accessibility', () => {
it.each([
['/content/events', 'Events, sorted by last updated, newest first'],
['/content/events?sort=updated_at:asc', 'Events, sorted by updated, ascending'],
['/content/events?sort=id:desc', 'Events, sorted by id, descending'],
['/content/events?sort=created_at:desc', 'Events, sorted by created, descending'],
])('captions the table for %s as "%s"', async (path, caption) => {
await mountList(path)
expect(text(main().querySelector('table caption'))).toBe(caption)
expect(main().querySelector('table caption')?.className).toContain('sr-only')
})
it('names Columns, the direction button and the select', async () => {
await mountList('/content/events')
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
expect(toolbar.querySelector('[aria-label="Sort by"]')).not.toBeNull()
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
expect(button('Columns')).toBeDefined()
})
it('names the icon-only Columns button on mobile', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(main().querySelector('button[aria-label="Columns"]')).not.toBeNull()
})
it('makes the controls 44 px high on mobile', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
expect(main().querySelector('[aria-label="Sort by"]')?.className).toContain('h-11')
expect(main().querySelector('[aria-label="Sort ascending"]')?.className).toContain(
'size-11',
)
expect(main().querySelector('button[aria-label="Columns"]')?.className).toContain('h-11')
})
it('moves focus into the sheet on mobile and keeps it there', async () => {
await mountList('/content/events', { width: MOBILE_WIDTH })
main().querySelector<HTMLElement>('button[aria-label="Columns"]')!.click()
await flushPromises()
const sheet = document.body.querySelector('[role="dialog"]')!
expect(sheet.contains(document.activeElement)).toBe(true)
})
it('toggles a field from its checkbox without closing the picker', async () => {
await mountList('/content/events')
button('Columns')!.click()
await flushPromises()
const box = document.body.querySelector<HTMLElement>(
'[data-testid="column-row"] [role="checkbox"]',
)!
box.focus()
box.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
box.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))
box.click()
await flushPromises()
expect(box.getAttribute('aria-checked')).toBe('false')
expect(document.body.querySelector('[data-testid="column-picker"]')).not.toBeNull()
})
})
describe('French', () => {
afterEach(() => setLocale('en'))
it('translates the toolbar, the headers, the count and the caption', async () => {
setLocale('fr')
await mountList('/content/events')
expect(text(main().querySelector('[data-testid="entry-toolbar"]'))).toBe(
'Trier par Dernière mise à jour Colonnes Modifier le type de contenu',
)
expect([...main().querySelectorAll('thead th')].map(text).slice(-3)).toEqual([
'Créé',
'Modifié',
'Modifié par',
])
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entrées')
expect(text(main().querySelector('table caption'))).toBe(
'Events, trié par dernière modification, plus récent en premier',
)
expect(button('Nouvelle entrée')).toBeDefined()
})
it('translates the empty state', async () => {
setLocale('fr')
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
expect(text(main())).toContain('Aucune entrée pour le moment')
expect(text(main())).toContain('ajouter la première entrée. Chaque entrée suit')
})
})
@@ -0,0 +1,289 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
SKELETON_DELAY_MS,
calls,
chooseSize,
eventEntries,
failWith,
installSelectPolyfills,
listPage,
listRequests,
mountList,
pending,
press,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(unmountAll)
const TOTAL = 37
function paged(_: string, query: URLSearchParams) {
const size = Number(query.get('size'))
const page = Number(query.get('page'))
const remaining = Math.max(0, Math.min(size, TOTAL - (page - 1) * size))
return listPage(
Array.from({ length: Math.min(remaining, 7) }, (__, index) => eventEntries[index]),
TOTAL,
query,
)
}
function main() {
return document.body.querySelector('#dashboard-content')!
}
function summary() {
return text(document.body.querySelector('[data-testid="paginator-summary"]'))
}
function pagerButtons() {
return [
...document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
]
}
function pagerLabels() {
return [...document.body.querySelectorAll('nav[aria-label="Pagination"] > *')].map(
(element) => element.getAttribute('aria-label') ?? text(element),
)
}
function lastRequest() {
return listRequests(calls).at(-1)
}
function alertText() {
return text(main().querySelector('[role="alert"], [role="status"]'))
}
describe('2 Paginated, 5 per page', () => {
it('shows the summary, the page size and the paginator', async () => {
await mountList('/content/events?size=5', { list: paged })
expect(summary()).toBe('Showing 1–5 of 37')
expect(text(document.body.querySelector('[aria-label="Rows per page"]'))).toBe('5 per page')
expect(pagerLabels()).toEqual([
'First page',
'Previous page',
'1',
'2',
'…',
'8',
'Next page',
'Last page',
])
expect(
pagerButtons()
.slice(0, 2)
.every((button) => button.disabled),
).toBe(true)
expect(
main()
.querySelector('nav[aria-label="Pagination"] [aria-current="page"]')
?.textContent?.trim(),
).toBe('1')
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
})
it('shows Previous, Page 1 of 8 and Next on mobile', async () => {
await mountList('/content/events?size=5', { list: paged, width: MOBILE_WIDTH })
const nav = text(main().querySelector('nav[aria-label="Pagination"]'))
expect(nav).toBe('Previous Page 1 of 8 Next')
expect(
main().querySelector<HTMLButtonElement>('[aria-label="Previous page"]')?.disabled,
).toBe(true)
})
it('sends page 2 on Next and updates the URL on success', async () => {
await mountList('/content/events?size=5', { list: paged })
await press('Next page')
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=2&size=5&sort=updated_at:desc',
)
expect(summary()).toBe('Showing 6–10 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
})
it('goes to the last page with Last', async () => {
await mountList('/content/events?size=5', { list: paged })
await press('Last page')
expect(summary()).toBe('Showing 36–37 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
expect(
pagerButtons()
.filter((button) =>
['Next page', 'Last page'].includes(button.getAttribute('aria-label')!),
)
.every((button) => button.disabled),
).toBe(true)
})
})
describe('Page size select', () => {
it.each([
['5 per page', 5, '/content/events?size=5'],
['25 per page', 25, '/content/events'],
['50 per page', 50, '/content/events?size=50'],
['100 per page', 100, '/content/events?size=100'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountList('/content/events?size=5&page=3', { list: paged })
await chooseSize(label)
expect(lastRequest()).toBe(
`/v1/content-type/events/entry?page=1&size=${size}&sort=updated_at:desc`,
)
expect(router.currentRoute.value.fullPath).toBe(url)
})
it('does not remember the page size', async () => {
await mountList('/content/events', { list: paged })
await chooseSize('50 per page')
await unmountAll()
await mountList('/content/events', { list: paged })
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
)
expect(Object.keys(localStorage).filter((key) => key.includes('page-size'))).toEqual([])
})
})
describe('Loading a page', () => {
it('shows Loading page 2…, disables the controls and marks the list busy', async () => {
vi.useFakeTimers()
const gate = pending()
await mountList('/content/events?size=5', {
list: (key, query) => (query.get('page') === '2' ? gate.promise : paged(key, query)),
})
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(summary()).toBe('Loading page 2…')
expect(pagerButtons().every((button) => button.disabled)).toBe(true)
expect(
document.body.querySelector<HTMLButtonElement>('[aria-label="Rows per page"]')
?.disabled,
).toBe(true)
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
expect(main().querySelectorAll('[data-testid="entry-skeleton-row"]')).toHaveLength(5)
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
})
})
describe('Error loading a page', () => {
it('keeps the rows, shows the notice with Retry and leaves the URL', async () => {
await mountList('/content/events?size=5', {
list: (key, query) => {
if (query.get('page') === '2') {
throw new Error('x')
}
return paged(key, query)
},
})
await press('Next page')
expect(alertText()).toContain("Couldn't load page 2. Showing page 1.")
expect(main().querySelectorAll('tbody tr')).toHaveLength(5)
expect(summary()).toBe('Showing 1–5 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
expect(main().querySelector('h1')?.textContent).toBe('Events')
})
it('Retry sends the failed page again and updates the URL on success', async () => {
let attempt = 0
await mountList('/content/events?size=5', {
list: (key, query) => {
if (query.get('page') === '2' && attempt++ === 0) {
throw new Error('x')
}
return paged(key, query)
},
})
await press('Next page')
const retry = [...main().querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Retry',
)!
retry.click()
await flushPromises()
expect(summary()).toBe('Showing 6–10 of 37')
expect(alertText()).toBe('')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
})
it('shows the error state, not a notice, when the first load fails', async () => {
await mountList('/content/events?page=2', { list: failWith(500) })
expect(text(main())).toContain("Couldn't load entries")
expect(text(main())).not.toContain("Couldn't load page")
})
})
describe('Out-of-range page', () => {
it('loads the last page, shows the notice and replaces the URL', async () => {
await mountList('/content/events?size=5&page=99', {
list: (key, query) =>
Number(query.get('page')) > 8 ? listPage([], TOTAL, query) : paged(key, query),
})
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=8&size=5&sort=updated_at:desc',
)
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 36–37 of 37')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
})
it('shows the empty state and cleans the URL when the total is 0', async () => {
await mountList('/content/events?page=3', { list: (_, query) => listPage([], 0, query) })
expect(text(main())).toContain('No entries yet')
expect(main().querySelector('[data-testid="paginator-summary"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/content/events')
})
})
describe('Focus-visible', () => {
it('gives the paginator, the select and the toolbar controls a 2 px primary outline with a 2 px offset', async () => {
await mountList('/content/events?size=5&page=3', { list: paged })
const controls = [
...pagerButtons(),
document.body.querySelector('[aria-label="Rows per page"]'),
document.body.querySelector('[aria-label="Sort by"]'),
document.body.querySelector('[aria-label="Sort ascending"]'),
[...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === 'Columns',
),
]
expect(controls.length).toBeGreaterThan(8)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})
@@ -0,0 +1,201 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import {
calls,
chooseSize,
chooseSort,
eventEntries,
installSelectPolyfills,
listPage,
listRequests,
mountList,
press,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(unmountAll)
function paged(_: string, query: URLSearchParams) {
return listPage(eventEntries.slice(0, Number(query.get('size')) > 5 ? 7 : 5), 37, query)
}
function iconPath(name: string) {
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
}
function lastRequest() {
return listRequests(calls).at(-1)
}
function selectText() {
return text(document.body.querySelector('[aria-label="Sort by"]'))
}
function directionButton() {
return document.body.querySelector<HTMLElement>(
'[aria-label="Sort ascending"], [aria-label="Sort descending"]',
)!
}
function sortedHeader() {
return [...document.body.querySelectorAll('thead th')].find((header) =>
header.querySelector('[data-sort-direction]'),
)
}
describe('Sort control', () => {
it.each([
['ID', 'id:desc', '/content/events?sort=id:desc', 'ID'],
['Created', 'created_at:desc', '/content/events?sort=created_at:desc', 'Created'],
['Last updated', 'updated_at:desc', '/content/events', 'Updated'],
])('choosing %s sends %s with page 1 and ends on %s', async (option, sort, url, header) => {
await mountList('/content/events?page=3', { list: paged })
await chooseSort(option)
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
expect(router.currentRoute.value.fullPath).toBe(url)
expect(text(sortedHeader())).toBe(header)
})
it('offers ID, Created and Last updated only', async () => {
await mountList('/content/events', { list: paged })
expect(selectText()).toBe('Sort by Last updated')
expect(document.body.querySelector('[aria-label="Sort by"]')).not.toBeNull()
})
it('keeps the direction when the field changes', async () => {
await mountList('/content/events?sort=updated_at:asc', { list: paged })
await chooseSort('ID')
expect(lastRequest()).toContain('sort=id:asc')
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:asc')
})
it('changes the URL when the response succeeds, not when the request is sent', async () => {
let release!: (value: unknown) => void
const gate = new Promise((resolve) => (release = resolve))
await mountList('/content/events', {
list: (key, query) => (query.get('sort') === 'id:desc' ? gate : paged(key, query)),
})
await chooseSort('ID')
expect(router.currentRoute.value.fullPath).toBe('/content/events')
release(listPage(eventEntries, 7, new URLSearchParams('page=1&size=25&sort=id:desc')))
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:desc')
})
})
describe('Direction button', () => {
it.each([
['/content/events', 'updated_at:asc', '/content/events?sort=updated_at:asc'],
['/content/events?sort=updated_at:asc', 'updated_at:desc', '/content/events'],
['/content/events?sort=id:desc', 'id:asc', '/content/events?sort=id:asc'],
])('from %s sends %s and ends on %s', async (from, sort, url) => {
await mountList(from, { list: paged })
await press(directionButton().getAttribute('aria-label')!)
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
expect(router.currentRoute.value.fullPath).toBe(url)
})
it('goes back to page 1', async () => {
await mountList('/content/events?size=5&page=3', { list: paged })
await press('Sort ascending')
expect(lastRequest()).toBe(
'/v1/content-type/events/entry?page=1&size=5&sort=updated_at:asc',
)
})
it.each([
['/content/events', 'Sort ascending', 'arrow-down-wide-narrow'],
['/content/events?sort=updated_at:asc', 'Sort descending', 'arrow-up-narrow-wide'],
])('on %s is named "%s" and draws %s', async (from, name, icon) => {
await mountList(from, { list: paged })
const button = directionButton()
expect(button.getAttribute('aria-label')).toBe(name)
expect(button.querySelector('svg path')?.getAttribute('d')).toBe(iconPath(icon))
})
it('shows the arrow of the direction on the sorted header', async () => {
await mountList('/content/events?sort=created_at:asc', { list: paged })
expect(sortedHeader()?.querySelector('[data-sort-direction="asc"]')).not.toBeNull()
expect(text(sortedHeader())).toBe('Created')
})
})
describe('Sort values in the URL', () => {
it('reads created_at:asc into the select and the direction icon', async () => {
await mountList('/content/events?sort=created_at:asc', { list: paged })
expect(lastRequest()).toContain('sort=created_at:asc')
expect(selectText()).toBe('Sort by Created')
expect(directionButton().getAttribute('aria-label')).toBe('Sort descending')
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=created_at:asc')
})
it.each(['title:asc', 'data:asc', 'id', 'id:sideways', 'UPDATED_AT:DESC'])(
'replaces sort=%s by the default in the request and the URL',
async (value) => {
await mountList(`/content/events?sort=${value}`, { list: paged })
expect(lastRequest()).toContain('sort=updated_at:desc')
expect(router.currentRoute.value.fullPath).toBe('/content/events')
expect(selectText()).toBe('Sort by Last updated')
},
)
it.each([
['page=abc', 'page=1&size=25'],
['page=0', 'page=1&size=25'],
['size=10', 'page=1&size=25'],
['size=100&page=2', 'page=2&size=100'],
])('reads ?%s as %s and cleans the URL when needed', async (query, requested) => {
await mountList(`/content/events?${query}`, { list: paged })
expect(lastRequest()).toContain(requested)
})
it('replaces an invalid size and page in the URL', async () => {
await mountList('/content/events?size=10&page=abc', { list: paged })
expect(router.currentRoute.value.fullPath).toBe('/content/events')
})
})
describe('Sort is kept across paging and size changes', () => {
it('carries sort=id:asc on every request and keeps it in the URL', async () => {
await mountList('/content/events?sort=id:asc&size=5', { list: paged })
await press('Next page')
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=2&size=5&sort=id:asc')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&sort=id:asc&page=2')
await chooseSize('50 per page')
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=1&size=50&sort=id:asc')
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=50&sort=id:asc')
})
})
+250
View File
@@ -0,0 +1,250 @@
import { vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import { mountAt, type SchemaMount } from './schemaTestKit'
export {
button,
calls,
customTypes,
failWith,
failWithBody,
manyTypes,
networkFailure,
pending,
text,
unmountAll,
} from './schemaTestKit'
addCollection(lucide)
export const SKELETON_DELAY_MS = 150
export const DEFAULT_SORT = 'updated_at:desc'
const MARTA = 3
const INES = 4
const JONAS = 5
type Row = [
number,
string,
string,
string,
boolean,
number | null,
string,
string,
string,
string,
number,
]
const ROWS: Row[] = [
[
42,
'Lime plaster workshop',
'2026-11-21',
'2026-11-22',
false,
18,
'Workshop',
'Two days of hands-on work with lime putty and old stone.',
'2026-10-12',
'2026-10-15',
MARTA,
],
[
41,
'Heat pumps: what the numbers say',
'2026-11-03',
'',
true,
500,
'Talk',
'A look at running costs after two winters.',
'2026-10-08',
'2026-10-14',
JONAS,
],
[
40,
'Open day at the Rue Verte site',
'2026-11-12',
'2026-11-12',
false,
120,
'Meetup',
'',
'2026-10-02',
'2026-10-12',
INES,
],
[
39,
'Winter windows clinic',
'2026-12-05',
'',
false,
null,
'Workshop',
'Bring a window, leave with a plan.',
'2026-09-30',
'2026-10-09',
MARTA,
],
[
38,
'Timber joinery for beginners',
'2027-01-14',
'2027-01-15',
false,
12,
'Workshop',
'Mortise, tenon and a lot of patience.',
'2026-09-28',
'2026-10-07',
INES,
],
[
37,
'Q&A with the site managers',
'2026-10-28',
'',
true,
null,
'Meetup',
'',
'2026-09-21',
'2026-10-02',
JONAS,
],
[
36,
'Reclaimed floorboards talk',
'2026-10-17',
'',
false,
60,
'Talk',
'Where to find them and how to lay them.',
'2026-09-14',
'2026-09-29',
MARTA,
],
]
export function entry(row: Row) {
const [
id,
title,
startsOn,
endsOn,
online,
capacity,
category,
description,
created,
updated,
by,
] = row
return {
id,
content_type_id: 1,
data: {
title,
starts_on: startsOn,
ends_on: endsOn,
online,
capacity,
category,
description,
},
created_by: by,
updated_by: by,
created_at: `${created}T09:14:00Z`,
updated_at: `${updated}T11:02:00Z`,
}
}
export const eventEntries = ROWS.map(entry)
export const userNames = [
{ id: MARTA, display_name: 'Marta Alves' },
{ id: INES, display_name: 'Inès Duarte' },
{ id: JONAS, display_name: 'Jonas Pereira' },
]
export function listPage(items: unknown[], total: number, query: URLSearchParams) {
return {
items,
total,
page: Number(query.get('page')),
size: Number(query.get('size')),
sort: query.get('sort'),
}
}
export function populated(key: string, query: URLSearchParams) {
return key === 'events' ? listPage(eventEntries, 7, query) : listPage([], 0, query)
}
export function lookupNames(ids: number[]) {
return userNames.filter((user) => ids.includes(user.id))
}
export function mountList(path: string, options: SchemaMount = {}) {
return mountAt(path, { list: populated, lookup: lookupNames, ...options })
}
export async function settle() {
await flushPromises()
}
export function listRequests(calls: string[]) {
return calls.filter((url) => /\/entry\?/.test(url) && !/size=5$/.test(url))
}
export function lookupRequests(calls: string[]) {
return calls.filter((url) => url.startsWith('/v1/user/lookup'))
}
export function installSelectPolyfills() {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
}
export function openSelect(trigger: Element | null) {
trigger?.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
}
export async function chooseSort(label: string) {
openSelect(document.body.querySelector('[aria-label="Sort by"]'))
await flushPromises()
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(candidate) => candidate.textContent?.trim() === label,
)!
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
option.click()
await flushPromises()
}
export async function chooseSize(label: string) {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
await flushPromises()
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
(candidate) => candidate.textContent?.trim() === label,
)!
option.click()
await flushPromises()
}
export async function press(label: string) {
document.body.querySelector<HTMLElement>(`[aria-label="${label}"]`)!.click()
await flushPromises()
}
+28
View File
@@ -126,6 +126,14 @@ export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 }
}
function defaultType(key: string) {
const found = customTypes.find((type) => type.key === key)
if (!found) {
throw new ApiError(404, undefined, 'content_type_not_found')
}
return found
}
export interface SchemaMount {
role?: string
create?: (body: PostedRequest['body']) => unknown
@@ -137,6 +145,10 @@ export interface SchemaMount {
builtin?: Responder
verified?: boolean
actions?: string[]
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
lookup?: (ids: number[]) => unknown
before?: () => void
}
let wrapper: VueWrapper | undefined
@@ -156,6 +168,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
puts.length = 0
deletes.length = 0
localStorage.clear()
options.before?.()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia()
setActivePinia(pinia)
@@ -199,6 +212,21 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
if (counted) {
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
}
const listed = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\?(.*)$/)
if (listed) {
return (options.list ?? (() => entryPage(0)))(
listed[1]!,
new URLSearchParams(listed[2]),
)
}
const looked = url.match(/^\/v1\/user\/lookup\?ids=(.*)$/)
if (looked) {
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
}
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
if (detailed) {
return (options.type ?? defaultType)(detailed[1]!)
}
if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))()
}
+3
View File
@@ -646,6 +646,9 @@ const boardGroupLabels: Record<string, Record<string, string>> = {
'Review and delete': '3 Review and delete',
'Field types': '4 Field types',
},
content: {
'Content and entry list': '5 Content and entry list',
},
}
const referenceGroups: Record<string, string> = {
+239
View File
@@ -6988,9 +6988,248 @@ const schemaStates: Record<string, VisualState> = {
},
}
const contentRows = [
[
42,
'Lime plaster workshop',
'2026-11-21',
'2026-11-22',
false,
18,
'Workshop',
'Two days of hands-on work with lime putty and old stone.',
'2026-10-12',
'2026-10-15',
3,
],
[
41,
'Heat pumps: what the numbers say',
'2026-11-03',
'',
true,
500,
'Talk',
'A look at running costs after two winters.',
'2026-10-08',
'2026-10-14',
5,
],
[
40,
'Open day at the Rue Verte site',
'2026-11-12',
'2026-11-12',
false,
120,
'Meetup',
'',
'2026-10-02',
'2026-10-12',
4,
],
[
39,
'Winter windows clinic',
'2026-12-05',
'',
false,
null,
'Workshop',
'Bring a window, leave with a plan.',
'2026-09-30',
'2026-10-09',
3,
],
[
38,
'Timber joinery for beginners',
'2027-01-14',
'2027-01-15',
false,
12,
'Workshop',
'Mortise, tenon and a lot of patience.',
'2026-09-28',
'2026-10-07',
4,
],
[
37,
'Q&A with the site managers',
'2026-10-28',
'',
true,
null,
'Meetup',
'',
'2026-09-21',
'2026-10-02',
5,
],
[
36,
'Reclaimed floorboards talk',
'2026-10-17',
'',
false,
60,
'Talk',
'Where to find them and how to lay them.',
'2026-09-14',
'2026-09-29',
3,
],
].map(
([
id,
title,
startsOn,
endsOn,
online,
capacity,
category,
description,
created,
updated,
by,
]) => ({
id,
content_type_id: 1,
data: {
title,
starts_on: startsOn,
ends_on: endsOn,
online,
capacity,
category,
description,
},
created_by: by,
updated_by: by,
created_at: `${created}T09:14:00Z`,
updated_at: `${updated}T11:02:00Z`,
}),
)
const contentTypeNames = [
'Events',
'Recipes',
'Press mentions',
'Frequently asked questions and answers',
'Job openings',
'Testimonials',
'Suppliers',
'Certifications',
'Case studies',
'Glossary terms',
'Partners',
'Downloads',
'Locations',
'Awards',
]
const contentManyTypes = contentTypeNames.map((name, index) => ({
id: index + 1,
key: name === 'Events' ? 'events' : name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
name,
icon: 'files',
fields: index === 0 ? schemaEventsFields : [],
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-15T09:30:00Z',
}))
const contentUsers = [
{ id: 3, display_name: 'Marta Alves' },
{ id: 4, display_name: 'Inès Duarte' },
{ id: 5, display_name: 'Jonas Pereira' },
]
const contentEventsType = schemaCustomTypes[0]!
function contentEntries(url: URL) {
const size = Number(url.searchParams.get('size'))
if (size === 5) {
return {
items: contentRows.slice(0, 5),
total: 37,
page: 1,
size: 5,
sort: 'updated_at:desc',
}
}
return { items: contentRows, total: 7, page: 1, size: 25, sort: 'updated_at:desc' }
}
function contentApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': schemaSession('owner'),
'GET /v1/content-type': { status: 200, body: schemaCustomTypes },
'GET /v1/content-type/events': { status: 200, body: contentEventsType },
'GET /v1/content-type/events/entry': { status: 200, bodyFor: contentEntries },
'GET /v1/user/lookup': { status: 200, body: contentUsers },
...overrides,
}
}
const contentStates: Record<string, VisualState> = {
Populated: { path: '/content/events', api: contentApi() },
'Paginated, 5 per page': { path: '/content/events?size=5', api: contentApi() },
'Columns menu': {
path: '/content/events',
api: contentApi(),
steps: async (page) => {
await page.getByRole('button', { name: 'Columns' }).click()
await page.getByText('Created and updated dates always show').waitFor()
await schemaSettle(page)
},
},
'Many types in the sidebar': {
path: '/content/events',
api: contentApi({ 'GET /v1/content-type': { status: 200, body: contentManyTypes } }),
steps: async (page) => {
if (schemaIsMobile(page)) {
await page.locator('[data-slot="toggle"]').first().click()
await page.getByRole('button', { name: 'All content types' }).click()
await page.getByText('Frequently asked questions and answers').first().waitFor()
}
await schemaSettle(page)
},
},
Loading: {
path: '/content/events',
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 200, hang: true } }),
},
'Empty for this type': {
path: '/content/events',
api: contentApi({
'GET /v1/content-type/events/entry': {
status: 200,
body: { items: [], total: 0, page: 1, size: 25, sort: 'updated_at:desc' },
},
}),
},
Error: {
path: '/content/events',
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 500 } }),
},
'Forbidden (403)': {
path: '/content/events',
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 403 } }),
},
'Type not found': {
path: '/content/unknown',
api: contentApi({
'GET /v1/content-type/unknown': { status: 404, body: 'content_type_not_found' },
'GET /v1/content-type/unknown/entry': { status: 404, body: 'content_type_not_found' },
}),
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: schemaStates,
content: contentStates,
authors: authorsStates,
activity: activityStates,
profile: {