|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The record title of the three approval objects that used to point |
| 5 | + * `nameField` at `id` (#20015). |
| 6 | + * |
| 7 | + * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? |
| 8 | + * derivation`, and an explicit `nameField` takes precedence over the |
| 9 | + * render-only `titleFormat`. A renderer that honours that order therefore |
| 10 | + * showed the raw id as the record page's H1 for `sys_approval_action`, |
| 11 | + * `sys_approval_approver` and `sys_approval_request`, because each of them |
| 12 | + * declared `nameField: 'id'` beside a composite `titleFormat`. |
| 13 | + * |
| 14 | + * Each object now points at `display_title`, a text formula over the same |
| 15 | + * columns. This file asserts, per object, through the real engine: |
| 16 | + * |
| 17 | + * 1. the title field ADR-0079's order resolves is `display_title`, not `id`; |
| 18 | + * 2. a seeded row read back through `findOne` carries the H1 the |
| 19 | + * `titleFormat` intended, and that H1 is not the row's id; |
| 20 | + * 3. the server-side title accessor (`resolveRecordTitle`) agrees; |
| 21 | + * 4. the formula adds no stored column. |
| 22 | + * |
| 23 | + * Where a source column is nullable, a row without it is titled by the other |
| 24 | + * column rather than failing to evaluate. |
| 25 | + */ |
| 26 | + |
| 27 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 28 | +import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; |
| 29 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 30 | +import { resolveDisplayField } from '@objectstack/spec/data'; |
| 31 | +import { SysApprovalAction } from './sys-approval-action.object.js'; |
| 32 | +import { SysApprovalApprover } from './sys-approval-approver.object.js'; |
| 33 | +import { SysApprovalRequest } from './sys-approval-request.object.js'; |
| 34 | + |
| 35 | +const SYS = { context: { isSystem: true } } as any; |
| 36 | + |
| 37 | +/** |
| 38 | + * The H1 a `titleFormat`-first renderer draws: each `{field}` placeholder |
| 39 | + * substituted with the row's value. It is the reference the formula has to |
| 40 | + * reproduce, not a second title resolver. The parsed schema carries |
| 41 | + * `titleFormat` as a `{ dialect: 'template', source }` envelope. |
| 42 | + */ |
| 43 | +function renderTitleFormat(titleFormat: unknown, row: Record<string, unknown>): string { |
| 44 | + const source = typeof titleFormat === 'string' ? titleFormat : (titleFormat as { source?: unknown })?.source; |
| 45 | + if (typeof source !== 'string') throw new Error(`titleFormat carries no template source: ${JSON.stringify(titleFormat)}`); |
| 46 | + return source.replace(/\{\{?\s*([a-zA-Z0-9_.]+)\s*\}?\}/g, (_m, key: string) => String(row[key] ?? '')); |
| 47 | +} |
| 48 | + |
| 49 | +/** The H1 under ADR-0079's order: the value at the resolved title field. */ |
| 50 | +function h1Of(schema: unknown, row: Record<string, unknown>): unknown { |
| 51 | + const field = resolveDisplayField(schema as any); |
| 52 | + return field === undefined ? undefined : row[field]; |
| 53 | +} |
| 54 | + |
| 55 | +/** The stored row as a hook body holds it: no formula value on it. */ |
| 56 | +function storedOnly(row: Record<string, unknown>): Record<string, unknown> { |
| 57 | + const { display_title: _omit, ...rest } = row; |
| 58 | + return rest; |
| 59 | +} |
| 60 | + |
| 61 | +describe('[#20015] approval objects resolve a real record title under ADR-0079 order', () => { |
| 62 | + let engine: ObjectQL; |
| 63 | + let driver: SqlDriver; |
| 64 | + |
| 65 | + beforeAll(async () => { |
| 66 | + engine = new ObjectQL(); |
| 67 | + driver = new SqlDriver({ |
| 68 | + client: 'better-sqlite3', |
| 69 | + connection: { filename: ':memory:' }, |
| 70 | + useNullAsDefault: true, |
| 71 | + }); |
| 72 | + engine.registerDriver(driver, true); |
| 73 | + await engine.init(); |
| 74 | + for (const o of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) { |
| 75 | + engine.registry.registerObject(o as any, 'com.objectstack.test.20015'); |
| 76 | + } |
| 77 | + await engine.syncSchemas(); |
| 78 | + }); |
| 79 | + |
| 80 | + afterAll(async () => { |
| 81 | + try { await engine?.destroy(); } catch { /* noop */ } |
| 82 | + }); |
| 83 | + |
| 84 | + it('points all three objects at display_title, a text formula', () => { |
| 85 | + for (const schema of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) { |
| 86 | + expect(resolveDisplayField(schema as any)).toBe('display_title'); |
| 87 | + const field = (schema.fields as Record<string, any>).display_title; |
| 88 | + expect(field?.type).toBe('formula'); |
| 89 | + expect(field?.returnType).toBe('text'); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + it('sys_approval_request: the H1 is "{process_name} · {record_id}", not the id', async () => { |
| 94 | + const created = await engine.insert('sys_approval_request', { |
| 95 | + process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_42', |
| 96 | + status: 'pending', |
| 97 | + }, SYS); |
| 98 | + // The write response carries the title too (read-your-write). |
| 99 | + expect(created.display_title).toBe('flow:deal_desk · opp_42'); |
| 100 | + const row = await engine.findOne('sys_approval_request', { where: { id: created.id } }, SYS); |
| 101 | + expect(row).not.toBeNull(); |
| 102 | + |
| 103 | + expect(h1Of(SysApprovalRequest, row!)).toBe('flow:deal_desk · opp_42'); |
| 104 | + expect(h1Of(SysApprovalRequest, row!)).not.toBe(row!.id); |
| 105 | + expect(h1Of(SysApprovalRequest, row!)).toBe(renderTitleFormat(SysApprovalRequest.titleFormat, row!)); |
| 106 | + expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('flow:deal_desk · opp_42'); |
| 107 | + }); |
| 108 | + |
| 109 | + it('sys_approval_action: the H1 is "{action} · {step_name}", and the action alone without a step', async () => { |
| 110 | + const request = await engine.insert('sys_approval_request', { |
| 111 | + process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_43', |
| 112 | + status: 'pending', |
| 113 | + }, SYS); |
| 114 | + |
| 115 | + const stepped = await engine.insert('sys_approval_action', { |
| 116 | + request_id: request.id, action: 'approve', step_name: 'manager_review', |
| 117 | + }, SYS); |
| 118 | + const row = await engine.findOne('sys_approval_action', { where: { id: stepped.id } }, SYS); |
| 119 | + expect(h1Of(SysApprovalAction, row!)).toBe('approve · manager_review'); |
| 120 | + expect(h1Of(SysApprovalAction, row!)).not.toBe(row!.id); |
| 121 | + expect(h1Of(SysApprovalAction, row!)).toBe(renderTitleFormat(SysApprovalAction.titleFormat, row!)); |
| 122 | + expect(resolveRecordTitle(SysApprovalAction, storedOnly(row!))).toBe('approve · manager_review'); |
| 123 | + |
| 124 | + const stepless = await engine.insert('sys_approval_action', { |
| 125 | + request_id: request.id, action: 'submit', |
| 126 | + }, SYS); |
| 127 | + const bare = await engine.findOne('sys_approval_action', { where: { id: stepless.id } }, SYS); |
| 128 | + expect(bare!.step_name ?? null).toBeNull(); |
| 129 | + expect(h1Of(SysApprovalAction, bare!)).toBe('submit'); |
| 130 | + expect(resolveRecordTitle(SysApprovalAction, storedOnly(bare!))).toBe('submit'); |
| 131 | + }); |
| 132 | + |
| 133 | + it('sys_approval_approver: the H1 is "{approver} · {request_id}", not the id', async () => { |
| 134 | + const request = await engine.insert('sys_approval_request', { |
| 135 | + process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_44', |
| 136 | + status: 'pending', |
| 137 | + }, SYS); |
| 138 | + const created = await engine.insert('sys_approval_approver', { |
| 139 | + request_id: request.id, approver: 'role:finance', |
| 140 | + }, SYS); |
| 141 | + const row = await engine.findOne('sys_approval_approver', { where: { id: created.id } }, SYS); |
| 142 | + |
| 143 | + expect(h1Of(SysApprovalApprover, row!)).toBe(`role:finance · ${request.id}`); |
| 144 | + expect(h1Of(SysApprovalApprover, row!)).not.toBe(row!.id); |
| 145 | + expect(h1Of(SysApprovalApprover, row!)).toBe(renderTitleFormat(SysApprovalApprover.titleFormat, row!)); |
| 146 | + expect(resolveRecordTitle(SysApprovalApprover, storedOnly(row!))).toBe(`role:finance · ${request.id}`); |
| 147 | + }); |
| 148 | + |
| 149 | + it('adds no stored column: the formula is computed on read', async () => { |
| 150 | + for (const [object, sourceColumn] of [ |
| 151 | + ['sys_approval_request', 'process_name'], |
| 152 | + ['sys_approval_action', 'step_name'], |
| 153 | + ['sys_approval_approver', 'approver'], |
| 154 | + ] as const) { |
| 155 | + const columns = Object.keys(await driver.getKnex()(object).columnInfo()); |
| 156 | + expect(columns).toContain(sourceColumn); |
| 157 | + expect(columns).not.toContain('display_title'); |
| 158 | + } |
| 159 | + }); |
| 160 | +}); |
0 commit comments