From 219c493c7fa6a386668efdf17c16cd42bed5a438 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 00:27:47 +0000 Subject: [PATCH 1/3] fix(plugin-approvals): an approval request is titled by its flow's label, never the raw record id WIP: title pointer moves to process_label (served by the approvals get), titleFormat drops {record_id}, display_title drops its record-id half. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24665-approval-request-title.md | 16 ++ .../src/sys-approval-display-title.test.ts | 72 +++--- ...approval-request-title.integration.test.ts | 219 ++++++++++++++++++ .../src/sys-approval-request.object.ts | 65 ++++-- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- 7 files changed, 324 insertions(+), 54 deletions(-) create mode 100644 .changeset/24665-approval-request-title.md create mode 100644 packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts diff --git a/.changeset/24665-approval-request-title.md b/.changeset/24665-approval-request-title.md new file mode 100644 index 00000000000..093ff9175e8 --- /dev/null +++ b/.changeset/24665-approval-request-title.md @@ -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. 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 e262a159a67..f762b93c39a 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 @@ -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'; @@ -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).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).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 () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts new file mode 100644 index 00000000000..1d04ab89d70 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24665] The served approval request page's title: the flow's label, served + * by the approvals get under the object's declared `nameField`, and never the + * subject record's id or the flow's machine name. + * + * ## The contract + * + * The request page (`sys_approval_request_detail`) authors no header, so the + * console titles it from the object's declaration over the row the approvals + * get serves: the `nameField` value, then the deprecated `titleFormat` only + * where that value is empty (ADR-0079). The pointer used to be `display_title`, + * a formula the get never served, so every page fell through to the template + * `{process_name} · {record_id}` — `flow:NAME · RECORD_ID` as its H1. + * + * Now the pointer is `process_label`, a stored column the get serves by the + * display-label chain (the column, else the config snapshot's label, else the + * humanized machine name). So: + * + * 1. the get serves a non-empty value under the declared `nameField`, and the + * list door serves the same (acceptance 1); + * 2. that value names no record id and no `flow:` machine name, whatever the + * subject record's state — readable, deleted by the platform, or + * unresolvable — because the title reads nothing of the subject + * (acceptance 2, one case per state); + * 3. a row with no label stored is titled by the humanized machine name, so + * the template fallback is not reached on this door at all. + * + * Driven through the real `ApprovalService` on a real `ObjectQL` over + * `SqlDriver` (better-sqlite3, in-memory, real DDL), with the plugin's own + * objects from its manifest. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ApprovalService } from './approval-service.js'; +import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; +const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; + +const FLOW = { name: 'invoice_approval', label: 'Invoice Sign-off', nodes: [{ id: 'review', type: 'approval', label: 'Finance review' }], edges: [] }; +/** The humanized machine name the display-label chain draws from `flow:invoice_approval`. */ +const HUMANIZED = 'Invoice Approval'; +const APPROVER = 'u_appr'; + +const sysUser = { + name: 'sys_user', + label: 'User', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Name', type: 'text' as const }, + email: { name: 'email', label: 'Email', type: 'text' as const }, + }, +}; +const invoice = { + name: 'invoice', + label: 'Invoice', + nameField: 'name', + fields: { + id: { name: 'id', label: 'Id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', label: 'Invoice Number', type: 'text' as const }, + }, +}; + +async function packageObjects(): Promise { + let manifest: any; + const ctx: any = { + getService: (name: string) => { + if (name === 'manifest') return { register: (m: any) => { manifest = m; } }; + throw new Error(`no service '${name}'`); + }, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }; + await new ApprovalsServicePlugin().init(ctx); + return manifest.objects; +} + +/** The title the declaration names on a served row: the value under `nameField`. */ +const NAME_FIELD = SysApprovalRequest.nameField as string; +const titleOf = (row: Record | null | undefined): unknown => row?.[NAME_FIELD]; + +describe('#24665 — the approval request title is the flow label the approvals get serves', () => { + let engine: ObjectQL; + let driver: SqlDriver; + let svc: ApprovalService; + let now = Date.parse('2026-10-20T09:00:00.000Z'); + + /** A request opened by the real service over a readable invoice. */ + const open = async (recordId: string): Promise => { + await engine.insert('invoice', { id: recordId, name: `INV-${recordId.slice(-4)}` }, { context: SYSTEM } as any); + const row: any = await svc.openNodeRequest({ + object: 'invoice', recordId, runId: `run_${recordId}`, nodeId: 'review', + flowName: FLOW.name, flowLabel: FLOW.label, nodeLabel: 'Finance review', + config: { approvers: [{ type: 'user', value: APPROVER }], behavior: 'first_response' } as any, + submitterId: 'u_sub', record: { id: recordId, name: `INV-${recordId.slice(-4)}` }, + }, asUser('u_sub')); + return row.id as string; + }; + + /** The title, asserted the same on both doors that serve a request row. */ + const servedTitle = async (id: string, reader: any): Promise => { + const got = await svc.getRequest(id, reader); + expect(got, `get ${id}`).not.toBeNull(); + const listed = (await svc.listRequests({ object: got!.object_name, recordId: got!.record_id }, reader)) + .find((r) => r.id === id); + expect(listed, `list ${id}`).toBeDefined(); + expect(titleOf(listed), `list ${id}`).toEqual(titleOf(got)); + const title = titleOf(got); + expect(typeof title).toBe('string'); + return title as string; + }; + + /** What the H1 must never carry, for one served row. */ + const expectNoIdNoMachineName = (title: string, recordId: string) => { + expect(title.trim()).not.toBe(''); + expect(title).not.toContain(recordId); + expect(title).not.toMatch(/flow:/); + }; + + beforeAll(async () => { + engine = new ObjectQL(); + driver = new SqlDriver({ + client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true, + }); + engine.registerDriver(driver, true); + await engine.init(); + for (const def of [sysUser, invoice, ...(await packageObjects())]) { + engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); + } + await engine.syncSchemas(); + for (const u of [{ id: 'u_sub', name: 'Sam' }, { id: APPROVER, name: 'Ada' }]) { + await engine.insert('sys_user', u, { context: SYSTEM } as any); + } + svc = new ApprovalService({ + engine: engine as any, + clock: { now: () => new Date(now += 1000) }, + automation: { + resume: async () => ({ status: 'completed' }), + cancelRun: async () => undefined, + getRun: async (runId: string) => ({ id: runId, status: 'paused' }), + getFlow: async () => FLOW, + } as any, + logger: { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} } as any, + }); + }); + + afterAll(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + }); + + it('the declaration: the title pointer is a stored, declared column, and the template names no record id', async () => { + expect(NAME_FIELD).toBe('process_label'); + expect(SysApprovalRequest.displayNameField).toBe(NAME_FIELD); + expect((SysApprovalRequest.fields as Record)[NAME_FIELD]).toMatchObject({ type: 'text' }); + // Stored: the column exists in the real DDL (a formula pointer has none, + // which is why the get could not serve it). + const columns = Object.keys(await driver.getKnex()('sys_approval_request').columnInfo()); + expect(columns).toContain(NAME_FIELD); + expect(columns).not.toContain('display_title'); + const template = SysApprovalRequest.titleFormat as unknown; + const source = typeof template === 'string' ? template : (template as { source?: string })?.source; + expect(source).toBe('{process_name}'); + expect(source).not.toContain('record_id'); + }); + + it('readable subject: the get serves the flow label under nameField, with no record id and no machine name', async () => { + const recordId = 'Rd4bl3Rec24665QQ'; + const id = await open(recordId); + const title = await servedTitle(id, asUser(APPROVER)); + expect(title).toBe(FLOW.label); + expectNoIdNoMachineName(title, recordId); + // The subject is still named — by the reference pair the Details tab draws. + const got = await svc.getRequest(id, asUser(APPROVER)); + expect(got).toMatchObject({ object_name: 'invoice', record_id: recordId }); + }); + + it('deleted subject: the platform-cancelled request keeps the flow label as its title', async () => { + const recordId = 'D3l3t3dRec24665Q'; + const id = await open(recordId); + // What the platform leaves when the subject is deleted: the record gone, + // the request cancelled with `record_deleted`. + await engine.delete('invoice', { where: { id: recordId }, context: SYSTEM } as any); + await engine.update('sys_approval_request', { id, status: 'cancelled', cancel_reason: 'record_deleted' }, { context: SYSTEM } as any); + const got = await svc.getRequest(id, SYSTEM); + expect(got).toMatchObject({ status: 'cancelled', cancel_reason: 'record_deleted', record_id: recordId }); + const title = await servedTitle(id, SYSTEM); + expect(title).toBe(FLOW.label); + expectNoIdNoMachineName(title, recordId); + }); + + it('unresolvable subject: a request about an object the registry does not hold keeps the flow label as its title', async () => { + const recordId = 'Un0penRec24665QQ'; + const id = 'areq_unresolvable_24665'; + await engine.insert('sys_approval_request', { + id, process_name: `flow:${FLOW.name}`, process_label: FLOW.label, + object_name: 'retired_invoice', record_id: recordId, status: 'pending', + current_step: 'review', current_step_label: 'Finance review', pending_approvers: APPROVER, + }, { context: SYSTEM } as any); + const title = await servedTitle(id, SYSTEM); + expect(title).toBe(FLOW.label); + expectNoIdNoMachineName(title, recordId); + }); + + it('a row with no label stored is titled by the humanized machine name on this door, so the template is never reached', async () => { + const recordId = 'N0LabelRec24665Q'; + const id = await open(recordId); + // The row the boot fill has not reached: no label column, no snapshot label. + await engine.update('sys_approval_request', { + id, process_label: null, node_config_json: JSON.stringify({ approvers: [{ type: 'user', value: APPROVER }] }), + }, { context: SYSTEM } as any); + const title = await servedTitle(id, SYSTEM); + expect(title).toBe(HUMANIZED); + expectNoIdNoMachineName(title, recordId); + }); +}); 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 6c23f086f45..68eb1b2700a 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -45,22 +45,31 @@ 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. 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}', + // [ADR-0079, ADR-0085] The record title is the flow's label, `process_label`: + // a STORED column, so every door that serves the row serves its title. The + // approvals get (`rowFromRequest`) serves it by the display-label chain — + // the column, else the config snapshot's label, else the humanized machine + // name — so the request page reached through that get always has a title, + // and it is never `flow:NAME`. The pointer used to be `display_title`, a + // formula: a formula has no column, the approvals get served no value for + // it, and the page fell through to `titleFormat`, the machine name and the + // raw subject record id. + // + // The title names no subject record. The request page names the subject on + // the card the `record_id` / `object_name` pair draws in the Details tab, + // which says so when the reader may not read the record or it was deleted; + // a title cannot, so a bare record id is never shown there. + // + // `titleFormat` is the deprecated fallback a renderer reaches only where + // `process_label` is empty: a row written before the column, until the boot + // fill labels it (`display-label-backfill.ts`). It names `{process_name}`, + // the machine source, because a placeholder template cannot say "else" — + // the same fallback `display_title` draws, so every door agrees on such a + // row too. `sys-approval-display-title.test.ts` and + // `sys-approval-request-title.integration.test.ts` pin both. + displayNameField: 'process_label', + nameField: 'process_label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{process_name}', // [ADR-0085] The flow is named by its label, `process_label`, never by the // machine source `process_name` (`flow:NAME`), and the step by its label, // `current_step_label`, never by the node id `current_step`, as the request @@ -150,17 +159,25 @@ export const SysApprovalRequest = ObjectSchema.create({ fields: { 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. 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`. + // [ADR-0079] The title text the data API computes on read: the flow's + // label, `process_label`, else the machine source `process_name` (required) + // where no label is stored — the text the title ladder draws from + // `nameField` and `titleFormat` above, on every row the engine writes (a + // writer stores a label or null, never a blank). A formula has no + // stored column. It names no subject record, as the title does not. + // + // It is no longer the title pointer (the approvals get serves no formula + // value), and it stays declared because stored activity rows name it: the + // activity mirror records the title field each row's summary and record + // label were composed from (`text_sources`, plugin-audit), and the read + // side serves those texts only while every field they name is one the + // reader is served. Retiring the field would withhold the text of every + // activity row written while it was the title. display_title: Field.formula({ label: 'Title', returnType: 'text', - 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)', + expression: F`record.process_label != null ? record.process_label : record.process_name`, + description: 'Record title: the label of the flow that opened this request, else its source (computed on read)', group: 'Target', }), diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index f01371aa782..60f5ae0efdc 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -25,7 +25,7 @@ export const esESObjects: NonNullable = { }, display_title: { label: "Título", - help: "Título del registro: el origen de la solicitud y el registro al que se refiere (calculado al leer)" + help: "Título del registro: la etiqueta del flujo que abrió esta solicitud o, si no hay etiqueta, su origen (calculado al leer)" }, organization_id: { label: "Organización", diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 2f66b8a59b7..65382fdc0e5 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -25,7 +25,7 @@ export const jaJPObjects: NonNullable = { }, display_title: { label: "タイトル", - help: "レコードタイトル:申請のソースと対象レコード(読み取り時に計算)" + help: "レコードタイトル:このリクエストを開いたフローのラベル、ラベルがない場合はソース(読み取り時に計算)" }, organization_id: { label: "組織", diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 718db470207..7203f97cbb5 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -25,7 +25,7 @@ export const zhCNObjects: NonNullable = { }, display_title: { label: "标题", - help: "记录标题:请求来源及其所涉记录(读取时计算)" + help: "记录标题:打开此请求的流程标签,未记录标签时为请求来源(读取时计算)" }, organization_id: { label: "组织", From 7b36bb83a20576c36da4200aeffc35ac83d07b21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 00:32:31 +0000 Subject: [PATCH 2/3] fix(plugin-approvals): regenerate the en bundle for display_title's new help text Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../plugin-approvals/src/translations/en.objects.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 226ff32f7a5..5e30c5a8f42 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -25,7 +25,7 @@ export const enObjects: NonNullable = { }, display_title: { label: "Title", - help: "Record title: the request source and the record it is about (computed on read)" + help: "Record title: the label of the flow that opened this request, else its source (computed on read)" }, organization_id: { label: "Organization", From 1b47ec0748a12fbf8a8ad312e80f3178aef3f35c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 00:37:00 +0000 Subject: [PATCH 3/3] test(plugin-approvals): type the served-title helper over the row contract Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/sys-approval-request-title.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts index 1d04ab89d70..a5ad0f6d2c4 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts @@ -81,7 +81,7 @@ async function packageObjects(): Promise { /** The title the declaration names on a served row: the value under `nameField`. */ const NAME_FIELD = SysApprovalRequest.nameField as string; -const titleOf = (row: Record | null | undefined): unknown => row?.[NAME_FIELD]; +const titleOf = (row: object | null | undefined): unknown => (row as Record | null | undefined)?.[NAME_FIELD]; describe('#24665 — the approval request title is the flow label the approvals get serves', () => { let engine: ObjectQL;