allow adding children records

This commit is contained in:
Maxime Duchêne-Savard 2026-08-01 21:56:46 -04:00
parent 27dd83f488
commit 832112072e
4 changed files with 67 additions and 102 deletions

View File

@ -19,7 +19,6 @@
interface ChildRecords { interface ChildRecords {
entity: Entity entity: Entity
relationshipField: Field relationshipField: Field
relationshipDirection?: 'INBOUND' | 'OUTBOUND'
records: Record<string, unknown>[] records: Record<string, unknown>[]
} }
interface EntityRecord { interface EntityRecord {
@ -59,7 +58,7 @@
} }
function childKey (child: ChildRecords) { function childKey (child: ChildRecords) {
return `${child.entity.id}-${child.relationshipField.id}-${child.relationshipDirection ?? 'INBOUND'}` return `${child.entity.id}-${child.relationshipField.id}`
} }
function editableChildFields (child: ChildRecords) { function editableChildFields (child: ChildRecords) {
@ -151,19 +150,17 @@
saving.value = true saving.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
const childUrl = new URL( const response = await fetch(
`${recordUrl.value}/children/${encodeURIComponent(child.entity.name)}/${child.relationshipField.id}`, `${recordUrl.value}/children/${encodeURIComponent(child.entity.name)}/${child.relationshipField.id}`,
window.location.origin, {
)
childUrl.searchParams.set('direction', child.relationshipDirection ?? 'INBOUND')
const response = await fetch(childUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${auth.token}`, 'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: formBody(fields, childValues[key]), body: formBody(fields, childValues[key]),
}) },
)
if (!response.ok) throw new Error('Child create failed') if (!response.ok) throw new Error('Child create failed')
const created = await response.json() as EntityRecord const created = await response.json() as EntityRecord
child.records.push(created.values) child.records.push(created.values)

View File

@ -32,6 +32,14 @@ test('admin opens a user file and views a linked entity record', async ({ page }
targetEntityId: 6, targetEntityId: 6,
relationshipType: 'ONE_TO_MANY', relationshipType: 'ONE_TO_MANY',
}, },
{
id: 14,
name: 'Tasks',
identifier: 'tasks',
type: 'RELATIONSHIP',
targetEntityId: 4,
relationshipType: 'ONE_TO_MANY',
},
], ],
}, },
values: { id: 42, title: projectTitle, owner: 7 }, values: { id: 42, title: projectTitle, owner: 7 },
@ -43,20 +51,22 @@ test('admin opens a user file and views a linked entity record', async ({ page }
fields: [ fields: [
{ id: 20, name: 'Description', identifier: 'description', type: 'TEXT' }, { id: 20, name: 'Description', identifier: 'description', type: 'TEXT' },
{ {
id: 21, id: 22,
name: 'Project', name: 'Notes',
identifier: 'project', identifier: 'notes',
type: 'RELATIONSHIP', type: 'RELATIONSHIP',
targetEntityId: 3, targetEntityId: 5,
relationshipType: 'ONE_TO_MANY',
}, },
], ],
}, },
relationshipField: { relationshipField: {
id: 21, id: 14,
name: 'Project', name: 'Tasks',
identifier: 'project', identifier: 'tasks',
type: 'RELATIONSHIP', type: 'RELATIONSHIP',
targetEntityId: 3, targetEntityId: 4,
relationshipType: 'ONE_TO_MANY',
}, },
records: tasks, records: tasks,
}, { }, {
@ -74,7 +84,6 @@ test('admin opens a user file and views a linked entity record', async ({ page }
targetEntityId: 6, targetEntityId: 6,
relationshipType: 'ONE_TO_MANY', relationshipType: 'ONE_TO_MANY',
}, },
relationshipDirection: 'OUTBOUND',
records: milestones, records: milestones,
}], }],
}) })
@ -117,9 +126,9 @@ test('admin opens a user file and views a linked entity record', async ({ page }
} }
await route.fulfill({ json: projectResponse() }) await route.fulfill({ json: projectResponse() })
}) })
await page.route('**/api/users/7/entities/Project/records/42/children/Task/21*', async route => { await page.route('**/api/users/7/entities/Project/records/42/children/Task/14', async route => {
const values = new URLSearchParams(route.request().postData() ?? '') const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 55, description: values.get('description'), project: 42 } const created = { id: 55, description: values.get('description'), notes: null }
tasks.push(created) tasks.push(created)
await route.fulfill({ await route.fulfill({
status: 201, status: 201,
@ -130,8 +139,7 @@ test('admin opens a user file and views a linked entity record', async ({ page }
}, },
}) })
}) })
await page.route('**/api/users/7/entities/Project/records/42/children/Milestone/13*', async route => { await page.route('**/api/users/7/entities/Project/records/42/children/Milestone/13', async route => {
expect(new URL(route.request().url()).searchParams.get('direction')).toBe('OUTBOUND')
const values = new URLSearchParams(route.request().postData() ?? '') const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 60, label: values.get('label') } const created = { id: 60, label: values.get('label') }
milestones.push(created) milestones.push(created)
@ -151,23 +159,23 @@ test('admin opens a user file and views a linked entity record', async ({ page }
identifier: 'note', identifier: 'note',
fields: [ fields: [
{ id: 30, name: 'Contents', identifier: 'contents', type: 'TEXT' }, { id: 30, name: 'Contents', identifier: 'contents', type: 'TEXT' },
{ id: 31, name: 'Task', identifier: 'task', type: 'RELATIONSHIP', targetEntityId: 4 },
], ],
}, },
relationshipField: { relationshipField: {
id: 31, id: 22,
name: 'Task', name: 'Notes',
identifier: 'task', identifier: 'notes',
type: 'RELATIONSHIP', type: 'RELATIONSHIP',
targetEntityId: 4, targetEntityId: 5,
relationshipType: 'ONE_TO_MANY',
}, },
records: notes, records: notes,
}], }],
}, },
})) }))
await page.route('**/api/users/7/entities/Task/records/55/children/Note/31*', async route => { await page.route('**/api/users/7/entities/Task/records/55/children/Note/22', async route => {
const values = new URLSearchParams(route.request().postData() ?? '') const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 56, contents: values.get('contents'), task: 55 } const created = { id: 56, contents: values.get('contents') }
notes.push(created) notes.push(created)
await route.fulfill({ await route.fulfill({
status: 201, status: 201,
@ -210,7 +218,7 @@ test('admin opens a user file and views a linked entity record', async ({ page }
const childForm = page.getByRole('form', { name: 'Add Task' }) const childForm = page.getByRole('form', { name: 'Add Task' })
await childForm.getByLabel('Description').fill('Prepare launch') await childForm.getByLabel('Description').fill('Prepare launch')
await childForm.getByRole('button', { name: 'Add Task' }).click() await childForm.getByRole('button', { name: 'Add Task' }).click()
await expect(page.getByRole('row').filter({ hasText: 'Prepare launch' })).toContainText('42') await expect(page.getByRole('row').filter({ hasText: 'Prepare launch' })).toBeVisible()
await page.getByRole('link', { name: 'View Task record 55' }).click() await page.getByRole('link', { name: 'View Task record 55' }).click()
await expect(page.getByRole('heading', { name: 'Task #55' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Task #55' })).toBeVisible()
@ -218,5 +226,5 @@ test('admin opens a user file and views a linked entity record', async ({ page }
const noteForm = page.getByRole('form', { name: 'Add Note' }) const noteForm = page.getByRole('form', { name: 'Add Note' })
await noteForm.getByLabel('Contents').fill('Nested child') await noteForm.getByLabel('Contents').fill('Nested child')
await noteForm.getByRole('button', { name: 'Add Note' }).click() await noteForm.getByRole('button', { name: 'Add Note' }).click()
await expect(page.getByRole('row').filter({ hasText: 'Nested child' })).toContainText('55') await expect(page.getByRole('row').filter({ hasText: 'Nested child' })).toBeVisible()
}) })

View File

@ -22,11 +22,8 @@ data class ChildEntityRecords(
val entity: EntityDefinition, val entity: EntityDefinition,
val relationshipField: EntityField, val relationshipField: EntityField,
val records: List<Map<String, Any?>>, val records: List<Map<String, Any?>>,
val relationshipDirection: RelationshipDirection = RelationshipDirection.INBOUND,
) )
enum class RelationshipDirection { INBOUND, OUTBOUND }
class EntityRecordRepository( class EntityRecordRepository(
private val database: Database, private val database: Database,
private val entities: EntityDefinitionRepository, private val entities: EntityDefinitionRepository,
@ -112,14 +109,12 @@ class EntityRecordRepository(
parentRecordId: Long, parentRecordId: Long,
childEntityName: String, childEntityName: String,
relationshipFieldId: Long, relationshipFieldId: Long,
relationshipDirection: RelationshipDirection,
submittedValues: Map<String, String>, submittedValues: Map<String, String>,
): EntityRecord? { ): EntityRecord? {
val definitions = entities.findAll() val definitions = entities.findAll()
val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null
if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null
val child = definitions.firstOrNull { it.name == childEntityName } ?: return null val child = definitions.firstOrNull { it.name == childEntityName } ?: return null
if (relationshipDirection == RelationshipDirection.OUTBOUND) {
val relationship = parent.fields.firstOrNull { val relationship = parent.fields.firstOrNull {
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.id it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.id
} ?: return null } ?: return null
@ -148,41 +143,11 @@ class EntityRecordRepository(
} }
return findChildRecord(child, childId) return findChildRecord(child, childId)
} }
val parentField = child.fields.firstOrNull {
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parent.id
} ?: return null
val fields = child.fields
val sql = "INSERT INTO ${quote(child.identifier)} (${fields.joinToString { quote(it.identifier) }}) " +
"VALUES (${fields.joinToString { "?" }}) RETURNING \"id\""
val childId = checkNotNull(database.queryOne(sql, bind = {
fields.forEachIndexed { index, field ->
when {
field.id == parentField.id -> setLong(index + 1, parentRecordId)
field.type == FieldType.USER -> setLong(index + 1, userId)
else -> bindField(index + 1, field, submittedValues[field.identifier].orEmpty())
}
}
}) { getLong("id") })
return findChildRecord(child, childId)
}
private fun findChildren(parentEntityId: Long, parentRecordId: Long): List<ChildEntityRecords> { private fun findChildren(parentEntityId: Long, parentRecordId: Long): List<ChildEntityRecords> {
val definitions = entities.findAll() val definitions = entities.findAll()
val inbound = definitions.flatMap { child -> val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return emptyList()
child.fields return parent.fields
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId }
.map { relationship ->
val columns = listOf("id") + child.fields.map(EntityField::identifier)
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(child.identifier)} " +
"WHERE ${quote(relationship.identifier)} = ? ORDER BY \"id\""
val records = database.queryList(sql, bind = { setLong(1, parentRecordId) }) {
toRecord(columns)
}
ChildEntityRecords(child, relationship, records)
}
}
val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return inbound
val outbound = parent.fields
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null } .filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null }
.mapNotNull { relationship -> .mapNotNull { relationship ->
val child = definitions.firstOrNull { it.id == relationship.targetEntityId } ?: return@mapNotNull null val child = definitions.firstOrNull { it.id == relationship.targetEntityId } ?: return@mapNotNull null
@ -190,9 +155,8 @@ class EntityRecordRepository(
?.let { it as? Number }?.toLong() ?.let { it as? Number }?.toLong()
val childIds = (outboundChildIds(relationship.id, parentRecordId) + listOfNotNull(storedTargetId)).distinct() val childIds = (outboundChildIds(relationship.id, parentRecordId) + listOfNotNull(storedTargetId)).distinct()
val records = childIds.mapNotNull { findValues(child, it) } val records = childIds.mapNotNull { findValues(child, it) }
ChildEntityRecords(child, relationship, records, RelationshipDirection.OUTBOUND) ChildEntityRecords(child, relationship, records)
} }
return inbound + outbound
} }
private fun outboundChildIds(fieldId: Long, sourceRecordId: Long): List<Long> = private fun outboundChildIds(fieldId: Long, sourceRecordId: Long): List<Long> =

View File

@ -1,7 +1,6 @@
package dev.mduchene.bolts.web package dev.mduchene.bolts.web
import dev.mduchene.bolts.entity.EntityRecordRepository import dev.mduchene.bolts.entity.EntityRecordRepository
import dev.mduchene.bolts.entity.RelationshipDirection
import dev.mduchene.bolts.user.LoginService import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.UserRepository import dev.mduchene.bolts.user.UserRepository
import io.javalin.router.JavalinDefaultRoutingApi import io.javalin.router.JavalinDefaultRoutingApi
@ -115,9 +114,6 @@ class UserController(
recordId, recordId,
childEntityName, childEntityName,
fieldId, fieldId,
ctx.queryParam("direction")?.let {
runCatching { RelationshipDirection.valueOf(it) }.getOrNull()
} ?: RelationshipDirection.INBOUND,
ctx.singleFormParams(), ctx.singleFormParams(),
) )
if (child == null) ctx.notFound() else ctx.status(201).json(child) if (child == null) ctx.notFound() else ctx.status(201).json(child)