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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in new issue
Block a user