diff --git a/.changeset/24007-approval-request-title-label.md b/.changeset/24007-approval-request-title-label.md new file mode 100644 index 0000000000..c62d0e0be6 --- /dev/null +++ b/.changeset/24007-approval-request-title-label.md @@ -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. diff --git a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts index af1e43423a..e262a159a6 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts @@ -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'; @@ -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', @@ -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); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index cf200f4691..8ac394bf6b 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -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}', @@ -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', }),