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
7 changes: 7 additions & 0 deletions .changeset/12357-formula-text-face.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions packages/fields/src/__tests__/formulaFaces.textFace-12357.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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(<Cell value={NAME} field={formulaField(extra)} />);
const text = htmlOf(<TextCellRenderer value={NAME} field={textField} />);
expect(formula).toBe(text);
});

it("the form face carries the text field's read-only classes", () => {
const formula = classesOf(<FormulaField value={NAME} onChange={noop} field={formulaField(extra)} readonly />);
const text = classesOf(<TextField value={NAME} onChange={noop} field={textField} readonly />);
expect(formula).toEqual(text);
});

it('neither face is drawn in monospace', () => {
for (const node of [
<Cell key="cell" value={NAME} field={formulaField(extra)} />,
<FormulaField key="form" value={NAME} onChange={noop} field={formulaField(extra)} readonly />,
]) {
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(<Cell value={200000} field={formulaField(extra)} />);
expect(container.textContent).toBe('200,000');
expect(container.querySelector('.tabular-nums')).not.toBeNull();
cleanup();
const form = render(<FormulaField value={200000} onChange={noop} field={formulaField(extra)} readonly />);
expect(form.container.textContent).toBe('200,000');
});
});
14 changes: 9 additions & 5 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2797,11 +2797,15 @@ export function FormulaCellRenderer({ value, field }: CellRendererProps): React.
return <span className="tabular-nums">{formatDate(safe as string | number, undefined, { locale })}</span>;
}

return (
<span className="text-gray-700 font-mono text-sm">
{String(safe)}
</span>
);
// 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 <TextCellRenderer value={value} field={field} />;
}

/**
Expand Down
13 changes: 6 additions & 7 deletions packages/fields/src/widgets/FormulaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<span className={`text-sm font-mono text-gray-700 ${props.className || ''}`}>
{String(safe)}
</span>
);
// 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 <span className={typedClassName}>{String(safe)}</span>;
}
Loading