feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ const CONTROLS: Record<FieldType, string> = {
date: 'date',
select: 'select',
media: 'media',
relation: 'relation',
}
function editor(type: FieldType) {
+5 -3
View File
@@ -50,9 +50,10 @@ function mediaField(rest: object = {}) {
const media = ENTRY_EDITORS.media
describe('media in the registry', () => {
it('is the seventh type, after select', () => {
expect(FIELD_TYPES).toHaveLength(7)
it('is the seventh type, after select and before relation', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[6]).toBe('media')
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[5]).toBe('select')
})
@@ -82,7 +83,7 @@ describe('media in the registry', () => {
)
})
it.each(FIELD_TYPES.filter((type) => type !== 'media'))(
it.each(FIELD_TYPES.filter((type) => type !== 'media' && type !== 'relation'))(
'%s keeps its builder options and has no hint',
(type) => {
const spec = fieldTypeSpec(type)
@@ -374,6 +375,7 @@ describe('media in the schema builder', () => {
min: null,
max: null,
options: [],
relation: null,
})
expect(payloadField({ ...field, required: true }).required).toBe(true)
})
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it } from 'vitest'
import type { ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText, relationLabel } from '@/lib/entryCells'
import { ENTRY_EDITORS } from '@/lib/entryEditors'
import {
buildData,
entryTitle,
initialRelations,
initialValues,
validateEntry,
valuesDiffer,
} from '@/lib/entryForm'
import { ruleMessage } from '@/lib/entryMessages'
import { groupFields } from '@/lib/entryLayout'
import { classifyRefusal, parseEntryRefusal } from '@/lib/entryServerError'
import { FIELD_TYPES, fieldTypeSpec } from '@/lib/fieldTypes'
import { isRelationId, relationIdsOf, summariesOf, type RelationSummary } from '@/lib/relationField'
import { relationRowOf } from '@/lib/relationRows'
import { ApiError } from '@/lib/apiError'
import { diffFields } from '@/lib/schemaBuilderDiff'
import { baselinePayload, fieldFromApi, formFromType } from '@/lib/schemaBuilderLoad'
import { createField } from '@/lib/schemaBuilderModel'
import { buildPayload, payloadField } from '@/lib/schemaBuilderPayload'
import { sampleEntry } from '@/lib/schemaBuilderSample'
import { validateField } from '@/lib/schemaBuilderValidation'
import { apiField } from './schemaFixtures'
const relation = ENTRY_EDITORS.relation
function field(kind: 'one_to_one' | 'one_to_many' | 'many_to_many', rest: object = {}) {
return apiField('Venue', 'venue', 'relation', {
options: [],
relation: { content_type: 'venues', kind },
...rest,
})
}
const single = field('one_to_many')
const many = field('many_to_many')
function target(id: number, name: string | null): RelationSummary {
return {
id,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: name },
}
}
describe('relation in the registry', () => {
it('is the eighth type, after media', () => {
expect(FIELD_TYPES).toHaveLength(8)
expect(FIELD_TYPES[7]).toBe('relation')
expect(FIELD_TYPES[6]).toBe('media')
})
it('describes itself', () => {
const spec = fieldTypeSpec('relation')
expect(spec.label()).toBe('Relation')
expect(spec.description()).toBe("Links to another type's entries")
expect(spec.icon).toBe('i-lucide-link')
expect(spec.editor.control).toBe('relation')
expect(spec.cell).toBe('relation')
expect(spec.pairs).toBe(false)
expect(spec.supportsUnique).toBe(false)
expect(spec.supportsLimits).toBe(false)
expect(spec.supportsDefault).toBe(false)
expect(spec.supportsChoices).toBe(false)
expect(spec.showsUnsupportedOptions).toBe(false)
expect(spec.builderHint).toBeNull()
expect(spec.holdsRelation).toBe(true)
})
it('is never the fallback type, while an unknown type still is', () => {
expect(fieldTypeSpec('relation')).not.toBe(fieldTypeSpec('text'))
expect(fieldTypeSpec('mystery')).toBe(fieldTypeSpec('text'))
expect(fieldFromApi(single, 1).type).toBe('relation')
})
it('has no counter and no help of its own', () => {
expect(relation.counter(single, 3)).toBeNull()
expect(relation.help(single)).toBe('')
})
it('never pairs with a neighbour', () => {
const fields = [
apiField('Online', 'online', 'boolean'),
single,
apiField('Cap', 'cap', 'number'),
]
expect(groupFields(fields).map((row) => row.length)).toEqual([1, 1, 1])
})
})
describe('empty and initial values', () => {
it.each([
['one_to_one', null],
['one_to_many', null],
['many_to_many', []],
] as const)('starts empty for %s on create', (kind, empty) => {
expect(relation.initial(field(kind), 3, true)).toEqual(empty)
})
it('has no default, so no Default tag ever shows', () => {
expect(relation.initial(field('one_to_many', { default: 3 }), undefined, true)).toBeNull()
})
it('takes the stored id or ids on edit', () => {
expect(relation.initial(single, 3, false)).toBe(3)
expect(relation.initial(single, null, false)).toBeNull()
expect(relation.initial(many, [11, 12], false)).toEqual([11, 12])
expect(relation.initial(many, undefined, false)).toEqual([])
})
it('copies the stored array', () => {
const stored = [11]
expect(relation.initial(many, stored, false)).not.toBe(stored)
})
})
describe('client validation of a relation value', () => {
it.each([
['single', true, null, 'required'],
['single', false, null, null],
['single', true, 3, null],
['single', false, 0, 'type'],
['single', false, '3', 'type'],
['single', false, [3], 'type'],
['many', true, [], 'required'],
['many', false, [], null],
['many', false, [11, 12], null],
['many', false, [11, 11], 'type'],
['many', false, 11, 'type'],
['many', false, Array.from({ length: 51 }, (_, index) => index + 1), 'type'],
['many', false, Array.from({ length: 50 }, (_, index) => index + 1), null],
])('%s required=%s value %j gives %s', (kind, required, value, rule) => {
const subject = kind === 'single' ? single : many
expect(relation.validate({ ...subject, required }, value)).toBe(rule)
})
it('treats a required relation whose targets are all missing as empty', () => {
const gone: RelationSummary = { id: 3, missing: true }
expect(
relation.validate({ ...single, required: true }, 3, { image: null, relations: [gone] }),
).toBe('required')
expect(
relation.validate({ ...single, required: false }, 3, {
image: null,
relations: [gone],
}),
).toBeNull()
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, { id: 4, missing: true }],
}),
).toBe('required')
expect(
relation.validate({ ...many, required: true }, [3, 4], {
image: null,
relations: [gone, target(4, 'Hall')],
}),
).toBeNull()
})
it('never produces min, max or options', () => {
const rules = [null, 0, '3', [3], [], [1, 1], 5].flatMap((value) =>
[single, many].map((subject) =>
relation.validate({ ...subject, required: true }, value),
),
)
expect(rules.filter((rule) => ['min', 'max', 'options'].includes(rule ?? ''))).toEqual([])
})
})
describe('messages of a relation field', () => {
it.each([
['required', 'Choose a Venue.'],
['type', 'Venue must point to an existing entry.'],
['unique', 'That Venue is already used by another entry.'],
] as const)('the rule %s reads "%s"', (rule, message) => {
expect(ruleMessage(rule, single)).toBe(message)
})
})
describe('the payload', () => {
it('sends the id, null, or an array of ids', () => {
expect(relation.payload(single, 3)).toBe(3)
expect(relation.payload(single, null)).toBeNull()
expect(relation.payload(single, undefined)).toBeNull()
expect(relation.payload(many, [11, 12])).toEqual([11, 12])
expect(relation.payload(many, undefined)).toEqual([])
})
it('puts null and [] in a create body, never absent', () => {
const fields = [single, { ...many, key: 'speakers' }]
const body = buildData(fields, initialValues(fields, null))
expect(body).toEqual({ venue: null, speakers: [] })
expect(Object.keys(body)).toEqual(['venue', 'speakers'])
})
})
describe('the ids helpers', () => {
it('accepts uint32 ids of at least 1', () => {
expect(isRelationId(1)).toBe(true)
expect(isRelationId(4294967295)).toBe(true)
expect(isRelationId(0)).toBe(false)
expect(isRelationId(4294967296)).toBe(false)
expect(isRelationId(1.5)).toBe(false)
expect(isRelationId('3')).toBe(false)
})
it('reads one id or many', () => {
expect(relationIdsOf(3)).toEqual([3])
expect(relationIdsOf([3, 'x', 4])).toEqual([3, 4])
expect(relationIdsOf(null)).toEqual([])
})
})
describe('summaries of an entry', () => {
const entry = {
id: 42,
data: { venue: 3, speakers: [11, 12] },
relations: {
venue: target(3, 'Rue Verte hall'),
speakers: [target(11, 'Ines Duarte'), target(12, 'Jonas Pereira')],
},
} as unknown as ContentEntry
it('wraps a single summary in an array', () => {
expect(summariesOf(entry, 'venue').map((summary) => summary.id)).toEqual([3])
expect(summariesOf(entry, 'speakers').map((summary) => summary.id)).toEqual([11, 12])
expect(summariesOf(entry, 'other')).toEqual([])
expect(summariesOf(null, 'venue')).toEqual([])
})
it('keeps only the summaries of the stored ids', () => {
const stale = { ...entry, data: { venue: 4, speakers: [12] } } as ContentEntry
const kept = initialRelations([single, { ...many, key: 'speakers' }], stale)
expect(kept).toEqual({ speakers: [entry.relations!.speakers![1]] })
})
})
describe('dirty tracking', () => {
it('compares ids by value and by order', () => {
expect(valuesDiffer([single], { venue: 3 }, { venue: 3 })).toBe(false)
expect(valuesDiffer([many], { venue: [11, 12] }, { venue: [11, 12] })).toBe(false)
expect(valuesDiffer([many], { venue: [12, 11] }, { venue: [11, 12] })).toBe(true)
expect(valuesDiffer([many], { venue: [11] }, { venue: [11, 12] })).toBe(true)
})
})
describe('labels and cells', () => {
it('uses the first field of the target, formatted like the list', () => {
expect(relationLabel(target(3, 'Rue Verte hall'))).toBe('Rue Verte hall')
expect(
relationLabel({
id: 5,
content_type: 'x',
first_field: { key: 'on', type: 'boolean', value: true },
}),
).toBe('Yes')
expect(
relationLabel({
id: 6,
content_type: 'x',
first_field: { key: 'on', type: 'number', value: 1500 },
}),
).toBe('1,500')
})
it('falls back to Entry N for a null first field', () => {
expect(relationLabel(target(7, null))).toBe('Entry 7')
expect(
relationLabel({
id: 8,
content_type: 'x',
first_field: { key: 'm', type: 'media', value: 9 },
}),
).toBe('Entry 8')
})
it('labels a missing target', () => {
expect(relationLabel({ id: 3, missing: true })).toBe('Entry no longer exists')
})
it('describes one chip, two chips and a +N, or an empty cell', () => {
const summaries = [11, 12, 13, 14].map((id) => target(id, `Speaker ${id}`))
const cell = describeCell(many, [11, 12, 13, 14], undefined, summaries)
expect(cell.kind).toBe('relation')
expect(cell.chips!.map((chip) => chip.label)).toEqual(['Speaker 11', 'Speaker 12'])
expect(cell.more).toBe(2)
expect(plainCellText(cell)).toBe('Speaker 11, Speaker 12, Speaker 13, Speaker 14')
expect(describeCell(many, []).kind).toBe('empty')
expect(describeCell(single, null).kind).toBe('empty')
expect(plainCellText(describeCell(many, []))).toBe('–')
})
it('never shows the numeric id when no summary came with the item', () => {
const cell = describeCell(single, 3)
expect(cell.chips).toEqual([{ id: 3, label: 'Entry no longer exists', missing: true }])
expect(cell.text).not.toContain('3')
})
it('titles an entry whose first field is a relation "Entry N"', () => {
const entry = {
id: 42,
data: { venue: 3 },
relations: { venue: target(3, 'Hall') },
} as unknown as ContentEntry
expect(entryTitle([single], entry)).toBe('Entry 42')
})
})
describe('rows of the picker', () => {
const fields = [
apiField('Name', 'name', 'text'),
apiField('City', 'city', 'text'),
apiField('Seats', 'seats', 'number'),
apiField('Notes', 'notes', 'text'),
]
const entry = (data: Record<string, unknown>) => ({ id: 3, data }) as unknown as ContentEntry
it('puts the first field in the title and up to two next fields in the second line', () => {
const row = relationRowOf(
entry({ name: 'Rue Verte hall', city: 'Lyon', seats: 120, notes: 'x' }),
'venues',
fields,
)
expect(row.title).toBe('Rue Verte hall')
expect(row.detail).toBe('Lyon · 120')
expect(row.summary.first_field).toEqual({
key: 'name',
type: 'text',
value: 'Rue Verte hall',
})
})
it('skips empty fields and falls back to Entry N', () => {
const row = relationRowOf(entry({ name: '', city: '', seats: 5 }), 'venues', fields)
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('5')
})
it('copes with a type that is not known yet', () => {
const row = relationRowOf(entry({ name: 'x' }), 'venues', [])
expect(row.title).toBe('Entry 3')
expect(row.detail).toBe('')
})
})
describe('server refusals', () => {
it.each([
[422, 'invalid_entry: venue: required', 'required'],
[422, 'invalid_entry: venue: type', 'type'],
[409, 'invalid_entry: venue: unique', 'unique'],
])('maps %i %s to a rule of the field', (status, body, rule) => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(status, undefined, body)),
)
expect(outcome).toMatchObject({ kind: 'field', rule })
})
it('maps unknown_field to a schema change', () => {
const outcome = classifyRefusal(
[single],
parseEntryRefusal(new ApiError(422, undefined, 'invalid_entry: venue: unknown_field')),
)
expect(outcome).toEqual({ kind: 'schema', key: 'venue' })
})
})
describe('the entry validation', () => {
it('reports the first rule of each relation field', () => {
const fields = [
{ ...single, required: true },
{ ...many, key: 'speakers', required: true },
]
const issues = validateEntry(fields, { venue: null, speakers: [] })
expect(issues.venue!.rule).toBe('required')
expect(issues.speakers!.rule).toBe('required')
expect(issues.venue!.message).toBe('Choose a Venue.')
})
})
describe('relation in the builder model', () => {
const events = {
id: 1,
key: 'events',
name: 'Events',
icon: null,
created_at: '',
updated_at: '',
}
it('loads the target and the kind', () => {
const loaded = fieldFromApi(field('many_to_many'), 1)
expect(loaded.relationType).toBe('venues')
expect(loaded.relationKind).toBe('many_to_many')
expect(loaded.saved).toBe(true)
})
it('sends the relation only for a relation field', () => {
const sent = payloadField(
createField(1, {
label: 'Venue',
key: 'venue',
type: 'relation',
relationType: 'venues',
}),
)
expect(sent).toEqual({
key: 'venue',
label: 'Venue',
type: 'relation',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: [],
relation: { content_type: 'venues', kind: 'one_to_many' },
})
expect(
payloadField(createField(2, { label: 'T', key: 't', relationType: 'venues' })).relation,
).toBeNull()
})
it('round trips a loaded field with no change', () => {
const type = { ...events, fields: [single, { ...many, key: 'speakers' }] }
let uid = 0
const form = formFromType(type, () => ++uid)
expect(buildPayload(form).fields).toEqual(baselinePayload(type))
expect(diffFields(baselinePayload(type), form.fields, 3)).toEqual([])
})
it('needs a related type', () => {
const created = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
expect(validateField(created, []).relation).toBe('Choose a related content type.')
expect(validateField({ ...created, relationType: 'venues' }, []).relation).toBeUndefined()
expect(validateField(createField(2, { label: 'T', key: 't' }), []).relation).toBeUndefined()
})
it('samples null for a single kind and [] for many_to_many', () => {
const singleField = createField(1, { label: 'Venue', key: 'venue', type: 'relation' })
const manyField = createField(2, {
label: 'Speakers',
key: 'speakers',
type: 'relation',
relationKind: 'many_to_many',
})
const data = sampleEntry('events', [singleField, manyField]).data
expect(data.venue).toBeNull()
expect(data.speakers).toEqual([])
})
})
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { ROW_DETAIL_FIELDS, relationRowOf } from '@/lib/relationRows'
function field(key: string, type: string): BuiltinField {
return { key, type, label: key } as unknown as BuiltinField
}
function entry(id: number, data: Record<string, unknown>): ContentEntry {
return { id, data } as unknown as ContentEntry
}
describe('relationRowOf', () => {
it('builds the summary from the first field', () => {
const row = relationRowOf(entry(7, { name: 'Hall' }), 'venues', [field('name', 'text')])
expect(row.id).toBe(7)
expect(row.title).toBe('Hall')
expect(row.summary).toEqual({
id: 7,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: 'Hall' },
})
})
it('falls back to an empty text summary when the type has no fields', () => {
const row = relationRowOf(entry(3, {}), 'venues', [])
expect(row.summary.first_field).toEqual({ key: '', type: 'text', value: null })
expect(row.detail).toBe('')
})
it('uses null when the first field is absent from the data', () => {
const row = relationRowOf(entry(3, {}), 'venues', [field('name', 'text')])
expect(row.summary.first_field).toEqual({ key: 'name', type: 'text', value: null })
})
it('joins at most the first detail fields and skips empty ones', () => {
const fields = [
field('name', 'text'),
field('city', 'text'),
field('country', 'text'),
field('zip', 'text'),
]
const row = relationRowOf(
entry(1, { name: 'Hall', city: 'Paris', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(ROW_DETAIL_FIELDS).toBe(2)
expect(row.detail).toBe('Paris · France')
const sparse = relationRowOf(
entry(1, { name: 'Hall', city: '', country: 'France', zip: '75000' }),
'venues',
fields,
)
expect(sparse.detail).toBe('France')
})
})
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
inverseNoun,
inverseSide,
kindFromInverseSide,
kindFromOwnerSide,
kindSentence,
ownerNoun,
ownerSide,
relationNames,
relationSample,
singularOf,
} from '@/lib/relationSentences'
import { RELATION_KINDS } from '@/lib/relationField'
const names = relationNames('Events', 'Venues', 'Venue')
describe('the names of both sides', () => {
it('draws the singular from the type name and the related singular from the field label', () => {
expect(names).toEqual({
ownerSingular: 'Event',
ownerPlural: 'Events',
relatedSingular: 'Venue',
relatedPlural: 'Venues',
})
})
it('removes only one final s and keeps one-letter names', () => {
expect(singularOf('Press mentions')).toBe('Press mention')
expect(singularOf('Glass')).toBe('Glas')
expect(singularOf('S')).toBe('S')
})
it('falls back when the related type is not chosen yet', () => {
expect(relationNames('Events', '', 'Place').relatedPlural).toBe('Places')
expect(relationNames('Events', 'Places', '').relatedSingular).toBe('Place')
expect(relationNames('', '', '').ownerSingular).toBe('Entry')
})
})
describe('the sentences of the cards', () => {
it.each([
['one_to_one', 'Each Event has one Venue, and each Venue has one Event.'],
['one_to_many', 'Each Event has one Venue. A Venue has many Events.'],
['many_to_many', 'Each Event has many Venues, and each Venue has many Events.'],
] as const)('reads %s as the design says', (kind, sentence) => {
expect(kindSentence(kind, names)).toBe(sentence)
})
})
describe('the preview', () => {
it.each([
['one_to_one', 'one', 'Venue', 'one', 'Event'],
['one_to_many', 'one', 'Venue', 'many', 'Events'],
['many_to_many', 'many', 'Venues', 'many', 'Events'],
] as const)('follows %s', (kind, owner, ownerWord, inverse, inverseWord) => {
expect(ownerSide(kind)).toBe(owner)
expect(ownerNoun(kind, names)).toBe(ownerWord)
expect(inverseSide(kind)).toBe(inverse)
expect(inverseNoun(kind, names)).toBe(inverseWord)
})
it('maps the selects of the sentences back to a kind', () => {
expect(kindFromOwnerSide('many', 'one_to_one')).toBe('many_to_many')
expect(kindFromOwnerSide('one', 'many_to_many')).toBe('one_to_many')
expect(kindFromOwnerSide('one', 'one_to_one')).toBe('one_to_one')
expect(kindFromInverseSide('one', 'many_to_many')).toBe('one_to_one')
expect(kindFromInverseSide('many', 'one_to_one')).toBe('one_to_many')
expect(kindFromInverseSide('many', 'many_to_many')).toBe('many_to_many')
})
it('keeps every kind stable through its own sides', () => {
for (const kind of RELATION_KINDS) {
expect(kindFromOwnerSide(ownerSide(kind), kind)).toBe(kind)
expect(kindFromInverseSide(inverseSide(kind), kind)).toBe(kind)
}
})
it('draws the example chips of the board', () => {
expect(relationSample('one_to_many')).toEqual({
ownerChips: ['Rue Verte hall'],
ownerMore: 0,
relatedChips: ['Open day', 'Lime plaster workshop'],
relatedMore: 1,
})
expect(relationSample('one_to_one').relatedChips).toEqual(['Open day'])
expect(relationSample('many_to_many').ownerChips).toHaveLength(2)
})
})
@@ -79,6 +79,7 @@ describe('create payload', () => {
min: null,
max: 80,
options: [],
relation: null,
},
{
key: 'starts_on',
@@ -90,6 +91,7 @@ describe('create payload', () => {
min: '2026-01-01',
max: '2027-12-31',
options: [],
relation: null,
},
{
key: 'online',
@@ -101,6 +103,7 @@ describe('create payload', () => {
min: null,
max: null,
options: [],
relation: null,
},
{
key: 'capacity',
@@ -112,6 +115,7 @@ describe('create payload', () => {
min: 1,
max: 5000,
options: [],
relation: null,
},
{
key: 'category',
@@ -123,6 +127,7 @@ describe('create payload', () => {
min: null,
max: null,
options: ['Talk', 'Workshop', 'Meetup'],
relation: null,
},
{
key: 'description',
@@ -134,12 +139,13 @@ describe('create payload', () => {
min: null,
max: 5000,
options: [],
relation: null,
},
],
})
})
it('has no icon key and exactly the nine field keys', () => {
it('has no icon key and exactly the ten field keys', () => {
const body = buildPayload(EVENTS)
expect(Object.keys(body).sort()).toEqual(['fields', 'key', 'name'])
@@ -151,6 +157,7 @@ describe('create payload', () => {
'max',
'min',
'options',
'relation',
'required',
'type',
'unique',
+1
View File
@@ -9,6 +9,7 @@ export function apiField(label: string, key: string, type: string, rest: object
min: null,
max: null,
options: null,
relation: null,
...rest,
}
}
+3
View File
@@ -1,4 +1,5 @@
import type { MediaMap } from '@/lib/mediaField'
import type { RelationDefinition, RelationMap } from '@/lib/relationField'
export interface BuiltinField {
key: string
@@ -10,6 +11,7 @@ export interface BuiltinField {
min: number | string | null
max: number | string | null
options: unknown[] | null
relation?: RelationDefinition | null
}
export interface ContentType {
@@ -27,6 +29,7 @@ export interface ContentEntry {
content_type: string
data: Record<string, unknown>
media?: MediaMap
relations?: RelationMap
created_by: number
updated_by: number
created_at: string
+64 -1
View File
@@ -3,19 +3,30 @@ import type { BuiltinField } from '@/lib/contentTypes'
import { fieldTypeSpec, type FieldCellKind } from '@/lib/fieldTypes'
import { formatDayValue } from '@/lib/entryValue'
import { isMissingImage, type MediaSummary } from '@/lib/mediaField'
import { isMissingRelation, relationIdsOf, type RelationSummary } from '@/lib/relationField'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export { formatDayValue }
export type CellKind = 'empty' | FieldCellKind
export interface RelationChip {
id: number
label: string
missing: boolean
}
export interface CellView {
kind: CellKind
text: string
yes?: boolean
image?: MediaSummary
chips?: RelationChip[]
more?: number
}
export const RELATION_CHIPS_SHOWN = 2
export const EMPTY_TEXT = '–'
const EMPTY: CellView = { kind: 'empty', text: '' }
@@ -48,7 +59,57 @@ function thumbnailCell(value: unknown, image: MediaSummary | undefined): CellVie
}
}
export function describeCell(field: BuiltinField, value: unknown, image?: MediaSummary): CellView {
function summaryField(first: { key: string; type: string }): BuiltinField {
return {
key: first.key,
label: '',
type: first.type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
}
}
export function relationLabel(summary: RelationSummary) {
if (isMissingRelation(summary)) {
return t('Entry no longer exists')
}
const first = summary.first_field
const cell = describeCell(summaryField(first), first.value)
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [summary.id])
: plainCellText(cell)
}
function relationCell(value: unknown, summaries: RelationSummary[]): CellView {
const ids = relationIdsOf(value)
if (ids.length === 0) {
return EMPTY
}
const chips = ids.map((id): RelationChip => {
const summary = summaries.find((candidate) => candidate.id === id) ?? {
id,
missing: true as const,
}
return { id, label: relationLabel(summary), missing: isMissingRelation(summary) }
})
return {
kind: 'relation',
text: chips.map((chip) => chip.label).join(', '),
chips: chips.slice(0, RELATION_CHIPS_SHOWN),
more: Math.max(0, chips.length - RELATION_CHIPS_SHOWN),
}
}
export function describeCell(
field: BuiltinField,
value: unknown,
image?: MediaSummary,
relations: RelationSummary[] = [],
): CellView {
if (value === undefined || value === null || value === '') {
return EMPTY
}
@@ -56,6 +117,8 @@ export function describeCell(field: BuiltinField, value: unknown, image?: MediaS
switch (kind) {
case 'thumbnail':
return thumbnailCell(value, image)
case 'relation':
return relationCell(value, relations)
case 'boolean':
return { kind, text: '', yes: value === true }
case 'number':
+24 -1
View File
@@ -4,8 +4,22 @@ import { rangeHelp, uniqueHelp, type EntryRule } from '@/lib/entryMessages'
import { asBoolean, asNumber, asText, charCount, isBlank, isIsoDay } from '@/lib/entryValue'
import type { FieldType } from '@/lib/fieldTypes'
import { isMediaId, isMissingImage, type MediaSummary } from '@/lib/mediaField'
import {
relationInitial,
relationPayload,
relationViolation,
type RelationSummary,
} from '@/lib/relationField'
export type FieldControl = 'input' | 'markdown' | 'number' | 'switch' | 'date' | 'select' | 'media'
export type FieldControl =
| 'input'
| 'markdown'
| 'number'
| 'switch'
| 'date'
| 'select'
| 'media'
| 'relation'
export interface FieldCounter {
text: string
@@ -14,6 +28,7 @@ export interface FieldCounter {
export interface EntryContext {
image: MediaSummary | null
relations: RelationSummary[]
}
export interface EntryEditorSpec {
@@ -197,4 +212,12 @@ export const ENTRY_EDITORS: Record<FieldType, EntryEditorSpec> = {
help: () => t('One image from your library.'),
counter: noCounter,
},
relation: {
control: 'relation',
initial: relationInitial,
validate: relationViolation,
payload: relationPayload,
help: noHelp,
counter: noCounter,
},
}
+17 -3
View File
@@ -2,9 +2,10 @@ import { t } from '@/i18n'
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { ruleMessage, type EntryRule } from '@/lib/entryMessages'
import { isBlank } from '@/lib/entryValue'
import { isBlank, sameValue } from '@/lib/entryValue'
import { fieldTypeSpec } from '@/lib/fieldTypes'
import { mediaOf, type MediaMap } from '@/lib/mediaField'
import { relationSummariesFor, type RelationSummaries } from '@/lib/relationField'
export type EntryValues = Record<string, unknown>
@@ -39,6 +40,17 @@ export function initialImages(fields: BuiltinField[], stored: ContentEntry | nul
return images
}
export function isRelationField(field: BuiltinField) {
return fieldTypeSpec(field.type).editor.control === 'relation'
}
export function initialRelations(
fields: BuiltinField[],
stored: ContentEntry | null,
): RelationSummaries {
return relationSummariesFor(fields, stored, isRelationField)
}
export function buildData(fields: BuiltinField[], values: EntryValues) {
return Object.fromEntries(
fields.map((field) => [
@@ -56,11 +68,13 @@ export function validateEntry(
fields: BuiltinField[],
values: EntryValues,
images: MediaMap = {},
relations: RelationSummaries = {},
): EntryIssues {
const issues: EntryIssues = {}
for (const field of fields) {
const rule = fieldTypeSpec(field.type).editor.validate(field, values[field.key], {
image: images[field.key] ?? null,
relations: relations[field.key] ?? [],
})
if (rule) {
issues[field.key] = issueFor(field, rule)
@@ -74,7 +88,7 @@ export function firstIssueKey(fields: BuiltinField[], issues: EntryIssues) {
}
export function valuesDiffer(fields: BuiltinField[], current: EntryValues, saved: EntryValues) {
return fields.some((field) => !Object.is(current[field.key], saved[field.key]))
return fields.some((field) => !sameValue(current[field.key], saved[field.key]))
}
export function carriesDefault(field: BuiltinField, value: unknown) {
@@ -91,7 +105,7 @@ export function entryTitle(fields: BuiltinField[], entry: ContentEntry | null) {
return entry ? t('Entry {0}', [entry.id]) : t('New entry')
}
const cell = describeCell(first, entry.data[first.key])
return cell.kind === 'empty' || cell.kind === 'thumbnail'
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? t('Entry {0}', [entry.id])
: plainCellText(cell)
}
+6
View File
@@ -26,6 +26,8 @@ function requiredMessage(field: BuiltinField) {
return t('Choose yes or no for {0}.', [field.label])
case 'media':
return t('Pick an image for {0}.', [field.label])
case 'relation':
return t('Choose a {0}.', [field.label])
default:
return t('Fill in {0}.', [field.label])
}
@@ -43,6 +45,8 @@ function typeMessage(field: BuiltinField) {
return t('{0} must be one of the choices.', [field.label])
case 'media':
return t('{0} must be an image from the library.', [field.label])
case 'relation':
return t('{0} must point to an existing entry.', [field.label])
default:
return t('{0} must be text.', [field.label])
}
@@ -76,6 +80,8 @@ function uniqueMessage(field: BuiltinField) {
return t('Another entry already uses this choice for {0}.', [field.label])
case 'date':
return t('Another entry already uses this date for {0}.', [field.label])
case 'relation':
return t('That {0} is already used by another entry.', [field.label])
default:
return t('Another entry already uses this {0}. Enter a different one.', [field.label])
}
+10
View File
@@ -57,3 +57,13 @@ export function freeSentinel(taken: string[], base = '__none__') {
}
return sentinel
}
export function sameValue(left: unknown, right: unknown) {
if (Array.isArray(left) && Array.isArray(right)) {
return (
left.length === right.length &&
left.every((item, index) => Object.is(item, right[index]))
)
}
return Object.is(left, right)
}
+64 -1
View File
@@ -1,4 +1,4 @@
import { t } from '@/i18n'
import { formatNumber, t } from '@/i18n'
import { ENTRY_EDITORS, type EntryEditorSpec } from '@/lib/entryEditors'
export const FIELD_TYPES = [
@@ -9,6 +9,7 @@ export const FIELD_TYPES = [
'date',
'select',
'media',
'relation',
] as const
export type FieldType = (typeof FIELD_TYPES)[number]
@@ -21,6 +22,7 @@ export type FieldCellKind =
| 'date'
| 'choice'
| 'thumbnail'
| 'relation'
export interface FieldTypeSpec {
icon: string
@@ -28,6 +30,7 @@ export interface FieldTypeSpec {
description: () => string
cell: FieldCellKind
narrowColumn: boolean
wideColumn: boolean
alignEnd: boolean
supportsUnique: boolean
supportsLimits: boolean
@@ -35,6 +38,8 @@ export interface FieldTypeSpec {
supportsChoices: boolean
showsUnsupportedOptions: boolean
builderHint: (() => string) | null
noDefaultCause: ((label: string, total: number) => string) | null
holdsRelation: boolean
pairs: boolean
editor: EntryEditorSpec
}
@@ -48,6 +53,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Short text, like a title or a name'),
cell: 'text',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
@@ -55,6 +61,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.text,
},
@@ -64,6 +72,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Long text with formatting, written in Markdown'),
cell: 'richtext',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: true,
@@ -71,6 +80,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.richtext,
},
@@ -80,6 +91,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('Whole or decimal numbers'),
cell: 'number',
narrowColumn: true,
wideColumn: false,
alignEnd: true,
supportsUnique: true,
supportsLimits: true,
@@ -87,6 +99,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.number,
},
@@ -96,6 +110,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('An on or off switch'),
cell: 'boolean',
narrowColumn: true,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
@@ -103,6 +118,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.boolean,
},
@@ -112,6 +129,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('A calendar day, without a time'),
cell: 'date',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: true,
@@ -119,6 +137,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: false,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: true,
editor: ENTRY_EDITORS.date,
},
@@ -128,6 +148,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One option from a list you define'),
cell: 'choice',
narrowColumn: false,
wideColumn: false,
alignEnd: false,
supportsUnique: true,
supportsLimits: false,
@@ -135,6 +156,8 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
supportsChoices: true,
showsUnsupportedOptions: true,
builderHint: null,
noDefaultCause: null,
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.select,
},
@@ -144,6 +167,7 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
description: () => t('One image from your media library'),
cell: 'thumbnail',
narrowColumn: true,
wideColumn: false,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
@@ -154,9 +178,48 @@ export const FIELD_TYPE_SPECS: Record<FieldType, FieldTypeSpec> = {
t(
'Holds one image from the media library. Entries link to the image instead of keeping a copy, so they always show its current version.',
),
noDefaultCause: (label, total) =>
total === 1
? t(
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
[label],
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: false,
pairs: false,
editor: ENTRY_EDITORS.media,
},
relation: {
icon: 'i-lucide-link',
label: () => t('Relation'),
description: () => t("Links to another type's entries"),
cell: 'relation',
narrowColumn: false,
wideColumn: true,
alignEnd: false,
supportsUnique: false,
supportsLimits: false,
supportsDefault: false,
supportsChoices: false,
showsUnsupportedOptions: false,
builderHint: null,
noDefaultCause: (label, total) =>
total === 1
? t(
"A relation can't have a default, so the existing entry would have no {0}. Make {0} optional to add it.",
[label],
)
: t(
"A relation can't have a default, so the {0} existing entries would have no {1}. Make {1} optional to add it.",
[formatNumber(total), label],
),
holdsRelation: true,
pairs: false,
editor: ENTRY_EDITORS.relation,
},
}
export function isFieldType(type: string): type is FieldType {
+3 -1
View File
@@ -51,7 +51,9 @@ export function usageEntryTitle(item: MediaUsageItem) {
},
first.value,
)
return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell)
return cell.kind === 'empty' || cell.kind === 'thumbnail' || cell.kind === 'relation'
? fallback
: plainCellText(cell)
}
function toEntry(item: MediaUsageItem): UsageEntry {
+170
View File
@@ -0,0 +1,170 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import type { EntryRule } from '@/lib/entryMessages'
export const RELATION_KINDS = ['one_to_one', 'one_to_many', 'many_to_many'] as const
export type RelationKind = (typeof RELATION_KINDS)[number]
export const DEFAULT_RELATION_KIND: RelationKind = 'one_to_many'
export const RELATION_MAX = 50
export const RELATION_PAGE_SIZE = 10
const RELATION_ID_MAX = 4294967295
export interface RelationDefinition {
content_type: string
kind: RelationKind
}
export interface RelationFirstField {
key: string
type: string
value: unknown
}
export interface RelationTarget {
id: number
content_type: string
first_field: RelationFirstField
}
export interface MissingRelation {
id: number
missing: true
}
export type RelationSummary = RelationTarget | MissingRelation
export type RelationMap = Record<string, RelationSummary | RelationSummary[]>
export type RelationSummaries = Record<string, RelationSummary[]>
export interface RelationContext {
relations: RelationSummary[]
}
export function isMissingRelation(summary: RelationSummary): summary is MissingRelation {
return 'missing' in summary
}
export function isRelationKind(value: unknown): value is RelationKind {
return (RELATION_KINDS as readonly unknown[]).includes(value)
}
export function isRelationId(value: unknown): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 1 &&
value <= RELATION_ID_MAX
)
}
export function holdsManyRelations(field: Pick<BuiltinField, 'relation'>) {
return field.relation?.kind === 'many_to_many'
}
export function emptyRelationValue(field: Pick<BuiltinField, 'relation'>) {
return holdsManyRelations(field) ? [] : null
}
export function relationIdsOf(value: unknown): number[] {
if (Array.isArray(value)) {
return value.filter(isRelationId)
}
return isRelationId(value) ? [value] : []
}
export function relationInitial(field: BuiltinField, stored: unknown, creating: boolean) {
if (creating) {
return emptyRelationValue(field)
}
if (holdsManyRelations(field)) {
return Array.isArray(stored) && stored.every(isRelationId) ? [...stored] : []
}
return isRelationId(stored) ? stored : null
}
function hasDuplicates(ids: number[]) {
return new Set(ids).size !== ids.length
}
function onlyMissing(ids: number[], summaries: RelationSummary[]) {
const gone = new Set(summaries.filter(isMissingRelation).map((summary) => summary.id))
return ids.length > 0 && ids.every((id) => gone.has(id))
}
function manyViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!Array.isArray(value)) {
return 'type'
}
if (value.length === 0) {
return field.required ? 'required' : null
}
if (value.length > RELATION_MAX || !value.every(isRelationId) || hasDuplicates(value)) {
return 'type'
}
return field.required && onlyMissing(value, context?.relations ?? []) ? 'required' : null
}
function singleViolation(field: BuiltinField, value: unknown, context?: RelationContext) {
if (value === null || value === undefined) {
return field.required ? 'required' : null
}
if (!isRelationId(value)) {
return 'type'
}
return field.required && onlyMissing([value], context?.relations ?? []) ? 'required' : null
}
export function relationViolation(
field: BuiltinField,
value: unknown,
context?: RelationContext,
): EntryRule | null {
return holdsManyRelations(field)
? manyViolation(field, value, context)
: singleViolation(field, value, context)
}
export function relationPayload(field: BuiltinField, value: unknown) {
if (holdsManyRelations(field)) {
return Array.isArray(value) ? [...value] : []
}
return value === undefined ? null : value
}
export function summariesOf(
entry: Pick<ContentEntry, 'relations'> | null | undefined,
key: string,
): RelationSummary[] {
const found = entry?.relations?.[key]
if (found === undefined) {
return []
}
return Array.isArray(found) ? found : [found]
}
export function relationSummariesFor(
fields: BuiltinField[],
entry: ContentEntry | null,
isRelation: (field: BuiltinField) => boolean,
): RelationSummaries {
const result: RelationSummaries = {}
for (const field of fields) {
if (!isRelation(field)) {
continue
}
const stored = relationIdsOf(entry?.data[field.key])
const summaries = summariesOf(entry, field.key).filter((summary) =>
stored.includes(summary.id),
)
if (summaries.length > 0) {
result[field.key] = summaries
}
}
return result
}
+47
View File
@@ -0,0 +1,47 @@
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
import { describeCell, isEmptyCell, plainCellText, relationLabel } from '@/lib/entryCells'
import type { RelationTarget } from '@/lib/relationField'
export const ROW_DETAIL_FIELDS = 2
const DETAIL_SEPARATOR = ' · '
const UNKNOWN_FIELD_TYPE = 'text'
const NO_TEXT_KINDS = ['thumbnail', 'relation']
export interface RelationRow {
id: number
title: string
detail: string
summary: RelationTarget
}
function summaryOf(entry: ContentEntry, targetKey: string, fields: BuiltinField[]): RelationTarget {
const first = fields[0]
return {
id: entry.id,
content_type: targetKey,
first_field: {
key: first?.key ?? '',
type: first?.type ?? UNKNOWN_FIELD_TYPE,
value: first ? (entry.data[first.key] ?? null) : null,
},
}
}
function detailOf(entry: ContentEntry, fields: BuiltinField[]) {
return fields
.slice(1, 1 + ROW_DETAIL_FIELDS)
.map((field) => describeCell(field, entry.data[field.key]))
.filter((cell) => !isEmptyCell(cell) && !NO_TEXT_KINDS.includes(cell.kind))
.map(plainCellText)
.join(DETAIL_SEPARATOR)
}
export function relationRowOf(
entry: ContentEntry,
targetKey: string,
fields: BuiltinField[],
): RelationRow {
const summary = summaryOf(entry, targetKey, fields)
return { id: entry.id, title: relationLabel(summary), detail: detailOf(entry, fields), summary }
}
+108
View File
@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import type { RelationKind } from '@/lib/relationField'
export type RelationSide = 'one' | 'many'
export interface RelationNames {
ownerSingular: string
ownerPlural: string
relatedSingular: string
relatedPlural: string
}
export interface RelationSample {
ownerChips: string[]
ownerMore: number
relatedChips: string[]
relatedMore: number
}
const FINAL_PLURAL = /s$/i
const SAMPLE_VENUES = ['Rue Verte hall', 'Rue Verte yard']
const SAMPLE_EVENTS = ['Open day', 'Lime plaster workshop']
const SAMPLE_EVENTS_MORE = 1
export function singularOf(name: string) {
return name.length > 1 ? name.replace(FINAL_PLURAL, '') : name
}
export function relationNames(
ownerName: string,
relatedName: string,
fieldLabel: string,
): RelationNames {
const ownerPlural = ownerName.trim() || t('Entries')
const ownerSingular = ownerName.trim() ? singularOf(ownerPlural) : t('Entry')
const relatedSingular = fieldLabel.trim() || singularOf(relatedName.trim()) || t('Entry')
return {
ownerSingular,
ownerPlural,
relatedSingular,
relatedPlural: relatedName.trim() || `${relatedSingular}s`,
}
}
export function ownerSide(kind: RelationKind): RelationSide {
return kind === 'many_to_many' ? 'many' : 'one'
}
export function inverseSide(kind: RelationKind): RelationSide {
return kind === 'one_to_one' ? 'one' : 'many'
}
export function kindFromOwnerSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'many') {
return 'many_to_many'
}
return inverseSide(kind) === 'one' ? 'one_to_one' : 'one_to_many'
}
export function kindFromInverseSide(side: RelationSide, kind: RelationKind): RelationKind {
if (side === 'one') {
return 'one_to_one'
}
return ownerSide(kind) === 'many' ? 'many_to_many' : 'one_to_many'
}
export function kindSentence(kind: RelationKind, names: RelationNames) {
const { ownerSingular, ownerPlural, relatedSingular, relatedPlural } = names
switch (kind) {
case 'one_to_one':
return t('Each {0} has one {1}, and each {1} has one {0}.', [
ownerSingular,
relatedSingular,
])
case 'one_to_many':
return t('Each {0} has one {1}. A {1} has many {2}.', [
ownerSingular,
relatedSingular,
ownerPlural,
])
default:
return t('Each {0} has many {1}, and each {2} has many {3}.', [
ownerSingular,
relatedPlural,
relatedSingular,
ownerPlural,
])
}
}
export function ownerNoun(kind: RelationKind, names: RelationNames) {
return ownerSide(kind) === 'many' ? names.relatedPlural : names.relatedSingular
}
export function inverseNoun(kind: RelationKind, names: RelationNames) {
return inverseSide(kind) === 'many' ? names.ownerPlural : names.ownerSingular
}
export function relationSample(kind: RelationKind): RelationSample {
const many = inverseSide(kind) === 'many'
return {
ownerChips: ownerSide(kind) === 'many' ? SAMPLE_VENUES : SAMPLE_VENUES.slice(0, 1),
ownerMore: 0,
relatedChips: many ? SAMPLE_EVENTS : SAMPLE_EVENTS.slice(0, 1),
relatedMore: many ? SAMPLE_EVENTS_MORE : 0,
}
}
+15 -18
View File
@@ -19,7 +19,7 @@ export interface FieldChange {
label: string
notes: ChangeNote[]
flag: ChangeFlag | null
mediaAdd?: boolean
noDefaultAdd?: boolean
}
type Bound = number | string | null
@@ -151,20 +151,15 @@ function changeNotes(before: PayloadField, after: PayloadField) {
return notes
}
function mediaAddNote(field: PayloadField, total: number) {
const summary = `${fieldTypeSpec(field.type).label()}, ${field.required ? t('required') : t('optional')}.`
function noDefaultAddNote(field: PayloadField, total: number) {
const spec = fieldTypeSpec(field.type)
const summary = `${spec.label()}, ${field.required ? t('required') : t('optional')}.`
if (field.required) {
const cause =
total === 1
? t(
"A media field can't have a default, so the existing entry would have no image. Make {0} optional to add it.",
[field.label],
)
: t(
"A media field can't have a default, so the {0} existing entries would have no image. Make {1} optional to add it.",
[formatNumber(total), field.label],
)
return note(`${summary} ${cause}`, '', true)
return note(
`${summary} ${spec.noDefaultCause?.(field.label, total) ?? ''}`.trim(),
'',
true,
)
}
if (total <= 0) {
return note(summary)
@@ -178,7 +173,7 @@ function mediaAddNote(field: PayloadField, total: number) {
function addNote(field: PayloadField, total: number) {
if (!fieldTypeSpec(field.type).supportsDefault) {
return mediaAddNote(field, total)
return noDefaultAddNote(field, total)
}
const parts = [
fieldTypeSpec(field.type).label(),
@@ -233,7 +228,7 @@ export function diffFields(
label: after.label,
notes,
flag: addFlag(after, notes, total),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { mediaAdd: true }),
...(fieldTypeSpec(after.type).supportsDefault ? {} : { noDefaultAdd: true }),
})
} else if (fieldSignature(previous) !== fieldSignature(after)) {
const notes = changeNotes(previous, after)
@@ -287,8 +282,10 @@ export function flaggedChanges(changes: readonly FieldChange[]) {
return changes.filter((change) => change.flag === 'mayNotFit')
}
export function isMediaOnlyClean(changes: readonly FieldChange[]) {
return changes.length > 0 && changes.every((change) => change.mediaAdd && change.flag === null)
export function isNoDefaultOnlyClean(changes: readonly FieldChange[]) {
return (
changes.length > 0 && changes.every((change) => change.noDefaultAdd && change.flag === null)
)
}
export function hasRefusedChange(changes: readonly FieldChange[]) {
+3
View File
@@ -7,6 +7,7 @@ import {
type FieldType,
} from '@/lib/schemaBuilderModel'
import { payloadField, type PayloadField } from '@/lib/schemaBuilderPayload'
import { DEFAULT_RELATION_KIND } from '@/lib/relationField'
const DATE_LENGTH = 10
@@ -42,6 +43,8 @@ export function fieldFromApi(source: BuiltinField, uid: number): BuilderField {
min: bound(source.min),
max: bound(source.max),
options: type === 'select' ? asOptions(source.options) : [],
relationType: source.relation?.content_type ?? '',
relationKind: source.relation?.kind ?? DEFAULT_RELATION_KIND,
saved: true,
})
}
+9
View File
@@ -1,4 +1,5 @@
import { FIELD_TYPE_SPECS, FIELD_TYPES, type FieldType } from '@/lib/fieldTypes'
import { DEFAULT_RELATION_KIND, type RelationKind } from '@/lib/relationField'
export { FIELD_TYPES, type FieldType }
export type KeyMode = 'auto' | 'manual'
@@ -39,6 +40,8 @@ export interface BuilderField {
min: string
max: string
options: string[]
relationType: string
relationKind: RelationKind
saved: boolean
}
@@ -71,6 +74,8 @@ export function createField(uid: number, overrides: Partial<BuilderField> = {}):
min: '',
max: '',
options: [],
relationType: '',
relationKind: DEFAULT_RELATION_KIND,
saved: false,
...overrides,
}
@@ -111,6 +116,10 @@ export function supportsChoices(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsChoices
}
export function holdsRelation(type: FieldType) {
return FIELD_TYPE_SPECS[type].holdsRelation
}
export function supportsLimits(type: FieldType) {
return FIELD_TYPE_SPECS[type].supportsLimits
}
+10
View File
@@ -1,5 +1,6 @@
import {
effectiveDefault,
holdsRelation,
isUnique,
supportsChoices,
supportsLimits,
@@ -7,6 +8,7 @@ import {
type BuilderField,
type BuilderForm,
} from '@/lib/schemaBuilderModel'
import type { RelationDefinition } from '@/lib/relationField'
export interface PayloadField {
key: string
@@ -18,6 +20,7 @@ export interface PayloadField {
min: number | string | null
max: number | string | null
options: string[]
relation: RelationDefinition | null
}
export interface ContentTypePayload {
@@ -50,6 +53,12 @@ export interface UpdatePayload {
fields: PayloadField[]
}
function relationFor(field: BuilderField): RelationDefinition | null {
return holdsRelation(field.type)
? { content_type: field.relationType, kind: field.relationKind }
: null
}
export function payloadField(field: BuilderField): PayloadField {
return {
key: field.key,
@@ -61,6 +70,7 @@ export function payloadField(field: BuilderField): PayloadField {
min: limit(field, field.min),
max: limit(field, field.max),
options: supportsChoices(field.type) ? trimmedOptions(field) : [],
relation: relationFor(field),
}
}
+4 -2
View File
@@ -30,7 +30,7 @@ export function previewRoutes(key: string) {
]
}
export function sampleValue(field: BuilderField): string | number | boolean | null {
export function sampleValue(field: BuilderField): string | number | boolean | null | never[] {
const given = effectiveDefault(field)
switch (field.type) {
case 'richtext':
@@ -49,6 +49,8 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
return given === '' ? SAMPLE_DATE : given
case 'media':
return null
case 'relation':
return field.relationKind === 'many_to_many' ? [] : null
case 'select': {
const choices = trimmedOptions(field).filter((option) => option !== '')
return given !== '' ? given : (choices[0] ?? null)
@@ -59,7 +61,7 @@ export function sampleValue(field: BuilderField): string | number | boolean | nu
}
export function sampleEntry(key: string, fields: readonly BuilderField[]) {
const data: Record<string, string | number | boolean | null> = {}
const data: Record<string, string | number | boolean | null | never[]> = {}
for (const field of fields) {
if (isValidFieldKey(field.key)) {
data[field.key] = sampleValue(field)
+2 -1
View File
@@ -1,7 +1,7 @@
import type { FieldErrors } from '@/lib/schemaBuilderValidation'
export type TypeTouch = 'name' | 'key' | 'fields'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices'
export type FieldTouch = 'label' | 'key' | 'min' | 'max' | 'default' | 'choices' | 'relation'
export function typeTouchKey(name: TypeTouch) {
return `type:${name}`
@@ -19,4 +19,5 @@ export const FIELD_ERROR_TOUCH: readonly (readonly [keyof FieldErrors, FieldTouc
['default', 'default'],
['options', 'choices'],
['choices', 'choices'],
['relation', 'relation'],
]
+5
View File
@@ -2,6 +2,7 @@ import { t } from '@/i18n'
import {
BUILDER_LIMITS,
RESERVED_TYPE_KEYS,
holdsRelation,
trimmedOptions,
type BuilderField,
type BuilderForm,
@@ -25,6 +26,7 @@ export interface FieldErrors {
default?: string
options?: string
choices?: Record<number, string>
relation?: string
}
export interface FormErrors {
@@ -190,6 +192,9 @@ export function validateField(field: BuilderField, earlierKeys: readonly string[
if (key) {
errors.key = key
}
if (holdsRelation(field.type) && field.relationType === '') {
errors.relation = t('Choose a related content type.')
}
if (field.type === 'text' || field.type === 'richtext') {
textLimits(field, errors)
} else if (field.type === 'number') {
+2 -2
View File
@@ -1,5 +1,5 @@
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict'
export type DeleteStage = 'closed' | 'blocked' | 'confirm' | 'failed' | 'conflict' | 'referenced'
export function isConfirmStage(stage: DeleteStage) {
return stage === 'confirm' || stage === 'failed'
return stage === 'confirm' || stage === 'failed' || stage === 'referenced'
}