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