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/olive-mails-repeat.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@object-ui/fields': patch
---

**Bug — a `code`/`text` value whose text is JSON rendered as the literal `[Object]`.** `coerceToSafeValue` classified strings by SHAPE: any string starting `{`/`[` and ending `}`/`]` was `JSON.parse`d and the result run through the reference-label extraction (`name || label || externalId || id || _id || '[Object]'`), which answers the placeholder for an object carrying none of those keys. Every text-like cell reaches that helper — `text`, `textarea`, `code`, `time`, `auto_number` and `qrcode` all register to `TextCellRenderer` — so a stored `{"ok": true}` displayed as `[Object]`, and `[1, 2, 3]` in a text field displayed as `1, 2, 3`.

A string is now returned verbatim, whatever its shape. The reference case the parse was written for (an unresolved external-id reference arriving as `'{"externalId":"…"}'`) belongs to reference-TYPED columns and is already handled there: `LookupCellRenderer` carries its own JSON-string branch, which resolves the label through the referenced object's schema and links to the record — neither of which the type-blind helper could do. The behaviour is scoped to the column type that owns it, not dropped. Object and array VALUES still coerce, so React error #310 stays fixed.
109 changes: 109 additions & 0 deletions packages/fields/src/__tests__/textCellJsonText-7246.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
/**
* 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#7246 — a `code` value whose TEXT is JSON rendered as the literal
* `[Object]`. The showcase Field Zoo record's Code Editor field (`f_code`)
* stores the STRING `{\n "ok": true\n}` (confirmed against
* `GET /api/v1/data/showcase_field_zoo/<id>`: a string, not an object), and the
* detail page showed `[Object]` instead of the code.
*
* Mechanism: `coerceToSafeValue` classified strings BY SHAPE — anything
* starting `{`/`[` and ending `}`/`]` was `JSON.parse`d, and the resulting
* object fell through to the reference-label extraction
* (`name || label || externalId || id || _id || '[Object]'`). `{"ok": true}`
* carries none of those keys, so the cell rendered the placeholder. `code`,
* `text`, `textarea`, `time`, `auto_number` and `qrcode` all register to
* `TextCellRenderer`, so every one of them lost any JSON-shaped text.
*
* Shape is not a type. The reference unwrapping belongs to reference-typed
* columns, and it already lives there: `LookupCellRenderer` carries its own
* JSON-string branch (resolving through the referenced object's schema and
* linking to the record, which the generic coercion never could). The control
* case at the bottom pins that the #1426 scenario still works where it belongs
* — this fix scopes the behaviour, it does not delete it.
*/
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { getCellRenderer, coerceToSafeValue, LookupCellRenderer } from '../index';

/** The exact string the reporting record stores in `f_code`. */
const F_CODE = '{\n "ok": true\n}';

/**
* Read the value the cell actually put in the DOM, byte-exact.
* `TruncatedText` mirrors the full text into `title`, so RTL's whitespace
* normalization can't hide a mangled multi-line value.
*/
function renderedText(ui: React.ReactElement): string {
const { container } = render(ui);
return container.textContent ?? '';
}

describe('text-like cells display JSON-shaped text verbatim (objectui#7246)', () => {
it('code: the reporting value renders as its text, not "[Object]"', () => {
const Cell = getCellRenderer('code');
const text = renderedText(<Cell value={F_CODE} field={{ type: 'code' } as any} />);
expect(text).toBe(F_CODE);
expect(text).not.toContain('[Object]');
});

it.each(['text', 'textarea', 'code'] as const)(
'%s: an object-literal string is not parsed away',
(type) => {
const Cell = getCellRenderer(type);
const value = '{"ok": true}';
expect(renderedText(<Cell value={value} field={{ type } as any} />)).toBe(value);
},
);

it.each(['text', 'textarea', 'code'] as const)(
'%s: an array-literal string is not joined away',
(type) => {
const Cell = getCellRenderer(type);
// The array branch used to `.join(', ')` this into `1, 2, 3`.
const value = '[1, 2, 3]';
expect(renderedText(<Cell value={value} field={{ type } as any} />)).toBe(value);
},
);

it('coerceToSafeValue returns every string verbatim, whatever its shape', () => {
expect(coerceToSafeValue(F_CODE)).toBe(F_CODE);
expect(coerceToSafeValue('{"ok": true}')).toBe('{"ok": true}');
expect(coerceToSafeValue('[{"name":"A"}]')).toBe('[{"name":"A"}]');
// The reference spelling itself: a *string* is text everywhere except a
// reference-typed column, which resolves it in its own renderer below.
expect(coerceToSafeValue('{"externalId":"Website Relaunch"}')).toBe(
'{"externalId":"Website Relaunch"}',
);
});

it('still coerces genuine OBJECT values — React error #310 stays fixed', () => {
// Unchanged behaviour: an expanded reference arriving as an OBJECT (the
// shape that actually reaches a cell) is still reduced to a label.
expect(coerceToSafeValue({ name: 'Dev Admin', id: 'u1' })).toBe('Dev Admin');
expect(coerceToSafeValue({ externalId: 'Website Relaunch' })).toBe('Website Relaunch');
expect(coerceToSafeValue([{ name: 'A' }, { externalId: 'B' }])).toBe('A, B');
});

it('CONTROL: a reference COLUMN still unwraps a JSON-string reference', () => {
// objectui#1426's scenario, on the column type it was written for. This is
// the half that must not regress when the generic coercion stops guessing.
const text = renderedText(
<LookupCellRenderer
value={'{"externalId":"Website Relaunch"}'}
field={{ type: 'lookup', reference_to: 'project' } as any}
/>,
);
expect(text).toContain('Website Relaunch');
expect(text).not.toContain('externalId');
});
});
26 changes: 20 additions & 6 deletions packages/fields/src/coerce-safe-value.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,10 +2,22 @@ import { describe, it, expect } from 'vitest';
import { coerceToSafeValue } from './index';

describe('coerceToSafeValue — reference / lookup values', () => {
it('extracts a label from a JSON-string reference (unresolved external-id ref)', () => {
// Regression: a master_detail/lookup value can arrive as a JSON-encoded
// string; it must render a label, not raw '{"externalId":"..."}'.
expect(coerceToSafeValue('{"externalId":"Website Relaunch"}')).toBe('Website Relaunch');
// The two JSON-STRING cases that used to live here pinned the shape-based
// `JSON.parse` branch, which objectui#7246 removed: this helper is reached by
// every text-like cell, so it may not decide a value's type by looking at its
// characters (a `code` field holding `{"ok": true}` rendered `[Object]`).
// Their scenario — objectui#1426's unresolved external-id reference — did not
// go away; it moved to the reference-TYPED renderer that can actually resolve
// it, and is pinned as the CONTROL case in
// `__tests__/textCellJsonText-7246.test.tsx`. Replaced rather than respelled:
// what they asserted is now the wrong answer at this seam.
it('returns a JSON-shaped STRING verbatim — shape is not a type', () => {
expect(coerceToSafeValue('{"externalId":"Website Relaunch"}')).toBe(
'{"externalId":"Website Relaunch"}',
);
expect(coerceToSafeValue('[{"name":"A"},{"externalId":"B"}]')).toBe(
'[{"name":"A"},{"externalId":"B"}]',
);
});

it('extracts a label from a reference object, name > label > externalId > id', () => {
Expand All@@ -15,8 +27,10 @@ describe('coerceToSafeValue — reference / lookup values', () => {
expect(coerceToSafeValue({ id: 'id1' })).toBe('id1');
});

it('handles a JSON-string array of references', () => {
expect(coerceToSafeValue('[{"name":"A"},{"externalId":"B"}]')).toBe('A, B');
it('joins a real ARRAY of references into labels', () => {
// The array case still coerces — an array VALUE (not a string that looks
// like one) is the shape that actually reaches a cell.
expect(coerceToSafeValue([{ name: 'A' }, { externalId: 'B' }])).toBe('A, B');
});

it('leaves plain strings and non-JSON-looking strings untouched', () => {
Expand Down
30 changes: 20 additions & 10 deletions packages/fields/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -324,20 +324,30 @@ export interface CellRendererProps {
* Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
* reference objects, and arrays so that no raw object is ever passed as
* a React child — preventing React error #310.
*
* A STRING is returned verbatim, whatever its shape. This helper is reached by
* every text-like cell (`text`, `textarea`, `code`, `time`, `auto_number`,
* `qrcode` all register to `TextCellRenderer`), so it may not classify a value
* by looking at its characters. It used to: any string starting `{`/`[` and
* ending `}`/`]` was `JSON.parse`d and the result run through the
* reference-label extraction below, which answers `[Object]` for an object
* carrying no name/label/externalId/id. That turned the showcase Field Zoo's
* `code` value `{"ok": true}` into the literal `[Object]`, and `[1, 2, 3]` in a
* text field into `1, 2, 3` (objectui#7246).
*
* Shape is not a type. The reference case the parse was written for
* (objectui#1426 — an unresolved external-id ref arriving as
* '{"externalId":"Website Relaunch"}') belongs to reference-TYPED columns and
* is handled there: `LookupCellRenderer` carries its own JSON-string branch,
* which resolves the label through the referenced object's schema and links to
* the record — neither of which this type-blind helper could ever do. Scoped,
* not dropped; both halves are pinned in
* `__tests__/textCellJsonText-7246.test.tsx`.
*/
export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
if (value == null) return value as null | undefined;
if (typeof value === 'number' || typeof value === 'boolean') return value;
if (typeof value === 'string') {
// A reference/expanded value can arrive as a JSON-encoded object string —
// e.g. an unresolved external-id reference '{"externalId":"Website Relaunch"}'.
// Parse and extract a human label instead of leaking raw JSON into the cell.
const s = value.trim();
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
try { return coerceToSafeValue(JSON.parse(s)); } catch { /* not JSON — fall through */ }
}
return value;
}
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) {
return value.map((v) => {
Expand Down
Loading