feat: relation field and picker
This commit is contained in:
1 parent
b45c99be3b
commit
37a77c9c22
92 files changed
+6826
-80
No files matched your search
@@ -10,6 +10,7 @@ const CONTROLS: Record<FieldType, string> = {
|
||||
date: 'date',
|
||||
select: 'select',
|
||||
media: 'media',
|
||||
relation: 'relation',
|
||||
}
|
||||
|
||||
function editor(type: FieldType) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export function apiField(label: string, key: string, type: string, rest: object
|
||||
min: null,
|
||||
max: null,
|
||||
options: null,
|
||||
relation: null,
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user