add entity viewer

This commit is contained in:
Maxime Duchêne-Savard 2026-07-30 15:37:00 -04:00
parent 285fc4aeec
commit 00d3f2b13b
10 changed files with 427 additions and 2 deletions

View File

@ -0,0 +1,86 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { auth } from '@/auth'
interface Field {
id: number
name: string
identifier: string
type: string
}
interface EntityRecord {
entity: { id: number, name: string, fields: Field[] }
values: Record<string, unknown>
}
const route = useRoute()
const router = useRouter()
const record = ref<EntityRecord>()
const loading = ref(true)
const errorMessage = ref('')
const userFilePath = computed(() => `/users/${route.params.userId}`)
function displayValue (value: unknown) {
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return String(value)
}
onMounted(async () => {
try {
const response = await fetch(
`/api/users/${route.params.userId}/entities/${route.params.entityId}/records/${route.params.recordId}`,
{ headers: { Authorization: `Bearer ${auth.token}` } },
)
if (response.status === 404) {
await router.replace(userFilePath.value)
return
}
if (!response.ok) throw new Error('Record request failed')
record.value = await response.json() as EntityRecord
} catch {
errorMessage.value = 'Unable to load this record.'
} finally {
loading.value = false
}
})
</script>
<template>
<section class="viewer-page">
<router-link class="back-link" :to="userFilePath"> Back to user file</router-link>
<p v-if="loading">Loading record</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
<template v-else-if="record">
<header>
<p class="eyebrow">Record viewer</p>
<h1>{{ record.entity.name }} #{{ record.values.id }}</h1>
</header>
<dl class="record-card">
<div><dt>ID</dt><dd>{{ displayValue(record.values.id) }}</dd></div>
<div v-for="field in record.entity.fields" :key="field.id">
<dt>{{ field.name }}</dt>
<dd>{{ displayValue(record.values[field.identifier]) }}</dd>
</div>
</dl>
</template>
</section>
</template>
<style scoped>
.viewer-page { width: 100%; max-width: 60rem; }
.back-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
.back-link:hover { text-decoration: underline; }
header { margin: 1.5rem 0 2rem; }
.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; }
.record-card { overflow: hidden; margin: 0; border: 1px solid var(--v0-divider); border-radius: 1rem; background: var(--v0-surface); }
.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 > div:last-child { border-bottom: 0; }
dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
dd { margin: 0; overflow-wrap: anywhere; }
</style>

View File

@ -0,0 +1,142 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { auth } from '@/auth'
interface Field {
id: number
name: string
identifier: string
type: string
}
interface LinkedEntity {
entity: { id: number, name: string, identifier: string, fields: Field[] }
records: Record<string, unknown>[]
}
interface UserFile {
user: { id: number, username: string, role: string }
linkedEntities: LinkedEntity[]
}
const route = useRoute()
const router = useRouter()
const file = ref<UserFile>()
const loading = ref(true)
const errorMessage = ref('')
function displayValue (value: unknown) {
if ([null, undefined, ''].includes(value as null | undefined | string)) return '—'
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
return String(value)
}
onMounted(async () => {
try {
const response = await fetch(`/api/users/${route.params.userId}/file`, {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (response.status === 404) {
await router.replace('/users')
return
}
if (!response.ok) throw new Error('User file request failed')
file.value = await response.json() as UserFile
} catch {
errorMessage.value = 'Unable to load this user file.'
} finally {
loading.value = false
}
})
</script>
<template>
<section class="user-file-page">
<router-link class="back-link" to="/users"> User Management</router-link>
<p v-if="loading">Loading user file</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
<template v-else-if="file">
<header>
<p class="eyebrow">User file</p>
<div class="title-row">
<h1>{{ file.user.username }}</h1>
<span class="role-badge">{{ file.user.role }}</span>
</div>
<p>All entity records linked to this user.</p>
</header>
<p v-if="file.linkedEntities.length === 0" class="empty-state">
No entities with user references are configured.
</p>
<article v-for="linked in file.linkedEntities" :key="linked.entity.id" 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>
</div>
<p v-if="linked.records.length === 0" class="no-records">No records linked to this user.</p>
<div v-else class="table-scroll">
<table>
<thead>
<tr>
<th>ID</th>
<th v-for="field in linked.entity.fields" :key="field.id">{{ field.name }}</th>
<th><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="record in linked.records" :key="String(record.id)">
<td>{{ displayValue(record.id) }}</td>
<td v-for="field in linked.entity.fields" :key="field.id">
{{ displayValue(record[field.identifier]) }}
</td>
<td>
<router-link
:aria-label="`View ${linked.entity.name} record ${record.id}`"
class="view-link"
:to="`/users/${file.user.id}/entities/${linked.entity.id}/records/${record.id}`"
>
View
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</template>
</section>
</template>
<style scoped>
.user-file-page { width: 100%; max-width: 78rem; }
.back-link, .view-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
.back-link:hover, .view-link:hover { text-decoration: underline; }
header { margin: 1.5rem 0 2rem; }
.eyebrow { margin: 0 0 0.5rem; color: var(--v0-primary); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.title-row { display: flex; align-items: center; gap: 0.8rem; }
h1, h2 { margin: 0; letter-spacing: -0.03em; }
h1 { font-size: 2rem; }
h2 { font-size: 1.25rem; }
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 p { margin: 0.35rem 0 0; font-size: 0.9rem; }
.empty-state, .no-records { padding: 1.5rem; }
.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>

View File

@ -53,7 +53,7 @@
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td><router-link class="user-link" :to="`/users/${user.id}`">{{ user.username }}</router-link></td>
<td><span class="role-badge">{{ user.role }}</span></td>
</tr>
</tbody>
@ -75,4 +75,6 @@
th { color: var(--v0-on-surface-variant); font-size: 0.75rem; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
.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; }
.user-link { color: var(--v0-primary); font-weight: 700; text-decoration: none; }
.user-link:hover { text-decoration: underline; }
</style>

View File

@ -8,7 +8,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import { auth } from '@/auth'
import EntityConfiguration from '@/pages/entity-configuration.vue'
import EntityRecordViewer from '@/pages/entity-record-viewer.vue'
import Index from '@/pages/index.vue'
import UserFile from '@/pages/user-file.vue'
import Users from '@/pages/users.vue'
const router = createRouter({
@ -23,6 +25,16 @@ const router = createRouter({
component: Users,
meta: { requiresAdmin: true },
},
{
path: '/users/:userId',
component: UserFile,
meta: { requiresAdmin: true },
},
{
path: '/users/:userId/entities/:entityId/records/:recordId',
component: EntityRecordViewer,
meta: { requiresAdmin: true },
},
{
path: '/admin/entities',
component: EntityConfiguration,

View File

@ -0,0 +1,61 @@
import { expect, type Page, test } from '@playwright/test'
async function useAdminSession (page: Page) {
await page.addInitScript(() => {
sessionStorage.setItem('bolts-session', JSON.stringify({
username: 'admin',
role: 'admin',
token: 'admin-token',
}))
})
}
test('admin opens a user file and views a linked entity record', async ({ page }) => {
await useAdminSession(page)
await page.route('**/api/users', route => route.fulfill({
json: [{ id: 7, username: 'alex', role: 'user' }],
}))
await page.route('**/api/users/7/file', route => route.fulfill({
json: {
user: { id: 7, username: 'alex', role: 'user' },
linkedEntities: [{
entity: {
id: 3,
name: 'Project',
identifier: 'project',
fields: [
{ id: 11, name: 'Title', identifier: 'title', type: 'TEXT' },
{ id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
],
},
records: [{ id: 42, title: 'Website refresh', owner: 7 }],
}],
},
}))
await page.route('**/api/users/7/entities/3/records/42', route => route.fulfill({
json: {
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: 'Website refresh', owner: 7 },
},
}))
await page.goto('/users')
await page.getByRole('link', { name: 'alex' }).click()
await expect(page.getByRole('heading', { name: 'alex' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Project' })).toBeVisible()
await expect(page.getByRole('row').filter({ hasText: 'Website refresh' })).toContainText('7')
await page.getByRole('link', { name: 'View Project record 42' }).click()
await expect(page.getByRole('heading', { name: 'Project #42' })).toBeVisible()
await expect(page.getByText('Website refresh')).toBeVisible()
await expect(page.getByText('Owner')).toBeVisible()
})

View File

@ -1,5 +1,6 @@
import dev.mduchene.bolts.entity.EntityDefinitionRepository
import dev.mduchene.bolts.entity.EntityMigrationService
import dev.mduchene.bolts.entity.EntityRecordRepository
import dev.mduchene.bolts.persistence.Database
import dev.mduchene.bolts.persistence.DatabaseConfig
import dev.mduchene.bolts.user.LoginService
@ -24,7 +25,7 @@ fun main() {
val controllers = listOf(
HomeController(),
AuthController(loginService),
UserController(users, loginService),
UserController(users, loginService, EntityRecordRepository(database, entities)),
EntityDefinitionController(entities, loginService, EntityMigrationService(database, entities)),
)

View File

@ -0,0 +1,78 @@
package dev.mduchene.bolts.entity
import dev.mduchene.bolts.persistence.Database
import java.sql.ResultSet
import java.time.temporal.TemporalAccessor
data class LinkedEntityRecords(
val entity: EntityDefinition,
val records: List<Map<String, Any?>>,
)
data class EntityRecord(
val entity: EntityDefinition,
val values: Map<String, Any?>,
)
class EntityRecordRepository(
private val database: Database,
private val entities: EntityDefinitionRepository,
) {
fun findLinkedToUser(userId: Long): List<LinkedEntityRecords> =
database.getConnection().use { connection ->
val tables = connection.metaData.getTables(null, null, "%", arrayOf("TABLE")).use { result ->
buildSet { while (result.next()) add(result.getString("TABLE_NAME").lowercase()) }
}
entities.findAll()
.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 }
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\""
val records = connection.prepareStatement(sql).use { statement ->
userFields.indices.forEach { statement.setLong(it + 1, userId) }
statement.executeQuery().use { result -> result.toRecords(columns) }
}
LinkedEntityRecords(entity, records)
}
}
fun findRecordLinkedToUser(userId: Long, entityId: Long, recordId: Long): EntityRecord? {
val entity = entities.findAll().firstOrNull { it.id == entityId } ?: return null
val userFields = entity.fields.filter { it.type == FieldType.USER }
if (userFields.isEmpty()) return null
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 \"id\" = ? AND ($predicate)"
val values = database.queryOne(sql, bind = {
setLong(1, recordId)
userFields.indices.forEach { setLong(it + 2, userId) }
}) { toRecord(columns) } ?: return null
return EntityRecord(entity, values)
}
private fun ResultSet.toRecords(columns: List<String>): List<Map<String, Any?>> =
buildList { while (next()) add(toRecord(columns)) }
private fun ResultSet.toRecord(columns: List<String>): Map<String, Any?> =
linkedMapOf<String, Any?>().also { values ->
columns.forEach { column -> values[column] = normalizedValue(getObject(column)) }
}
private fun normalizedValue(value: Any?): Any? = when (value) {
is java.sql.Date -> value.toLocalDate().toString()
is java.sql.Timestamp -> value.toInstant().toString()
is TemporalAccessor -> value.toString()
is java.util.Date -> value.toInstant().toString()
else -> value
}
private fun quote(identifier: String) = "\"${identifier.replace("\"", "\"\"")}\""
}

View File

@ -44,6 +44,13 @@ class UserRepository(private val database: Database) {
return database.queryList(sql, map = ResultSet::toUser)
}
fun findById(id: Long): User? =
database.queryOne(
"SELECT id, username, password_hash, role FROM users WHERE id = ?",
bind = { setLong(1, id) },
map = ResultSet::toUser,
)
fun updateRole(id: Long, role: String) {
val sql = "UPDATE users SET role = ? WHERE id = ?"
database.executeUpdate(sql) {

View File

@ -1,5 +1,7 @@
package dev.mduchene.bolts.web
import dev.mduchene.bolts.entity.LinkedEntityRecords
data class ErrorResponse(
val message: String,
)
@ -15,3 +17,8 @@ data class UserResponse(
val username: String,
val role: String,
)
data class UserFileResponse(
val user: UserResponse,
val linkedEntities: List<LinkedEntityRecords>,
)

View File

@ -1,5 +1,6 @@
package dev.mduchene.bolts.web
import dev.mduchene.bolts.entity.EntityRecordRepository
import dev.mduchene.bolts.user.LoginService
import dev.mduchene.bolts.user.UserRepository
import io.javalin.router.JavalinDefaultRoutingApi
@ -7,6 +8,7 @@ import io.javalin.router.JavalinDefaultRoutingApi
class UserController(
private val users: UserRepository,
private val loginService: LoginService,
private val records: EntityRecordRepository,
) : Controller {
override fun register(routes: JavalinDefaultRoutingApi) {
routes.get("/api/users") { ctx ->
@ -20,5 +22,32 @@ class UserController(
)
})
}
routes.get("/api/users/{userId}/file") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get
val userId = ctx.pathParam("userId").toLongOrNull()
val user = userId?.let(users::findById)
if (user == null) {
ctx.notFound()
return@get
}
ctx.json(
UserFileResponse(
UserResponse(user.id, user.username, user.role),
records.findLinkedToUser(user.id),
),
)
}
routes.get("/api/users/{userId}/entities/{entityId}/records/{recordId}") { ctx ->
if (!ctx.requireAdmin(loginService)) return@get
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@get
}
val record = records.findRecordLinkedToUser(userId, entityId, recordId)
if (record == null) ctx.notFound() else ctx.json(record)
}
}
}