Skip to content

Commit 6e68d8f

Browse files
fix(plugin-detail): record page body tables total only declared-additive columns, not ordinals (#29966)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB4xjiquevJqfBXiUoqfQwa1)_ Fixes #29943 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:记录页正文子表(全宽明细表)底部的「本页合计」不再把所有数字列相加:金额(`currency`)列在本页各行同一币种时合计并按该币种显示;普通数字列只有在父对象声明了对该列 `sum` 的汇总字段时才合计;其余列不合计,没有可合计的列时整行不画。 - **为什么改**:hotclm 合同页把版本号、期次也加了起来(「本页合计 版本号 10」「期次 15」),EUR 合同的金额还按无币种裸数相加;字段类型本身分不清「序号」和「数量」,只能读作者已经声明过的东西。 - **风险与回滚**:已发布页面的可见行为变化:以前被合计的普通数字列(数量等)若父对象没有 sum 汇总,合计会消失;币种混合或未知的金额列也不再合计。只改 `BodyTableTotals` 一处,不发新请求、不加 spec 键;回滚即还原本 PR。 - **先看**:`ui/packages/plugin-detail/src/RelatedList.tsx` 的 `BodyTableTotals` 与新增的 `rolledUpSumFields` / `agreedRowCurrency`;`ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx` 新增的 objectstack#29943 用例组。 - **两分钟验证**:在仓根运行 `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx`,预期 12 个测试全部通过(序号列无合计、金额列按 € 合计、父对象 sum 汇总的数字列才合计、混合币种无合计)。 - **需要你做的**:无,席位落地。 ## What changes `BodyTableTotals` (the totals line under a body table of the default record page, objectstack#27055 Target 8) totalled every column typed `currency | number | integer | decimal`. Target 8 promised "a totals row for numeric columns", and the type alone cannot tell an ordinal from a quantity: `clm_contract_version.version_no` and `clm_payment_plan.seq` are `Field.number` with `scale: 0`. The line now totals only what something already declared additive: | Column | Before | After | |---|---|---| | `currency`, every counted row in one known currency | summed, code from `currencyConfig.defaultCurrency ?? currency` whatever the mode | summed, formatted in that currency through `formatCurrency` (the cell's formatter) | | `currency`, rows in different currencies, or one row's currency unknown | summed (bare, or in the field's `defaultCurrency`) | no total | | `number` / `integer` / `decimal`, the parent object rolls it up with `sum` | summed | summed (unchanged) | | `number` / `integer` / `decimal`, no such roll-up (ordinals, and amounts stored as `number`) | summed | no total | | anything else | no total | no total | When no column qualifies the line is not drawn, as before. **Which currency a row is in.** Each counted row resolves through `@object-ui/i18n`'s `resolveFieldCurrency(def, tenantCurrency, row)`, the reader the currency cell and the grid footer (`useColumnSummary`) already share: the field's code under `currencyMode: 'fixed'`, the tenant default (`useLocalization().currency`) under `dynamic`, the row's sibling code field (`currencyConfig.currencyField`) under `record`. One consequence for a fixture that carried `currencyConfig: { defaultCurrency: 'USD' }` with no mode: the spec parses that to `dynamic` and does not read `defaultCurrency` there, so the total now shows the tenant default, the same currency the column's cells display. The old reader ignored the mode and could print a total in a currency the cells above it did not show. The existing #27055 fixture now declares `currencyMode: 'fixed'`. **Which number columns a roll-up declares additive (the reader used).** `rolledUpSumFields` reads the record page's own object schema from `useRecordContext().objectSchema`, which `RecordDetailView` provides (`objectSchema={objectDef}`) around `RecordPageColumns`, the only place a body table is drawn. A parent field counts when it is `type: 'summary'` with `summaryOperations.function === 'sum'`, `summaryOperations.object` naming this list's object (`api || objectName`), and `summaryOperations.field` naming the column. Both field-container shapes the metadata API serves are read. `relationshipField` is not compared, because which foreign key a sum runs through does not change whether the child column adds up. No fetch was added: the parent schema was already in context (the PM's mechanism assumption holds). ## Tests New top-level `describe` in the existing body-table harness, `RelatedList.bodyTable-27055.test.tsx` (hotclm's payment-plan fixture): - ordinal columns (`seq` `number` with `scale: 0`, `version_no` `integer`) with no roll-up: no totals line at all; - CONTROL: a `fixed` EUR `currency` column beside the ordinals totals `€60.25` and `seq` stays untotalled; - a `number` column the parent rolls up with `sum`: totalled `383,000.5` (map-shaped and array-shaped parent fields); - CONTROL: the same column with no record context, an empty parent, a `count` roll-up, a `max` roll-up, and a `sum` over another object: not totalled; - a `record`-mode amount whose rows all carry `EUR` totals `€600.00`; mixed `EUR`/`USD` rows: no total; one row with a blank code: no total; - a `dynamic` amount totals in the tenant default (`CNY` via `LocalizationProvider`) and gets no total without one; - the #27055 case updated: `quantity` (a `number` with no roll-up) is no longer totalled, the `fixed` USD `total` reads `$65.50`. **Ablation.** The fix commit restored to the old type set (the gate line `if (!isCurrency && !(ROLLED_UP_FIELD_TYPES.has(def.type) && rolledUp.has(key))) continue;` replaced by `if (!isCurrency && !ROLLED_UP_FIELD_TYPES.has(def.type)) continue;`, i.e. every number column totalled) through `scripts/ablation-replace.mjs`: anchor 1 → 0, blob `4270c0b477d1` → `d31d47e2ca1d`; 5 tests red, including "totals no ordinal column — and draws no line when nothing else adds up" and the #27055 case. Restored: blob == HEAD `4270c0b477d1`, `git diff HEAD` empty. The test imports `../RelatedList` from source, so no build sat between the mutation and the run. ## Acceptance notes - **hotclm's amounts (what the order asked me to report).** Per the card, hotclm's payment-plan `planned_amount` / `actual_amount` are `number` fields whose currency lives on the parent contract's `currency_code`. Under this rule they get no total unless the contract declares a `summary` field that sums them; with such a roll-up they total as plain numbers, with no currency symbol, because a `number` column carries no currency of its own. The way to get a currency-formatted total is the spec's own: declare the amount as `currency` with `currencyMode: 'record'` and a `currencyField` on the child row. The parent's `currency_code` is not read, because ADR-0144 D2 puts a `record`-mode amount's code on the amount's own object. I could not open the hotclm screenshots or metadata from this container (the path in the order is not in this project's shared folder), so this reading comes from the card text. - **A `record`-mode amount whose code column is not drawn.** An authored-column related list projects (`$select`) only the drawn columns plus predicate operands, so a `record`-mode amount whose `currencyField` is not among the columns arrives without its code. The total then reads as unknown and is not drawn (honest, not wrong). The cell above it has the same gap. Adding the code field to the projection is a fetch change outside this card. Reported in the report, not filed. - No new spec key; `Metadata-protocol: none`. - In-flight overlap re-checked at PR time: #29897's PR #29958 touches only `app-shell/.../ResourceEditPage.tsx`; no open PR touches `ui/packages/plugin-detail/**` beside this one. ## Verification All on head `ad05c644` (shared box; lock VERDICT lines quoted). <details><summary>Build, type-check, vitest</summary> - `pnpm --workspace-concurrency=2 --filter "@object-ui/plugin-detail^..." build`: VERDICT command-exit 0 (6m04s) - `pnpm --filter @object-ui/plugin-detail type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`): VERDICT command-exit 0 - `pnpm --filter @object-ui/plugin-detail build`, then `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-detail/`: VERDICT command-exit 0 · Test Files 369 passed | 1 skipped (370) · Tests 3250 passed | 18 skipped (3268) - `pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx`: 12 passed (12) </details> <details><summary>Derived gates (`dispatch-gates --commands`, 35 lines, then `--ran`)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --ran FILE --repo objectstack-ai/objectstack`: exit 0, "Run reconciliation — 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN." </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. `pnpm lint` was not run locally: the repo-wide lint is the CI run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 section is the dispatch's required Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfSkDtGdeCZgsqhyHH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 509c40a commit 6e68d8f

3 files changed

Lines changed: 260 additions & 27 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): a record page's body tables total only the columns declared additive — a version number or an instalment number is no longer summed
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
**Visible behaviour change on a released page.** The 「Totals (this page)」 line under a body table of the default record page (a related list drawn full width in the details column) used to add up every `currency`, `number`, `integer` and `decimal` column. Ordinals were summed with the quantities: a contract's versions v1–v4 read 「版本号 10」, five instalments 「期次 15」. The line now totals:
11+
12+
- a `currency` column, when every counted row is in one known currency, formatted in it. Each row's currency is the one its cell displays (`resolveFieldCurrency`): the field's code under `currencyMode: 'fixed'`, the tenant default under `dynamic`, the row's own code field under `record`. Rows in different currencies, or a row whose currency is unknown (a `record`-mode row with a blank code, a `dynamic` amount with no tenant default), give that column no total rather than a bare or wrong-currency sum. A `currencyConfig` without `currencyMode: 'fixed'` no longer formats in its `defaultCurrency`, which the spec does not read outside `fixed`; it shows the tenant default, as the column's cells do;
13+
- a `number`, `integer` or `decimal` column only when the record's own object declares a `summary` field whose `summaryOperations` sums that child field (`function: 'sum'`, `object` naming the child). The roll-up is the author's statement that the column adds up. Without one, as for an amount stored as `number` with its currency on the parent, the column gets no total;
14+
- nothing else. When no column qualifies the line is not drawn.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
import type { LucideIcon } from 'lucide-react';
5151
import type { DataSource, FieldMetadata } from '@object-ui/types';
5252
import type { ViewFilterElement } from '@objectstack/spec/ui';
53-
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard } from '@object-ui/fields';
53+
import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard, formatCurrency } from '@object-ui/fields';
5454
import {
5555
buildExpandFields,
5656
collectPredicateFieldRefs,
@@ -85,9 +85,9 @@ import {
8585
type ActionResult,
8686
type FilterNode,
8787
} from '@object-ui/core';
88-
import { useSafeFieldLabel } from '@object-ui/react';
88+
import { useSafeFieldLabel, useRecordContext } from '@object-ui/react';
8989
import { usePermissions } from '@object-ui/permissions';
90-
import { useDisplayLocale } from '@object-ui/i18n';
90+
import { useDisplayLocale, useLocalization, resolveFieldCurrency } from '@object-ui/i18n';
9191
import { useDetailTranslation } from './useDetailTranslation';
9292
import { isMaskedDetailFieldType } from './fieldEnrichment';
9393
import {
@@ -691,46 +691,110 @@ const RelatedListLoadFailed: React.FC<{ error: unknown; onRetry: () => void }> =
691691
* seam itself — `@object-ui/core`'s `parent-scope` — so one place answers it.
692692
*/
693693

694-
/** Field types a body table totals (objectstack#27055, Target 8). */
695-
const TOTALLED_FIELD_TYPES = new Set(['currency', 'number', 'integer', 'decimal']);
694+
/**
695+
* The number types a body table totals ONLY when a roll-up declares the column
696+
* additive (objectstack#29943). A `number` / `integer` / `decimal` is as often
697+
* an ordinal (a version number, an instalment number) as a quantity, and the
698+
* type alone cannot tell them apart: objectstack#27055 Target 8 summed both,
699+
* which printed 「版本号 10」 under versions v1–v4.
700+
*/
701+
const ROLLED_UP_FIELD_TYPES = new Set(['number', 'integer', 'decimal']);
702+
703+
/**
704+
* The child fields the record's own object rolls up with `sum`: a `summary`
705+
* field on the parent whose `summaryOperations` sums `field` of this child
706+
* `object` is the author's own declaration that the column adds up. Read off
707+
* the parent schema the record page already holds (`useRecordContext`), in
708+
* either container shape the metadata API serves. The roll-up's
709+
* `relationshipField` is not compared: which foreign key a sum runs through
710+
* does not change whether the child column is additive.
711+
*/
712+
function rolledUpSumFields(parentFields: unknown, childObject: string | undefined): ReadonlySet<string> {
713+
const out = new Set<string>();
714+
if (!childObject || !parentFields || typeof parentFields !== 'object') return out;
715+
const defs: unknown[] = Array.isArray(parentFields) ? parentFields : Object.values(parentFields as Record<string, unknown>);
716+
for (const def of defs) {
717+
const d = def as { type?: unknown; summaryOperations?: { object?: unknown; field?: unknown; function?: unknown } } | null;
718+
const ops = d?.type === 'summary' ? d.summaryOperations : undefined;
719+
if (ops?.function === 'sum' && ops.object === childObject && typeof ops.field === 'string' && ops.field !== '') {
720+
out.add(ops.field);
721+
}
722+
}
723+
return out;
724+
}
725+
726+
/**
727+
* The one currency a currency column's counted rows are in, or `undefined`
728+
* when any row's currency is unknown or two rows disagree: each row resolves
729+
* through `resolveFieldCurrency`, the cell's own reader (a `fixed` code, the
730+
* tenant default for `dynamic`, the row's sibling code field for `record`).
731+
* A sum across currencies, or in a currency nobody named, is not a total.
732+
*/
733+
function agreedRowCurrency(def: any, rows: readonly any[], tenantCurrency: string | undefined): string | undefined {
734+
let agreed: string | undefined;
735+
for (const row of rows) {
736+
const code = resolveFieldCurrency(def, tenantCurrency, row);
737+
if (!code) return undefined;
738+
if (agreed === undefined) agreed = code;
739+
else if (agreed.toUpperCase() !== code.toUpperCase()) return undefined;
740+
}
741+
return agreed;
742+
}
696743

697744
/**
698-
* The totals line of a body table: the sum of each numeric column over the
745+
* The totals line of a body table: the sum of each additive column over the
699746
* rows on show (the page), labelled as the page's — never as the whole
700-
* list's, which a windowed list does not hold. Currency sums keep the field's
701-
* declared currency. Nothing is drawn without a numeric column.
747+
* list's, which a windowed list does not hold. Which columns are additive
748+
* (objectstack#29943):
749+
*
750+
* 1. a `currency` column — amounts add up by type — totalled only when every
751+
* counted row is in one known currency, and formatted in it;
752+
* 2. a `number` / `integer` / `decimal` column only when the record's object
753+
* rolls it up with `sum` (`rolledUpSumFields`);
754+
* 3. nothing else. A line with nothing to total is not drawn.
702755
*/
703-
const BodyTableTotals: React.FC<{ rows: readonly any[]; columns: any[] | undefined; objectSchema: any }> = ({
756+
const BodyTableTotals: React.FC<{ rows: readonly any[]; columns: any[] | undefined; objectSchema: any; childObject: string | undefined }> = ({
704757
rows,
705758
columns,
706759
objectSchema,
760+
childObject,
707761
}) => {
708762
const { t } = useDetailTranslation();
709763
// The totals format in the reader's display locale, never the machine's.
710764
const displayLocale = useDisplayLocale();
765+
// The tenant default currency (ADR-0053) a `dynamic` amount displays in.
766+
const { currency: tenantCurrency } = useLocalization();
767+
// The record page's own object: the parent whose roll-ups name this child.
768+
const parentSchema = useRecordContext()?.objectSchema as { fields?: unknown } | undefined;
769+
const rolledUp = rolledUpSumFields(parentSchema?.fields, childObject);
711770
const fields: Record<string, any> = (objectSchema?.fields as any) ?? {};
712771
const totals: Array<{ key: string; label: string; text: string }> = [];
713772
for (const col of columns ?? []) {
714773
const key = col?.accessorKey || columnIdentity(col);
715774
const def = key ? fields[key] : undefined;
716-
if (!key || !def || !TOTALLED_FIELD_TYPES.has(def.type)) continue;
775+
if (!key || !def) continue;
776+
const isCurrency = def.type === 'currency';
777+
if (!isCurrency && !(ROLLED_UP_FIELD_TYPES.has(def.type) && rolledUp.has(key))) continue;
717778
let sum = 0;
718-
let counted = 0;
779+
const counted: any[] = [];
719780
for (const row of rows) {
720781
const n = typeof row?.[key] === 'number' ? row[key] : Number(row?.[key]);
721782
if (row?.[key] === null || row?.[key] === undefined || row?.[key] === '' || !Number.isFinite(n)) continue;
722783
sum += n;
723-
counted += 1;
784+
counted.push(row);
724785
}
725-
if (counted === 0) continue;
726-
const currency = def.type === 'currency' ? def.currencyConfig?.defaultCurrency ?? def.currency : undefined;
786+
if (counted.length === 0) continue;
727787
let text: string;
728-
try {
729-
text = currency
730-
? new Intl.NumberFormat(displayLocale, { style: 'currency', currency }).format(sum)
731-
: new Intl.NumberFormat(displayLocale, { maximumFractionDigits: 2 }).format(sum);
732-
} catch {
733-
text = String(sum);
788+
if (isCurrency) {
789+
const currency = agreedRowCurrency(def, counted, tenantCurrency);
790+
if (!currency) continue;
791+
text = formatCurrency(sum, currency, displayLocale);
792+
} else {
793+
try {
794+
text = new Intl.NumberFormat(displayLocale, { maximumFractionDigits: 2 }).format(sum);
795+
} catch {
796+
text = String(sum);
797+
}
734798
}
735799
totals.push({ key, label: col.header || col.label || def.label || key, text });
736800
}
@@ -3466,7 +3530,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
34663530
</Button>
34673531
</div>
34683532
)}
3469-
{bodyTable && <BodyTableTotals rows={paginatedData} columns={effectiveColumns} objectSchema={objectSchema} />}
3533+
{bodyTable && <BodyTableTotals rows={paginatedData} columns={effectiveColumns} objectSchema={objectSchema} childObject={api || objectName} />}
34703534
</>
34713535
)}
34723536
{trailingDialogs}
@@ -3705,7 +3769,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
37053769
truncation condition: the full list page is worth opening even when
37063770
every row fits here (sort, filter, export, a shareable URL). An
37073771
empty list has nothing to see in full. */}
3708-
{bodyTable && <BodyTableTotals rows={paginatedData} columns={effectiveColumns} objectSchema={objectSchema} />}
3772+
{bodyTable && <BodyTableTotals rows={paginatedData} columns={effectiveColumns} objectSchema={objectSchema} childObject={api || objectName} />}
37093773

37103774
{onViewAll && !isEmpty && !bodyTable && (
37113775
<div className="mt-3 pt-3 border-t flex justify-center">

0 commit comments

Comments
 (0)