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
@@ -74,6 +74,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the builder'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '3 Review and delete',
|
||||
@@ -97,6 +103,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
states: ['Thumbnail column', 'Columns menu with Thumbnail', 'Missing image: tooltip'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the list'],
|
||||
alias: {},
|
||||
},
|
||||
],
|
||||
entry: [
|
||||
{
|
||||
@@ -116,6 +128,12 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '7 Relations',
|
||||
states: ['Relation in the entry form'],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: schemaBoard,
|
||||
group: '10 Media picker for fields',
|
||||
|
||||
+211
-2
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user