allow adding a record to user
This commit is contained in:
parent
c7566bf4a0
commit
225a3e8f75
@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
}
|
||||
interface LinkedEntity {
|
||||
entity: { id: number, name: string, identifier: string, fields: Field[] }
|
||||
relationshipField: Field
|
||||
records: Record<string, unknown>[]
|
||||
}
|
||||
interface UserFile {
|
||||
@ -23,6 +24,66 @@
|
||||
const file = ref<UserFile>()
|
||||
const loading = ref(true)
|
||||
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) {
|
||||
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
|
||||
@ -71,14 +132,36 @@
|
||||
No entities with user references are configured.
|
||||
</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>
|
||||
<h2>{{ linked.entity.name }}</h2>
|
||||
<p>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
|
||||
</div>
|
||||
|
||||
<button type="button" @click="startAddingChild(linked)">Add {{ linked.entity.name }}</button>
|
||||
</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>
|
||||
|
||||
<div v-else class="table-scroll">
|
||||
@ -130,7 +213,7 @@
|
||||
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; }
|
||||
.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; }
|
||||
.empty-state, .no-records { padding: 1.5rem; }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
@ -138,5 +221,14 @@
|
||||
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; }
|
||||
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); }
|
||||
</style>
|
||||
|
||||
@ -12,6 +12,7 @@ async function useAdminSession (page: Page) {
|
||||
|
||||
test('admin opens a user file and views a linked entity record', async ({ page }) => {
|
||||
let projectTitle = 'Website refresh'
|
||||
const projects: Record<string, unknown>[] = [{ id: 42, title: 'Website refresh', owner: 7 }]
|
||||
const tasks: Record<string, unknown>[] = []
|
||||
const projectResponse = () => ({
|
||||
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' },
|
||||
],
|
||||
},
|
||||
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 => {
|
||||
if (route.request().method() === 'PATCH') {
|
||||
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('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 expect(page).toHaveURL('/users/7/entities/Project/records/42')
|
||||
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
|
||||
|
||||
@ -8,6 +8,7 @@ import java.time.temporal.TemporalAccessor
|
||||
|
||||
data class LinkedEntityRecords(
|
||||
val entity: EntityDefinition,
|
||||
val relationshipField: EntityField,
|
||||
val records: List<Map<String, Any?>>,
|
||||
)
|
||||
|
||||
@ -37,19 +38,41 @@ class EntityRecordRepository(
|
||||
.filter { entity ->
|
||||
entity.identifier.lowercase() in tables && entity.fields.any { it.type == FieldType.USER }
|
||||
}
|
||||
.map { entity ->
|
||||
val userFields = entity.fields.filter { it.type == FieldType.USER }
|
||||
.flatMap { entity ->
|
||||
entity.fields.filter { it.type == FieldType.USER }.map { userField ->
|
||||
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)} " +
|
||||
"WHERE $predicate ORDER BY \"id\""
|
||||
"WHERE ${quote(userField.identifier)} = ? ORDER BY \"id\""
|
||||
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) }
|
||||
}
|
||||
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? {
|
||||
val definitions = entities.findAll()
|
||||
|
||||
@ -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 ->
|
||||
if (!ctx.requireAdmin(loginService)) return@get
|
||||
val userId = ctx.pathParam("userId").toLongOrNull()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user