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
13 changes: 13 additions & 0 deletions .changeset/25897-record-read-mode-wraps-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/plugin-detail': patch
---

fix(plugin-detail): the record page's read mode wraps a text value instead of cutting it to one line, and a highlighted date keeps its day

Clause-②: no
Metadata-protocol: none

- **Details body.** A `text` field in a `record:details` section, read mode, was drawn with the grid's one-line cell (one line, an ellipsis), so a case subject read "Data export timing out for large…" in a two-column section with room to spare. It now reads through the text field's own read display and wraps, as a `textarea` already did. The same holds for every type that used the grid's text cell (`code`, `time`, `auto_number`, …). A host that registered its own cell renderer for one of those types keeps it. Grid cells are unchanged and still truncate, with the whole value on hover.
- **Highlights strip.** A `date` or `datetime` chip now takes the wide column (16rem floor) that email, phone and URL chips take. On the 9rem column the compact datetime face, and a date in Chinese or Japanese, overran the value box, so the ellipsis landed inside the day ("2026/10/2…"). A chip whose line runs out still truncates, with the whole value as its hover title.

What to do: nothing.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-detail.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"21ccb887f5b2ccb7f48b871632b94f43e15f2264810fa6d08f6ba107367dd7f6","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-detail","sources":54,"sha256":"c13b11a725295ab5e510c572d774271e3ec9a7120442907249bace960974f9bd","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
29 changes: 27 additions & 2 deletions ui/packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import {
RECORD_POINTER_CARD_TYPE,
coerceToSafeValue,
TextAreaField,
TextCellRenderer,
TextField,
} from '@object-ui/fields';
import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types';
import { applyDetailAutoLayout } from './autoLayout';
Expand Down Expand Up @@ -102,8 +104,9 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number
}

/**
* The `onChange` a read display is handed. `TextAreaField`'s `readonly` branch
* renders no control and never calls it; the widget contract requires the slot.
* The `onChange` a read display is handed. The `readonly` branches of
* `TextAreaField` and `TextField` render no control and never call it; the
* widget contract requires the slot.
*/
const ignoreReadOnlyChange = (): void => {};

Expand Down Expand Up @@ -580,6 +583,28 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
if (CellRenderer === BooleanCellRenderer && !fieldEditable && typeof value === 'boolean') {
return booleanLabel(value);
}
// A value the table draws with the GRID's one-line text cell reads
// through `TextField`'s own read display instead (objectstack#25897),
// the same move the `textarea` branch above makes. `TextCellRenderer`
// is `TruncatedText` — `truncate`, one line and an ellipsis — which
// cut a case subject to "Data export timing out for large…" in a
// two-column section with room to spare. Read mode wraps a text-like
// value; truncation belongs to layouts that declare it, the grid row
// and the highlights strip, and both keep it. Matched by renderer
// identity, as the boolean above is, so every type the table maps to
// the text cell (`text`, `code`, `time`, `auto_number`, …) wraps and a
// host that registered its own face for one of them keeps that face.
// The value is coerced exactly as `TextCellRenderer` coerced it.
if (CellRenderer === TextCellRenderer) {
return (
<TextField
field={enrichedField as unknown as FieldMetadata}
value={String(coerceToSafeValue(value))}
onChange={ignoreReadOnlyChange}
readonly
/>
);
}
if (CellRenderer) {
return (
<CellRenderer
Expand Down
11 changes: 11 additions & 0 deletions ui/packages/plugin-detail/src/HeaderHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,22 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
// the HotCRM lead read "+1-555-0" that way. (2) is about the
// editor, which the declared type picks, so it keeps
// `resolvedType`.
//
// `date` and `datetime` joined (1) in objectstack#25897: the
// strip writes every date's year (objectui#13331), and a 9rem chip
// leaves its value about 103px after the padding. Chromium (Inter,
// the strip's 14px semibold) read the compact `datetime` face at
// 151-164px (`2026/10/26 上午10:00`, `10/26/2026 10:00 am`) and
// the zh-CN `date` face at 114px (`2026年10月26日`), so the
// ellipsis landed inside the day: "2026/10/2…". A date that loses
// its day is worse than a wider chip; the 16rem floor leaves 216px.
const isWide =
drawnType === 'email' ||
drawnType === 'url' ||
drawnType === 'phone' ||
drawnType === 'textarea' ||
drawnType === 'date' ||
drawnType === 'datetime' ||
(!!resolvedType && EXPANDABLE_FIELD_TYPES.has(resolvedType));
const isBoolean = resolvedType === 'boolean';
// The SAME definition the body grid draws its em-dash from
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* 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.
*/

/**
* objectstack#25897: the case record's highlight strip showed its SLA due
* datetime as "2026/10/2…" — the ellipsis landed inside the day.
*
* A 9rem chip leaves its value about 103px once its padding and separator are
* paid. Chromium (Inter, the strip's 14px semibold) read the compact
* `datetime` face at 151-164px and the zh-CN `date` face at 114px, so both
* date types joined the wide display types on the 16rem floor (216px). The
* strip still declares truncation for a line that runs out, with the whole
* value as the clip box's `title`; that half is unchanged and pinned here too.
*
* happy-dom has no layout engine, so these are class-semantics pins, the same
* approach as the objectui#11684 and objectui#12523 pins beside this file.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import * as React from 'react';
import type { HighlightField } from '@object-ui/types';
import { HeaderHighlight } from '../HeaderHighlight';

const CLOCK = '2026-10-23T12:00:00.000Z';
const SLA_DUE = '2026-10-26T02:00:00.000Z';

beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(CLOCK));
});

afterEach(() => {
cleanup();
vi.useRealTimers();
});

/** The HotCRM case's strip, with its own field types. */
const caseSchema = {
fields: {
priority: { type: 'text', label: 'Priority' },
sla_due_date: { type: 'datetime', label: 'SLA Due' },
opened_on: { type: 'date', label: 'Opened On' },
},
};

const fields: HighlightField[] = [
{ name: 'priority', label: 'Priority' },
{ name: 'sla_due_date', label: 'SLA Due' },
{ name: 'opened_on', label: 'Opened On' },
];

const data = { priority: 'High', sla_due_date: SLA_DUE, opened_on: '2026-10-20' };

/** The chip column (`group flex flex-col …`) whose label is `label`. */
const chip = (label: string) => screen.getByText(label).closest('div.group') as HTMLElement;

describe('a date chip gets the floor its value fits in (objectstack#25897)', () => {
it('gives a `datetime` chip the 16rem floor, the grow and the content cap', () => {
render(<HeaderHighlight fields={fields} data={data} objectSchema={caseSchema} />);

const due = chip('SLA Due');
expect(due).toHaveClass('grow', 'max-w-max', 'basis-[16rem]', 'min-w-[min(16rem,100%)]');
expect(due).not.toHaveClass('basis-[9rem]');
});

it('gives a `date` chip the same floor', () => {
render(<HeaderHighlight fields={fields} data={data} objectSchema={caseSchema} />);

const opened = chip('Opened On');
expect(opened).toHaveClass('basis-[16rem]', 'min-w-[min(16rem,100%)]');
expect(opened).not.toHaveClass('basis-[9rem]');
});

it('CONTROL: a plain `text` chip keeps the 9rem floor', () => {
render(<HeaderHighlight fields={fields} data={data} objectSchema={caseSchema} />);

const priority = chip('Priority');
expect(priority).toHaveClass('basis-[9rem]', 'min-w-[min(9rem,100%)]');
expect(priority).not.toHaveClass('basis-[16rem]');
});

it('keeps the declared truncation for a line that runs out, with the whole value reachable', () => {
render(<HeaderHighlight fields={fields} data={data} objectSchema={caseSchema} />);

const clipBox = chip('SLA Due').querySelector('span.truncate');
expect(clipBox).not.toBeNull();
expect(clipBox).toHaveAttribute('title', SLA_DUE);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* 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.
*/

/**
* objectstack#25897 — `record:details` READ mode wraps a plain `text` value
* instead of cutting it to one line.
*
* ## The defect
*
* A HotCRM case read its subject as "Data export timing out for large…" in a
* two-column section with room to its right. The row resolved `text` through
* `getCellRenderer`, whose table maps it to `TextCellRenderer` — the GRID's
* one-line cell, `TruncatedText` (`truncate`: no wrap, an ellipsis). The ruling
* (triage on the card): "Read mode wraps text-like values; truncation only
* where a layout declares it, with the full value reachable."
*
* ## What is pinned, and how
*
* The row now draws every value the table sends to the text cell through
* `TextField`'s own read display, the move objectui#11577 made for `textarea`.
* The value cell is compared, as markup, with what `TextField` renders
* read-only for the same value, and no element between the drawn text and the
* row may carry a utility that stops it wrapping. happy-dom applies no
* Tailwind, so the class list is named here only for that negative check.
*
* Controls:
* - the GRID keeps its one-line cell: `getCellRenderer('text')` is still
* `TextCellRenderer`, which still truncates with the whole value as its
* `title` (truncation where the layout declares it);
* - a host that registered its own `text` face keeps it on the record page.
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, cleanup, act, waitFor } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { TextField, TextCellRenderer, getCellRenderer, registerFieldRenderer } from '@object-ui/fields';
import { RecordDetailsRenderer } from '../record-details';

/** The case subject the browser pass read cut. */
const SUBJECT = 'Data export timing out for large datasets';

const DESKTOP_WIDTH = 1280;
const MOBILE_WIDTH = 375;
const pinViewport = (width: number) => () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
};

const caseSchema = {
name: 'support_case',
label: 'Case',
nameField: 'case_number',
fields: {
case_number: { type: 'text', label: 'Case Number' },
subject: { type: 'text', label: 'Subject' },
reference: { type: 'auto_number', label: 'Reference' },
},
};
const BODY_FIELDS = ['subject', 'reference'];

async function renderBody(record: Record<string, unknown>) {
const ds = { getObjectSchema: vi.fn(async () => caseSchema) };
render(
<RecordContextProvider
objectName="support_case"
recordId="C2"
data={{ id: 'C2', case_number: 'CASE-00002', ...record }}
objectSchema={caseSchema}
dataSource={ds as never}
>
<RecordDetailsRenderer schema={{ fields: BODY_FIELDS } as never} />
</RecordContextProvider>,
);
await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled());
await act(async () => {
await ds.getObjectSchema.mock.results[0].value;
});
}

function markupOf(element: React.ReactElement): string {
const { container, unmount } = render(element);
const html = container.innerHTML;
unmount();
return html;
}

const textReadDisplay = (name: string, type: string, value: string) =>
markupOf(<TextField field={{ name, type, label: name } as never} value={value} onChange={() => {}} readonly />);

/** The element holding a row's drawn value, found from the label the row prints. */
function valueCellOf(label: string, layout: 'desktop' | 'mobile'): HTMLElement {
const box = screen.getByText(label).nextElementSibling as HTMLElement | null;
expect(box).not.toBeNull();
if (layout === 'mobile') return box!;
expect(box!.firstElementChild).not.toBeNull();
return box!.firstElementChild as HTMLElement;
}

/** The innermost element whose text is exactly the value. */
function drawingNodeOf(cell: HTMLElement, value: string): HTMLElement {
const hits = [cell, ...Array.from(cell.querySelectorAll<HTMLElement>('*'))].filter(
(el) => el.textContent === value && !Array.from(el.children).some((c) => c.textContent === value),
);
expect(hits).toHaveLength(1);
return hits[0];
}

/** Utilities that keep a value on one line. */
const ONE_LINE = ['truncate', 'whitespace-nowrap', 'text-ellipsis', 'line-clamp-1'];

function oneLineClassesBetween(node: HTMLElement, stop: HTMLElement): string[] {
const found: string[] = [];
for (let el: HTMLElement | null = node; el; el = el.parentElement) {
for (const cls of ONE_LINE) if (el.classList.contains(cls)) found.push(cls);
if (el === stop) break;
}
return found;
}

beforeEach(() => {
// `useRecordEditable` probes `POST /api/v1/security/explain`; happy-dom would
// send that to a real socket, which the repo's network-escape guard fails the
// file for (objectui#6640). Its answer is orthogonal to the read display.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ allowed: true }),
text: async () => '{"allowed":true}',
})) as never);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

for (const layout of ['desktop', 'mobile'] as const) {
describe(`objectstack#25897 — record:details read mode, ${layout}: a text value wraps`, () => {
beforeEach(pinViewport(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH));

it('a `text` cell holds exactly the read display TextField renders for it', async () => {
await renderBody({ subject: SUBJECT });
expect(valueCellOf('Subject', layout).innerHTML).toBe(textReadDisplay('subject', 'text', SUBJECT));
});

it('nothing between the drawn subject and the row keeps it on one line', async () => {
await renderBody({ subject: SUBJECT });
const cell = valueCellOf('Subject', layout);
const node = drawingNodeOf(cell, SUBJECT);
expect(node.textContent).toBe(SUBJECT);
expect(oneLineClassesBetween(node, cell)).toEqual([]);
});
});
}

describe('objectstack#25897 — every type the table sends to the text cell reads the same way', () => {
beforeEach(pinViewport(DESKTOP_WIDTH));

it('an `auto_number` value reads through TextField too', async () => {
await renderBody({ subject: SUBJECT, reference: 'REF-000123' });
const cell = valueCellOf('Reference', 'desktop');
expect(cell.innerHTML).toBe(textReadDisplay('reference', 'auto_number', 'REF-000123'));
expect(oneLineClassesBetween(drawingNodeOf(cell, 'REF-000123'), cell)).toEqual([]);
});
});

describe('objectstack#25897 — CONTROLS', () => {
beforeEach(pinViewport(DESKTOP_WIDTH));

it('the grid keeps its one-line text cell, titled with the whole value', () => {
expect(getCellRenderer('text')).toBe(TextCellRenderer);
const { container } = render(
<TextCellRenderer value={SUBJECT} field={{ name: 'subject', type: 'text' } as never} />,
);
const span = container.querySelector('span');
expect(span).toHaveClass('truncate');
expect(span).toHaveAttribute('title', SUBJECT);
});

it("a host's own `text` face is kept on the record page", async () => {
const shipped = getCellRenderer('text');
const HostText = ({ value }: { value: unknown }) => <em data-host-text>{String(value)}</em>;
try {
registerFieldRenderer('text', HostText as never);
await renderBody({ subject: SUBJECT });
const cell = valueCellOf('Subject', 'desktop');
expect(cell.querySelector('em[data-host-text]')?.textContent).toBe(SUBJECT);
} finally {
registerFieldRenderer('text', shipped);
}
});
});
Loading
Loading