From 1ba2ba518497a09e91b02489cf45818a9acf1c9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 17:41:53 +0000 Subject: [PATCH 1/2] fix(plugin-detail): body-table totals add up only declared-additive columns A record page body table totalled every currency/number/integer/decimal column, so ordinals such as a version number or an instalment number were summed. A currency column now totals only when its counted rows share one known currency (the cell's own resolveFieldCurrency), and a number column only when the record's own object rolls it up with sum. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSkDtGdeCZgsqhyHH --- ui/packages/plugin-detail/src/RelatedList.tsx | 108 +++++++++--- .../RelatedList.bodyTable-27055.test.tsx | 165 +++++++++++++++++- 2 files changed, 246 insertions(+), 27 deletions(-) diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 35dd358fd01..4270c0b477d 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -50,7 +50,7 @@ import { import type { LucideIcon } from 'lucide-react'; import type { DataSource, FieldMetadata } from '@object-ui/types'; import type { ViewFilterElement } from '@objectstack/spec/ui'; -import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, RecordPickerDialog, deriveLookupColumns, MaskedCellRenderer, RecordHoverCard, formatCurrency } from '@object-ui/fields'; import { buildExpandFields, collectPredicateFieldRefs, @@ -85,9 +85,9 @@ import { type ActionResult, type FilterNode, } from '@object-ui/core'; -import { useSafeFieldLabel } from '@object-ui/react'; +import { useSafeFieldLabel, useRecordContext } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; -import { useDisplayLocale } from '@object-ui/i18n'; +import { useDisplayLocale, useLocalization, resolveFieldCurrency } from '@object-ui/i18n'; import { useDetailTranslation } from './useDetailTranslation'; import { isMaskedDetailFieldType } from './fieldEnrichment'; import { @@ -691,46 +691,110 @@ const RelatedListLoadFailed: React.FC<{ error: unknown; onRetry: () => void }> = * seam itself — `@object-ui/core`'s `parent-scope` — so one place answers it. */ -/** Field types a body table totals (objectstack#27055, Target 8). */ -const TOTALLED_FIELD_TYPES = new Set(['currency', 'number', 'integer', 'decimal']); +/** + * The number types a body table totals ONLY when a roll-up declares the column + * additive (objectstack#29943). A `number` / `integer` / `decimal` is as often + * an ordinal (a version number, an instalment number) as a quantity, and the + * type alone cannot tell them apart: objectstack#27055 Target 8 summed both, + * which printed 「版本号 10」 under versions v1–v4. + */ +const ROLLED_UP_FIELD_TYPES = new Set(['number', 'integer', 'decimal']); + +/** + * The child fields the record's own object rolls up with `sum`: a `summary` + * field on the parent whose `summaryOperations` sums `field` of this child + * `object` is the author's own declaration that the column adds up. Read off + * the parent schema the record page already holds (`useRecordContext`), in + * either container shape the metadata API serves. The roll-up's + * `relationshipField` is not compared: which foreign key a sum runs through + * does not change whether the child column is additive. + */ +function rolledUpSumFields(parentFields: unknown, childObject: string | undefined): ReadonlySet { + const out = new Set(); + if (!childObject || !parentFields || typeof parentFields !== 'object') return out; + const defs: unknown[] = Array.isArray(parentFields) ? parentFields : Object.values(parentFields as Record); + for (const def of defs) { + const d = def as { type?: unknown; summaryOperations?: { object?: unknown; field?: unknown; function?: unknown } } | null; + const ops = d?.type === 'summary' ? d.summaryOperations : undefined; + if (ops?.function === 'sum' && ops.object === childObject && typeof ops.field === 'string' && ops.field !== '') { + out.add(ops.field); + } + } + return out; +} + +/** + * The one currency a currency column's counted rows are in, or `undefined` + * when any row's currency is unknown or two rows disagree: each row resolves + * through `resolveFieldCurrency`, the cell's own reader (a `fixed` code, the + * tenant default for `dynamic`, the row's sibling code field for `record`). + * A sum across currencies, or in a currency nobody named, is not a total. + */ +function agreedRowCurrency(def: any, rows: readonly any[], tenantCurrency: string | undefined): string | undefined { + let agreed: string | undefined; + for (const row of rows) { + const code = resolveFieldCurrency(def, tenantCurrency, row); + if (!code) return undefined; + if (agreed === undefined) agreed = code; + else if (agreed.toUpperCase() !== code.toUpperCase()) return undefined; + } + return agreed; +} /** - * The totals line of a body table: the sum of each numeric column over the + * The totals line of a body table: the sum of each additive column over the * rows on show (the page), labelled as the page's — never as the whole - * list's, which a windowed list does not hold. Currency sums keep the field's - * declared currency. Nothing is drawn without a numeric column. + * list's, which a windowed list does not hold. Which columns are additive + * (objectstack#29943): + * + * 1. a `currency` column — amounts add up by type — totalled only when every + * counted row is in one known currency, and formatted in it; + * 2. a `number` / `integer` / `decimal` column only when the record's object + * rolls it up with `sum` (`rolledUpSumFields`); + * 3. nothing else. A line with nothing to total is not drawn. */ -const BodyTableTotals: React.FC<{ rows: readonly any[]; columns: any[] | undefined; objectSchema: any }> = ({ +const BodyTableTotals: React.FC<{ rows: readonly any[]; columns: any[] | undefined; objectSchema: any; childObject: string | undefined }> = ({ rows, columns, objectSchema, + childObject, }) => { const { t } = useDetailTranslation(); // The totals format in the reader's display locale, never the machine's. const displayLocale = useDisplayLocale(); + // The tenant default currency (ADR-0053) a `dynamic` amount displays in. + const { currency: tenantCurrency } = useLocalization(); + // The record page's own object: the parent whose roll-ups name this child. + const parentSchema = useRecordContext()?.objectSchema as { fields?: unknown } | undefined; + const rolledUp = rolledUpSumFields(parentSchema?.fields, childObject); const fields: Record = (objectSchema?.fields as any) ?? {}; const totals: Array<{ key: string; label: string; text: string }> = []; for (const col of columns ?? []) { const key = col?.accessorKey || columnIdentity(col); const def = key ? fields[key] : undefined; - if (!key || !def || !TOTALLED_FIELD_TYPES.has(def.type)) continue; + if (!key || !def) continue; + const isCurrency = def.type === 'currency'; + if (!isCurrency && !(ROLLED_UP_FIELD_TYPES.has(def.type) && rolledUp.has(key))) continue; let sum = 0; - let counted = 0; + const counted: any[] = []; for (const row of rows) { const n = typeof row?.[key] === 'number' ? row[key] : Number(row?.[key]); if (row?.[key] === null || row?.[key] === undefined || row?.[key] === '' || !Number.isFinite(n)) continue; sum += n; - counted += 1; + counted.push(row); } - if (counted === 0) continue; - const currency = def.type === 'currency' ? def.currencyConfig?.defaultCurrency ?? def.currency : undefined; + if (counted.length === 0) continue; let text: string; - try { - text = currency - ? new Intl.NumberFormat(displayLocale, { style: 'currency', currency }).format(sum) - : new Intl.NumberFormat(displayLocale, { maximumFractionDigits: 2 }).format(sum); - } catch { - text = String(sum); + if (isCurrency) { + const currency = agreedRowCurrency(def, counted, tenantCurrency); + if (!currency) continue; + text = formatCurrency(sum, currency, displayLocale); + } else { + try { + text = new Intl.NumberFormat(displayLocale, { maximumFractionDigits: 2 }).format(sum); + } catch { + text = String(sum); + } } totals.push({ key, label: col.header || col.label || def.label || key, text }); } @@ -3466,7 +3530,7 @@ export const RelatedList: React.FC = ({ )} - {bodyTable && } + {bodyTable && } )} {trailingDialogs} @@ -3705,7 +3769,7 @@ export const RelatedList: React.FC = ({ truncation condition: the full list page is worth opening even when every row fits here (sort, filter, export, a shareable URL). An empty list has nothing to see in full. */} - {bodyTable && } + {bodyTable && } {onViewAll && !isEmpty && !bodyTable && (
diff --git a/ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx b/ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx index bd1ab2f213c..da3db0ebac6 100644 --- a/ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx +++ b/ui/packages/plugin-detail/src/__tests__/RelatedList.bodyTable-27055.test.tsx @@ -10,13 +10,19 @@ * objectstack#27055, Target 8 — a related list the default record page draws * as the record's BODY table (its relationship id in * `RecordBodyTablesContext`): the full table face, collapsible from its - * title, ⤢ to enlarge in place, a totals line under its numeric columns, and + * title, ⤢ to enlarge in place, a totals line under its additive columns, and * no 「查看全部」 footer. The same list outside the set keeps its own face. + * + * objectstack#29943 — which columns the totals line adds up: a `currency` + * column in one known currency, and a `number` column only when the record's + * own object rolls it up with `sum`; an ordinal (`version_no`, `seq`) never. */ import { describe, it, expect, vi, beforeAll } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import React from 'react'; +import { RecordContextProvider } from '@object-ui/react'; +import { LocalizationProvider } from '@object-ui/i18n'; import { RelatedList } from '../RelatedList'; import { RecordBodyTablesContext, RelatedEnlargeContext } from '../relatedListCompact'; @@ -27,7 +33,7 @@ beforeAll(() => { const fields = { name: { type: 'text', label: 'Product' }, quantity: { type: 'number', label: 'Quantity' }, - total: { type: 'currency', label: 'Total', currencyConfig: { defaultCurrency: 'USD' } }, + total: { type: 'currency', label: 'Total', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' } }, }; const rows = [ { id: 'l1', name: 'Widget', quantity: 2, total: 20 }, @@ -67,9 +73,10 @@ describe('RelatedList — the body-table face (objectstack#27055)', () => { expect(document.querySelector('thead')).not.toBeNull(); const totals = document.querySelector('[data-body-table-totals]') as HTMLElement; expect(totals).toBeTruthy(); - expect(totals.querySelector('[data-body-table-total="quantity"]')?.textContent).toContain('5'); - expect(totals.querySelector('[data-body-table-total="total"]')?.textContent).toContain('65.50'); - // The text column is not totalled. + expect(totals.querySelector('[data-body-table-total="total"]')?.textContent).toContain('$65.50'); + // The text column is not totalled, nor a number column no roll-up declares + // additive (objectstack#29943). + expect(totals.querySelector('[data-body-table-total="quantity"]')).toBeNull(); expect(totals.querySelector('[data-body-table-total="name"]')).toBeNull(); expect(screen.queryByText(/View all/i)).not.toBeInTheDocument(); }); @@ -92,3 +99,151 @@ describe('RelatedList — the body-table face (objectstack#27055)', () => { expect(screen.getByText(/View all/i)).toBeInTheDocument(); }); }); + +/** + * objectstack#29943 — the totals line adds up only the columns something + * declared additive. The fixtures are hotclm's: a contract's versions + * (`version_no`, an ordinal) and its payment plan (`seq`, an ordinal, beside + * two amounts). + */ +describe('RelatedList — which body-table columns are totalled (objectstack#29943)', () => { + const planFields = { + name: { type: 'text', label: 'Instalment' }, + seq: { type: 'number', label: 'Seq', scale: 0 }, + version_no: { type: 'integer', label: 'Version' }, + planned_amount: { type: 'number', label: 'Planned', scale: 2 }, + fee: { type: 'currency', label: 'Fee', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } }, + paid: { type: 'currency', label: 'Paid', currencyConfig: { currencyMode: 'record', currencyField: 'currency_code' } }, + dynamic_fee: { type: 'currency', label: 'Dynamic fee' }, + currency_code: { type: 'text', label: 'Currency' }, + }; + const planRows = [ + { id: 'p1', name: 'First', seq: 1, version_no: 1, planned_amount: 1000, fee: 10, paid: 100, dynamic_fee: 1, currency_code: 'EUR' }, + { id: 'p2', name: 'Second', seq: 2, version_no: 2, planned_amount: 2000.5, fee: 20.25, paid: 200, dynamic_fee: 2, currency_code: 'EUR' }, + { id: 'p3', name: 'Third', seq: 3, version_no: 3, planned_amount: 380000, fee: 30, paid: 300, dynamic_fee: 3, currency_code: 'EUR' }, + ]; + const contract = (fields: Record) => ({ name: 'clm_contract', fields }); + const rollUp = (fn: string, field: string, object = 'clm_payment_plan') => ({ + type: 'summary', + summaryOperations: { object, field, function: fn }, + }); + + function renderPlan(opts: { + columns: string[]; + rows?: readonly Record[]; + parent?: { name: string; fields: unknown }; + tenantCurrency?: string; + }) { + const rows = opts.rows ?? planRows; + const list = ( + + ({ field, label: (planFields as any)[field].label }))} + data={rows as any[]} + dataSource={{ find: vi.fn(async () => rows), getObjectSchema: vi.fn(async () => ({ name: 'clm_payment_plan', fields: planFields })) } as any} + /> + + ); + const withParent = opts.parent ? ( + + {list} + + ) : ( + list + ); + return render( + opts.tenantCurrency ? ( + {withParent} + ) : ( + withParent + ), + ); + } + + const totalsLine = () => document.querySelector('[data-body-table-totals]') as HTMLElement | null; + const totalOf = (key: string) => totalsLine()?.querySelector(`[data-body-table-total="${key}"]`)?.textContent ?? null; + + it('totals no ordinal column — and draws no line when nothing else adds up', async () => { + renderPlan({ columns: ['name', 'seq', 'version_no'], parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalsLine()).toBeNull(); + }); + + it('CONTROL — a currency column beside the ordinals still totals, in its own currency', async () => { + renderPlan({ columns: ['name', 'seq', 'fee'], parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalOf('seq')).toBeNull(); + expect(totalOf('fee')).toContain('€60.25'); + }); + + it('totals a number column the parent rolls up with sum', async () => { + renderPlan({ + columns: ['name', 'seq', 'planned_amount'], + parent: contract({ planned_total: rollUp('sum', 'planned_amount') }), + }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalOf('planned_amount')).toContain('383,000.5'); + // The roll-up names one column; the ordinal beside it stays untotalled. + expect(totalOf('seq')).toBeNull(); + }); + + it('reads the roll-up in the array-shaped field container too', async () => { + renderPlan({ + columns: ['name', 'planned_amount'], + parent: contract([{ name: 'planned_total', ...rollUp('sum', 'planned_amount') }] as any), + }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalOf('planned_amount')).toContain('383,000.5'); + }); + + it('CONTROL — the same number column without a sum roll-up is not totalled', async () => { + for (const parent of [ + undefined, // no record page in scope at all + contract({}), + contract({ instalments: rollUp('count', 'planned_amount') }), + contract({ planned_max: rollUp('max', 'planned_amount') }), + contract({ other_total: rollUp('sum', 'planned_amount', 'clm_invoice') }), + ]) { + const { unmount } = renderPlan({ columns: ['name', 'planned_amount'], parent }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalsLine()).toBeNull(); + unmount(); + } + }); + + it('a record-mode amount totals in the one currency its rows carry', async () => { + renderPlan({ columns: ['name', 'paid'], parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalOf('paid')).toContain('€600.00'); + }); + + it('mixed-currency rows get no total', async () => { + const rows = planRows.map((row, i) => ({ ...row, currency_code: i === 1 ? 'USD' : 'EUR' })); + renderPlan({ columns: ['name', 'paid'], rows, parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalsLine()).toBeNull(); + }); + + it('a row whose currency is unknown gets no total, not a bare sum', async () => { + const rows = planRows.map((row, i) => ({ ...row, currency_code: i === 2 ? '' : 'EUR' })); + renderPlan({ columns: ['name', 'paid'], rows, parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalsLine()).toBeNull(); + }); + + it('a dynamic amount totals in the tenant default currency, and not at all without one', async () => { + const first = renderPlan({ columns: ['name', 'dynamic_fee'], parent: contract({}), tenantCurrency: 'CNY' }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalOf('dynamic_fee')).toMatch(/CN¥|¥/); + expect(totalOf('dynamic_fee')).toContain('6.00'); + first.unmount(); + renderPlan({ columns: ['name', 'dynamic_fee'], parent: contract({}) }); + await waitFor(() => expect(screen.getByText('First')).toBeInTheDocument()); + expect(totalsLine()).toBeNull(); + }); +}); From ad05c644c36cb0ebda2934afadd5d069648fed89 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 17:48:54 +0000 Subject: [PATCH 2/2] chore(changeset): plugin-detail body-table totals Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfSkDtGdeCZgsqhyHH --- .../29943-body-table-totals-additive-columns.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/29943-body-table-totals-additive-columns.md diff --git a/.changeset/29943-body-table-totals-additive-columns.md b/.changeset/29943-body-table-totals-additive-columns.md new file mode 100644 index 00000000000..661e3874efb --- /dev/null +++ b/.changeset/29943-body-table-totals-additive-columns.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-detail': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +**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: + +- 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; +- 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; +- nothing else. When no column qualifies the line is not drawn.