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
15 changes: 8 additions & 7 deletions src/objects/contract-version.object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { F } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
Expand All @@ -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<n> · <kind>")
* 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<n> · <kind>")
* 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',
Expand All @@ -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', {
Expand Down
16 changes: 9 additions & 7 deletions src/objects/deviation.object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { F } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
Expand All @@ -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 ("<clause> · <status>") stamped by `mirror.hook.ts`.
* `withdrawn`, enforced in `contract.hook.ts`). `display_name` is a formula
* ("<clause> · <status>"): 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',
Expand All @@ -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',
Expand Down
159 changes: 19 additions & 140 deletions src/objects/mirror.hook.ts
Original file line number Diff line number Diff line change
@@ -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<version_no> · <kind>"
* clm_review "<stage> · <reviewer>"
* clm_deviation "<clause title> · <status>"
* clm_signature "<method> · <status>"
* clm_obligation "<title>"
* 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',
Expand Down Expand Up @@ -191,10 +74,6 @@ const paymentPlanDisplayName: Hook = {
};

export default [
contractVersionDisplayName,
reviewDisplayName,
deviationDisplayName,
signatureDisplayName,
obligationDisplayName,
paymentPlanDisplayName,
];
16 changes: 9 additions & 7 deletions src/objects/review.object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { F } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
Expand All @@ -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',
Expand All @@ -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',
Expand Down
15 changes: 8 additions & 7 deletions src/objects/signature.object.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { F } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

/**
Expand All @@ -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',
Expand All @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions src/translations/en/objects.lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions src/translations/zh-CN/objects.lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = {
fields: {
display_name: {
label: '审查',
help: '自动维护的展示名,形如「环节 · 审查人」。',
help: '由环节和审查人组成,按读者的语言显示,形如「法务 · 审查人姓名」。',
},
contract: {
label: '合同',
Expand Down Expand Up @@ -85,7 +85,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = {
fields: {
display_name: {
label: '偏离',
help: '自动维护的展示名,形如「条款标题 · 状态」。',
help: '由条款标题和状态组成,按读者的语言显示,形如「保密义务 · 未决」。',
},
contract: {
label: '合同',
Expand Down Expand Up @@ -147,7 +147,7 @@ export const lifecycle: Record<string, ObjectTranslationData> = {
fields: {
display_name: {
label: '签署记录',
help: '自动维护的展示名,形如「签署方式 · 状态」。',
help: '由签署方式和状态组成,按读者的语言显示,形如「湿签 · 已完成」。',
},
contract: {
label: '合同',
Expand Down
Loading