From c3954f9154995efa6982682f5273c24ef3e9c75d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:07:29 +0000 Subject: [PATCH 1/2] fix(fields): draw a text-valued formula as text, not monospace (objectui#12357) The formula cell's text face now renders through TextCellRenderer and the read-only form face takes TextField's read-only classes, for a declared `returnType: 'text'` and an undeclared string result alike. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12357-formula-text-face.md | 7 ++ .../formulaFaces.returnType-11748.test.tsx | 8 +- .../formulaFaces.textFace-12357.test.tsx | 103 ++++++++++++++++++ packages/fields/src/index.tsx | 14 ++- packages/fields/src/widgets/FormulaField.tsx | 13 +-- 5 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 .changeset/12357-formula-text-face.md create mode 100644 packages/fields/src/__tests__/formulaFaces.textFace-12357.test.tsx diff --git a/.changeset/12357-formula-text-face.md b/.changeset/12357-formula-text-face.md new file mode 100644 index 0000000000..ef7d67ee4f --- /dev/null +++ b/.changeset/12357-formula-text-face.md @@ -0,0 +1,7 @@ +--- +'@object-ui/fields': patch +--- + +fix(fields): a text-valued formula is drawn as text, not in a monospace code font (objectui#12357) + +The formula cell now draws a text result through `TextCellRenderer`, and the read-only form face (`FormulaField`) draws it with `TextField`'s read-only classes, whether the formula declares `returnType: 'text'` or declares no `returnType` at all. Both faces used to print every result that was not a number, boolean or date in monospace, so a formula yielding a person's name read as code beside the text columns around it. diff --git a/packages/fields/src/__tests__/formulaFaces.returnType-11748.test.tsx b/packages/fields/src/__tests__/formulaFaces.returnType-11748.test.tsx index 4f02728eae..13f974a844 100644 --- a/packages/fields/src/__tests__/formulaFaces.returnType-11748.test.tsx +++ b/packages/fields/src/__tests__/formulaFaces.returnType-11748.test.tsx @@ -168,7 +168,8 @@ describe('number: both faces draw the number face (objectui#11748)', () => { it.each(FACES)('%s — no returnType: a string of digits stays text (nothing says it is a quantity)', (_face, draw) => { const { container } = renderSession('en-US', draw('200000')); expect(container.textContent).toBe('200000'); - expect(container.querySelector('.font-mono')).not.toBeNull(); + // Text, in the text face's font (objectui#12357), not monospace. + expect(container.querySelector('.font-mono')).toBeNull(); }); }); @@ -291,11 +292,12 @@ describe("date: both faces draw the date field's read-only face, formatDate's de // ── text ─────────────────────────────────────────────────────────────────── -describe('text: the value as the cell prints it, in monospace (objectui#11748)', () => { +describe('text: the value as the cell prints it (objectui#11748)', () => { it.each(FACES)("%s — returnType 'text' over a number stays text: the declaration wins", (_face, draw) => { const { container } = renderSession('en-US', draw(200000, { returnType: 'text' })); expect(container.textContent).toBe('200000'); - expect(container.querySelector('.font-mono')).not.toBeNull(); + // Text, in the text face's font (objectui#12357), not monospace. + expect(container.querySelector('.font-mono')).toBeNull(); }); // The form face read `value == null` and printed `String(value)`, so these diff --git a/packages/fields/src/__tests__/formulaFaces.textFace-12357.test.tsx b/packages/fields/src/__tests__/formulaFaces.textFace-12357.test.tsx new file mode 100644 index 0000000000..11f3fc76ce --- /dev/null +++ b/packages/fields/src/__tests__/formulaFaces.textFace-12357.test.tsx @@ -0,0 +1,103 @@ +/** + * 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. + */ + +/** + * objectui#12357 — a text-valued formula is drawn the way a text field is + * drawn, in the table cell and in the read-only form face, whether it declares + * `returnType: 'text'` or declares nothing. + * + * Both faces used to fall through to a monospace span for every result that + * was not a number, boolean or date, so a formula yielding a person's name + * (HotCRM's Leads Name column, `crm_lead.full_name`) read as code beside the + * proportional text columns around it. Declaring `returnType: 'text'` changed + * nothing, and most code-authored formulas arrive with no `returnType`, so + * the pin covers both. + * + * The comparison is against the text field's own faces, rendered beside the + * formula for the same value: the cell's markup equals `TextCellRenderer`'s, + * and the form face's classes equal `TextField`'s read-only branch's. A + * numeric result is the control: it still reads through the number face. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { TextCellRenderer, TextField, getCellRenderer } from '../index'; +import { FormulaField } from '../widgets/FormulaField'; + +afterEach(() => cleanup()); + +const noop = () => {}; + +type FormulaExtra = Record; +const formulaField = (extra: FormulaExtra) => ({ type: 'formula', name: 'full_name', ...extra }) as never; +const textField = { type: 'text', name: 'first_name' } as never; + +const Cell = getCellRenderer('formula'); + +const NAME = 'Ada Lovelace'; + +const DECLARATIONS: Array<[string, FormulaExtra]> = [ + ["declared returnType 'text'", { returnType: 'text' }], + ['no returnType', {}], +]; + +function htmlOf(node: React.ReactElement): string { + const { container } = render(node); + const html = container.innerHTML; + cleanup(); + return html; +} + +function classesOf(node: React.ReactElement): string[] { + const { container } = render(node); + const el = container.firstElementChild; + const classes = el ? Array.from(el.classList).sort() : []; + cleanup(); + return classes; +} + +describe('a text-valued formula is drawn as text, not as code (objectui#12357)', () => { + describe.each(DECLARATIONS)('%s', (_label, extra) => { + it('the cell draws exactly what the text cell draws for the same value', () => { + const formula = htmlOf(); + const text = htmlOf(); + expect(formula).toBe(text); + }); + + it("the form face carries the text field's read-only classes", () => { + const formula = classesOf(); + const text = classesOf(); + expect(formula).toEqual(text); + }); + + it('neither face is drawn in monospace', () => { + for (const node of [ + , + , + ]) { + const { container } = render(node); + expect(container.textContent).toBe(NAME); + expect(container.querySelector('.font-mono')).toBeNull(); + cleanup(); + } + }); + }); + + // The control: the text face did not swallow the number face. + it.each([ + ["declared returnType 'number'", { returnType: 'number' }], + ['no returnType, a JS number', {}], + ] as Array<[string, FormulaExtra]>)('control — %s still reads through the number face', (_label, extra) => { + const { container } = render(); + expect(container.textContent).toBe('200,000'); + expect(container.querySelector('.tabular-nums')).not.toBeNull(); + cleanup(); + const form = render(); + expect(form.container.textContent).toBe('200,000'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 9d001eda37..dd5533a119 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -2797,11 +2797,15 @@ export function FormulaCellRenderer({ value, field }: CellRendererProps): React. return {formatDate(safe as string | number, undefined, { locale })}; } - return ( - - {String(safe)} - - ); + // TEXT is drawn by the text cell (objectui#12357), declared `text` or + // undeclared alike, so a formula that yields a person's name reads in the + // column's own font beside the text fields around it. This printed every + // remaining result in monospace, so a Leads Name column built by a formula + // read as code; declaring `returnType: 'text'` changed nothing, and most + // code-authored formulas arrive with no `returnType` at all. An expanded + // record or an array is coerced to its name / joined text by the same + // `coerceToSafeValue` both renderers call, so it takes this face too. + return ; } /** diff --git a/packages/fields/src/widgets/FormulaField.tsx b/packages/fields/src/widgets/FormulaField.tsx index 0b56e452c4..2252bf8514 100644 --- a/packages/fields/src/widgets/FormulaField.tsx +++ b/packages/fields/src/widgets/FormulaField.tsx @@ -37,7 +37,8 @@ import { coerceToSafeValue } from '../coerceToSafeValue.js'; * it; only a JS boolean is a boolean, as there (objectui#8593). * - date: `formatDate`'s DEFAULT face, as `DateField`'s read-only branch * draws it (objectui#8194). - * - text: the value as the cell prints it, in monospace as before. + * - text: the value as the cell prints it, in the font `TextField`'s + * read-only branch draws (objectui#12357), declared `text` or not. * * Both faces also share the emptiness floor (`isEmptyValue` on the coerced * value), so an empty value reads the shared affordance in both. @@ -130,10 +131,8 @@ export function FormulaField({ value, field, ...props }: FieldWidgetComponentPro } // Text: the coerced value, as the cell prints it (an expanded record reads - // its name, not `[object Object]`). - return ( - - {String(safe)} - - ); + // its name, not `[object Object]`), drawn the way `TextField`'s read-only + // branch draws a text value (objectui#12357): the form's own font, not the + // monospace this used to print, which made a person's name read as code. + return {String(safe)}; } From 9126ff6277e93545e28805ab483c25ca58e43ded Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 18:26:31 +0000 Subject: [PATCH 2/2] test(fields): the 11683 text-face pins read the number face, not monospace (objectui#12357) Two assertions used the old monospace span as the proxy for "drawn as text"; they now assert the number face is absent and monospace is gone. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .../formulaDatetimeCells.copiedText-11683.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx index 7d2f7d615d..d60a71df3d 100644 --- a/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx +++ b/packages/fields/src/__tests__/formulaDatetimeCells.copiedText-11683.test.tsx @@ -155,7 +155,10 @@ describe('a formula with no returnType formats a numeric result as a number (obj it.each(LOCALES)('%s — a string of digits stays text: only a JS number is read as a number', (locale) => { const { container } = renderSession(locale, formulaCell('200000')); expect(container.textContent).toBe('200000'); - expect(container.querySelector('.font-mono')).not.toBeNull(); + // Drawn by the text face, not the number face; the text face is the text + // cell's own, no longer monospace (objectui#12357). + expect(container.querySelector('.tabular-nums')).toBeNull(); + expect(container.querySelector('.font-mono')).toBeNull(); }); }); @@ -176,6 +179,9 @@ describe('a declared returnType is read as declared (objectui#11683)', () => { it.each(LOCALES)("%s — returnType 'text' over a number stays text: the declaration wins", (locale) => { const { container } = renderSession(locale, formulaCell(200000, { returnType: 'text' })); expect(container.textContent).toBe('200000'); - expect(container.querySelector('.font-mono')).not.toBeNull(); + // Drawn by the text face, not the number face; the text face is the text + // cell's own, no longer monospace (objectui#12357). + expect(container.querySelector('.tabular-nums')).toBeNull(); + expect(container.querySelector('.font-mono')).toBeNull(); }); });