only allow one one-to-one record

This commit is contained in:
Maxime Duchêne-Savard 2026-08-01 23:40:54 -04:00
parent 85af8a5859
commit 7e0ef77158
2 changed files with 72 additions and 5 deletions

View File

@ -8,6 +8,7 @@
name: string name: string
identifier: string identifier: string
type: string type: string
relationshipType?: 'ONE_TO_ONE' | 'ONE_TO_MANY'
} }
interface LinkedEntity { interface LinkedEntity {
entity: { id: number, name: string, identifier: string, fields: Field[] } entity: { id: number, name: string, identifier: string, fields: Field[] }
@ -36,6 +37,14 @@
return linked.entity.fields.filter(field => field.id !== linked.relationshipField.id) return linked.entity.fields.filter(field => field.id !== linked.relationshipField.id)
} }
function isOneToOne (linked: LinkedEntity) {
return linked.relationshipField.relationshipType === 'ONE_TO_ONE'
}
function createLabel (linked: LinkedEntity) {
return `${isOneToOne(linked) ? 'Create' : 'Add'} ${linked.entity.name}`
}
function inputType (field: Field) { function inputType (field: Field) {
if (['NUMBER', 'RELATIONSHIP', 'USER'].includes(field.type)) return 'number' if (['NUMBER', 'RELATIONSHIP', 'USER'].includes(field.type)) return 'number'
if (field.type === 'DATE') return 'date' if (field.type === 'DATE') return 'date'
@ -136,15 +145,22 @@
<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 v-if="isOneToOne(linked)">{{ linked.records.length === 0 ? 'Not created' : 'Created' }}</p>
<p v-else>{{ linked.records.length }} linked {{ linked.records.length === 1 ? 'record' : 'records' }}</p>
</div> </div>
<button type="button" @click="startAddingChild(linked)">Add {{ linked.entity.name }}</button> <button
v-if="!isOneToOne(linked) || linked.records.length === 0"
type="button"
@click="startAddingChild(linked)"
>
{{ createLabel(linked) }}
</button>
</div> </div>
<form <form
v-if="addingChildKey === childKey(linked)" v-if="addingChildKey === childKey(linked)"
:aria-label="`Add ${linked.entity.name}`" :aria-label="createLabel(linked)"
class="child-form" class="child-form"
@submit.prevent="addChild(linked)" @submit.prevent="addChild(linked)"
> >
@ -157,12 +173,21 @@
</label> </label>
<div class="form-actions"> <div class="form-actions">
<button :disabled="saving" type="submit">{{ saving ? 'Adding…' : `Add ${linked.entity.name}` }}</button> <button :disabled="saving" type="submit">{{ saving ? 'Saving…' : createLabel(linked) }}</button>
<button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button> <button :disabled="saving" type="button" @click="addingChildKey = ''">Cancel</button>
</div> </div>
</form> </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">
{{ isOneToOne(linked) ? `No ${linked.entity.name} has been created.` : 'No records linked to this user.' }}
</p>
<dl v-else-if="isOneToOne(linked)" class="record-details">
<template v-for="field in linked.entity.fields" :key="field.id">
<dt>{{ field.name }}</dt>
<dd>{{ displayValue(linked.records[0][field.identifier]) }}</dd>
</template>
</dl>
<div v-else class="table-scroll"> <div v-else class="table-scroll">
<table> <table>
@ -217,6 +242,9 @@
.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; }
.record-details { display: grid; grid-template-columns: minmax(8rem, 12rem) 1fr; margin: 0; padding: 1.25rem; gap: 0.75rem 1rem; }
.record-details dt { color: var(--v0-on-surface-variant); font-size: 0.8rem; font-weight: 700; text-transform: uppercase; }
.record-details dd { margin: 0; }
table { width: 100%; border-collapse: collapse; text-align: left; white-space: nowrap; } 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, 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; }

View File

@ -16,6 +16,7 @@ test('admin opens a user file and views a linked entity record', async ({ page }
const tasks: Record<string, unknown>[] = [] const tasks: Record<string, unknown>[] = []
const notes: Record<string, unknown>[] = [] const notes: Record<string, unknown>[] = []
const milestones: Record<string, unknown>[] = [] const milestones: Record<string, unknown>[] = []
const profiles: Record<string, unknown>[] = []
const projectResponse = () => ({ const projectResponse = () => ({
entity: { entity: {
id: 3, id: 3,
@ -107,6 +108,24 @@ test('admin opens a user file and views a linked entity record', async ({ page }
}, },
relationshipField: { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' }, relationshipField: { id: 12, name: 'Owner', identifier: 'owner', type: 'USER' },
records: projects, records: projects,
}, {
entity: {
id: 8,
name: 'Profile',
identifier: 'profile',
fields: [
{ id: 50, name: 'Biography', identifier: 'biography', type: 'TEXT' },
{ id: 51, name: 'User', identifier: 'user', type: 'USER', relationshipType: 'ONE_TO_ONE' },
],
},
relationshipField: {
id: 51,
name: 'User',
identifier: 'user',
type: 'USER',
relationshipType: 'ONE_TO_ONE',
},
records: profiles,
}], }],
}, },
})) }))
@ -119,6 +138,15 @@ test('admin opens a user file and views a linked entity record', async ({ page }
json: { entity: projectResponse().entity, values: created, children: [] }, json: { entity: projectResponse().entity, values: created, children: [] },
}) })
}) })
await page.route('**/api/users/7/children/Profile/51', async route => {
const values = new URLSearchParams(route.request().postData() ?? '')
const created = { id: 70, biography: values.get('biography'), user: 7 }
profiles.push(created)
await route.fulfill({
status: 201,
json: { entity: { id: 8, name: 'Profile' }, 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() ?? '')
@ -190,6 +218,17 @@ 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')
const profile = page.locator('.entity-card').filter({ has: page.getByRole('heading', { name: 'Profile' }) })
await expect(profile.getByText('No Profile has been created.')).toBeVisible()
await expect(profile.locator('table')).not.toBeVisible()
await profile.getByRole('button', { name: 'Create Profile' }).click()
const profileForm = profile.getByRole('form', { name: 'Create Profile' })
await profileForm.getByLabel('Biography').fill('Product specialist')
await profileForm.getByRole('button', { name: 'Create Profile' }).click()
await expect(profile.getByText('Product specialist')).toBeVisible()
await expect(profile.getByRole('button', { name: 'Create Profile' })).not.toBeVisible()
await expect(profile.locator('table')).not.toBeVisible()
await page.getByRole('button', { name: 'Add Project' }).click() await page.getByRole('button', { name: 'Add Project' }).click()
const projectForm = page.getByRole('form', { name: 'Add Project' }) const projectForm = page.getByRole('form', { name: 'Add Project' })
await projectForm.getByLabel('Title').fill('Mobile application') await projectForm.getByLabel('Title').fill('Mobile application')