From 72fa60c239126400da8d33b0cd06a2917e7062b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 17:10:24 +0000 Subject: [PATCH] fix(objects): child names render option labels in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version, review, deviation and signature names were stored English strings composed by mirror.hook.ts, so a zh-CN reader saw "v2 · Clean", "Legal · Legal Counsel 1" and "Wet ink · Completed". @objectstack 18.3 ships label(record.) (objectstack#26608), which a formula field evaluates in the request's locale. Each of the four display_name fields becomes a text formula, and the four stamping hooks are retired. The obligation and payment-plan mirrors hold no option label and stay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017nzWFRUo2U7evMkPdVniAn --- src/objects/contract-version.object.ts | 15 +- src/objects/deviation.object.ts | 16 +- src/objects/mirror.hook.ts | 159 +++----------------- src/objects/review.object.ts | 16 +- src/objects/signature.object.ts | 15 +- src/translations/en/objects.lifecycle.ts | 6 +- src/translations/zh-CN/objects.lifecycle.ts | 6 +- 7 files changed, 59 insertions(+), 174 deletions(-) diff --git a/src/objects/contract-version.object.ts b/src/objects/contract-version.object.ts index 18b9fe6..efb2f0b 100644 --- a/src/objects/contract-version.object.ts +++ b/src/objects/contract-version.object.ts @@ -1,3 +1,4 @@ +import { F } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -10,9 +11,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Two versions gate the contract's state machine: `approved → signing` needs * a current `clean` version, and `signing → active` needs a `final_signed` * one (`contract.hook.ts`). At most one version of a contract is current - * (`contract-version.hook.ts`). `display_name` is a stored mirror ("v · ") - * stamped by `mirror.hook.ts` — a stored title, never a formula, so the record - * is searchable and pickable. + * (`contract-version.hook.ts`). `display_name` is a formula ("v · ") + * whose kind is `label(record.kind)`: the option label in the READER's locale + * (`v2 · 清稿` to a zh-CN reader, `v2 · Clean` to an en-US one), on every + * surface the server serves the name to (issue #157, objectstack#26608). */ export const ContractVersion = ObjectSchema.create({ name: 'clm_contract_version', @@ -33,12 +35,11 @@ export const ContractVersion = ObjectSchema.create({ ], fields: { - display_name: Field.text({ + display_name: Field.formula({ label: 'Version', group: 'version', - readonly: true, - searchable: true, - maxLength: 80, + returnType: 'text', + expression: F`'v' + string(record.version_no) + ' · ' + label(record.kind)`, description: 'Filled in automatically from the version number and kind, e.g. "v2 · Clean".', }), contract: Field.masterDetail('clm_contract', { diff --git a/src/objects/deviation.object.ts b/src/objects/deviation.object.ts index 1224132..7368268 100644 --- a/src/objects/deviation.object.ts +++ b/src/objects/deviation.object.ts @@ -1,3 +1,4 @@ +import { F } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -10,8 +11,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * `open` deviation, and an accepted deviation from a clause that * `requires_legal_head` pulls the head of legal into the ladder (F6). * `status` is a one-way state machine (`open` → `accepted` / `rejected` / - * `withdrawn`, enforced in `contract.hook.ts`). `display_name` is a stored - * mirror (" · ") stamped by `mirror.hook.ts`. + * `withdrawn`, enforced in `contract.hook.ts`). `display_name` is a formula + * (" · "): the clause title read through the one reference + * hop, and `label(record.status)`, the option label in the READER's locale + * (issue #157, objectstack#26608). */ export const Deviation = ObjectSchema.create({ name: 'clm_deviation', @@ -30,13 +33,12 @@ export const Deviation = ObjectSchema.create({ ], fields: { - display_name: Field.text({ + display_name: Field.formula({ label: 'Deviation', group: 'deviation', - readonly: true, - searchable: true, - maxLength: 200, - description: 'Filled in automatically as "clause title · status".', + returnType: 'text', + expression: F`record.clause.title + ' · ' + label(record.status)`, + description: 'Composed as "clause title · status", in the reader\'s language.', }), contract: Field.masterDetail('clm_contract', { label: 'Contract', diff --git a/src/objects/mirror.hook.ts b/src/objects/mirror.hook.ts index c6a2da5..91bb4f0 100644 --- a/src/objects/mirror.hook.ts +++ b/src/objects/mirror.hook.ts @@ -1,151 +1,34 @@ import type { Hook, HookContext } from '@objectstack/spec/data'; -import type { HookApi } from './_hook-api.js'; /** - * Stored `display_name` mirrors for the six contract children (DESIGN.md - * §03). Each child's title is a STORED text field — never a formula, because - * a formula is not searchable and cannot be a `nameField` — so something has - * to write it. These hooks do, on insert and whenever one of the inputs the - * title is built from changes: + * Stored `display_name` mirrors for the two contract children whose title is + * not composed of option labels (DESIGN.md §03). Each title is a STORED text + * field, so something has to write it. These hooks do, on insert and whenever + * one of the inputs the title is built from changes: * - * clm_contract_version "v · " - * clm_review " · " - * clm_deviation " · " - * clm_signature " · " * clm_obligation "" * clm_payment_plan "#<seq> · <planned_date>" * - * The English option labels are repeated inline (a lowered hook body has no - * module scope, and cannot read the object's own option list). A stored - * title is untranslatable by nature; English is the source language - * (DESIGN.md §01) — which is why the payment mirror is `#<seq>` and not a - * localized instalment word: a value stamped once cannot be re-rendered for - * the next reader, so the localized form belongs to the zh-CN bundle - * (card 11), never to the stored column. + * The other four children name themselves with a formula `display_name` + * instead: a name composed of select options reads `label(record.<field>)`, + * which renders the option label in the READER's locale, where a stored string + * has the one language it was written in (issue #157, objectstack#26608): + * + * clm_contract_version "v<version_no> · label(kind)" contract-version.object.ts + * clm_review "label(stage) · <reviewer name>" review.object.ts + * clm_deviation "<clause title> · label(status)" deviation.object.ts + * clm_signature "label(method) · label(status)" signature.object.ts + * + * The two kept here hold no option label. An obligation's title is the text + * its author typed. The payment mirror is `#<seq>` and not a localized + * instalment word: a value stamped once cannot be re-rendered for the next + * reader, so the localized form belongs to the zh-CN bundle (card 11), never + * to the stored column. * * `display_name` is `readonly`: the engine keeps a key a before-hook assigned, * so the stamp survives the read-only strip while a hand-typed value does not. */ -const contractVersionDisplayName: Hook = { - name: 'contract_version_display_name', - object: 'clm_contract_version', - events: ['beforeInsert', 'beforeUpdate'], - priority: 300, - description: 'Stamp display_name as "v<version_no> · <kind>".', - handler: async (ctx: HookContext) => { - const { event, input } = ctx; - const previous = ctx.previous ?? {}; - const stale = event === 'beforeInsert' || input.version_no !== undefined || input.kind !== undefined || !previous.display_name; - if (!stale) return; - const KIND: Record<string, string> = { - draft: 'Draft', - internal_redline: 'Internal redline', - counterparty_redline: 'Counterparty redline', - clean: 'Clean', - final_signed: 'Final signed', - }; - const versionNo = input.version_no !== undefined ? input.version_no : previous.version_no; - const kind = input.kind !== undefined ? input.kind : previous.kind; - const kindLabel = typeof kind === 'string' ? (KIND[kind] ?? kind) : ''; - input.display_name = `v${versionNo ?? '?'} · ${kindLabel}`.trim(); - }, -}; - -const reviewDisplayName: Hook = { - name: 'review_display_name', - object: 'clm_review', - events: ['beforeInsert', 'beforeUpdate'], - priority: 300, - description: 'Stamp display_name as "<stage> · <reviewer name>".', - handler: async (ctx: HookContext) => { - const { event, input } = ctx; - const previous = ctx.previous ?? {}; - const stale = event === 'beforeInsert' || input.stage !== undefined || input.reviewer !== undefined || !previous.display_name; - if (!stale) return; - const STAGE: Record<string, string> = { - legal: 'Legal', - finance: 'Finance', - compliance: 'Compliance', - business: 'Business', - }; - const stage = input.stage !== undefined ? input.stage : previous.stage; - const reviewer = input.reviewer !== undefined ? input.reviewer : previous.reviewer; - const stageLabel = typeof stage === 'string' ? (STAGE[stage] ?? stage) : ''; - let reviewerLabel = typeof reviewer === 'string' && reviewer ? reviewer : ''; - const api = ctx.api as HookApi | undefined; - if (reviewerLabel && api) { - // The title is cosmetic; a user row the caller cannot read must not - // block the review write. The id stands in for the name in that case. - try { - const user = await api.object('sys_user').findOne({ where: { id: reviewer }, fields: ['id', 'name', 'email'] }); - const name = typeof user?.name === 'string' && user.name ? user.name : (typeof user?.email === 'string' ? user.email : ''); - if (name) reviewerLabel = name; - } catch { - // keep the id - } - } - input.display_name = `${stageLabel} · ${reviewerLabel || 'Unassigned'}`; - }, -}; - -const deviationDisplayName: Hook = { - name: 'deviation_display_name', - object: 'clm_deviation', - events: ['beforeInsert', 'beforeUpdate'], - priority: 300, - description: 'Stamp display_name as "<clause title> · <status>".', - handler: async (ctx: HookContext) => { - const { event, input } = ctx; - const previous = ctx.previous ?? {}; - const stale = event === 'beforeInsert' || input.clause !== undefined || input.status !== undefined || !previous.display_name; - if (!stale) return; - const STATUS: Record<string, string> = { - open: 'Open', - accepted: 'Accepted', - rejected: 'Rejected', - withdrawn: 'Withdrawn', - }; - const clause = input.clause !== undefined ? input.clause : previous.clause; - const status = input.status !== undefined ? input.status : previous.status; - const statusLabel = typeof status === 'string' ? (STATUS[status] ?? status) : STATUS.open; - let clauseLabel = typeof clause === 'string' && clause ? clause : ''; - const api = ctx.api as HookApi | undefined; - if (clauseLabel && api) { - const row = await api.object('clm_clause').findOne({ where: { id: clause }, fields: ['id', 'title'] }); - if (typeof row?.title === 'string' && row.title) clauseLabel = row.title; - } - input.display_name = `${clauseLabel || 'Clause'} · ${statusLabel}`; - }, -}; - -const signatureDisplayName: Hook = { - name: 'signature_display_name', - object: 'clm_signature', - events: ['beforeInsert', 'beforeUpdate'], - priority: 300, - description: 'Stamp display_name as "<method> · <status>".', - handler: async (ctx: HookContext) => { - const { event, input } = ctx; - const previous = ctx.previous ?? {}; - const stale = event === 'beforeInsert' || input.method !== undefined || input.status !== undefined || !previous.display_name; - if (!stale) return; - const METHOD: Record<string, string> = { esign: 'E-signature', wet_ink: 'Wet ink' }; - const STATUS: Record<string, string> = { - draft: 'Draft', - sent: 'Sent', - completed: 'Completed', - declined: 'Declined', - voided: 'Voided', - }; - const method = input.method !== undefined ? input.method : previous.method; - const status = input.status !== undefined ? input.status : previous.status; - const methodLabel = typeof method === 'string' ? (METHOD[method] ?? method) : METHOD.esign; - const statusLabel = typeof status === 'string' ? (STATUS[status] ?? status) : STATUS.draft; - input.display_name = `${methodLabel} · ${statusLabel}`; - }, -}; - const obligationDisplayName: Hook = { name: 'obligation_display_name', object: 'clm_obligation', @@ -191,10 +74,6 @@ const paymentPlanDisplayName: Hook = { }; export default [ - contractVersionDisplayName, - reviewDisplayName, - deviationDisplayName, - signatureDisplayName, obligationDisplayName, paymentPlanDisplayName, ]; diff --git a/src/objects/review.object.ts b/src/objects/review.object.ts index 794abd7..9f6f202 100644 --- a/src/objects/review.object.ts +++ b/src/objects/review.object.ts @@ -1,3 +1,4 @@ +import { F } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -8,8 +9,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * * `comments` is what the requester sees; `internal_note` is legal's own * working note and is withheld by field-level security (DESIGN.md §04, - * card 04). `display_name` is a stored mirror ("<stage> · <reviewer>") - * stamped by `mirror.hook.ts`. + * card 04). `display_name` is a formula ("<stage> · <reviewer>"): the stage is + * `label(record.stage)`, the option label in the READER's locale, and the + * reviewer is the user's name read through the one reference hop (issue #157, + * objectstack#26608). */ export const Review = ObjectSchema.create({ name: 'clm_review', @@ -28,13 +31,12 @@ export const Review = ObjectSchema.create({ ], fields: { - display_name: Field.text({ + display_name: Field.formula({ label: 'Review', group: 'review', - readonly: true, - searchable: true, - maxLength: 160, - description: 'Filled in automatically as "stage · reviewer".', + returnType: 'text', + expression: F`label(record.stage) + ' · ' + record.reviewer.name`, + description: 'Composed as "stage · reviewer", in the reader\'s language.', }), contract: Field.masterDetail('clm_contract', { label: 'Contract', diff --git a/src/objects/signature.object.ts b/src/objects/signature.object.ts index 1c00659..72f613b 100644 --- a/src/objects/signature.object.ts +++ b/src/objects/signature.object.ts @@ -1,3 +1,4 @@ +import { F } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; /** @@ -14,8 +15,9 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * machine (`draft` → `sent` / `completed` / `voided`, `sent` → `completed` / * `declined` / `voided`, `declined` → `draft`; wet ink may complete straight * from draft) enforced in `contract.hook.ts`; which status a round may be - * CREATED in is the `signature_initial_status` rule below. `display_name` is a stored - * mirror ("<method> · <status>") stamped by `mirror.hook.ts`. + * CREATED in is the `signature_initial_status` rule below. `display_name` is a + * formula ("<method> · <status>") of two `label()` calls: the option labels in + * the READER's locale (issue #157, objectstack#26608). */ export const Signature = ObjectSchema.create({ name: 'clm_signature', @@ -34,13 +36,12 @@ export const Signature = ObjectSchema.create({ ], fields: { - display_name: Field.text({ + display_name: Field.formula({ label: 'Signature', group: 'round', - readonly: true, - searchable: true, - maxLength: 80, - description: 'Filled in automatically as "method · status".', + returnType: 'text', + expression: F`label(record.method) + ' · ' + label(record.status)`, + description: 'Composed as "method · status", in the reader\'s language.', }), contract: Field.masterDetail('clm_contract', { label: 'Contract', diff --git a/src/translations/en/objects.lifecycle.ts b/src/translations/en/objects.lifecycle.ts index a55944e..ad5476c 100644 --- a/src/translations/en/objects.lifecycle.ts +++ b/src/translations/en/objects.lifecycle.ts @@ -17,7 +17,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: 'Review', - help: 'Filled in automatically as "stage · reviewer".', + help: 'Composed as "stage · reviewer", in the reader\'s language.', }, contract: { label: 'Contract', @@ -85,7 +85,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: 'Deviation', - help: 'Filled in automatically as "clause title · status".', + help: 'Composed as "clause title · status", in the reader\'s language.', }, contract: { label: 'Contract', @@ -147,7 +147,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: 'Signature', - help: 'Filled in automatically as "method · status".', + help: 'Composed as "method · status", in the reader\'s language.', }, contract: { label: 'Contract', diff --git a/src/translations/zh-CN/objects.lifecycle.ts b/src/translations/zh-CN/objects.lifecycle.ts index eb2992f..4265f92 100644 --- a/src/translations/zh-CN/objects.lifecycle.ts +++ b/src/translations/zh-CN/objects.lifecycle.ts @@ -17,7 +17,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: '审查', - help: '自动维护的展示名,形如「环节 · 审查人」。', + help: '由环节和审查人组成,按读者的语言显示,形如「法务 · 审查人姓名」。', }, contract: { label: '合同', @@ -85,7 +85,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: '偏离', - help: '自动维护的展示名,形如「条款标题 · 状态」。', + help: '由条款标题和状态组成,按读者的语言显示,形如「保密义务 · 未决」。', }, contract: { label: '合同', @@ -147,7 +147,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = { fields: { display_name: { label: '签署记录', - help: '自动维护的展示名,形如「签署方式 · 状态」。', + help: '由签署方式和状态组成,按读者的语言显示,形如「湿签 · 已完成」。', }, contract: { label: '合同',