feat: relation field and picker

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

No files matched your search

+211 -2
View File
@@ -8104,13 +8104,222 @@ function withMediaExceptions(screen: string, states: Record<string, VisualState>
)
}
function relationFieldOf(label: string, key: string, target: string, kind: string) {
return schemaField(label, key, 'relation', {
options: [],
relation: { content_type: target, kind },
})
}
function relationTypeOf(id: number, key: string, name: string, fields: object[]) {
return {
id,
key,
name,
icon: 'files',
fields,
created_at: '2026-09-01T09:30:00Z',
updated_at: '2026-10-01T09:30:00Z',
}
}
const relationVenueRows = [
{ id: 3, name: 'Rue Verte hall', city: 'Lyon', details: '120 seats' },
{ id: 4, name: 'Rue Verte yard', city: 'Lyon', details: 'outdoor' },
{ id: 5, name: 'Rue du Port studio', city: 'Nantes', details: '24 seats' },
]
const relationSpeakerRows = [
{ id: 11, name: 'Inès Duarte' },
{ id: 12, name: 'Jonas Pereira' },
{ id: 13, name: 'Ana Costa' },
{ id: 14, name: 'Luc Moreau' },
]
const relationVenuesType = relationTypeOf(4, 'venues', 'Venues', [
schemaField('Name', 'name', 'text', { required: true, options: [] }),
schemaField('City', 'city', 'text', { options: [] }),
schemaField('Details', 'details', 'text', { options: [] }),
])
const relationSpeakersType = relationTypeOf(5, 'speakers', 'Speakers', [
schemaField('Name', 'name', 'text', { required: true, options: [] }),
])
function relationTypes(events: object) {
return [events, ...schemaCustomTypes.slice(1), relationSpeakersType, relationVenuesType]
}
function venueSummary(id: number) {
const row = relationVenueRows.find((candidate) => candidate.id === id)!
return {
id,
content_type: 'venues',
first_field: { key: 'name', type: 'text', value: row.name },
}
}
function speakerSummary(id: number) {
const row = relationSpeakerRows.find((candidate) => candidate.id === id)!
return {
id,
content_type: 'speakers',
first_field: { key: 'name', type: 'text', value: row.name },
}
}
function relationSearch(url: URL) {
const needle = (url.searchParams.get('q') ?? '').toLowerCase()
const matches = relationVenueRows.filter((row) => row.name.toLowerCase().includes(needle))
return {
items: matches.map((row) => ({
id: row.id,
content_type: 'venues',
data: { name: row.name, city: row.city, details: row.details },
created_by: 3,
updated_by: 3,
created_at: '2026-10-02T09:14:00Z',
updated_at: '2026-10-12T11:02:00Z',
relations: {},
})),
total: matches.length,
page: 1,
size: 10,
}
}
const relationBuilderStates: Record<string, VisualState> = {
'Relation in the builder': {
exception: {
maxPercent: { desktop: 27, mobile: 34.5 },
reason: 'the board draws only the inspector under its own title, without the type bar, field list and API preview, plus a stray board miniature, while the app is the full builder and a bottom sheet on mobile',
approvedBy: 'Florian Sylvain',
},
path: '/schema/events',
api: schemaApi({
'GET /v1/content-type': { status: 200, body: relationTypes(schemaCustomTypes[0]!) },
'GET /v1/content-type/events/entry': { status: 200, body: schemaEntryPage(0) },
}),
steps: async (page) => {
await schemaAddField(page, 'Venue')
await page.locator('label:has([role="radio"][value="relation"])').click()
await page
.locator('[data-testid="relation-picker"] button[role="combobox"]')
.first()
.click()
await page.getByRole('option', { name: /Venues/ }).click()
await schemaSettle(page)
},
},
}
const relationEntryType = relationTypeOf(1, 'events', 'Events', [
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
])
const relationEntryStates: Record<string, VisualState> = {
'Relation in the entry form': {
exception: {
maxPercent: { desktop: 10, mobile: 17 },
reason: 'on mobile the board reserves a spacer under the open list, hides the meta block and action bar and draws a stray miniature, while the app overlays the list on the next field and shows the meta block and sticky actions',
approvedBy: 'Florian Sylvain',
},
path: '/content/events/42',
api: entryApi({
'GET /v1/content-type': { status: 200, body: relationTypes(relationEntryType) },
'GET /v1/content-type/events': { status: 200, body: relationEntryType },
'GET /v1/content-type/events/entry/42': {
status: 200,
body: entryRecord(
{ venue: null, speakers: [11, 12] },
{ relations: { speakers: [speakerSummary(11), speakerSummary(12)] } },
),
},
'GET /v1/content-type/venues/entry': { status: 200, bodyFor: relationSearch },
}),
steps: async (page) => {
const input = entryField(page, 'venue').getByRole('combobox')
await input.click()
await input.fill('rue')
await page.getByRole('option').first().waitFor()
await schemaSettle(page)
},
},
}
const relationListType = relationTypeOf(1, 'events', 'Events', [
schemaField('Title', 'title', 'text', { required: true, unique: true, max: 80, options: [] }),
schemaField('Starts on', 'starts_on', 'date', { required: true, options: [] }),
relationFieldOf('Venue', 'venue', 'venues', 'one_to_many'),
relationFieldOf('Speakers', 'speakers', 'speakers', 'many_to_many'),
schemaField('Ends on', 'ends_on', 'date', { options: [] }),
schemaField('Online', 'online', 'boolean', { default: false, options: [] }),
schemaField('Capacity', 'capacity', 'number', { min: 1, max: 5000, options: [] }),
schemaField('Category', 'category', 'select', { options: ['Talk', 'Workshop', 'Meetup'] }),
schemaField('Description', 'description', 'richtext', { options: [] }),
])
const relationListVenues: Record<number, number | null> = { 42: 3, 41: null, 40: 4 }
const relationListSpeakers: Record<number, number[]> = { 42: [11, 12, 13, 14], 41: [12], 40: [] }
const relationListRows = contentRows.slice(0, 3).map((row) => {
const id = Number(row.id)
const venue = relationListVenues[id] ?? null
const speakers = relationListSpeakers[id] ?? []
return {
...row,
data: { ...row.data, venue, speakers },
relations: {
...(venue === null ? {} : { venue: venueSummary(venue) }),
...(speakers.length === 0 ? {} : { speakers: speakers.map(speakerSummary) }),
},
}
})
const relationListColumns = ['title', 'starts_on', 'venue', 'speakers']
const relationContentStates: Record<string, VisualState> = {
'Relation in the list': {
exception: {
maxPercent: { desktop: 10, mobile: 17.5 },
reason: 'on mobile the app card list has the sort and columns toolbar, the meta line and the New entry button that the board does not draw',
approvedBy: 'Florian Sylvain',
},
path: '/content/events',
api: contentApi({
'GET /v1/content-type': { status: 200, body: relationTypes(relationListType) },
'GET /v1/content-type/events': { status: 200, body: relationListType },
'GET /v1/content-type/events/entry': {
status: 200,
body: { items: relationListRows, total: 3, page: 1, size: 25 },
},
}),
steps: async (page) => {
await page.evaluate(
([key, value]) => localStorage.setItem(key!, value!),
[thumbnailColumnsKey, JSON.stringify(relationListColumns)],
)
await page.reload()
await page.locator('tbody tr, [data-testid="entry-card"]').first().waitFor()
await schemaSettle(page)
},
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
schema: { ...schemaStates, ...withMediaExceptions('schema', mediaSchemaStates) },
content: { ...contentStates, ...mediaContentStates },
schema: {
...schemaStates,
...withMediaExceptions('schema', mediaSchemaStates),
...relationBuilderStates,
},
content: { ...contentStates, ...mediaContentStates, ...relationContentStates },
entry: {
...entryStates,
...withMediaExceptions('entry', { ...mediaEntryStates, ...mediaPickerStates }),
...relationEntryStates,
},
authors: authorsStates,
activity: activityStates,