allow adding a record to user

This commit is contained in:
Maxime Duchêne-Savard 2026-07-31 15:12:31 -04:00
parent c7566bf4a0
commit 225a3e8f75
4 changed files with 165 additions and 10 deletions

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { auth } from '@/auth' import { auth } from '@/auth'
@ -11,6 +11,7 @@
} }
interface LinkedEntity { interface LinkedEntity {
entity: { id: number, name: string, identifier: string, fields: Field[] } entity: { id: number, name: string, identifier: string, fields: Field[] }
relationshipField: Field
records: Record<string, unknown>[] records: Record<string, unknown>[]
} }
interface UserFile { interface UserFile {
@ -23,6 +24,66 @@
const file = ref<UserFile>() const file = ref<UserFile>()
const loading = ref(true) const loading = ref(true)
const errorMessage = ref('') const errorMessage = ref('')
const saving = ref(false)
const addingChildKey = ref('')
const childValues = reactive<Record<string, Record<string, string | boolean>>>({})
function childKey (linked: LinkedEntity) {
return `${linked.entity.id}-${linked.relationshipField.id}`
}
function editableFields (linked: LinkedEntity) {
return linked.entity.fields.filter(field => field.id !== linked.relationshipField.id)
}
function inputType (field: Field) {
if (['NUMBER', 'RELATIONSHIP', 'USER'].includes(field.type)) return 'number'
if (field.type === 'DATE') return 'date'
if (field.type === 'EMAIL') return 'email'
if (field.type === 'PHONE') return 'tel'
return 'text'
}
function startAddingChild (linked: LinkedEntity) {
const key = childKey(linked)
childValues[key] = {}
for (const field of editableFields(linked)) {
childValues[key][field.identifier] = field.type === 'BOOLEAN' ? false : ''
}
addingChildKey.value = key
errorMessage.value = ''
}
async function addChild (linked: LinkedEntity) {
const key = childKey(linked)
const body = new URLSearchParams()
for (const field of editableFields(linked)) {
body.set(field.identifier, String(childValues[key][field.identifier] ?? ''))
}
saving.value = true
errorMessage.value = ''
try {
const response = await fetch(
`/api/users/${route.params.userId}/children/${encodeURIComponent(linked.entity.name)}/${linked.relationshipField.id}`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
},
)
if (!response.ok) throw new Error('Child create failed')
const created = await response.json() as { values: Record<string, unknown> }
linked.records.push(created.values)
addingChildKey.value = ''
} catch {
errorMessage.value = `The ${linked.entity.name} could not be added. Check the field values and try again.`
} finally {
saving.value = false
}
}
function displayValue (value: unknown) { function displayValue (value: unknown) {
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—' if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
@ -71,14 +132,36 @@
No entities with user references are configured. No entities with user references are configured.
</p> </p>
<article v-for="linked in file.linkedEntities" :key="linked.entity.id" class="entity-card"> <article v-for="linked in file.linkedEntities" :key="childKey(linked)" class="entity-card">
<div class="entity-heading"> <div class="entity-heading">
<div> <div>
<h2>{{ linked.entity.name }}</h2> <h2>{{ linked.entity.name }}</h2>
<p>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p> <p>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
</div> </div>
<button type="button" @click="startAddingChild(linked)">Add {{ linked.entity.name }}</button>
</div> </div>
<form
v-if="addingChildKey === childKey(linked)"
:aria-label="`Add ${linked.entity.name}`"
class="child-form"
@submit.prevent="addChild(linked)"
>
<label v-for="field in editableFields(linked)" :key="field.id">
<span>{{ field.name }}</span>
<input v-if="field.type === 'BOOLEAN'" v-model="childValues[childKey(linked)][field.identifier]" type="checkbox">
<input v-else v-model="childValues[childKey(linked)][field.identifier]" :type="inputType(field)">
</label>
<div class="form-actions">
<button :disabled="saving" type="submit">{{ saving ? 'Adding…' : `Add ${linked.entity.name}` }}</button>
<button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button>
</div>
</form>
<p v-if="linked.records.length === 0" class="no-records">No records linked to this user.</p> <p v-if="linked.records.length === 0" class="no-records">No records linked to this user.</p>
<div v-else class="table-scroll"> <div v-else class="table-scroll">
@ -130,7 +213,7 @@
header > p:last-child, .entity-heading p { color: var(--v0-on-surface-variant); } header > p:last-child, .entity-heading p { color: var(--v0-on-surface-variant); }
.role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; } .role-badge { padding: 0.25rem 0.6rem; border-radius: 999px; color: var(--v0-primary); background: color-mix(in srgb, var(--v0-primary) 14%, transparent); font-size: 0.8rem; font-weight: 700; }
.entity-card, .empty-state { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); } .entity-card, .empty-state { margin-bottom: 1.25rem; overflow: hidden; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.entity-heading { padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); } .entity-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.entity-heading p { margin: 0.35rem 0 0; font-size: 0.9rem; } .entity-heading p { margin: 0.35rem 0 0; font-size: 0.9rem; }
.empty-state, .no-records { padding: 1.5rem; } .empty-state, .no-records { padding: 1.5rem; }
.table-scroll { overflow-x: auto; } .table-scroll { overflow-x: auto; }
@ -138,5 +221,14 @@
th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); } th, td { padding: 0.85rem 1rem; border-bottom: 1px solid var(--v0-divider); }
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; } th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; } tbody tr:last-child td { border-bottom: 0; }
button { padding: 0.65rem 0.9rem; border: 1px solid var(--v0-divider); border-radius: 0.6rem; color: inherit; background: var(--v0-surface); font: inherit; font-weight: 700; cursor: pointer; }
button[type="submit"] { color: var(--v0-on-primary); border-color: var(--v0-primary); background: var(--v0-primary); }
button:disabled { opacity: 0.55; cursor: wait; }
.child-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 1rem; padding: 1.25rem; border-bottom: 1px solid var(--v0-divider); }
.child-form label { display: grid; gap: 0.45rem; }
.child-form label > span { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
input { width: 100%; padding: 0.65rem 0.75rem; border: 1px solid var(--v0-divider); border-radius: 0.5rem; color: inherit; background: var(--v0-background); font: inherit; }
input[type="checkbox"] { width: 1.1rem; }
.form-actions { display: flex; align-items: end; justify-content: flex-end; gap: 0.5rem; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
</style> </style>

View File

@ -12,6 +12,7 @@ async function useAdminSession (page: Page) {
test('admin opens a user file and views a linked entity record', async ({ page }) => { test('admin opens a user file and views a linked entity record', async ({ page }) => {
let projectTitle = 'Website refresh' let projectTitle = 'Website refresh'
const projects: Record<string, unknown>[] = [{ id: 42, title: 'Website refresh', owner: 7 }]
const tasks: Record<string, unknown>[] = [] const tasks: Record<string, unknown>[] = []
const projectResponse = () => ({ const projectResponse = () => ({
entity: { entity: {
@ -68,10 +69,20 @@ test('admin opens a user file and views a linked entity record', async ({ page }
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' }, { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
], ],
}, },
records: [{ id: 42, title: 'Website refresh', owner: 7 }], relationshipField: { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
records: projects,
}], }],
}, },
})) }))
await page.route('**/api/users/7/children/Project/12', async route => {
const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 43, title: values.get('title'), owner: 7 }
projects.push(created)
await route.fulfill({
status: 201,
json: { entity: projectResponse().entity, values: created, children: [] },
})
})
await page.route('**/api/users/7/entities/Project/records/42', async route => { await page.route('**/api/users/7/entities/Project/records/42', async route => {
if (route.request().method() === 'PATCH') { if (route.request().method() === 'PATCH') {
const values = new URLSearchParams(route.request().postData() ?? '') const values = new URLSearchParams(route.request().postData() ?? '')
@ -100,6 +111,12 @@ test('admin opens a user file and views a linked entity record', async ({ page }
await expect(page.getByRole('heading', { name: 'Project' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Project' })).toBeVisible()
await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7') await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7')
await page.getByRole('button', { name: 'Add Project' }).click()
const projectForm = page.getByRole('form', { name: 'Add Project' })
await projectForm.getByLabel('Title').fill('Mobile application')
await projectForm.getByRole('button', { name: 'Add Project' }).click()
await expect(page.getByRole('row').filter({ hasText: 'Mobile application' })).toContainText('7')
await page.getByRole('link', { name: 'View Project record 42' }).click() await page.getByRole('link', { name: 'View Project record 42' }).click()
await expect(page).toHaveURL('/users/7/entities/Project/records/42') await expect(page).toHaveURL('/users/7/entities/Project/records/42')
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()

View File

@ -8,6 +8,7 @@ import java.time.temporal.TemporalAccessor
data class LinkedEntityRecords( data class LinkedEntityRecords(
val entity: EntityDefinition, val entity: EntityDefinition,
val relationshipField: EntityField,
val records: List<Map<String, Any?>>, val records: List<Map<String, Any?>>,
) )
@ -37,20 +38,42 @@ class EntityRecordRepository(
.filter { entity -> .filter { entity ->
entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER } entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER }
} }
.map { entity -> .flatMap { entity ->
val userFields = entity.fields.filter { it.type == FieldType.USER } entity.fields.filter { it.type == FieldType.USER }.map { userField ->
val columns = listOf("id") + entity.fields.map(EntityField::identifier) val columns = listOf("id") + entity.fields.map(EntityField::identifier)
val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" }
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " + val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " +
"WHERE $predicate ORDER BY \"id\"" "WHERE ${quote(userField.identifier)} = ? ORDER BY \"id\""
val records = connection.prepareStatement(sql).use { statement -> val records = connection.prepareStatement(sql).use { statement ->
userFields.indices.forEach { statement.setLong(it + 1, userId) } statement.setLong(1, userId)
statement.executeQuery().use { result -> result.toRecords(columns) } statement.executeQuery().use { result -> result.toRecords(columns) }
} }
LinkedEntityRecords(entity, records) LinkedEntityRecords(entity, userField, records)
}
} }
} }
fun createChildForUser(
userId: Long,
childEntityName: String,
userFieldId: Long,
submittedValues: Map<String, String>,
): EntityRecord? {
val child = entities.findAll().firstOrNull { it.name == childEntityName } ?: return null
val userField = child.fields.firstOrNull {
it.id == userFieldId && it.type == FieldType.USER
} ?: 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 ->
if (field.id == userField.id) setLong(index + 1, userId)
else bindField(index + 1, field, submittedValues[field.identifier].orEmpty())
}
}) { getLong("id") })
return findChildRecord(child, childId)
}
fun findRecordLinkedToUser(userId: Long, entityName: String, recordId: Long): EntityRecord? { fun findRecordLinkedToUser(userId: Long, entityName: String, recordId: Long): EntityRecord? {
val definitions = entities.findAll() val definitions = entities.findAll()
val entity = definitions.firstOrNull { it.name == entityName } ?: return null val entity = definitions.firstOrNull { it.name == entityName } ?: return null

View File

@ -38,6 +38,29 @@ class UserController(
), ),
) )
} }
routes.post("/api/users/{userId}/children/{childEntityName}/{fieldId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@post
val userId = ctx.pathParam("userId").toLongOrNull()
val childEntityName = ctx.pathParam("childEntityName")
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
if (userId == null || fieldId == null || users.findById(userId) == null) {
ctx.notFound()
return@post
}
try {
val child = records.createChildForUser(
userId,
childEntityName,
fieldId,
ctx.singleFormParams(),
)
if (child == null) ctx.notFound() else ctx.status(201).json(child)
} catch (exception: IllegalArgumentException) {
ctx.badRequest(exception.message ?: "Invalid field value")
} catch (_: SQLException) {
ctx.badRequest("The child record violates an entity constraint")
}
}
routes.get("/api/users/{userId}/entities/{entityName}/records/{recordId}") { ctx -> routes.get("/api/users/{userId}/entities/{entityName}/records/{recordId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get if (!ctx.requireAdmin(loginService)) return@get
val userId = ctx.pathParam("userId").toLongOrNull() val userId = ctx.pathParam("userId").toLongOrNull()