Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/24665-approval-request-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-approvals': patch
---

fix(plugin-approvals): an approval request is titled by its flow's label, and its title no longer shows the raw record id or the flow's machine name

Clause-②: no

- **The request page's heading is the flow's label.** Opening an approval request (Setup → Approvals → Requests, or from the approvals inbox) showed `flow:invoice_approval · Rd4bl3Rec12489QQ` as the page heading: the flow's machine name and the raw id of the record the request is about. It now shows the flow's label, such as `Invoice Sign-off`, whether the record is readable, was deleted, or cannot be resolved.
- **The title pointer is a column the approvals API serves.** `sys_approval_request` now declares `nameField` (and the deprecated `displayNameField`) as `process_label`, the stored flow-label column the request lists already show. The approvals API (`GET /api/v1/approvals/requests/:id` and its list) serves that column, so a console titles the page from it. The pointer was `display_title`, a computed field the approvals API does not serve, so the console drew the `titleFormat` template instead.
- **The record is named on the Details tab, not in the title.** The Details tab's record card, drawn from the `record_id` / `object_name` pair, names the record and says when the reader may not read it or the platform deleted it. A title cannot say either, so it shows no record id.
- **The fallback is the flow's source.** `titleFormat` is now `{process_name}` (was `{process_name} · {record_id}`). A renderer draws it only where `process_label` is empty: a request written before that column, until the startup backfill labels it. The approvals API never reaches it, because it serves a label for every request (the stored one, else the label recorded when the request was opened, else the flow's name humanized).
- **`display_title` computes the same text.** The formula is now `process_label`, else `process_name`, with no ` · record_id` half, so the data API's `display_title` equals the title every door draws. It stays declared because existing activity entries name it as the source of their text.
- **Search results follow.** The global search palette titles a request by `nameField` first, so a request now appears there under its flow's label.

A patch: one platform object's title pointer, its title template and its title formula's expression change. No field, export, type, accepted value or stored column is added or removed.
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,28 @@
* `sys_approval_approver` and `sys_approval_request`, because each of them
* declared `nameField: 'id'` beside a composite `titleFormat`.
*
* Each object now points at `display_title`, a text formula over the same
* columns. This file asserts, per object, through the real engine:
* `sys_approval_action` and `sys_approval_approver` point at `display_title`,
* a text formula over the same columns. This file asserts, per object, through
* the real engine:
*
* 1. the title field ADR-0079's order resolves is `display_title`, not `id`;
* 1. the title field ADR-0079's order resolves is not `id`;
* 2. a seeded row read back through `findOne` carries the H1 the
* `titleFormat` intended, and that H1 is not the row's id;
* 3. the server-side title accessor (`resolveRecordTitle`) agrees;
* 4. the formula adds no stored column.
* 4. a formula title adds no stored column.
*
* Where a source column is nullable, a row without it is titled by the other
* column rather than failing to evaluate.
*
* `sys_approval_request` is the one object whose formula and `titleFormat`
* name different columns (#24007): the formula's flow half is the flow's label
* (`process_label`, nullable), with `process_name` as its fallback, while
* `titleFormat` keeps `{process_name}` because a template cannot express that
* fallback. So for that object point 2 is asserted where they agree — a row
* with no stored label, where both draw `process_name` — and a row with a
* label pins the formula's own text and the template's difference from it.
* `sys_approval_request` points at `process_label`, the flow's label, a
* STORED column: the approvals get serves no formula value, so a formula
* pointer left the request page with no title and the page drew `titleFormat`
* (#24665). Its title names no subject record. `titleFormat` is
* `{process_name}`, the fallback a renderer draws where no label is stored,
* and `display_title` (kept declared) computes the same text on every row: the
* label where one is stored, else `process_name`. So for that object point 2
* is asserted on a row with a label (the pointer's value) and on a row
* without one (the template's), and neither carries the record id.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -89,49 +92,64 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o
try { await engine?.destroy(); } catch { /* noop */ }
});

it('points all three objects at display_title, a text formula', () => {
for (const schema of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
it('points sys_approval_request at process_label, a stored text column, and the other two at display_title, a text formula', () => {
expect(resolveDisplayField(SysApprovalRequest as any)).toBe('process_label');
expect(SysApprovalRequest.displayNameField).toBe('process_label');
expect((SysApprovalRequest.fields as Record<string, any>).process_label?.type).toBe('text');
for (const schema of [SysApprovalAction, SysApprovalApprover]) {
expect(resolveDisplayField(schema as any)).toBe('display_title');
}
for (const schema of [SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
const field = (schema.fields as Record<string, any>).display_title;
expect(field?.type).toBe('formula');
expect(field?.returnType).toBe('text');
}
});

it('sys_approval_request: with a flow label stored, the H1 is "{process_label} · {record_id}"', async () => {
it('sys_approval_request: with a flow label stored, the H1 is the label alone, never the record id', async () => {
// An authored label that is not the humanized machine name ("Deal Desk"),
// so the title can only have come from the stored label.
const created = await engine.insert('sys_approval_request', {
process_name: 'flow:deal_desk', process_label: 'Discount Sign-off',
object_name: 'opportunity', record_id: 'opp_45', status: 'pending',
}, SYS);
expect(created.display_title).toBe('Discount Sign-off · opp_45');
// `display_title` computes the same text (read-your-write).
expect(created.display_title).toBe('Discount Sign-off');
const row = await engine.findOne('sys_approval_request', { where: { id: created.id } }, SYS);
expect(row).not.toBeNull();

expect(h1Of(SysApprovalRequest, row!)).toBe('Discount Sign-off · opp_45');
expect(h1Of(SysApprovalRequest, row!)).toBe('Discount Sign-off');
expect(h1Of(SysApprovalRequest, row!)).not.toBe(row!.id);
expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('Discount Sign-off · opp_45');
// The pairing, restated: `titleFormat` keeps the machine source, so a
// renderer that reads it first draws a different text for this row.
expect(renderTitleFormat(SysApprovalRequest.titleFormat, row!)).toBe('flow:deal_desk · opp_45');
expect(String(h1Of(SysApprovalRequest, row!))).not.toContain('opp_45');
expect(row!.display_title).toBe('Discount Sign-off');
expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('Discount Sign-off');
// The pairing, restated: `titleFormat` keeps the machine source, and a
// renderer reaches it only where the pointer's value is empty — not here.
expect(renderTitleFormat(SysApprovalRequest.titleFormat, row!)).toBe('flow:deal_desk');
});

it('sys_approval_request: with no flow label stored, the H1 is "{process_name} · {record_id}", never "null · …"', async () => {
it('sys_approval_request: with no flow label stored, the H1 is the template\'s "{process_name}", which display_title matches, never "null" and never the record id', async () => {
const created = await engine.insert('sys_approval_request', {
process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_42',
status: 'pending',
}, SYS);
// The write response carries the title too (read-your-write).
expect(created.display_title).toBe('flow:deal_desk · opp_42');
// The write response carries the computed text too (read-your-write).
expect(created.display_title).toBe('flow:deal_desk');
const row = await engine.findOne('sys_approval_request', { where: { id: created.id } }, SYS);
expect(row).not.toBeNull();
expect(row!.process_label ?? null).toBeNull();

expect(h1Of(SysApprovalRequest, row!)).toBe('flow:deal_desk · opp_42');
expect(h1Of(SysApprovalRequest, row!)).not.toBe(row!.id);
expect(h1Of(SysApprovalRequest, row!)).toBe(renderTitleFormat(SysApprovalRequest.titleFormat, row!));
expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBe('flow:deal_desk · opp_42');
// The pointer's value is empty, so ADR-0079's order falls through to the
// deprecated template: the machine source, the same text the formula draws.
expect(h1Of(SysApprovalRequest, row!) ?? null).toBeNull();
const fallback = renderTitleFormat(SysApprovalRequest.titleFormat, row!);
expect(fallback).toBe('flow:deal_desk');
expect(fallback).not.toContain('opp_42');
expect(fallback).not.toContain('null');
expect(row!.display_title).toBe(fallback);
// The server-side accessor reads the pointer and never invents a title:
// no label stored, no title (the boot fill labels such a row).
expect(resolveRecordTitle(SysApprovalRequest, storedOnly(row!))).toBeUndefined();
});

it('sys_approval_action: the H1 is "{action} · {step_name}", and the action alone without a step', async () => {
Expand Down
Loading
Loading