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