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

@@ -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)}`
}