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
10 changes: 10 additions & 0 deletions .changeset/27430-formula-currency-related-list.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions ui/packages/plugin-detail/src/RecordDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/plugin-detail/src/RelatedList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1941,6 +1941,11 @@ export const RelatedList: React.FC<RelatedListProps> = ({
// 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<RecordDetailPanel record={RECORD} objectName="project" recordId="p1" objectSchema={{ fields: FIELDS }} />,
);
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(
<RelatedList
title="Projects"
type="table"
api="project"
objectName="project"
referenceField="parent"
parentId="P-1"
dataSource={dataSource as any}
/>,
);
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);
});
});
Loading