diff --git a/.changeset/27430-formula-currency-related-list.md b/.changeset/27430-formula-currency-related-list.md new file mode 100644 index 00000000000..f9e47f23fea --- /dev/null +++ b/.changeset/27430-formula-currency-related-list.md @@ -0,0 +1,10 @@ +--- +'@object-ui/plugin-detail': patch +--- + +fix(plugin-detail): a formula declaring `returnType: 'currency'` reads as money in related lists and the quick-look panel + +Clause-②: no +Metadata-protocol: none + +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. diff --git a/ui/packages/plugin-detail/src/RecordDetailPanel.tsx b/ui/packages/plugin-detail/src/RecordDetailPanel.tsx index 29fc97f27c8..a0aa3aec007 100644 --- a/ui/packages/plugin-detail/src/RecordDetailPanel.tsx +++ b/ui/packages/plugin-detail/src/RecordDetailPanel.tsx @@ -188,6 +188,11 @@ export function buildRecordDetailFields( // refuses a field-level `currency`, so without this a fixed-EUR amount // read in the tenant's currency here (objectui#12242). currencyConfig: def.currencyConfig, + // A formula's declared result type, which picks its face in the formula + // cell: `currency` draws through the currency cell with the + // `currencyConfig` above. Without it a money formula read as a bare + // number here (objectstack#27430). + returnType: def.returnType, precision: def.precision, scale: (def as any).scale, // Beside `scale`, whose scale-0 grouping heuristic it overrides in the diff --git a/ui/packages/plugin-detail/src/RelatedList.tsx b/ui/packages/plugin-detail/src/RelatedList.tsx index 716888d064f..8504bb7aa4a 100644 --- a/ui/packages/plugin-detail/src/RelatedList.tsx +++ b/ui/packages/plugin-detail/src/RelatedList.tsx @@ -1941,6 +1941,11 @@ export const RelatedList: React.FC = ({ // refuses a field-level `currency`, so without this a fixed-EUR amount // read in the tenant's currency in this list (objectui#12242). ...(def.currencyConfig && { currencyConfig: def.currencyConfig }), + // A formula's declared result type, which picks its face in the formula + // cell: `currency` draws through the currency cell with the + // `currencyConfig` above. Without it a money formula read as a bare + // number in this list (objectstack#27430). + ...(def.returnType && { returnType: def.returnType }), ...(def.precision !== undefined && { precision: def.precision }), ...((def as any).scale !== undefined && { scale: (def as any).scale }), // Beside `scale`, whose scale-0 grouping heuristic it overrides in the diff --git a/ui/packages/plugin-detail/src/__tests__/formulaReturnTypeRelay-27430.test.tsx b/ui/packages/plugin-detail/src/__tests__/formulaReturnTypeRelay-27430.test.tsx new file mode 100644 index 00000000000..2d4baf79adf --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/formulaReturnTypeRelay-27430.test.tsx @@ -0,0 +1,112 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27430 row 1: a formula's declared `returnType` reaches the two + * remaining places this package hand-copies field metadata for its cells + * (the record page's key list and the grid were objectstack#27494): + * + * - `RelatedList`'s `makeCell` bag; + * - `buildRecordDetailFields` (`RecordDetailPanel`), the quick-look overlay. + * + * The formula cell picks its face from `returnType`, and a `currency` one + * draws through the currency cell with the formula's own `currencyConfig`. + * Both copies carried `currencyConfig` and `scale` but not `returnType`, so + * a money formula read as a bare `90,000.00`. + * + * ── Directions on the base tree (predicted before the first run) ───────── + * currency formula, related list and panel RED (`90,000.00`) + * number formula control GREEN (the number face is right) + * + * The viewport is pinned to desktop: `RelatedList` swaps its table for a + * gallery under `useIsMobile()`, and the gallery draws no table cells. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { FieldSchema } from '@objectstack/spec/data'; + +import '@object-ui/components'; +import { RecordDetailPanel, buildRecordDetailFields } from '../RecordDetailPanel'; +import { RelatedList } from '../RelatedList'; + +// `RecordDetailPanel` mounts `DetailView`, whose `useRecordEditable` probes +// `POST /api/v1/security/explain`; one double at module scope, never torn down. +vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => ({ record: { visible: true } }) })), +); + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); +afterEach(cleanup); + +const USD_FIXED = { currencyMode: 'fixed', defaultCurrency: 'USD' } as const; + +const FIELDS = { + budget: { type: 'currency', label: 'Budget', currencyConfig: USD_FIXED }, + // The showcase's Budget Remaining, as `project.object.ts` declares it. + remaining: { type: 'formula', label: 'Budget Remaining', returnType: 'currency', currencyConfig: USD_FIXED, scale: 2 }, + // The control: a declared number keeps the bare grouped number. + ratio: { type: 'formula', label: 'Burn Ratio', returnType: 'number', scale: 2 }, +}; + +const RECORD = { id: 'p1', budget: 150000, remaining: 90000, ratio: 1234.5 }; + +function expectMoneyFormula(text: string) { + expect(text).toContain('$150,000.00'); + expect(text, 'the currency formula draws as money').toContain('$90,000.00'); + expect(text, 'CONTROL: a number formula keeps the bare grouped number').toContain('1,234.50'); + expect(text).not.toContain('$1,234.50'); +} + +describe('a formula returnType reaches the related list and the quick-look panel (objectstack#27430)', () => { + it('CONTROL: the fixture is a spec-valid formula field def', () => { + expect(FieldSchema.safeParse({ name: 'remaining', expression: 'record.budget', ...FIELDS.remaining }).success).toBe(true); + }); + + it('buildRecordDetailFields carries returnType from the object field', () => { + const built = buildRecordDetailFields(RECORD, { fields: FIELDS }, new Set(['id'])); + expect(built.find((f) => f.name === 'remaining')?.returnType).toBe('currency'); + expect(built.find((f) => f.name === 'ratio')?.returnType).toBe('number'); + }); + + it('RecordDetailPanel: the currency formula reads as money; a number formula stays bare', async () => { + const { container } = render( + , + ); + await waitFor(() => expect(container.textContent).toContain('1,234.5')); + expectMoneyFormula(container.textContent ?? ''); + }); + + it('RelatedList: the currency formula cell reads as money; a number formula cell stays bare', async () => { + const dataSource = { + getObjectSchema: vi.fn(async () => ({ name: 'project', fields: { name: { type: 'text', label: 'Name' }, ...FIELDS } })), + find: vi.fn(async () => ({ data: [{ ...RECORD, name: 'Tower' }], total: 1 })), + }; + const { container } = render( + , + ); + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()); + await waitFor(() => expect(container.textContent).toContain('Tower')); + const cells = Array.from(container.querySelectorAll('tbody td')).map((td) => (td.textContent ?? '').trim()); + expect(cells, `cells: ${JSON.stringify(cells)}`).toContain('$90,000.00'); + expect(cells.some((c) => c.includes('1,234.50') && !c.includes('$'))).toBe(true); + }); +});