diff --git a/.changeset/25897-record-read-mode-wraps-text.md b/.changeset/25897-record-read-mode-wraps-text.md new file mode 100644 index 00000000000..6de5082fdd7 --- /dev/null +++ b/.changeset/25897-record-read-mode-wraps-text.md @@ -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. diff --git a/scripts/sdui-manifest.sources/plugin-detail.json b/scripts/sdui-manifest.sources/plugin-detail.json index 2586fca555c..986c530b31b 100644 --- a/scripts/sdui-manifest.sources/plugin-detail.json +++ b/scripts/sdui-manifest.sources/plugin-detail.json @@ -1 +1 @@ -{"package":"ui/packages/plugin-detail","sources":54,"sha256":"21ccb887f5b2ccb7f48b871632b94f43e15f2264810fa6d08f6ba107367dd7f6","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} +{"package":"ui/packages/plugin-detail","sources":54,"sha256":"c13b11a725295ab5e510c572d774271e3ec9a7120442907249bace960974f9bd","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"} diff --git a/ui/packages/plugin-detail/src/DetailSection.tsx b/ui/packages/plugin-detail/src/DetailSection.tsx index 818637c83e6..c250f9bfcac 100644 --- a/ui/packages/plugin-detail/src/DetailSection.tsx +++ b/ui/packages/plugin-detail/src/DetailSection.tsx @@ -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'; @@ -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 => {}; @@ -580,6 +583,28 @@ export const DetailSection: React.FC = ({ 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 ( + + ); + } if (CellRenderer) { return ( = ({ // 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 diff --git a/ui/packages/plugin-detail/src/__tests__/HeaderHighlight.dateWideFloor-25897.test.tsx b/ui/packages/plugin-detail/src/__tests__/HeaderHighlight.dateWideFloor-25897.test.tsx new file mode 100644 index 00000000000..ec4e7c881bd --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/HeaderHighlight.dateWideFloor-25897.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + const clipBox = chip('SLA Due').querySelector('span.truncate'); + expect(clipBox).not.toBeNull(); + expect(clipBox).toHaveAttribute('title', SLA_DUE); + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textReadWraps-25897.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textReadWraps-25897.test.tsx new file mode 100644 index 00000000000..5bb34bbbb2f --- /dev/null +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textReadWraps-25897.test.tsx @@ -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) { + const ds = { getObjectSchema: vi.fn(async () => caseSchema) }; + render( + + + , + ); + 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( {}} 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('*'))].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( + , + ); + 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 }) => {String(value)}; + 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); + } + }); +}); diff --git a/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textareaReadDisplay-11577.test.tsx b/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textareaReadDisplay-11577.test.tsx index b8771282826..59e2dfc8243 100644 --- a/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textareaReadDisplay-11577.test.tsx +++ b/ui/packages/plugin-detail/src/renderers/__tests__/record-details.textareaReadDisplay-11577.test.tsx @@ -38,15 +38,16 @@ * Controls, per the ruling's pins: * - a single-line value reads back as the same single line (green on both * sides of the fix: its text is what the pin holds unchanged); - * - other plain-text types (`text`, `email`) still render their own cell - * renderer's markup, unchanged, even when handed a multi-line value. + * - other plain-text types (`text`, `email`) never borrow the textarea's + * display: `email` keeps its own cell, and `text` reads through + * `TextField`'s read display since objectstack#25897. */ 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 { TextAreaField, TextCellRenderer, EmailCellRenderer } from '@object-ui/fields'; +import { TextAreaField, TextField, EmailCellRenderer } from '@object-ui/fields'; import { RecordDetailsRenderer } from '../record-details'; /** A blank line inside and a trailing newline: the bytes a one-line cell drops. */ @@ -194,11 +195,17 @@ for (const layout of ['desktop', 'mobile'] as const) { describe('objectui#11577 — CONTROL: other plain-text types render their own cell, unchanged', () => { beforeEach(pinViewport(DESKTOP_WIDTH)); - it('`text` keeps TextCellRenderer, even for a multi-line value', async () => { + // `text` left the grid's one-line cell in objectstack#25897 (its own pin is + // `record-details.textReadWraps-25897`); it reads through `TextField`'s read + // display, which wraps but does NOT keep line breaks. So the control here is + // that a `text` row never borrows the textarea's display. + it('`text` reads the TextField display, not the TextAreaField one, even for a multi-line value', async () => { await renderBody({ summary: MULTI_LINE }); - expect(valueCellOf('Summary', 'desktop').innerHTML).toBe( - markupOf(), + const cell = valueCellOf('Summary', 'desktop'); + expect(cell.innerHTML).toBe( + markupOf( {}} readonly />), ); + expect(cell.innerHTML).not.toBe(textareaReadDisplay(MULTI_LINE)); }); it('`email` keeps EmailCellRenderer', async () => {