allow adding children records
This commit is contained in:
parent
27dd83f488
commit
832112072e
@ -19,7 +19,6 @@
|
||||
interface ChildRecords {
|
||||
entity: Entity
|
||||
relationshipField: Field
|
||||
relationshipDirection?: 'INBOUND' | 'OUTBOUND'
|
||||
records: Record<string, unknown>[]
|
||||
}
|
||||
interface EntityRecord {
|
||||
@ -59,7 +58,7 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -151,19 +150,17 @@
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const childUrl = new URL(
|
||||
const response = await fetch(
|
||||
`${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',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.token}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${auth.token}`,
|
||||
'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')
|
||||
const created = await response.json() as EntityRecord
|
||||
child.records.push(created.values)
|
||||
|
||||
@ -32,6 +32,14 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
||||
targetEntityId: 6,
|
||||
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 },
|
||||
@ -43,20 +51,22 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
||||
fields: [
|
||||
{ id: 20, name: 'Description', identifier: 'description', type: 'TEXT' },
|
||||
{
|
||||
id: 21,
|
||||
name: 'Project',
|
||||
identifier: 'project',
|
||||
id: 22,
|
||||
name: 'Notes',
|
||||
identifier: 'notes',
|
||||
type: 'RELATIONSHIP',
|
||||
targetEntityId: 3,
|
||||
targetEntityId: 5,
|
||||
relationshipType: 'ONE_TO_MANY',
|
||||
},
|
||||
],
|
||||
},
|
||||
relationshipField: {
|
||||
id: 21,
|
||||
name: 'Project',
|
||||
identifier: 'project',
|
||||
id: 14,
|
||||
name: 'Tasks',
|
||||
identifier: 'tasks',
|
||||
type: 'RELATIONSHIP',
|
||||
targetEntityId: 3,
|
||||
targetEntityId: 4,
|
||||
relationshipType: 'ONE_TO_MANY',
|
||||
},
|
||||
records: tasks,
|
||||
}, {
|
||||
@ -74,7 +84,6 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
||||
targetEntityId: 6,
|
||||
relationshipType: 'ONE_TO_MANY',
|
||||
},
|
||||
relationshipDirection: 'OUTBOUND',
|
||||
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 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 created = { id: 55, description: values.get('description'), project: 42 }
|
||||
const created = { id: 55, description: values.get('description'), notes: null }
|
||||
tasks.push(created)
|
||||
await route.fulfill({
|
||||
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 => {
|
||||
expect(new URL(route.request().url()).searchParams.get('direction')).toBe('OUTBOUND')
|
||||
await page.route('**/api/users/7/entities/Project/records/42/children/Milestone/13', async route => {
|
||||
const values = new URLSearchParams(route.request().postData() ?? '')
|
||||
const created = { id: 60, label: values.get('label') }
|
||||
milestones.push(created)
|
||||
@ -151,23 +159,23 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
||||
identifier: 'note',
|
||||
fields: [
|
||||
{ id: 30, name: 'Contents', identifier: 'contents', type: 'TEXT' },
|
||||
{ id: 31, name: 'Task', identifier: 'task', type: 'RELATIONSHIP', targetEntityId: 4 },
|
||||
],
|
||||
},
|
||||
relationshipField: {
|
||||
id: 31,
|
||||
name: 'Task',
|
||||
identifier: 'task',
|
||||
id: 22,
|
||||
name: 'Notes',
|
||||
identifier: 'notes',
|
||||
type: 'RELATIONSHIP',
|
||||
targetEntityId: 4,
|
||||
targetEntityId: 5,
|
||||
relationshipType: 'ONE_TO_MANY',
|
||||
},
|
||||
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 created = { id: 56, contents: values.get('contents'), task: 55 }
|
||||
const created = { id: 56, contents: values.get('contents') }
|
||||
notes.push(created)
|
||||
await route.fulfill({
|
||||
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' })
|
||||
await childForm.getByLabel('Description').fill('Prepare launch')
|
||||
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 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' })
|
||||
await noteForm.getByLabel('Contents').fill('Nested child')
|
||||
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()
|
||||
})
|
||||
|
||||
@ -22,11 +22,8 @@ data class ChildEntityRecords(
|
||||
val entity: EntityDefinition,
|
||||
val relationshipField: EntityField,
|
||||
val records: List<Map<String, Any?>>,
|
||||
val relationshipDirection: RelationshipDirection = RelationshipDirection.INBOUND,
|
||||
)
|
||||
|
||||
enum class RelationshipDirection { INBOUND, OUTBOUND }
|
||||
|
||||
class EntityRecordRepository(
|
||||
private val database: Database,
|
||||
private val entities: EntityDefinitionRepository,
|
||||
@ -112,77 +109,45 @@ class EntityRecordRepository(
|
||||
parentRecordId: Long,
|
||||
childEntityName: String,
|
||||
relationshipFieldId: Long,
|
||||
relationshipDirection: RelationshipDirection,
|
||||
submittedValues: Map<String, String>,
|
||||
): EntityRecord? {
|
||||
val definitions = entities.findAll()
|
||||
val parent = definitions.firstOrNull { it.name == parentEntityName } ?: return null
|
||||
if (findRecordLinkedToUser(userId, parentEntityName, parentRecordId) == null) return null
|
||||
val child = definitions.firstOrNull { it.name == childEntityName } ?: return null
|
||||
if (relationshipDirection == RelationshipDirection.OUTBOUND) {
|
||||
val relationship = parent.fields.firstOrNull {
|
||||
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.id
|
||||
} ?: return null
|
||||
val storedTargetId = findValues(parent, parentRecordId)?.get(relationship.identifier) as? Number
|
||||
if (
|
||||
relationship.relationshipType == RelationshipType.ONE_TO_ONE &&
|
||||
(storedTargetId != null || outboundChildIds(relationship.id, parentRecordId).isNotEmpty())
|
||||
) {
|
||||
throw IllegalArgumentException("This relationship already has a child")
|
||||
}
|
||||
val childId = insertRecord(child, userId, submittedValues)
|
||||
database.executeUpdate(
|
||||
"INSERT INTO entity_record_relationships (field_id, source_record_id, target_record_id) VALUES (?, ?, ?)",
|
||||
) {
|
||||
setLong(1, relationship.id)
|
||||
setLong(2, parentRecordId)
|
||||
setLong(3, childId)
|
||||
}
|
||||
if (relationship.relationshipType == RelationshipType.ONE_TO_ONE) {
|
||||
database.executeUpdate(
|
||||
"UPDATE ${quote(parent.identifier)} SET ${quote(relationship.identifier)} = ? WHERE \"id\" = ?",
|
||||
) {
|
||||
setLong(1, childId)
|
||||
setLong(2, parentRecordId)
|
||||
}
|
||||
}
|
||||
return findChildRecord(child, childId)
|
||||
}
|
||||
val parentField = child.fields.firstOrNull {
|
||||
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parent.id
|
||||
val relationship = parent.fields.firstOrNull {
|
||||
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == child.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())
|
||||
}
|
||||
val storedTargetId = findValues(parent, parentRecordId)?.get(relationship.identifier) as? Number
|
||||
if (
|
||||
relationship.relationshipType == RelationshipType.ONE_TO_ONE &&
|
||||
(storedTargetId != null || outboundChildIds(relationship.id, parentRecordId).isNotEmpty())
|
||||
) {
|
||||
throw IllegalArgumentException("This relationship already has a child")
|
||||
}
|
||||
val childId = insertRecord(child, userId, submittedValues)
|
||||
database.executeUpdate(
|
||||
"INSERT INTO entity_record_relationships (field_id, source_record_id, target_record_id) VALUES (?, ?, ?)",
|
||||
) {
|
||||
setLong(1, relationship.id)
|
||||
setLong(2, parentRecordId)
|
||||
setLong(3, childId)
|
||||
}
|
||||
if (relationship.relationshipType == RelationshipType.ONE_TO_ONE) {
|
||||
database.executeUpdate(
|
||||
"UPDATE ${quote(parent.identifier)} SET ${quote(relationship.identifier)} = ? WHERE \"id\" = ?",
|
||||
) {
|
||||
setLong(1, childId)
|
||||
setLong(2, parentRecordId)
|
||||
}
|
||||
}) { getLong("id") })
|
||||
}
|
||||
return findChildRecord(child, childId)
|
||||
}
|
||||
|
||||
private fun findChildren(parentEntityId: Long, parentRecordId: Long): List<ChildEntityRecords> {
|
||||
val definitions = entities.findAll()
|
||||
val inbound = definitions.flatMap { child ->
|
||||
child.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
|
||||
val parent = definitions.firstOrNull { it.id == parentEntityId } ?: return emptyList()
|
||||
return parent.fields
|
||||
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null }
|
||||
.mapNotNull { relationship ->
|
||||
val child = definitions.firstOrNull { it.id == relationship.targetEntityId } ?: return@mapNotNull null
|
||||
@ -190,9 +155,8 @@ class EntityRecordRepository(
|
||||
?.let { it as? Number }?.toLong()
|
||||
val childIds = (outboundChildIds(relationship.id, parentRecordId) + listOfNotNull(storedTargetId)).distinct()
|
||||
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> =
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package dev.mduchene.bolts.web
|
||||
|
||||
import dev.mduchene.bolts.entity.EntityRecordRepository
|
||||
import dev.mduchene.bolts.entity.RelationshipDirection
|
||||
import dev.mduchene.bolts.user.LoginService
|
||||
import dev.mduchene.bolts.user.UserRepository
|
||||
import io.javalin.router.JavalinDefaultRoutingApi
|
||||
@ -115,9 +114,6 @@ class UserController(
|
||||
recordId,
|
||||
childEntityName,
|
||||
fieldId,
|
||||
ctx.queryParam("direction")?.let {
|
||||
runCatching { RelationshipDirection.valueOf(it) }.getOrNull()
|
||||
} ?: RelationshipDirection.INBOUND,
|
||||
ctx.singleFormParams(),
|
||||
)
|
||||
if (child == null) ctx.notFound() else ctx.status(201).json(child)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user