allow editing individual records
This commit is contained in:
parent
00d3f2b13b
commit
c6b1127e30
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, 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'
|
||||||
|
|
||||||
@ -7,19 +7,40 @@
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
identifier: string
|
identifier: string
|
||||||
type: string
|
type: 'TEXT' | 'NUMBER' | 'BOOLEAN' | 'DATE' | 'EMAIL' | 'PHONE' | 'RELATIONSHIP' | 'USER'
|
||||||
|
targetEntityId?: number
|
||||||
|
relationshipType?: 'ONE_TO_ONE' | 'ONE_TO_MANY'
|
||||||
|
}
|
||||||
|
interface Entity {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
fields: Field[]
|
||||||
|
}
|
||||||
|
interface ChildRecords {
|
||||||
|
entity: Entity
|
||||||
|
relationshipField: Field
|
||||||
|
records: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
interface EntityRecord {
|
interface EntityRecord {
|
||||||
entity: { id: number, name: string, fields: Field[] }
|
entity: Entity
|
||||||
values: Record<string, unknown>
|
values: Record<string, unknown>
|
||||||
|
children: ChildRecords[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const record = ref<EntityRecord>()
|
const record = ref<EntityRecord>()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
|
const editValues = reactive<Record<string, string | boolean>>({})
|
||||||
|
const childValues = reactive<Record<string, Record<string, string | boolean>>>({})
|
||||||
|
const addingChildKey = ref('')
|
||||||
const userFilePath = computed(() => `/users/${route.params.userId}`)
|
const userFilePath = computed(() => `/users/${route.params.userId}`)
|
||||||
|
const recordUrl = computed(
|
||||||
|
() => `/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
|
||||||
|
)
|
||||||
|
|
||||||
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 '—'
|
||||||
@ -27,12 +48,60 @@
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function inputType (field: Field) {
|
||||||
try {
|
if (field.type === 'NUMBER' || field.type === 'RELATIONSHIP') return 'number'
|
||||||
const response = await fetch(
|
if (field.type === 'DATE') return 'date'
|
||||||
`/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
|
if (field.type === 'EMAIL') return 'email'
|
||||||
{ headers: { Authorization: `Bearer ${auth.token}` } },
|
if (field.type === 'PHONE') return 'tel'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
function childKey (child: ChildRecords) {
|
||||||
|
return `${child.entity.id}-${child.relationshipField.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function editableChildFields (child: ChildRecords) {
|
||||||
|
return child.entity.fields.filter(field =>
|
||||||
|
field.id !== child.relationshipField.id && field.type !== 'USER',
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditValues () {
|
||||||
|
if (!record.value) return
|
||||||
|
for (const field of record.value.entity.fields) {
|
||||||
|
editValues[field.identifier] = field.type === 'BOOLEAN'
|
||||||
|
? record.value.values[field.identifier] === true
|
||||||
|
: String(record.value.values[field.identifier] ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing () {
|
||||||
|
resetEditValues()
|
||||||
|
editing.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddingChild (child: ChildRecords) {
|
||||||
|
const key = childKey(child)
|
||||||
|
childValues[key] = {}
|
||||||
|
for (const field of editableChildFields(child)) {
|
||||||
|
childValues[key][field.identifier] = field.type === 'BOOLEAN' ? false : ''
|
||||||
|
}
|
||||||
|
addingChildKey.value = key
|
||||||
|
errorMessage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formBody (fields: Field[], values: Record<string, string | boolean>) {
|
||||||
|
const body = new URLSearchParams()
|
||||||
|
for (const field of fields) body.set(field.identifier, String(values[field.identifier] ?? ''))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecord () {
|
||||||
|
try {
|
||||||
|
const response = await fetch(recordUrl.value, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.token}` },
|
||||||
|
})
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
await router.replace(userFilePath.value)
|
await router.replace(userFilePath.value)
|
||||||
return
|
return
|
||||||
@ -44,22 +113,112 @@
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecord () {
|
||||||
|
if (!record.value) return
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
const fields = record.value.entity.fields.filter(field => field.type !== 'USER')
|
||||||
|
try {
|
||||||
|
const response = await fetch(recordUrl.value, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${auth.token}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: formBody(fields, editValues),
|
||||||
})
|
})
|
||||||
|
if (!response.ok) throw new Error('Record update failed')
|
||||||
|
record.value = await response.json() as EntityRecord
|
||||||
|
editing.value = false
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'The record could not be saved. Check the field values and try again.'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addChild (child: ChildRecords) {
|
||||||
|
const fields = editableChildFields(child)
|
||||||
|
const key = childKey(child)
|
||||||
|
saving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${recordUrl.value}/children/${child.entity.id}/${child.relationshipField.id}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${auth.token}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
addingChildKey.value = ''
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = `The ${child.entity.name} could not be added. Check the field values and try again.`
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadRecord)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="viewer-page">
|
<section class="viewer-page">
|
||||||
<router-link class="back-link" :to="userFilePath">← Back to user file</router-link>
|
<router-link class="back-link" :to="userFilePath">← Back to user file</router-link>
|
||||||
<p v-if="loading">Loading record…</p>
|
<p v-if="loading">Loading record…</p>
|
||||||
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
<p v-else-if="errorMessage && !record" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
<template v-else-if="record">
|
<template v-else-if="record">
|
||||||
<header>
|
<header>
|
||||||
|
<div>
|
||||||
<p class="eyebrow">Record viewer</p>
|
<p class="eyebrow">Record viewer</p>
|
||||||
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
|
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="!editing" type="button" @click="startEditing">Edit record</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl class="record-card">
|
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<form v-if="editing" aria-label="Edit record" class="record-card edit-form" @submit.prevent="saveRecord">
|
||||||
|
<div class="readonly-field"><span>ID</span><span>{{ record.values.id }}</span></div>
|
||||||
|
|
||||||
|
<label v-for="field in record.entity.fields" :key="field.id">
|
||||||
|
<span>{{ field.name }}</span>
|
||||||
|
|
||||||
|
<span v-if="field.type === 'USER'" class="readonly-value">
|
||||||
|
{{ displayValue(record.values[field.identifier]) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'BOOLEAN'"
|
||||||
|
v-model="editValues[field.identifier]"
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="editValues[field.identifier]"
|
||||||
|
:inputmode="field.type === 'NUMBER' || field.type === 'RELATIONSHIP' ? 'numeric' : undefined"
|
||||||
|
:type="inputType(field)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button :disabled="saving" type="submit">{{ saving ? 'Saving…' : 'Save changes' }}</button>
|
||||||
|
<button :disabled="saving" type="button" @click="editing = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<dl v-else class="record-card">
|
||||||
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
|
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
|
||||||
|
|
||||||
<div v-for="field in record.entity.fields" :key="field.id">
|
<div v-for="field in record.entity.fields" :key="field.id">
|
||||||
@ -67,20 +226,135 @@
|
|||||||
<dd>{{ displayValue(record.values[field.identifier]) }}</dd>
|
<dd>{{ displayValue(record.values[field.identifier]) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<section class="children-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Related records</p>
|
||||||
|
<h2>Children</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="record.children.length === 0" class="empty-state">No child entity types are configured.</p>
|
||||||
|
|
||||||
|
<article v-for="child in record.children" :key="childKey(child)" class="child-card">
|
||||||
|
<div class="child-heading">
|
||||||
|
<div>
|
||||||
|
<h3>{{ child.entity.name }}</h3>
|
||||||
|
<p>Linked by {{ child.relationshipField.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="child.relationshipField.relationshipType !== 'ONE_TO_ONE' || child.records.length === 0"
|
||||||
|
type="button"
|
||||||
|
@click="startAddingChild(child)"
|
||||||
|
>
|
||||||
|
Add {{ child.entity.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="addingChildKey === childKey(child)"
|
||||||
|
:aria-label="`Add ${child.entity.name}`"
|
||||||
|
class="child-form"
|
||||||
|
@submit.prevent="addChild(child)"
|
||||||
|
>
|
||||||
|
<label v-for="field in editableChildFields(child)" :key="field.id">
|
||||||
|
<span>{{ field.name }}</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'BOOLEAN'"
|
||||||
|
v-model="childValues[childKey(child)][field.identifier]"
|
||||||
|
type="checkbox"
|
||||||
|
>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="childValues[childKey(child)][field.identifier]"
|
||||||
|
:type="inputType(field)"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button :disabled="saving" type="submit">{{ saving ? 'Adding…' : `Add ${child.entity.name}` }}</button>
|
||||||
|
<button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p v-if="child.records.length === 0" class="empty-state">No children yet.</p>
|
||||||
|
|
||||||
|
<div v-else class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th v-for="field in child.entity.fields" :key="field.id">{{ field.name }}</th>
|
||||||
|
<th><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="childRecord in child.records" :key="String(childRecord.id)">
|
||||||
|
<td>{{ displayValue(childRecord.id) }}</td>
|
||||||
|
|
||||||
|
<td v-for="field in child.entity.fields" :key="field.id">
|
||||||
|
{{ displayValue(childRecord[field.identifier]) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<router-link
|
||||||
|
:aria-label="`View ${child.entity.name} record ${childRecord.id}`"
|
||||||
|
class="back-link"
|
||||||
|
:to="`/users/${route.params.userId}/entities/${child.entity.id}/records/${childRecord.id}`"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.viewer-page { width: 100%; max-width: 60rem; }
|
.viewer-page { width: 100%; max-width: 72rem; }
|
||||||
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
|
||||||
.back-link:hover { text-decoration: underline; }
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
header, .child-heading, .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||||
header { margin: 1.5rem 0 2rem; }
|
header { margin: 1.5rem 0 2rem; }
|
||||||
|
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; }
|
||||||
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
h1 { margin: 0; font-size: 2rem; letter-spacing: -0.04em; }
|
h1, h2, h3 { margin: 0; letter-spacing: -0.04em; }
|
||||||
.record-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
h1 { font-size: 2rem; }
|
||||||
.record-card > div { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
.record-card, .child-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
|
||||||
|
.record-card > div, .edit-form > label, .readonly-field { display: grid; grid-template-columns: minmax(10rem, 1fr) 2fr; gap: 1rem; padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||||
.record-card > div:last-child { border-bottom: 0; }
|
.record-card > div:last-child { border-bottom: 0; }
|
||||||
dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
|
dt, label > span:first-child, .readonly-field > span:first-child { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
|
||||||
dd { margin: 0; overflow-wrap: anywhere; }
|
dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
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; }
|
||||||
|
.readonly-value { color: inherit; font-size: inherit; font-weight: 400; text-transform: none; }
|
||||||
|
.form-actions { display: flex !important; justify-content: flex-end; grid-template-columns: none !important; }
|
||||||
|
.error { padding: 0.9rem 1rem; border-radius: 0.6rem; color: #9f1d1d; background: #fee2e2; }
|
||||||
|
.children-section { margin-top: 2.5rem; }
|
||||||
|
.section-heading { margin-bottom: 1rem; }
|
||||||
|
.child-card { margin-bottom: 1rem; }
|
||||||
|
.child-heading { padding: 1rem 1.25rem; border-bottom: 1px solid var(--v0-divider); }
|
||||||
|
.child-heading p { margin: 0.3rem 0 0; color: var(--v0-on-surface-variant); font-size: 0.85rem; }
|
||||||
|
.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 .form-actions { align-items: end; }
|
||||||
|
.empty-state { margin: 0; padding: 1.25rem; color: var(--v0-on-surface-variant); }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; }
|
||||||
|
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; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -11,6 +11,46 @@ 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'
|
||||||
|
const tasks: Record<string, unknown>[] = []
|
||||||
|
const projectResponse = () => ({
|
||||||
|
entity: {
|
||||||
|
id: 3,
|
||||||
|
name: 'Project',
|
||||||
|
identifier: 'project',
|
||||||
|
fields: [
|
||||||
|
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
|
||||||
|
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
values: { id: 42, title: projectTitle, owner: 7 },
|
||||||
|
children: [{
|
||||||
|
entity: {
|
||||||
|
id: 4,
|
||||||
|
name: 'Task',
|
||||||
|
identifier: 'task',
|
||||||
|
fields: [
|
||||||
|
{ id: 20, name: 'Description', identifier: 'description', type: 'TEXT' },
|
||||||
|
{
|
||||||
|
id: 21,
|
||||||
|
name: 'Project',
|
||||||
|
identifier: 'project',
|
||||||
|
type: 'RELATIONSHIP',
|
||||||
|
targetEntityId: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
relationshipField: {
|
||||||
|
id: 21,
|
||||||
|
name: 'Project',
|
||||||
|
identifier: 'project',
|
||||||
|
type: 'RELATIONSHIP',
|
||||||
|
targetEntityId: 3,
|
||||||
|
},
|
||||||
|
records: tasks,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
await useAdminSession(page)
|
await useAdminSession(page)
|
||||||
await page.route('**/api/users', route => route.fulfill({
|
await page.route('**/api/users', route => route.fulfill({
|
||||||
json: [{ id: 7, username: 'alex', role: 'user' }],
|
json: [{ id: 7, username: 'alex', role: 'user' }],
|
||||||
@ -32,20 +72,26 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
|||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
await page.route('**/api/users/7/entities/3/records/42', route => route.fulfill({
|
await page.route('**/api/users/7/entities/3/records/42', async route => {
|
||||||
|
if (route.request().method() === 'PATCH') {
|
||||||
|
const values = new URLSearchParams(route.request().postData() ?? '')
|
||||||
|
projectTitle = values.get('title') ?? projectTitle
|
||||||
|
}
|
||||||
|
await route.fulfill({ json: projectResponse() })
|
||||||
|
})
|
||||||
|
await page.route('**/api/users/7/entities/3/records/42/children/4/21', async route => {
|
||||||
|
const values = new URLSearchParams(route.request().postData() ?? '')
|
||||||
|
const created = { id: 55, description: values.get('description'), project: 42 }
|
||||||
|
tasks.push(created)
|
||||||
|
await route.fulfill({
|
||||||
|
status: 201,
|
||||||
json: {
|
json: {
|
||||||
entity: {
|
entity: projectResponse().children.at(0)!.entity,
|
||||||
id: 3,
|
values: created,
|
||||||
name: 'Project',
|
children: [],
|
||||||
identifier: 'project',
|
|
||||||
fields: [
|
|
||||||
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
|
|
||||||
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
values: { id: 42, title: 'Website refresh', owner: 7 },
|
})
|
||||||
},
|
})
|
||||||
}))
|
|
||||||
|
|
||||||
await page.goto('/users')
|
await page.goto('/users')
|
||||||
await page.getByRole('link', { name: 'alex' }).click()
|
await page.getByRole('link', { name: 'alex' }).click()
|
||||||
@ -58,4 +104,16 @@ test('admin opens a user file and views a linked entity record', async ({ page }
|
|||||||
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
|
||||||
await expect(page.getByText('Website refresh')).toBeVisible()
|
await expect(page.getByText('Website refresh')).toBeVisible()
|
||||||
await expect(page.getByText('Owner')).toBeVisible()
|
await expect(page.getByText('Owner')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit record' }).click()
|
||||||
|
const editForm = page.getByRole('form', { name: 'Edit record' })
|
||||||
|
await editForm.getByLabel('Title').fill('Mobile application')
|
||||||
|
await editForm.getByRole('button', { name: 'Save changes' }).click()
|
||||||
|
await expect(page.getByText('Mobile application')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Task' }).click()
|
||||||
|
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')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
package dev.mduchene.bolts.entity
|
package dev.mduchene.bolts.entity
|
||||||
|
|
||||||
import dev.mduchene.bolts.persistence.Database
|
import dev.mduchene.bolts.persistence.Database
|
||||||
|
import java.sql.PreparedStatement
|
||||||
import java.sql.ResultSet
|
import java.sql.ResultSet
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.temporal.TemporalAccessor
|
import java.time.temporal.TemporalAccessor
|
||||||
|
|
||||||
data class LinkedEntityRecords(
|
data class LinkedEntityRecords(
|
||||||
@ -12,6 +14,13 @@ data class LinkedEntityRecords(
|
|||||||
data class EntityRecord(
|
data class EntityRecord(
|
||||||
val entity: EntityDefinition,
|
val entity: EntityDefinition,
|
||||||
val values: Map<String, Any?>,
|
val values: Map<String, Any?>,
|
||||||
|
val children: List<ChildEntityRecords> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChildEntityRecords(
|
||||||
|
val entity: EntityDefinition,
|
||||||
|
val relationshipField: EntityField,
|
||||||
|
val records: List<Map<String, Any?>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
class EntityRecordRepository(
|
class EntityRecordRepository(
|
||||||
@ -43,21 +52,130 @@ class EntityRecordRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? {
|
fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? {
|
||||||
val entity = entities.findAll().firstOrNull { it.id == entityId } ?: return null
|
val definitions = entities.findAll()
|
||||||
val userFields = entity.fields.filter { it.type == FieldType.USER }
|
val entity = definitions.firstOrNull { it.id == entityId } ?: return null
|
||||||
if (userFields.isEmpty()) return null
|
val values = findValues(entity, recordId) ?: return null
|
||||||
|
if (!isAccessibleToUser(entity, values, userId, definitions, mutableSetOf())) return null
|
||||||
|
return EntityRecord(entity, values, findChildren(entity.id, recordId))
|
||||||
|
}
|
||||||
|
|
||||||
val columns = listOf("id") + entity.fields.map(EntityField::identifier)
|
fun updateRecordLinkedToUser(
|
||||||
val predicate = userFields.joinToString(" OR ") { "${quote(it.identifier)} = ?" }
|
userId: Long,
|
||||||
val sql = "SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} " +
|
entityId: Long,
|
||||||
"WHERE \"id\" = ? AND ($predicate)"
|
recordId: Long,
|
||||||
val values = database.queryOne(sql, bind = {
|
submittedValues: Map<String, String>,
|
||||||
setLong(1, recordId)
|
): EntityRecord? {
|
||||||
userFields.indices.forEach { setLong(it + 2, userId) }
|
val current = findRecordLinkedToUser(userId, entityId, recordId) ?: return null
|
||||||
}) { toRecord(columns) } ?: return null
|
val fields = current.entity.fields.filter { it.type != FieldType.USER && it.identifier in submittedValues }
|
||||||
|
if (fields.isNotEmpty()) {
|
||||||
|
val assignments = fields.joinToString { "${quote(it.identifier)} = ?" }
|
||||||
|
val sql = "UPDATE ${quote(current.entity.identifier)} SET $assignments WHERE \"id\" = ?"
|
||||||
|
database.executeUpdate(sql) {
|
||||||
|
fields.forEachIndexed { index, field ->
|
||||||
|
bindField(index + 1, field, submittedValues.getValue(field.identifier))
|
||||||
|
}
|
||||||
|
setLong(fields.size + 1, recordId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findRecordLinkedToUser(userId, entityId, recordId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChild(
|
||||||
|
userId: Long,
|
||||||
|
parentEntityId: Long,
|
||||||
|
parentRecordId: Long,
|
||||||
|
childEntityId: Long,
|
||||||
|
relationshipFieldId: Long,
|
||||||
|
submittedValues: Map<String, String>,
|
||||||
|
): EntityRecord? {
|
||||||
|
if (findRecordLinkedToUser(userId, parentEntityId, parentRecordId) == null) return null
|
||||||
|
val child = entities.findAll().firstOrNull { it.id == childEntityId } ?: return null
|
||||||
|
val parentField = child.fields.firstOrNull {
|
||||||
|
it.id == relationshipFieldId && it.type == FieldType.RELATIONSHIP && it.targetEntityId == parentEntityId
|
||||||
|
} ?: 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> =
|
||||||
|
entities.findAll().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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findChildRecord(entity: EntityDefinition, recordId: Long): EntityRecord? {
|
||||||
|
val values = findValues(entity, recordId) ?: return null
|
||||||
return EntityRecord(entity, values)
|
return EntityRecord(entity, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun findValues(entity: EntityDefinition, recordId: Long): Map<String, Any?>? {
|
||||||
|
val columns = listOf("id") + entity.fields.map(EntityField::identifier)
|
||||||
|
return database.queryOne(
|
||||||
|
"SELECT ${columns.joinToString { quote(it) }} FROM ${quote(entity.identifier)} WHERE \"id\" = ?",
|
||||||
|
bind = { setLong(1, recordId) },
|
||||||
|
) { toRecord(columns) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isAccessibleToUser(
|
||||||
|
entity: EntityDefinition,
|
||||||
|
values: Map<String, Any?>,
|
||||||
|
userId: Long,
|
||||||
|
definitions: List<EntityDefinition>,
|
||||||
|
visited: MutableSet<Pair<Long, Long>>,
|
||||||
|
): Boolean {
|
||||||
|
val recordId = (values["id"] as? Number)?.toLong() ?: return false
|
||||||
|
if (!visited.add(entity.id to recordId)) return false
|
||||||
|
if (entity.fields.any { it.type == FieldType.USER && (values[it.identifier] as? Number)?.toLong() == userId }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return entity.fields
|
||||||
|
.filter { it.type == FieldType.RELATIONSHIP && it.targetEntityId != null }
|
||||||
|
.any { field ->
|
||||||
|
val parentId = (values[field.identifier] as? Number)?.toLong() ?: return@any false
|
||||||
|
val parent = definitions.firstOrNull { it.id == field.targetEntityId } ?: return@any false
|
||||||
|
val parentValues = findValues(parent, parentId) ?: return@any false
|
||||||
|
isAccessibleToUser(parent, parentValues, userId, definitions, visited)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PreparedStatement.bindField(index: Int, field: EntityField, rawValue: String) {
|
||||||
|
val value = rawValue.trim()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
setObject(index, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (field.type) {
|
||||||
|
FieldType.TEXT, FieldType.EMAIL, FieldType.PHONE -> setString(index, value)
|
||||||
|
FieldType.NUMBER -> setBigDecimal(index, value.toBigDecimalOrNull() ?: invalid(field))
|
||||||
|
FieldType.BOOLEAN -> setBoolean(index, value.toBooleanStrictOrNull() ?: invalid(field))
|
||||||
|
FieldType.DATE -> setObject(index, runCatching { LocalDate.parse(value) }.getOrElse { invalid(field) })
|
||||||
|
FieldType.RELATIONSHIP, FieldType.USER -> setLong(index, value.toLongOrNull() ?: invalid(field))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalid(field: EntityField): Nothing =
|
||||||
|
throw IllegalArgumentException("Invalid value for ${field.name}")
|
||||||
|
|
||||||
private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> =
|
private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> =
|
||||||
buildList { while (next()) add(toRecord(columns)) }
|
buildList { while (next()) add(toRecord(columns)) }
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import dev.mduchene.bolts.entity.EntityRecordRepository
|
|||||||
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
|
||||||
|
import java.sql.SQLException
|
||||||
|
|
||||||
class UserController(
|
class UserController(
|
||||||
private val users: UserRepository,
|
private val users: UserRepository,
|
||||||
@ -49,5 +50,58 @@ class UserController(
|
|||||||
val record = records.findRecordLinkedToUser(userId, entityId, recordId)
|
val record = records.findRecordLinkedToUser(userId, entityId, recordId)
|
||||||
if (record == null) ctx.notFound() else ctx.json(record)
|
if (record == null) ctx.notFound() else ctx.json(record)
|
||||||
}
|
}
|
||||||
|
routes.patch("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@patch
|
||||||
|
val userId = ctx.pathParam("userId").toLongOrNull()
|
||||||
|
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||||
|
val recordId = ctx.pathParam("recordId").toLongOrNull()
|
||||||
|
if (userId == null || entityId == null || recordId == null || users.findById(userId) == null) {
|
||||||
|
ctx.notFound()
|
||||||
|
return@patch
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
val record = records.updateRecordLinkedToUser(userId, entityId, recordId, ctx.singleFormParams())
|
||||||
|
if (record == null) ctx.notFound() else ctx.json(record)
|
||||||
|
} catch (exception: IllegalArgumentException) {
|
||||||
|
ctx.badRequest(exception.message ?: "Invalid field value")
|
||||||
|
} catch (_: SQLException) {
|
||||||
|
ctx.badRequest("The record violates an entity constraint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routes.post(
|
||||||
|
"/api/users/{userId}/entities/{entityId}/records/{recordId}/children/{childEntityId}/{fieldId}",
|
||||||
|
) { ctx ->
|
||||||
|
if (!ctx.requireAdmin(loginService)) return@post
|
||||||
|
val userId = ctx.pathParam("userId").toLongOrNull()
|
||||||
|
val entityId = ctx.pathParam("entityId").toLongOrNull()
|
||||||
|
val recordId = ctx.pathParam("recordId").toLongOrNull()
|
||||||
|
val childEntityId = ctx.pathParam("childEntityId").toLongOrNull()
|
||||||
|
val fieldId = ctx.pathParam("fieldId").toLongOrNull()
|
||||||
|
if (
|
||||||
|
userId == null || entityId == null || recordId == null || childEntityId == null || fieldId == null ||
|
||||||
|
users.findById(userId) == null
|
||||||
|
) {
|
||||||
|
ctx.notFound()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val child = records.createChild(
|
||||||
|
userId,
|
||||||
|
entityId,
|
||||||
|
recordId,
|
||||||
|
childEntityId,
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun io.javalin.http.Context.singleFormParams(): Map<String, String> =
|
||||||
|
formParamMap().mapValues { (_, values) -> values.firstOrNull().orEmpty() }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user