Skip to content

Commit 359e935

Browse files
fix(plugin-detail): a returnType currency formula reads as money in related lists and the quick-look panel (#27800)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Refs #27430 (row 1's remainder only; rows 2 to 4 stay open) Refs #27494 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:声明了 `returnType: 'currency'` 的公式字段(如「剩余预算」),在相关列表的单元格和快速查看面板里现在显示为货币 `$90,000.00`,与它所依据的货币字段一致;声明为 `number` 或未声明的公式仍显示普通数字。 - **为什么改**:这两处各自从一份手挑的键列表重建字段,漏了 `returnType`,公式单元格已有的货币分支看不到它。列表网格和记录页已在 #27689 修好,这是 #27430 第 1 行剩下的两处。 - **风险与回滚**:只多转发一个已在 spec 中声明的键,不改 spec、不加新的金额格式化器;只影响声明了 `returnType` 的公式字段的显示。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-detail/src/RelatedList.tsx` 中 `makeCell` 字段包里 `currencyConfig` 旁新增的一行;`RecordDetailPanel.tsx` 中 `buildRecordDetailFields` 新增的一行。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/formulaReturnTypeRelay-27430.test.tsx`,预期 4 个用例全部通过。 - **需要你做的**:无,席位落地。 ## What changes Triage ruling (6495354739 on #27494): the formula cell reads `returnType` and uses the currency field's existing currency face. PR #27689 did that for the list grid's configured columns and the record page's `ENRICHED_FIELD_METADATA_KEYS`. Two more places in `@object-ui/plugin-detail` rebuild a field from a hand-picked key list and dropped `returnType`, so `FormulaCellRenderer` / `FormulaField`'s existing `currency` branch never saw it: 1. `RelatedList.tsx`, the `makeCell` field bag: `returnType` is forwarded beside `currencyConfig`, only when the def declares it (the bag's own `...(x && { k })` idiom). 2. `RecordDetailPanel.tsx`, `buildRecordDetailFields` (the quick-look overlay, a published export): `returnType` is copied beside `currencyConfig`. No second money formatter, no spec change, no new key. `buildRecordDetailFields`'s signature and return type (`Array of Record string-to-any`) are unchanged, so `Clause-②: no`; `returnType` is already a declared `FieldSchema` key, so `Metadata-protocol: none`. Changeset: `@object-ui/plugin-detail` patch. ## Tests New `ui/packages/plugin-detail/src/__tests__/formulaReturnTypeRelay-27430.test.tsx`, the same pin shape as #27689 (a formula with `returnType: 'currency'`, `currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'USD' }`, `scale: 2`, value 90000; a `returnType: 'number'` control at 1234.5): - CONTROL: the fixture formula is spec-valid (`FieldSchema.safeParse`). - `buildRecordDetailFields` carries `returnType` for both formulas. - `RecordDetailPanel` draws `$90,000.00` beside `$150,000.00`; the number formula draws `1,234.50` with no currency sign. - `RelatedList` table cells: `$90,000.00`; the number formula cell is `1,234.50` with no currency sign. Ablation (`scripts/ablation-replace.mjs`, each fix line deleted on its own, anchor 1 to 0, restored to the HEAD blob with `git diff HEAD` empty; vitest resolves the package from src): - RelatedList line deleted: 1 failed, 3 passed; the cells read `["Tower","$150,000.00","90,000.00","1,234.50"]`. - RecordDetailPanel line deleted: 2 failed, 2 passed (`expected undefined to be 'currency'`; the panel text lacks `$90,000.00`). ## Acceptance notes - The related list's page totals (`BodyTableTotals`) sum only `TOTALLED_FIELD_TYPES` and take the currency from a `currency` field's def; a formula column gets no total, as before. Not changed here. ## Verification <details><summary>Targeted tests, typecheck and lint</summary> - New file: `Test Files 1 passed (1)`, `Tests 4 passed (4)`. - `pnpm --filter @object-ui/plugin-detail test` (whole package, before the `origin/main` merge): `Test Files 338 passed | 1 skipped (339)`, `Tests 3078 passed | 18 skipped (3096)`, lock VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-detail type-check` exit 0 and `lint` exit 0 (before the merge); `type-check` exit 0 again at merged head `60638520`. - At merged head `60638520` (main moved 8 commits, none under `ui/packages/plugin-detail`, `fields` or the components renderers): the new file plus `currencyConfigRelay-12242` and `formulaReturnTypeRelay-27494`, exit 0. </details> <details><summary>Derived gates (dispatch-gates --commands, 34 families)</summary> Run twice, at `4d4d462b`+changeset and again at merged head `60638520`; the derivation was identical both times. All 34 exit 0; `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Includes `check-sdui-manifest` (and its self-test), `check:nul-bytes`, `check:type-check-debt`, `check:type-check-coverage`, `check:published-files`, `check:test-source-alias`, `check-empty-changeset`, `check-changeset-no-major`, `check-closing-keyword-parity`. Every other family is CI's; the seat reads the PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYEbuRp8dS61F3RKwo)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 198da49 commit 359e935

4 files changed

Lines changed: 132 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): a formula declaring `returnType: 'currency'` reads as money in related lists and the quick-look panel
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A `Field.formula` with `returnType: 'currency'` and its own `currencyConfig` read as a bare grouped number (`90,000.00`) in a related list and in the quick-look panel, beside the currency fields it was computed from (`$150,000.00`). The related list's cell builder and `buildRecordDetailFields` rebuilt the field from a picked key list that left `returnType` out, so the formula cell never saw it. Both now hand `returnType` through beside `currencyConfig`, the way the list grid and the record page already do, and the formula reads `$90,000.00`. A formula declaring `number`, or nothing, keeps the bare number.

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ export function buildRecordDetailFields(
188188
// refuses a field-level `currency`, so without this a fixed-EUR amount
189189
// read in the tenant's currency here (objectui#12242).
190190
currencyConfig: def.currencyConfig,
191+
// A formula's declared result type, which picks its face in the formula
192+
// cell: `currency` draws through the currency cell with the
193+
// `currencyConfig` above. Without it a money formula read as a bare
194+
// number here (objectstack#27430).
195+
returnType: def.returnType,
191196
precision: def.precision,
192197
scale: (def as any).scale,
193198
// Beside `scale`, whose scale-0 grouping heuristic it overrides in the

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,6 +1941,11 @@ export const RelatedList: React.FC<RelatedListProps> = ({
19411941
// refuses a field-level `currency`, so without this a fixed-EUR amount
19421942
// read in the tenant's currency in this list (objectui#12242).
19431943
...(def.currencyConfig && { currencyConfig: def.currencyConfig }),
1944+
// A formula's declared result type, which picks its face in the formula
1945+
// cell: `currency` draws through the currency cell with the
1946+
// `currencyConfig` above. Without it a money formula read as a bare
1947+
// number in this list (objectstack#27430).
1948+
...(def.returnType && { returnType: def.returnType }),
19441949
...(def.precision !== undefined && { precision: def.precision }),
19451950
...((def as any).scale !== undefined && { scale: (def as any).scale }),
19461951
// Beside `scale`, whose scale-0 grouping heuristic it overrides in the
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#27430 row 1: a formula's declared `returnType` reaches the two
11+
* remaining places this package hand-copies field metadata for its cells
12+
* (the record page's key list and the grid were objectstack#27494):
13+
*
14+
* - `RelatedList`'s `makeCell` bag;
15+
* - `buildRecordDetailFields` (`RecordDetailPanel`), the quick-look overlay.
16+
*
17+
* The formula cell picks its face from `returnType`, and a `currency` one
18+
* draws through the currency cell with the formula's own `currencyConfig`.
19+
* Both copies carried `currencyConfig` and `scale` but not `returnType`, so
20+
* a money formula read as a bare `90,000.00`.
21+
*
22+
* ── Directions on the base tree (predicted before the first run) ─────────
23+
* currency formula, related list and panel RED (`90,000.00`)
24+
* number formula control GREEN (the number face is right)
25+
*
26+
* The viewport is pinned to desktop: `RelatedList` swaps its table for a
27+
* gallery under `useIsMobile()`, and the gallery draws no table cells.
28+
*/
29+
30+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
31+
import { render, cleanup, waitFor } from '@testing-library/react';
32+
import '@testing-library/jest-dom';
33+
import * as React from 'react';
34+
import { FieldSchema } from '@objectstack/spec/data';
35+
36+
import '@object-ui/components';
37+
import { RecordDetailPanel, buildRecordDetailFields } from '../RecordDetailPanel';
38+
import { RelatedList } from '../RelatedList';
39+
40+
// `RecordDetailPanel` mounts `DetailView`, whose `useRecordEditable` probes
41+
// `POST /api/v1/security/explain`; one double at module scope, never torn down.
42+
vi.stubGlobal(
43+
'fetch',
44+
vi.fn(async () => ({ ok: true, json: async () => ({ record: { visible: true } }) })),
45+
);
46+
47+
beforeEach(() => {
48+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 });
49+
});
50+
afterEach(cleanup);
51+
52+
const USD_FIXED = { currencyMode: 'fixed', defaultCurrency: 'USD' } as const;
53+
54+
const FIELDS = {
55+
budget: { type: 'currency', label: 'Budget', currencyConfig: USD_FIXED },
56+
// The showcase's Budget Remaining, as `project.object.ts` declares it.
57+
remaining: { type: 'formula', label: 'Budget Remaining', returnType: 'currency', currencyConfig: USD_FIXED, scale: 2 },
58+
// The control: a declared number keeps the bare grouped number.
59+
ratio: { type: 'formula', label: 'Burn Ratio', returnType: 'number', scale: 2 },
60+
};
61+
62+
const RECORD = { id: 'p1', budget: 150000, remaining: 90000, ratio: 1234.5 };
63+
64+
function expectMoneyFormula(text: string) {
65+
expect(text).toContain('$150,000.00');
66+
expect(text, 'the currency formula draws as money').toContain('$90,000.00');
67+
expect(text, 'CONTROL: a number formula keeps the bare grouped number').toContain('1,234.50');
68+
expect(text).not.toContain('$1,234.50');
69+
}
70+
71+
describe('a formula returnType reaches the related list and the quick-look panel (objectstack#27430)', () => {
72+
it('CONTROL: the fixture is a spec-valid formula field def', () => {
73+
expect(FieldSchema.safeParse({ name: 'remaining', expression: 'record.budget', ...FIELDS.remaining }).success).toBe(true);
74+
});
75+
76+
it('buildRecordDetailFields carries returnType from the object field', () => {
77+
const built = buildRecordDetailFields(RECORD, { fields: FIELDS }, new Set(['id']));
78+
expect(built.find((f) => f.name === 'remaining')?.returnType).toBe('currency');
79+
expect(built.find((f) => f.name === 'ratio')?.returnType).toBe('number');
80+
});
81+
82+
it('RecordDetailPanel: the currency formula reads as money; a number formula stays bare', async () => {
83+
const { container } = render(
84+
<RecordDetailPanel record={RECORD} objectName="project" recordId="p1" objectSchema={{ fields: FIELDS }} />,
85+
);
86+
await waitFor(() => expect(container.textContent).toContain('1,234.5'));
87+
expectMoneyFormula(container.textContent ?? '');
88+
});
89+
90+
it('RelatedList: the currency formula cell reads as money; a number formula cell stays bare', async () => {
91+
const dataSource = {
92+
getObjectSchema: vi.fn(async () => ({ name: 'project', fields: { name: { type: 'text', label: 'Name' }, ...FIELDS } })),
93+
find: vi.fn(async () => ({ data: [{ ...RECORD, name: 'Tower' }], total: 1 })),
94+
};
95+
const { container } = render(
96+
<RelatedList
97+
title="Projects"
98+
type="table"
99+
api="project"
100+
objectName="project"
101+
referenceField="parent"
102+
parentId="P-1"
103+
dataSource={dataSource as any}
104+
/>,
105+
);
106+
await waitFor(() => expect(container.querySelector('table')).not.toBeNull());
107+
await waitFor(() => expect(container.textContent).toContain('Tower'));
108+
const cells = Array.from(container.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim());
109+
expect(cells, `cells: ${JSON.stringify(cells)}`).toContain('$90,000.00');
110+
expect(cells.some((c) => c.includes('1,234.50') && !c.includes('$'))).toBe(true);
111+
});
112+
});

0 commit comments

Comments
 (0)