feat: entry list at /content/:key
This commit is contained in:
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)
|
||||
|
||||
@@ -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'],
|
||||
['`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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'),
|
||||
])
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in new issue
Block a user