From d4005d8784736062257ebbdf8b9a3f47f38214a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:30:19 +0000 Subject: [PATCH] fix(plugin-grid): a rich-content cell keeps the grid's one-line row (objectui#12480) data-table's one-line clamp (`truncate`) bounds inline content only. The markdown/html/richtext cell renderers draw a block document, so one long value grew its row to the document's height and pushed later rows below the scroll box while the footer still counted them. ObjectGrid now wraps a rich-content column's renderer in a one-line clamp unless the column is authored `wrap: true`. Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB Co-authored-by: Claude --- .changeset/12480-studio-grid-missing-rows.md | 5 + packages/plugin-grid/src/ObjectGrid.tsx | 41 ++++- .../ObjectGrid.richTextOneLine-12480.test.tsx | 159 ++++++++++++++++++ 3 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 .changeset/12480-studio-grid-missing-rows.md create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.richTextOneLine-12480.test.tsx diff --git a/.changeset/12480-studio-grid-missing-rows.md b/.changeset/12480-studio-grid-missing-rows.md new file mode 100644 index 0000000000..0ecb194ace --- /dev/null +++ b/.changeset/12480-studio-grid-missing-rows.md @@ -0,0 +1,5 @@ +--- +'@object-ui/plugin-grid': patch +--- + +A `markdown`, `html` or `richtext` cell keeps the grid's one-line row (objectui#12480). The grid's one-line clamp bounded inline content only, so a long rich-content value (a multi-paragraph `description`) grew its whole row to the document's height and pushed the rows after it below the scroll box, while the footer still counted them. A rich-content column now shows the first line of its document; a column authored `wrap: true` still shows the whole document. Affects the Studio Data pillar's Records grid and the console list pages alike. diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 4f0fc183e4..887bcfcb39 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -35,7 +35,7 @@ import { createSafeTranslation } from '@object-ui/i18n'; // what stops a seventh site picking a convention of its own. import { resolveGridCellRendering, gridCellRendererForFixedKey, linkCellRenderer, BADGE_PREFIX_RENDERER_KEY } from './cellRendererResolution'; import { isMaskedGridColumn, isWithheldGridColumn } from './maskedColumn'; -import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer, withKnownObjects } from '@object-ui/fields'; +import { formatCurrency, formatCompactCurrency, formatDate, formatPercent, percentCellScale, humanizeLabel, getBadgeColorClasses, getBadgeHexAppearance, FieldEditWidget, hasFieldEditWidget, DISCRETE_EDIT_TYPES, coerceToSafeValue, MaskedCellRenderer, withKnownObjects, RICH_TEXT_FIELD_TYPES } from '@object-ui/fields'; import { useLocalization, useDisplayLocale, resolveFieldCurrency, pickLocalized } from '@object-ui/i18n'; // Two resolvers, two vocabularies — the repo spells the distinction into the // NAMES (objectui#4167). `resolveInlineI18nLabel` is the spec's own @@ -5024,7 +5024,44 @@ export const ObjectGrid: React.FC = ({ return isUnmaterializedFieldType(fieldDef) ? { ...col, sortable: false } : col; }; - const applyColumnChrome = (col: any) => withSortability(applyDensity(col)); + /** + * objectui#12480 — a rich-content cell keeps the grid's one-line row. + * + * `data-table` clamps a cell body to one line with `truncate` unless the + * column is authored `wrap: true`. `truncate` (nowrap + overflow hidden + + * ellipsis) bounds INLINE content only: it never bounds a body's height. The + * three rich-content renderers (`markdown`, `html`, `richtext` — the + * `RICH_TEXT_FIELD_TYPES` table in `@object-ui/fields`) draw a BLOCK document + * (paragraphs, headings, lists), and blocks stack under nowrap, so one such + * value grew its whole row to the document's height. Measured in Chromium at + * 1280 x 900 on HotCRM's `crm_account` shape: one long `description` made its + * row 456 px tall; the rows after it fell below the scroll box's fold while + * the footer still counted them, which read as "fewer rows drawn, a gap + * before the last one". Every other cell renderer stays within one line + * (measured per type on the same harness; the readings are in the PR). + * + * So a rich-content column that is not `wrap: true` wraps its renderer's + * output in a one-line clamp: the first line of the document shows, the rest + * is cut. The renderer itself is unchanged (its sanitising pipeline stays the + * trust boundary), and `wrap: true` keeps the whole document, as authored. + */ + const withOneLineRichText = (col: any) => { + if (col.wrap === true || typeof col.cell !== 'function') return col; + const fieldDef = (objectSchema as any)?.fields?.[col.accessorKey]; + const { rendererType } = resolveGridCellRendering(fieldDef); + if (!rendererType || !(RICH_TEXT_FIELD_TYPES as readonly string[]).includes(rendererType)) return col; + const cell = col.cell; + return { + ...col, + cell: (value: any, row: any) => ( +
+ {cell(value, row)} +
+ ), + }; + }; + + const applyColumnChrome = (col: any) => withOneLineRichText(withSortability(applyDensity(col))); const orderedColumns = hasPinnedColumns ? [ diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.richTextOneLine-12480.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.richTextOneLine-12480.test.tsx new file mode 100644 index 0000000000..3b4fe6ba24 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.richTextOneLine-12480.test.tsx @@ -0,0 +1,159 @@ +/** + * 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#12480 — a rich-content cell keeps the grid's one-line row. + * + * The Studio Data pillar's Records grid on HotCRM's `crm_account` (10 records) + * drew rows 1–8, a tall empty band, row 9, and no row 10, under a footer + * reading "10 records". Root cause: `data-table`'s one-line clamp is + * `truncate`, which bounds INLINE content only. The `markdown` / `html` / + * `richtext` cell renderers draw a block document, and blocks stack under + * nowrap, so the one record with a long `description` grew its row to the + * document's height (456 px in Chromium) and pushed row 10 below the scroll + * box's fold. The console list pages render through the same `ObjectGrid`, so + * they did the same. + * + * The fix wraps a rich-content column's renderer in a one-line clamp unless the + * column is authored `wrap: true`. jsdom does no layout, so these pins read the + * class contract and the row count; the pixel readings (before and after, 8 / + * 9 / 10 rows, Studio and the console list body) are in the PR body. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, beforeAll, vi } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ActionProvider, SchemaRendererProvider } from '@object-ui/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +const DOC = '**Strategic Customer**\n\nA long account history.\n\n- one\n- two\n- three'; + +const FIELDS = { + id: { type: 'text' }, + name: { type: 'text', label: 'Name' }, + description: { type: 'markdown', label: 'Description' }, + notes_html: { type: 'html', label: 'Html' }, + notes_rich: { type: 'richtext', label: 'Rich' }, +}; + +function makeRows(n: number, rich: boolean) { + return Array.from({ length: n }, (_, i) => ({ + id: `r${i + 1}`, + name: `Account ${i + 1}`, + ...(rich && i === n - 2 + ? { + description: DOC, + notes_html: '

Title

one

two

', + notes_rich: '

Rich text

second

', + } + : {}), + })); +} + +function renderGrid(rows: Record[], columns: any[]) { + const ds = { + find: vi.fn(async () => ({ data: rows, total: rows.length, hasMore: false, pageSize: 50 })), + getObjectSchema: vi.fn(async (name: string) => ({ name, fields: FIELDS })), + } as any; + return render( + + + + + , + ); +} + +const bodyRows = () => + Array.from(document.querySelectorAll('tbody > tr')).filter( + (tr) => tr.getAttribute('data-testid') !== 'add-record-row', + ); + +const clamps = () => Array.from(document.querySelectorAll('[data-testid="grid-rich-text-one-line"]')); + +const ORIGINAL_INNER_WIDTH = window.innerWidth; + +beforeAll(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = vi.fn() as any; + } +}); + +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: ORIGINAL_INNER_WIDTH }); + cleanup(); +}); + +function desktop() { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1280 }); +} + +describe('objectui#12480 — a rich-content cell keeps the one-line row', () => { + it('10 rows with a markdown, an html and a richtext column: 10 rows drawn, each rich cell inside a one-line clamp', async () => { + desktop(); + renderGrid(makeRows(10, true), [ + { field: 'name', label: 'Name' }, + { field: 'description', label: 'Description' }, + { field: 'notes_html', label: 'Html' }, + { field: 'notes_rich', label: 'Rich' }, + ]); + await waitFor(() => expect(screen.getByText('Account 10')).toBeInTheDocument()); + expect(bodyRows()).toHaveLength(10); + + // One clamp per rich-content cell: three rich columns x ten rows. + await waitFor(() => expect(clamps()).toHaveLength(30)); + for (const el of clamps()) { + expect(el).toHaveClass('line-clamp-1'); + expect(el.closest('td')).not.toBeNull(); + } + + // The documents themselves are drawn INSIDE the clamp, not beside it. + const row9 = bodyRows()[8] as HTMLElement; + const rowClamps = Array.from(row9.querySelectorAll('[data-testid="grid-rich-text-one-line"]')); + expect(rowClamps).toHaveLength(3); + await waitFor(() => expect(rowClamps[0].querySelector('ul')).not.toBeNull()); + expect(rowClamps[1].querySelector('h2')).not.toBeNull(); + expect(rowClamps[2].querySelector('strong')).not.toBeNull(); + + // Over-correction control: the plain text column is not clamped this way. + expect(screen.getByText('Account 9').closest('[data-testid="grid-rich-text-one-line"]')).toBeNull(); + }); + + it('a rich-content column authored `wrap: true` keeps the whole document (no clamp)', async () => { + desktop(); + renderGrid(makeRows(10, true), [ + { field: 'name', label: 'Name' }, + { field: 'description', label: 'Description', wrap: true }, + ]); + await waitFor(() => expect(screen.getByText('Account 10')).toBeInTheDocument()); + expect(bodyRows()).toHaveLength(10); + await waitFor(() => expect((bodyRows()[8] as HTMLElement).querySelector('ul')).not.toBeNull()); + expect(clamps()).toHaveLength(0); + }); + + it('CONTROL — an 8-row object with no rich-content column is unchanged: 8 rows, no clamp, cells still truncate', async () => { + desktop(); + renderGrid(makeRows(8, false), [{ field: 'name', label: 'Name' }]); + await waitFor(() => expect(screen.getByText('Account 8')).toBeInTheDocument()); + expect(bodyRows()).toHaveLength(8); + expect(clamps()).toHaveLength(0); + expect(screen.getByText('Account 8').closest('td')?.querySelector('.truncate')).not.toBeNull(); + }); +});