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
12 changes: 12 additions & 0 deletions .changeset/24007-approval-request-title-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/plugin-approvals': patch
---

Clause-②: no

fix(plugin-approvals): an approval request's record title reads the flow's label, not `flow:NAME`

- **`sys_approval_request.display_title` reads `process_label`.** The title is now the flow's label and the record id (`Discount Sign-off · opp_45`), the same label the request lists show. A row with no label stored falls back to `process_name` (`flow:deal_desk · opp_42`), so the title never reads `null · …`. Every surface that draws the request's title through `nameField` shows it: the record page's heading and breadcrumb, and the request cell of the action history list.
- **`titleFormat` still names `{process_name}`.** A placeholder template cannot fall back, and `process_label` can be null after the boot fill (a row it could not label, or a failed fill run). A renderer that reads `titleFormat` ahead of `nameField`, such as the global search palette, still shows the machine source.

A patch: one formula on one platform object's computed title. No export, type, accepted value or stored column changes.
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
*
* 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.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
Expand Down Expand Up @@ -90,7 +98,26 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o
}
});

it('sys_approval_request: the H1 is "{process_name} · {record_id}", not the id', async () => {
it('sys_approval_request: with a flow label stored, the H1 is "{process_label} · {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');
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!)).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');
});

it('sys_approval_request: with no flow label stored, the H1 is "{process_name} · {record_id}", never "null · …"', async () => {
const created = await engine.insert('sys_approval_request', {
process_name: 'flow:deal_desk', object_name: 'opportunity', record_id: 'opp_42',
status: 'pending',
Expand All @@ -99,6 +126,7 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o
expect(created.display_title).toBe('flow:deal_desk · opp_42');
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,19 @@ export const SysApprovalRequest = ObjectSchema.create({
isSystem: true,
managedBy: 'engine-owned',
description: 'Live approval instance tracked per submission',
// [ADR-0079] The record title is `display_title`, a text formula over the
// same two columns `titleFormat` names. The pointer used to be `id`: once a
// renderer honours ADR-0079's order (an explicit `nameField` wins over
// `titleFormat`), that made the record page's H1 the raw id. `titleFormat`
// stays for renderers that still read it first;
// `sys-approval-display-title.test.ts` holds the two to the same text.
// [ADR-0079] The record title is `display_title`, a text formula. The
// pointer used to be `id`: once a renderer honours ADR-0079's order (an
// explicit `nameField` wins over `titleFormat`), that made the record page's
// H1 the raw id. The formula reads the flow's label (`process_label`) and
// falls back to the machine source (`process_name`) where no label is
// stored. `titleFormat` stays for renderers that still read it first — the
// global search palette (`searchAll` in `@objectstack/metadata-protocol`)
// ranks it above `nameField` — and keeps `{process_name}`: a placeholder
// template cannot say "else", and `process_label` can be null after the boot
// fill (a row the fill could not label, or a failed fill run), where a
// `{process_label}` template draws a title with no source at all.
// `sys-approval-display-title.test.ts` holds the two to the same text where
// no label is stored, and pins where they differ.
displayNameField: 'display_title',
nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: '{process_name} · {record_id}',
Expand Down Expand Up @@ -128,12 +135,15 @@ export const SysApprovalRequest = ObjectSchema.create({
id: Field.text({ label: 'Request ID', required: true, readonly: true, group: 'System' }),

// [ADR-0079] The record title (`nameField` above). A formula is computed on
// read and has no stored column. Both source columns are required, so the
// expression needs no null guard.
// read and has no stored column. Its flow half is the flow's label,
// `process_label`, the same column the request lists show. That column is
// nullable — a row the boot fill could not label, or one written before
// the fill ran — so the expression guards it and falls back to
// `process_name`, which is required, as is `record_id`.
display_title: Field.formula({
label: 'Title',
returnType: 'text',
expression: F`record.process_name + ' · ' + record.record_id`,
expression: F`record.process_label != null ? record.process_label + ' · ' + record.record_id : record.process_name + ' · ' + record.record_id`,
description: 'Record title: the request source and the record it is about (computed on read)',
group: 'Target',
}),
Expand Down
Loading