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
5 changes: 5 additions & 0 deletions .changeset/12480-studio-grid-missing-rows.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 39 additions & 2 deletions packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -5024,7 +5024,44 @@ export const ObjectGrid: React.FC<ObjectGridComponentProps> = ({
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) => (
<div className="line-clamp-1" data-testid="grid-rich-text-one-line">
{cell(value, row)}
</div>
),
};
};

const applyColumnChrome = (col: any) => withOneLineRichText(withSortability(applyDensity(col)));

const orderedColumns = hasPinnedColumns
? [
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '<h2>Title</h2><p>one</p><p>two</p>',
notes_rich: '<p>Rich <strong>text</strong></p><p>second</p>',
}
: {}),
}));
}

function renderGrid(rows: Record<string, unknown>[], 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(
<ActionProvider>
<SchemaRendererProvider dataSource={ds}>
<ObjectGrid
schema={{
type: 'object-grid',
objectName: 'test_object',
columns,
pagination: { pageSize: 50 },
} as any}
dataSource={ds}
/>
</SchemaRendererProvider>
</ActionProvider>,
);
}

const bodyRows = () =>
Array.from(document.querySelectorAll('tbody > tr')).filter(
(tr) => tr.getAttribute('data-testid') !== 'add-record-row',
);

const clamps = () => Array.from(document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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();
});
});
Loading