diff --git a/.changeset/olive-mails-repeat.md b/.changeset/olive-mails-repeat.md new file mode 100644 index 0000000000..488ed71f25 --- /dev/null +++ b/.changeset/olive-mails-repeat.md @@ -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. diff --git a/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx b/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx new file mode 100644 index 0000000000..649d4759c1 --- /dev/null +++ b/packages/fields/src/__tests__/textCellJsonText-7246.test.tsx @@ -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/`: 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(); + 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()).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()).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( + , + ); + expect(text).toContain('Website Relaunch'); + expect(text).not.toContain('externalId'); + }); +}); diff --git a/packages/fields/src/coerce-safe-value.test.ts b/packages/fields/src/coerce-safe-value.test.ts index bf461fb3e6..854e11461a 100644 --- a/packages/fields/src/coerce-safe-value.test.ts +++ b/packages/fields/src/coerce-safe-value.test.ts @@ -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', () => { @@ -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', () => { diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index a22a8afbd4..a6bc6c9e35 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -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) => {