From 66d51856a642d0a5f5ae63cd7bc5d16e5aedbe8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:35:55 +0000 Subject: [PATCH 1/4] feat(plugin-detail): fold a kept all-empty section, and an all-empty body (objectui#13386) Ruling C on objectui#13386: in read-only mode an all-empty section that is kept (hideEmpty false, or omitted where no default resolves it) draws its heading plus one "Show N empty fields" row, the toggle a partly-filled section uses, instead of a column of em-dashes. Edit mode and print pages draw every row, as before. A body whose sections are all empty folds once more into one row (objectui#12618 item 2). Claude-Session: https://claude.ai/code/session_01AqaieDWuDnBRyh3u8epGx6 Co-authored-by: Claude --- packages/plugin-detail/src/DetailSection.tsx | 46 ++- packages/plugin-detail/src/DetailView.tsx | 21 +- .../plugin-detail/src/EmptySectionsFold.tsx | 105 +++++++ .../DetailSection.allEmptyFold-13386.test.tsx | 290 ++++++++++++++++++ ...ailSectionAuthoredHideEmpty-10485.test.tsx | 37 ++- packages/plugin-detail/src/index.tsx | 4 +- ...ecord-details.emptySectionDefault.test.tsx | 23 +- ...ord-details.hideEmptyRetired-7129.test.tsx | 12 +- .../src/renderers/record-details.tsx | 3 +- packages/types/src/record-components.ts | 6 +- packages/types/src/views.ts | 12 +- packages/types/src/zod/views.zod.ts | 2 +- 12 files changed, 519 insertions(+), 42 deletions(-) create mode 100644 packages/plugin-detail/src/EmptySectionsFold.tsx create mode 100644 packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 06ee54859e..818637c83e 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -338,8 +338,9 @@ export const DetailSection: React.FC = ({ // the key — a premise the 17.3.0 pin move made false. The spec declares it // on `RecordDetailsProps.sections[]` and its describe() promises exactly // this: hiding is the renderer default, an all-empty section then renders - // nothing at all (no heading, no skeleton), and `false` keeps the heading - // and the label skeleton on an all-empty record. + // nothing at all (no heading, no skeleton), and `false` keeps the section + // on an all-empty record — in read-only mode as its heading plus one fold + // row (objectui#13386, `foldAllEmptySection` below). // // ⚠️ `=== true`, and the DEFAULT IS NOT HERE. `RecordDetailsRenderer` // resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section), @@ -365,11 +366,33 @@ export const DetailSection: React.FC = ({ const allFieldsEmpty = section.fields.length > 0 && filledCount === 0; const hideAllEmptySection = section.hideEmpty === true && allFieldsEmpty && !isEditing; + // objectui#13386 (ruling C, batch #7 item 4, maintainer 2026-10-23) — what a + // KEPT all-empty section draws for a reader: its heading plus ONE fold row, + // the same "Show N empty fields" toggle a partly-filled section uses, instead + // of a column of em-dashes. `@objectstack/spec`'s `hideEmpty` describe() now + // states exactly this (objectstack#25511). No key, shape or default moved: + // the hide above still decides WHETHER the section draws, this decides only + // HOW a section that draws looks. + // + // "Kept" is every all-empty section the hide does not take — an authored + // `false`, and an omitted key wherever no default resolves it (`detail-view`, + // the `detail-section` node, the `record:details` direct-`fields` fallback). + // Omitted and `false` already meant the same on those surfaces, and the fold + // keeps them meaning the same, so `!hideAllEmptySection` and not + // `=== false`. + // + // Same two boundaries as the heuristic above, for the same reasons: edit mode + // renders every row as an input (the ruling leaves it unchanged), and a print + // page prints every declared row with no toggle (objectui#12717). + const foldAllEmptySection = + allFieldsEmpty && !hideAllEmptySection && !isEditing && !isPrintPage; + const hideEmptyEffective = - !showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection); + !showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection || foldAllEmptySection); - // Filter out empty fields when the auto-hide heuristic kicked in, or when an - // all-empty section is hiding itself (its early return is below every hook). + // Filter out empty fields when the auto-hide heuristic kicked in, when an + // all-empty section is folded, or when one is hiding itself (its early + // return is below every hook). const visibleFields = hideEmptyEffective ? section.fields.filter((field) => !isEmptyValue(field)) : section.fields; @@ -772,7 +795,7 @@ export const DetailSection: React.FC = ({ // Hide entire section when all fields are empty AND the user has not asked to // reveal them. Who decides this is the authored `hideEmpty` (`hideEmptyEffective` - // above, default on, `false` to keep the skeleton); this line is the mechanism + // above, default on, `false` to keep the section); this line is the mechanism // it reaches, which is why restoring the read there needed no new exit here. // This early return MUST come AFTER every hook above (including // the virtual-scroll useEffect) — never before. When a section is all-empty @@ -782,18 +805,19 @@ export const DetailSection: React.FC = ({ // than during the previous render"). This is the master-detail drill-in // crash: navigating account → project reuses this DetailSection fiber, and // its sections flip from empty to populated. Keeping the guard below all - // hooks makes the hook count invariant. - if (visibleFields.length === 0 && emptyCount === section.fields.length) return null; + // hooks makes the hook count invariant. A FOLDED all-empty section is not + // hidden: it draws its heading and the fold row (objectui#13386). + if (visibleFields.length === 0 && emptyCount === section.fields.length && !foldAllEmptySection) return null; const renderedFields = visibleCount !== undefined ? layoutFields.slice(0, visibleCount) : layoutFields; - const showEmptyToggle = emptyCount > 0 && shouldAutoHideEmpty; + const showEmptyToggle = emptyCount > 0 && (shouldAutoHideEmpty || foldAllEmptySection); const content = ( <> - {isMobile ? ( + {renderedFields.length === 0 ? null : isMobile ? (
{renderedFields.map(renderField)}
@@ -811,7 +835,7 @@ export const DetailSection: React.FC = ({ )} {showEmptyToggle && ( -
+
0 && 'mt-3')}> +
+ ); + + if (!revealed) return toggle; + return ( + <> + {children} + {toggle} + + ); +}; diff --git a/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx new file mode 100644 index 0000000000..1fe451fabe --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx @@ -0,0 +1,290 @@ +/** + * 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#13386 (ruling C, batch #7 item 4, maintainer 2026-10-23) — a KEPT + * all-empty section folds like a partly-filled one, and a body whose sections + * are all empty folds once more (item 2 of objectui#12618). + * + * The ruling's done-when, case by case: + * + * - read-only, an all-empty `hideEmpty: false` section draws its heading plus + * ONE "Show N empty fields" row — the toggle the mixed sections use — and no + * em-dash; the row reveals every field and folds them back; + * - edit mode is unchanged: every field is an editable row, no fold; + * - a mixed section is unchanged (objectui#7129 Q2-C); + * - a body whose sections are all empty draws ONE row for the whole body. + * + * The fixture is the HotCRM opportunity's 「交易说明」 (Deal Narrative) section, + * four prose fields a new deal leaves empty, beside its siblings. + */ + +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { PrintPageProvider } from '@object-ui/react'; +import type { DetailViewSchema, DetailViewSection } from '@object-ui/types'; +import { DetailSection } from '../DetailSection'; +import { DetailView } from '../DetailView'; +import { EmptySectionsFold, emptySectionsFoldCount } from '../EmptySectionsFold'; + +const objectSchema = { + fields: { + customer_background: { type: 'textarea', label: 'Customer Background' }, + project_background: { type: 'textarea', label: 'Project Background' }, + risk_analysis: { type: 'textarea', label: 'Risk Analysis' }, + payment_terms: { type: 'textarea', label: 'Payment Terms' }, + description: { type: 'textarea', label: 'Description' }, + next_step: { type: 'text', label: 'Next Step' }, + type: { type: 'text', label: 'Type' }, + lead_source: { type: 'text', label: 'Lead Source' }, + }, +}; + +const NARRATIVE_LABELS = ['Customer Background', 'Project Background', 'Risk Analysis', 'Payment Terms']; + +const narrative = (overrides: Partial = {}): DetailViewSection => ({ + title: 'Deal Narrative', + hideEmpty: false, + fields: [ + { name: 'customer_background', label: 'Customer Background' }, + { name: 'project_background', label: 'Project Background' }, + { name: 'risk_analysis', label: 'Risk Analysis' }, + { name: 'payment_terms', label: 'Payment Terms' }, + ], + ...overrides, +}); + +const notes = (overrides: Partial = {}): DetailViewSection => ({ + title: 'Notes & Next Steps', + hideEmpty: false, + fields: [ + { name: 'description', label: 'Description' }, + { name: 'next_step', label: 'Next Step' }, + ], + ...overrides, +}); + +const classification: DetailViewSection = { + title: 'Classification', + fields: [ + { name: 'type', label: 'Type' }, + { name: 'lead_source', label: 'Lead Source' }, + ], +}; + +const placeholders = () => screen.queryAllByTitle('No value'); +const foldButton = () => screen.queryByRole('button', { name: /empty field/i }); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('objectui#13386 — a kept all-empty section folds (read-only)', () => { + it('draws its heading plus ONE "Show 4 empty fields" row, and no em-dash', () => { + render(); + + expect(screen.getByText('Deal Narrative')).toBeInTheDocument(); + expect(foldButton()?.textContent).toMatch(/Show 4 empty fields/); + expect(screen.getAllByRole('button', { name: /empty field/i })).toHaveLength(1); + expect(placeholders()).toHaveLength(0); + for (const label of NARRATIVE_LABELS) { + expect(screen.queryByText(label)).toBeNull(); + } + }); + + it('the row reveals every field, and folds them back', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Show 4 empty fields/ })); + for (const label of NARRATIVE_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(placeholders()).toHaveLength(4); + + fireEvent.click(screen.getByRole('button', { name: /Hide empty fields/ })); + expect(placeholders()).toHaveLength(0); + expect(foldButton()?.textContent).toMatch(/Show 4 empty fields/); + }); + + it('folds an untitled, borderless section too: the row is the whole section', () => { + render( + , + ); + expect(foldButton()?.textContent).toMatch(/Show 4 empty fields/); + expect(placeholders()).toHaveLength(0); + }); + + it('CONTROL — `hideEmpty: true` still hides the all-empty section outright', () => { + const { container } = render( + , + ); + expect(container.textContent).toBe(''); + }); +}); + +describe('objectui#13386 — edit mode is unchanged', () => { + it('every field of an all-empty section is an editable row, with no fold', () => { + render( + {}} + />, + ); + + expect(foldButton()).toBeNull(); + for (const label of NARRATIVE_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(screen.getAllByRole('textbox')).toHaveLength(4); + }); +}); + +describe('objectui#13386 — a print page prints every row (objectui#12717)', () => { + it('a kept all-empty section prints every declared row, with no toggle', () => { + render( + + + , + ); + expect(foldButton()).toBeNull(); + expect(placeholders()).toHaveLength(4); + }); +}); + +describe('objectui#13386 — a mixed section is unchanged (objectui#7129 Q2-C)', () => { + it('a small partly-filled section keeps its empty row in place', () => { + render( + , + ); + expect(screen.getByText('Expansion deal')).toBeInTheDocument(); + expect(screen.getByText('Next Step')).toBeInTheDocument(); + expect(placeholders()).toHaveLength(1); + expect(foldButton()).toBeNull(); + }); + + it('a large partly-filled section folds its empty rows behind the toggle, as before', () => { + render( + , + ); + expect(screen.getByText('Long-time account')).toBeInTheDocument(); + expect(foldButton()?.textContent).toMatch(/Show 3 empty fields/); + expect(placeholders()).toHaveLength(0); + }); +}); + +describe('objectui#13386 — a body whose sections are all empty folds once more (#12618 item 2)', () => { + const detailView = (sections: DetailViewSection[], data: Record): DetailViewSchema => ({ + type: 'detail-view', + data: { id: 'opp-1', ...data }, + sections, + }); + + it('draws ONE row for the whole body, counting every kept empty field', () => { + render( + , + ); + + expect(screen.getAllByRole('button', { name: /empty field/i })).toHaveLength(1); + expect(foldButton()?.textContent).toMatch(/Show 8 empty fields/); + for (const heading of ['Deal Narrative', 'Notes & Next Steps', 'Classification']) { + expect(screen.queryByText(heading)).toBeNull(); + } + expect(placeholders()).toHaveLength(0); + }); + + it('revealing it draws each section folded on its own, and a row folds the body back', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Show 6 empty fields/ })); + expect(screen.getByText('Deal Narrative')).toBeInTheDocument(); + expect(screen.getByText('Notes & Next Steps')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show 4 empty fields/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show 2 empty fields/ })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Hide empty fields/ })); + expect(screen.queryByText('Deal Narrative')).toBeNull(); + expect(foldButton()?.textContent).toMatch(/Show 6 empty fields/); + }); + + it('CONTROL — one filled field anywhere in the body and every section draws as before', () => { + render( + , + ); + + expect(screen.getByText('Classification')).toBeInTheDocument(); + expect(screen.getByText('New Business')).toBeInTheDocument(); + expect(screen.getByText('Deal Narrative')).toBeInTheDocument(); + expect(screen.getByText('Notes & Next Steps')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show 4 empty fields/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Show 2 empty fields/ })).toBeInTheDocument(); + }); + + it('CONTROL — a single kept section is already one row: it keeps its heading', () => { + render(); + + expect(screen.getByText('Deal Narrative')).toBeInTheDocument(); + expect(screen.queryByText('Notes & Next Steps')).toBeNull(); + expect(foldButton()?.textContent).toMatch(/Show 4 empty fields/); + }); + + it('edit mode and a print page draw every section', () => { + const sections = [narrative(), notes()]; + const body =
sections
; + + const editing = render( + + {body} + , + ); + expect(screen.getByTestId('sections')).toBeInTheDocument(); + expect(foldButton()).toBeNull(); + editing.unmount(); + + render( + + + {body} + + , + ); + expect(screen.getByTestId('sections')).toBeInTheDocument(); + expect(foldButton()).toBeNull(); + }); + + it('the count: hidden and field-less sections neither count nor block the fold', () => { + const empty = { title: 'Financials', fields: [] } as DetailViewSection; + expect(emptySectionsFoldCount([narrative(), notes(), empty, { ...classification, hideEmpty: true }], {})).toBe(6); + // A filled field in a HIDDEN-when-empty section is still a filled field. + expect( + emptySectionsFoldCount([narrative(), notes(), { ...classification, hideEmpty: true }], { type: 'x' }), + ).toBe(0); + // Whitespace is empty here, as it is for the section fold (`hasCellValue`). + expect(emptySectionsFoldCount([narrative(), notes()], { next_step: ' ' })).toBe(6); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx b/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx index 8b1f5f8250..8b500e366c 100644 --- a/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx +++ b/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx @@ -51,9 +51,11 @@ * `SchemaRenderer` and the real registry, over a record on which every field * of the section is empty. `true` hides the section: no heading, no * skeleton. A sibling node in the SAME render, identical but for the key, - * draws its heading and its placeholder, so the absence is a decision about + * draws its heading and its fold row, so the absence is a decision about * `hideEmpty` and not an artefact of the fixture. `false` and an omitted key - * both keep the heading and the placeholder — the node's declared default. + * both keep the section — the node's declared default. Since objectui#13386 + * (ruling C) a kept all-empty section draws, read-only, its heading plus ONE + * fold row in place of its placeholders, so "kept" is read off the fold row. */ import React from 'react'; @@ -110,6 +112,18 @@ const placeholdersIn = (heading: HTMLElement | null) => { return card ? card.querySelectorAll('[title="No value"]').length : 0; }; +/** + * The fold row a kept all-empty section draws in read-only mode + * (objectui#13386): the text of the section's "Show N empty field(s)" toggle. + */ +const foldIn = (heading: HTMLElement | null) => { + const card = heading?.closest('.bg-card') ?? null; + const button = card + ? Array.from(card.querySelectorAll('button')).find((b) => /empty field/i.test(b.textContent ?? '')) + : undefined; + return button?.textContent ?? null; +}; + /** * Renders the node under test beside a CONTROL node that is identical except * for its title and for carrying no `hideEmpty`, over the same all-empty record. @@ -150,25 +164,28 @@ describe('objectui#10485 — an authored detail-section hideEmpty reaches Detail renderBesideControl({ hideEmpty: true }); // The lit control: the same all-empty section, without the key, in the - // same render, draws its heading and its placeholder. + // same render, draws its heading and its fold row. const control = screen.getByText(CONTROL_TITLE); - expect(placeholdersIn(control)).toBe(1); + expect(foldIn(control)).toMatch(/Show 1 empty field/); expect(screen.queryByText(TITLE)).toBeNull(); - expect(screen.getAllByTitle('No value')).toHaveLength(1); + expect(screen.getAllByRole('button', { name: /empty field/i })).toHaveLength(1); + expect(screen.queryAllByTitle('No value')).toHaveLength(0); }); - it('`hideEmpty: false` keeps the heading and the label skeleton of an all-empty section', () => { + it('`hideEmpty: false` keeps an all-empty section: its heading plus one fold row', () => { renderBesideControl({ hideEmpty: false }); - expect(placeholdersIn(screen.getByText(TITLE))).toBe(1); - expect(placeholdersIn(screen.getByText(CONTROL_TITLE))).toBe(1); + expect(foldIn(screen.getByText(TITLE))).toMatch(/Show 1 empty field/); + expect(foldIn(screen.getByText(CONTROL_TITLE))).toMatch(/Show 1 empty field/); + expect(placeholdersIn(screen.getByText(TITLE))).toBe(0); }); it('an OMITTED hideEmpty keeps the all-empty section too: the node declares no hide by default', () => { render(); - expect(screen.getByText('Street')).toBeTruthy(); - expect(placeholdersIn(screen.getByText(TITLE))).toBe(1); + expect(foldIn(screen.getByText(TITLE))).toMatch(/Show 1 empty field/); + // Folded, not dropped: the row is one click away. + expect(screen.queryByText('Street')).toBeNull(); }); }); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 29b3b91949..00c430659a 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -406,7 +406,7 @@ ComponentRegistry.register('detail-section', DetailSectionNode, { name: 'hideEmpty', type: 'boolean', description: - 'When every field in the section is empty, `true` hides the whole section (no heading, no skeleton). Omitted or `false`, an all-empty section keeps its heading and label skeleton. Empty fields in a section that still has a filled one are not governed by this key.', + 'When every field in the section is empty, `true` hides the whole section (no heading, no skeleton). Omitted or `false`, an all-empty section is kept: in read-only mode it draws its heading plus one row that counts the empty fields and reveals them on request; in edit mode every field is an editable row. Empty fields in a section that still has a filled one are not governed by this key.', }, ], }); @@ -548,7 +548,7 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // `record:reference_rail`'s own component-level `hideEmpty` input below. inputs: [ { name: 'columns', type: 'enum', enum: ['1', '2', '3', '4'], description: 'Number of columns for field layout (1-4)' }, - { name: 'sections', type: 'array', of: 'object', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: \'contact_info\' }` inherits the object\'s `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order), its presentation (label, icon, description, collapse) and its `visibleWhen` all come from the group, so the section restates none of it — the predicate is evaluated against the bound record exactly as the entry form evaluates it, and a group it hides renders nothing (a `visibleWhen` written on a section itself is refused by the spec and not read: gate the group and reference it) and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps that heading and its label skeleton on a brand-new record. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature. @objectstack/spec 18 declares a ninth, `count` (`{ field }`): the renderer fills the heading\'s `{{ count }}` hole with how many values that multi-value field holds on the current record — `{ name: \'tagging\', label: \'{{ count }} tags\', count: { field: \'tags\' }, fields: [\'tags\'] }` reads `3 tags` when three are selected. An absent or empty value counts 0; `count` changes only the heading, never whether the section renders, so with `hideEmpty` on an all-empty section is still absent. The hole is filled after the heading is translated, so each locale of an inline label map and a translated objects.._sections..label write the same `{{ count }}`. The spec refuses `count` beside `group`, on a section with no `label`, and on a label with no hole; a heading on a section without `count` prints as written. Like the eight, `count` has no designer control yet.' }, + { name: 'sections', type: 'array', of: 'object', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: \'contact_info\' }` inherits the object\'s `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order), its presentation (label, icon, description, collapse) and its `visibleWhen` all come from the group, so the section restates none of it — the predicate is evaluated against the bound record exactly as the entry form evaluates it, and a group it hides renders nothing (a `visibleWhen` written on a section itself is refused by the spec and not read: gate the group and reference it) and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps the section on a brand-new record: in read-only mode as its heading plus one fold row that counts the empty fields and reveals them on request (the same fold a partly-filled section uses, objectui#13386), in edit mode as every field an editable row. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature. @objectstack/spec 18 declares a ninth, `count` (`{ field }`): the renderer fills the heading\'s `{{ count }}` hole with how many values that multi-value field holds on the current record — `{ name: \'tagging\', label: \'{{ count }} tags\', count: { field: \'tags\' }, fields: [\'tags\'] }` reads `3 tags` when three are selected. An absent or empty value counts 0; `count` changes only the heading, never whether the section renders, so with `hideEmpty` on an all-empty section is still absent. The hole is filled after the heading is translated, so each locale of an inline label map and a translated objects.._sections..label write the same `{{ count }}`. The spec refuses `count` beside `group`, on a section with no `label`, and on a label with no hole; a heading on a section without `count` prints as written. Like the eight, `count` has no designer control yet.' }, { name: 'fields', type: 'array', of: 'string', description: 'Explicit field list (overrides highlightFields)' }, // `hideFields` is DECLARED, not merely honoured (objectui#3808). The spec // declares it (objectstack#5611) and `RecordDetailsRenderer` has read it diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx index bef6641990..64d372826e 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx @@ -69,7 +69,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { RecordContextProvider } from '@object-ui/react'; import { RecordDetailsRenderer } from '../record-details'; @@ -304,10 +304,13 @@ describe('record:details — an authored `hideEmpty` decides the ALL-EMPTY secti expect(emptyPlaceholders()).toHaveLength(0); }); - it('`hideEmpty: false` KEEPS the heading and the label skeleton of an all-empty section', () => { + it('`hideEmpty: false` KEEPS an all-empty section: its heading plus one fold row (#13386)', () => { // The escape hatch, and the half that makes the key worth declaring: a // brand-new record keeps the structure its author wrote. Under objectui#7129 - // this spelling parsed nowhere and read nowhere; it now does both. + // this spelling parsed nowhere and read nowhere; it now does both. Ruling C + // on objectui#13386 settled HOW it is kept for a reader: the heading plus + // ONE fold row — the toggle a partly-filled section uses — not a column of + // placeholders. The rows are one click away. renderDetails({ sections: [ { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: false }, @@ -317,10 +320,18 @@ describe('record:details — an authored `hideEmpty` decides the ALL-EMPTY secti expect(screen.getByText('Firmographics')).toBeInTheDocument(); expect(screen.getByText('Deal Terms')).toBeInTheDocument(); + for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + expect(emptyPlaceholders()).toHaveLength(0); + const fold = screen.getByRole('button', { name: /Show 4 empty fields/ }); + + fireEvent.click(fold); for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { expect(screen.getByText(label)).toBeInTheDocument(); } expect(emptyPlaceholders()).toHaveLength(4); + expect(screen.getByRole('button', { name: /Hide empty fields/ })).toBeInTheDocument(); }); it('the OTHER domain is untouched: all three spellings render the same partly-filled section (#7129 Q2-C)', () => { @@ -356,7 +367,7 @@ describe('record:details — an authored `hideEmpty` decides the ALL-EMPTY secti }); describe('record:details — the default reaches ONLY the surface that declares the key (#8603)', () => { - it('the direct-`fields` fallback body keeps its skeleton when every field is empty', () => { + it('the direct-`fields` fallback body is kept when every field is empty, folded (#13386)', () => { // No `sections`, so the body falls back to the authored `fields` list and // `DetailView` synthesizes the section itself. There is no entry for an // author to write `hideEmpty` on, in the spec or anywhere else, so the @@ -366,8 +377,12 @@ describe('record:details — the default reaches ONLY the surface that declares // ⚠️ This case is what makes `=== true` in `DetailSection` load-bearing // rather than stylistic: under `!== false` the synthesized section would // take the hide, and this body would be empty. + // + // Kept, it folds like every kept all-empty section (objectui#13386): one + // fold row, and the rows behind it. renderDetails({ fields: ['stage', 'amount', 'close_date', 'next_step'] }, {}); + fireEvent.click(screen.getByRole('button', { name: /Show 4 empty fields/ })); for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { expect(screen.getByText(label)).toBeInTheDocument(); } diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx index 29887d21ee..cbc5378d1a 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx @@ -71,7 +71,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { RecordContextProvider } from '@object-ui/react'; import { RecordDetailsProps } from '@objectstack/spec/ui'; @@ -263,7 +263,7 @@ describe('DetailViewSection.hideEmpty — all four parties declare and honour it it('4/4 — `record:details` READS the key end to end, in BOTH directions', () => { // An all-empty section is the case this key owns and the case the read // decides: `hideEmpty: true` (and the renderer default) makes the whole - // section disappear, `false` keeps its heading and label skeleton. + // section disappear, `false` keeps it — folded, read-only (#13386). // // ⚠️ Deliberately end-to-end rather than "the renderer passes it on". // Measured on #7129's ablation: `RecordDetailsRenderer` spreads `...s`, so @@ -312,11 +312,15 @@ describe('DetailViewSection.hideEmpty — all four parties declare and honour it expect(screen.queryAllByTitle('No value')).toHaveLength(0); hidden.unmount(); - // (b) authored `false` — heading and skeleton stay. Same fixture, same - // control, so the pair isolates the key and nothing else. + // (b) authored `false` — the section stays. Same fixture, same control, so + // the pair isolates the key and nothing else. Read-only, a kept all-empty + // section is its heading plus one fold row (ruling C, objectui#13386), and + // the rows are behind it. const kept = renderWith({ hideEmpty: false }); expect(screen.getByText('Firmographics')).toBeInTheDocument(); expect(screen.getByText('Deal Terms')).toBeInTheDocument(); + expect(screen.queryAllByTitle('No value')).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: /Show 3 empty fields/ })); for (const label of ['Stage', 'Amount', 'Close Date']) { expect(screen.getByText(label)).toBeInTheDocument(); } diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 90106dfff7..3dd0370806 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -1036,7 +1036,8 @@ const RecordDetailsBody: React.FC = ({ // entry with no schema default, and states the fallback as the // renderer's: hiding is on, so a section whose fields are ALL empty // renders nothing at all — no heading, no skeleton — and `false` keeps - // the heading and the label skeleton a brand-new record needs. + // the section a brand-new record needs (read-only, its heading plus one + // fold row since objectui#13386; edit mode, every row). // // ⭐ The default is resolved HERE, on an AUTHORED section, and that // placement is the whole design. `DetailSection` tests `=== true`, so diff --git a/packages/types/src/record-components.ts b/packages/types/src/record-components.ts index e845cdd817..993323435a 100644 --- a/packages/types/src/record-components.ts +++ b/packages/types/src/record-components.ts @@ -224,8 +224,10 @@ export interface RecordDetailsComponentProps { * Hide this section's empty fields (`sections[].hideEmpty`, `z.boolean()` * on the spec row since 17.3.0). Unauthored, `RecordDetailsRenderer` reads * it as on (`s.hideEmpty ?? true`), and a section whose fields are ALL - * empty then renders nothing; `false` keeps the empty rows and the - * section's heading skeleton. Permitted beside `group`. + * empty then renders nothing; `false` keeps the section — in read-only + * mode its heading plus one fold row that counts the empty fields and + * reveals them on request (objectui#13386), in edit mode every row. + * Permitted beside `group`. * * Missing here until objectui#12348: the spec declared it and the renderer * honoured it, so this published face was the one layer that gave a diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 5e28ca14a6..47f292b2b8 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -360,8 +360,12 @@ export interface DetailViewSection { * Hide this section's empty fields, and — when EVERY field is empty — hide * the whole section: no heading, no skeleton. * - * Set `false` to keep an all-empty section's heading and label skeleton, the - * spelling a brand-new record needs so its authored sections do not vanish. + * Set `false` to keep an all-empty section, the spelling a brand-new record + * needs so its authored sections do not vanish. A kept all-empty section + * draws, in read-only mode, its heading plus ONE fold row that counts the + * empty fields and reveals them on request — the same fold a partly-filled + * section uses (ruling C on objectui#13386); in edit mode it draws every + * field as an editable row. * * ## ⚠️ Omitting it does NOT mean the same thing on both consumers * @@ -375,8 +379,8 @@ export interface DetailViewSection { * declared on. * - **`detail-view`** (`DetailViewRenderer`, whose registration takes a * `sections` input) hands each section to `DetailSection` unchanged. No - * default is applied there, so an omitted key leaves the all-empty section - * rendering its heading and skeleton. Only an explicit `true` hides it. + * default is applied there, so an omitted key keeps the all-empty section + * (folded, as above). Only an explicit `true` hides it. * * Measured at objectui#8603, non-vacuously — a sibling control section that * must render was present in every case, and it rendered in all six: diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 6ea22bd793..2ac53d3dee 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -117,7 +117,7 @@ export const DetailViewSectionSchema = z.object({ hideEmpty: z .boolean() .optional() - .describe('Hide an all-empty section entirely; `false` keeps its heading and label skeleton'), + .describe('Hide an all-empty section entirely; `false` keeps it — read-only as its heading plus one fold row, edit mode as every row'), // Closed vocabulary — the six design-system tint tokens // `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` resolves, and the six // `@objectstack/spec` declares on its strict `record:details` section schema From d94ce8fed2344cd2c428dbbd6553eaab32d8acf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:36:56 +0000 Subject: [PATCH 2/4] docs(plugin-detail): changeset and docs for the all-empty fold (objectui#13386) Claude-Session: https://claude.ai/code/session_01AqaieDWuDnBRyh3u8epGx6 Co-authored-by: Claude --- .changeset/13386-fold-all-empty-section.md | 9 ++++++++ content/docs/guide/print-pages.md | 3 +++ packages/plugin-detail/README.md | 26 ++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 .changeset/13386-fold-all-empty-section.md diff --git a/.changeset/13386-fold-all-empty-section.md b/.changeset/13386-fold-all-empty-section.md new file mode 100644 index 0000000000..fefcffa43c --- /dev/null +++ b/.changeset/13386-fold-all-empty-section.md @@ -0,0 +1,9 @@ +--- +"@object-ui/plugin-detail": minor +"@object-ui/types": patch +--- + +Clause-②: no +Metadata-protocol: semantic + +A record section whose fields are all empty, and that the page keeps (`hideEmpty: false`), now draws its heading plus one "Show N empty fields" row in read-only mode, the same fold a partly-filled section uses, instead of a placeholder row per field. Edit mode still draws every field as an editable row, and a print page still prints every row. When every section of a detail body is empty and at least two are kept, the body draws one "Show N empty fields" row for all of them (objectui#13386, objectui#12618). diff --git a/content/docs/guide/print-pages.md b/content/docs/guide/print-pages.md index 4f0e71d0e0..f771bad907 100644 --- a/content/docs/guide/print-pages.md +++ b/content/docs/guide/print-pages.md @@ -73,6 +73,9 @@ needs. the cap's lines and a note under them naming the cap and, when the server reports one, the total — a printed copy never drops lines in silence. - A collapsible `record:details` section draws open on a print page. +- A `record:details` section prints every declared field, empty ones included, + with no "Show N empty fields" row. A section `hideEmpty` hides still prints + nothing. A block reads whether it is on a print page with `usePrintPage()` from `@object-ui/react`, which returns the page's `print` declaration or `null`. diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index 7fcff68776..b1b19e231e 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -183,6 +183,32 @@ default), a section whose fields are all empty is still absent. The spec refuses `count` beside `group`, on a section without `label`, and on a label with no hole. A section without `count` prints its heading as written. +### Empty fields and empty sections (`hideEmpty`) + +A section that still has a filled field and enough empty ones folds the empty +rows behind a **Show N empty fields** row; the reader can reveal them and fold +them back. No authored value changes this. + +A section whose fields are ALL empty is decided by `hideEmpty`. On +`record:details` it defaults to on, and such a section renders nothing at all. +`hideEmpty: false` keeps it, which a brand-new record needs: + +```json +{ "group": "narrative", "hideEmpty": false } +``` + +In read-only mode a kept all-empty section draws its heading plus one +**Show N empty fields** row, the same fold a partly-filled section uses, and +not one placeholder row per field. In edit mode it draws every field as an +editable row. On `detail-view` and the `detail-section` node no default is +resolved, so an omitted key keeps the section the same way, and only `true` +hides it. + +When every section of a body is empty and at least two are kept, the body +draws ONE **Show N empty fields** row for all of them. Revealing it draws each +section folded on its own. On a print page every declared row prints, empty +ones included, with no toggle. + ### Basic Example ```tsx From 2a834bd6b3ff5d153a232c57a7efd6daa7477087 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:46:31 +0000 Subject: [PATCH 3/4] fix(plugin-detail): keep the body fold below the section boundaries; update pins (objectui#13386) The body fold reads every section above the per-section error boundaries, so a malformed section now turns it off instead of throwing and blanking the body. Pins that read an all-empty section's rows now reveal the fold first. Claude-Session: https://claude.ai/code/session_01AqaieDWuDnBRyh3u8epGx6 Co-authored-by: Claude --- .../plugin-detail/src/EmptySectionsFold.tsx | 30 ++++++++++++------- .../DetailSection.allEmptyFold-13386.test.tsx | 20 +++++++++++++ ...ilSection.emptinessAuthority-8376.test.tsx | 27 ++++++++++------- .../DetailSection.emptyArray-8474.test.tsx | 27 ++++++++++------- .../__tests__/inlineCredentialGate.test.tsx | 6 ++++ ...unresolvedIdentityFailClosed-9054.test.tsx | 6 +++- 6 files changed, 85 insertions(+), 31 deletions(-) diff --git a/packages/plugin-detail/src/EmptySectionsFold.tsx b/packages/plugin-detail/src/EmptySectionsFold.tsx index a4fe2bd957..7b80f2b889 100644 --- a/packages/plugin-detail/src/EmptySectionsFold.tsx +++ b/packages/plugin-detail/src/EmptySectionsFold.tsx @@ -37,17 +37,27 @@ export interface EmptySectionsFoldProps { * `DetailSection` folds by. */ export function emptySectionsFoldCount(sections: DetailViewSection[], data: any): number { - let kept = 0; - let emptyFields = 0; - for (const section of sections) { - const fields = section.fields ?? []; - if (fields.length === 0) continue; - if (fields.some((field) => hasCellValue(data?.[field.name] ?? field.value))) return 0; - if (section.hideEmpty === true) continue; - kept += 1; - emptyFields += fields.length; + // This runs ABOVE every per-section error boundary in `DetailView`, so a + // malformed section must not throw from here and blank the whole body + // (objectui#8497): a section with no field array, or anything that throws + // while reading, turns the fold off, leaving each section to render, or to + // fail visibly, inside its own boundary. + try { + let kept = 0; + let emptyFields = 0; + for (const section of sections) { + if (!Array.isArray(section?.fields)) return 0; + const fields = section.fields; + if (fields.length === 0) continue; + if (fields.some((field) => hasCellValue(data?.[field?.name] ?? field?.value))) return 0; + if (section.hideEmpty === true) continue; + kept += 1; + emptyFields += fields.length; + } + return kept >= 2 ? emptyFields : 0; + } catch { + return 0; } - return kept >= 2 ? emptyFields : 0; } /** diff --git a/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx index 1fe451fabe..d0eee6b027 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx @@ -288,3 +288,23 @@ describe('objectui#13386 — a body whose sections are all empty folds once more expect(emptySectionsFoldCount([narrative(), notes()], { next_step: ' ' })).toBe(6); }); }); + +describe('objectui#13386 — the body fold never blanks the body (objectui#8497)', () => { + it('a malformed section turns the fold off instead of throwing or hiding its failure', () => { + const exploding = { + title: 'Boom', + fields: [ + { + name: 'boom_field', + get value(): never { + throw new Error('section-local failure'); + }, + }, + ], + } as unknown as DetailViewSection; + expect(emptySectionsFoldCount([exploding, narrative(), notes()], {})).toBe(0); + expect( + emptySectionsFoldCount([{ title: 'Orphan' } as unknown as DetailViewSection, narrative(), notes()], {}), + ).toBe(0); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx index fc8c4bdda9..dc642651e2 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.emptinessAuthority-8376.test.tsx @@ -218,7 +218,7 @@ describe('DetailSection — one definition of emptiness, and it trims (#8376)', expect(affordances(), 'both revealed rows draw the affordance').toHaveLength(2); }); - it('⭐ AMPLIFICATION — one whitespace-only value must not suppress the all-empty skeleton', () => { + it('⭐ AMPLIFICATION — one whitespace-only value must not suppress the all-empty fold', () => { // The consequence that reaches furthest. 4 fields, every one of them empty // except `notes`, which holds only spaces. Under the raw test that was // `filledCount === 1`, which is all `shouldAutoHideEmpty` needs: the section @@ -229,21 +229,28 @@ describe('DetailSection — one definition of emptiness, and it trims (#8376)', // section with nothing visible would have dropped it entirely). expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true); - // The skeleton the heuristic reserves for an all-empty section: every label - // keeps its row, so the record reads as a structure waiting to be filled. + // The section is ALL empty, so it takes the all-empty fold (objectui#13386, + // ruling C): ONE row that counts all FOUR fields. Had one whitespace-only value counted as + // filled, the section would be partly filled instead — a lone `notes` row + // over a blank plus a toggle counting THREE — which is the defect this + // case exists to catch. + expect( + screen.queryByRole('button', { name: /empty fields/i })?.textContent, + 'the fold counts every row, `notes` included', + ).toMatch(/Show 4 empty fields/); + expect(shown('notes'), '`notes` is folded with the rest, not drawn alone').toBe(false); + expect(affordances(), 'nothing is drawn alone before the reveal').toHaveLength(0); + + // Revealed, every label keeps its row, so the record reads as a structure + // waiting to be filled. + fireEvent.click(screen.getByRole('button', { name: /Show 4 empty fields/ })); for (const label of ['notes', 'stage', 'amount', 'close_date']) { expect( shown(label), - `row \`${label}\` must survive the all-empty skeleton — one whitespace-only value must not arm auto-hide and bury the genuinely empty rows`, + `row \`${label}\` must survive the all-empty fold — one whitespace-only value must not arm auto-hide and bury the genuinely empty rows`, ).toBe(true); } expect(affordances(), 'all four rows draw the `No value` affordance').toHaveLength(4); - - // Nothing was hidden, so there is nothing to offer to reveal. - expect( - screen.queryByRole('button', { name: /empty fields/i }), - 'no rows were hidden, so no show-empty toggle is offered', - ).toBeNull(); }); it('NON-REGRESSION — object-valued cells are still FILLED (the half that must NOT delegate)', () => { diff --git a/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx index 3fb60b12a0..2a57e83585 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.emptyArray-8474.test.tsx @@ -223,7 +223,7 @@ describe('DetailSection — an empty array is not a cell value (#8474)', () => { expect(affordances(), 'both revealed rows draw the affordance').toHaveLength(2); }); - it('⭐ AMPLIFICATION — one empty array must not suppress the all-empty skeleton', () => { + it('⭐ AMPLIFICATION — one empty array must not suppress the all-empty fold', () => { // The consequence that reaches furthest, and the one a single-row fixture // misses. 4 fields, every one of them empty except `tags`, which holds `[]`. // Before the fix that was `filledCount === 1`, all `shouldAutoHideEmpty` @@ -234,21 +234,28 @@ describe('DetailSection — an empty array is not a cell value (#8474)', () => { // CONTROL — the section rendered at all. expect(shown('Details'), 'CONTROL: the section heading rendered').toBe(true); - // The skeleton the heuristic reserves for an all-empty section: every label - // keeps its row, so the record reads as a structure waiting to be filled. + // The section is ALL empty, so it takes the all-empty fold (objectui#13386, + // ruling C): ONE row that counts all FOUR fields. Had one empty array counted as + // filled, the section would be partly filled instead — a lone `tags` row + // over a blank plus a toggle counting THREE — which is the defect this + // case exists to catch. + expect( + screen.queryByRole('button', { name: /empty fields/i })?.textContent, + 'the fold counts every row, `tags` included', + ).toMatch(/Show 4 empty fields/); + expect(shown('tags'), '`tags` is folded with the rest, not drawn alone').toBe(false); + expect(affordances(), 'nothing is drawn alone before the reveal').toHaveLength(0); + + // Revealed, every label keeps its row, so the record reads as a structure + // waiting to be filled. + fireEvent.click(screen.getByRole('button', { name: /Show 4 empty fields/ })); for (const label of ['tags', 'stage', 'amount', 'close_date']) { expect( shown(label), - `row \`${label}\` must survive the all-empty skeleton — one empty array must not arm auto-hide and bury the genuinely empty rows`, + `row \`${label}\` must survive the all-empty fold — one empty array must not arm auto-hide and bury the genuinely empty rows`, ).toBe(true); } expect(affordances(), 'all four rows draw the `No value` affordance').toHaveLength(4); - - // Nothing was hidden, so there is nothing to offer to reveal. - expect( - screen.queryByRole('button', { name: /empty fields/i }), - 'no rows were hidden, so no show-empty toggle is offered', - ).toBeNull(); }); it('NON-REGRESSION — a POPULATED array is still a value, and so is a populated object', () => { diff --git a/packages/plugin-detail/src/__tests__/inlineCredentialGate.test.tsx b/packages/plugin-detail/src/__tests__/inlineCredentialGate.test.tsx index 0c4047e09d..ddd6193f35 100644 --- a/packages/plugin-detail/src/__tests__/inlineCredentialGate.test.tsx +++ b/packages/plugin-detail/src/__tests__/inlineCredentialGate.test.tsx @@ -301,6 +301,12 @@ describe('blast radius — the detail row honours the shared exclusion, minus wh it(`\`${type}\` ${exempt ? 'keeps' : 'loses'} its detail inline-edit affordance`, () => { renderSection({ name: 'f', label: 'F' }, { fields: { f: { type } } }, { f: sampleFor(type) }); + // A sample that is an empty value (`signature`, `filter-condition`) makes + // the one-field section all-empty, and a kept all-empty section folds its + // rows behind one "Show N empty fields" row (objectui#13386). Reveal it, + // so the affordance is read off the row itself. + const fold = screen.queryByRole('button', { name: /empty field/i }); + if (fold) fireEvent.click(fold); expect(hasPencil()).toBe(exempt); }); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx index 9542bb2521..a2290a6928 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.unresolvedIdentityFailClosed-9054.test.tsx @@ -50,7 +50,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; import * as React from 'react'; import { RecordContextProvider } from '@object-ui/react'; @@ -211,6 +211,10 @@ describe('objectui#9054 — an unresolvable field identity is EXCLUDED, not kept redactFields: ['salary'], }); + // The lone placeholder makes the body all-empty, so it is folded behind one + // "Show 1 empty field" row (objectui#13386); the row counting it is itself + // the entry's survival. Revealed, the placeholder row is there. + fireEvent.click(screen.getByRole('button', { name: /Show 1 empty field/ })); expect(ghostRows()).toHaveLength(1); expect(container.textContent).not.toBe(''); }); From 8914a8c337c45e1d02fabb2ce0977612eb3f4678 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:52:20 +0000 Subject: [PATCH 4/4] chore(plugin-detail): type the body fold's record as a record (objectui#13386) Claude-Session: https://claude.ai/code/session_01AqaieDWuDnBRyh3u8epGx6 Co-authored-by: Claude --- packages/plugin-detail/src/EmptySectionsFold.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/plugin-detail/src/EmptySectionsFold.tsx b/packages/plugin-detail/src/EmptySectionsFold.tsx index 7b80f2b889..17c2a76336 100644 --- a/packages/plugin-detail/src/EmptySectionsFold.tsx +++ b/packages/plugin-detail/src/EmptySectionsFold.tsx @@ -18,7 +18,7 @@ export interface EmptySectionsFoldProps { /** The sections the body draws, as handed to each `DetailSection`. */ sections: DetailViewSection[]; /** The record as the sections read it (record plus the inline-edit draft). */ - data: any; + data: Record | null | undefined; isEditing: boolean; /** The rendered sections. */ children: React.ReactNode; @@ -36,7 +36,10 @@ export interface EmptySectionsFoldProps { * Emptiness is `hasCellValue` over `data[name] ?? field.value`, the reading * `DetailSection` folds by. */ -export function emptySectionsFoldCount(sections: DetailViewSection[], data: any): number { +export function emptySectionsFoldCount( + sections: DetailViewSection[], + data: Record | null | undefined, +): number { // This runs ABOVE every per-section error boundary in `DetailView`, so a // malformed section must not throw from here and blank the whole body // (objectui#8497): a section with no field array, or anything that throws