Skip to content

Commit 46c56ee

Browse files
feat(plugin-detail): fold a kept all-empty record section, and an all-empty body (objectui#13386) (#13483)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D3jMyxwR7kHLu9j9TjYFimf)_ Fixes #13386 Fixes #12618 Seat: domain:ui#4 · card thread `session_01AqaieDWuDnBRyh3u8epGx6` · claim 6391455338 Ruling-ref: 6369306878 (batch #7 item 4, letter C) Clause-②: no · Metadata-protocol: semantic (the spec side landed as #25612) ## What changes Ruling C: in read-only mode an all-empty section that the page keeps draws its heading plus ONE "Show N empty fields" row, the same toggle a partly-filled section uses, instead of one em-dash row per field. Edit mode is unchanged, and a body whose sections are all empty folds once more (item 2 of #12618). | Where | Before | After | |---|---|---| | `DetailSection`, all-empty section kept (`hideEmpty: false`, or omitted where no default resolves it), read-only | heading + one `No value` row per field | heading + one "Show N empty fields" row; the toggle reveals the rows and "Hide empty fields" folds them back | | same, edit mode | every field an editable row | unchanged | | same, print page | every row prints | unchanged: every row prints, no toggle (#12717) | | `hideEmpty: true` all-empty section | renders nothing | unchanged | | partly-filled section | heuristic fold (#7129 Q2-C) | unchanged | | detail body (`DetailView` sections) where every section is empty and at least two are kept | one folded section after another | ONE "Show N empty fields" row for the body (new `EmptySectionsFold`); revealing it draws each section folded on its own | No key, shape or default moved, and no new i18n key: both rows reuse `detail.showEmptyFields` / `detail.hideEmptyFields`. The `hideEmpty` descriptions in `@object-ui/types` (`DetailViewSection`, `RecordDetailsSection`, the zod mirror) and the two `@object-ui/plugin-detail` registration texts now say what a kept section draws. ## Readings taken (for the seat's review) 1. **"Kept" includes an omitted key where no default resolves it.** The ruling names `hideEmpty: false`. On `detail-view`, the `detail-section` node and the `record:details` direct-`fields` fallback, omitted already meant the same as `false` (keep), and their descriptions said so. The fold keeps them equal, so the test is "kept" (`!hideAllEmptySection`), not `=== false`. On `record:details` authored sections nothing changes for an omitted key: it still resolves `?? true` and hides. 2. **Copy.** The ruling's 「N 个空字段 · 展开」 is described as "the same fold the mixed sections use", so the row is that fold's existing copy: `Show 4 empty fields` (en), `显示 4 个空字段` (zh). No new string. 3. **Body fold threshold.** The body folds only when every section that draws anything is all-empty AND at least two are kept: one kept section is already one row, so folding it again would only drop its heading. Section groups (`sectionGroups`) turn the body fold off, as they draw fields of their own beside the sections. A malformed section (no field array, or a read that throws) turns it off too, so each section still renders or fails inside its own boundary (#8497). ## Tests - New: `packages/plugin-detail/src/__tests__/DetailSection.allEmptyFold-13386.test.tsx` — the 「交易说明」 fixture: heading + one "Show 4 empty fields" row and no em-dash; reveal and fold back; untitled section; `hideEmpty: true` control; edit mode draws four textboxes and no fold; print page prints four rows; mixed sections (small and large) unchanged; body fold counts 8 over three sections, reveals each section folded, folds back; controls for a filled field anywhere, a single kept section, edit mode and print page; `emptySectionsFoldCount` rules including malformed sections. - Reverse check: with `DetailSection.tsx` at base, 6 of the new file's cases go red (the edit-mode, print, mixed and count cases stay green, as they pin unchanged behaviour). - Updated pins that read an all-empty kept section's rows, now through the fold: `record-details.hideEmptyRetired-7129` 4/4(b), `record-details.emptySectionDefault` (`false` keeps; direct-`fields` fallback), `detailSectionAuthoredHideEmpty-10485`, the AMPLIFICATION cases of `DetailSection.emptyArray-8474` and `DetailSection.emptinessAuthority-8376` (now: the fold counts all four rows, so the empty value is not counted as filled), `inlineCredentialGate` (`signature`'s empty sample reveals the fold before reading the pencil), `record-details.unresolvedIdentityFailClosed-9054` (the legacy `fieldName` entry survives as "Show 1 empty field"). ## Verification (local) - `pnpm exec vitest run packages/plugin-detail/ packages/types/` — green after the pin updates (the first full run surfaced the six pins above; their files re-run green: 132 passed). - `pnpm --filter @object-ui/plugin-detail type-check`, `pnpm --filter @object-ui/types type-check` — clean. - `pnpm --filter @object-ui/plugin-detail lint` — 0 errors. - `check:new-line-citations` 0 new · `check:doc-json` · `check:doc-fences` · `check:control-bytes` · `check:i18n-keys` · `check-changeset-presence` — pass. - `node scripts/check-governed-queue-guard.mjs --test <diff>` — NOT GOVERNED. - NOT MEASURED: `check:doc-snippets` / `check:doc-examples` (need the whole build; CI runs them). NOT MEASURED: screenshots of the HotCRM opportunity record at 1280 px (#12618 acceptance 2), reason: no HotCRM console stack in this container; the 「交易说明」 case is pinned in the DOM test above. ## Acceptance notes - The `hideEmpty` describe text on the installed spec pin still carries the pre-#25612 wording; the next pin move picks up the ruled text. Nothing here reads that prose. - [x] Adds no product-specific widget, dialog, hook or string (see AGENTS.md › Product-neutral renderer) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AqaieDWuDnBRyh3u8epGx6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee7e3a6 commit 46c56ee

19 files changed

Lines changed: 635 additions & 63 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@object-ui/plugin-detail": minor
3+
"@object-ui/types": patch
4+
---
5+
6+
Clause-②: no
7+
Metadata-protocol: semantic
8+
9+
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).

‎content/docs/guide/print-pages.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ needs.
7373
the cap's lines and a note under them naming the cap and, when the server
7474
reports one, the total — a printed copy never drops lines in silence.
7575
- A collapsible `record:details` section draws open on a print page.
76+
- A `record:details` section prints every declared field, empty ones included,
77+
with no "Show N empty fields" row. A section `hideEmpty` hides still prints
78+
nothing.
7679

7780
A block reads whether it is on a print page with `usePrintPage()` from
7881
`@object-ui/react`, which returns the page's `print` declaration or `null`.

‎packages/plugin-detail/README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,32 @@ default), a section whose fields are all empty is still absent. The spec refuses
183183
`count` beside `group`, on a section without `label`, and on a label with no
184184
hole. A section without `count` prints its heading as written.
185185

186+
### Empty fields and empty sections (`hideEmpty`)
187+
188+
A section that still has a filled field and enough empty ones folds the empty
189+
rows behind a **Show N empty fields** row; the reader can reveal them and fold
190+
them back. No authored value changes this.
191+
192+
A section whose fields are ALL empty is decided by `hideEmpty`. On
193+
`record:details` it defaults to on, and such a section renders nothing at all.
194+
`hideEmpty: false` keeps it, which a brand-new record needs:
195+
196+
```json
197+
{ "group": "narrative", "hideEmpty": false }
198+
```
199+
200+
In read-only mode a kept all-empty section draws its heading plus one
201+
**Show N empty fields** row, the same fold a partly-filled section uses, and
202+
not one placeholder row per field. In edit mode it draws every field as an
203+
editable row. On `detail-view` and the `detail-section` node no default is
204+
resolved, so an omitted key keeps the section the same way, and only `true`
205+
hides it.
206+
207+
When every section of a body is empty and at least two are kept, the body
208+
draws ONE **Show N empty fields** row for all of them. Revealing it draws each
209+
section folded on its own. On a print page every declared row prints, empty
210+
ones included, with no toggle.
211+
186212
### Basic Example
187213

188214
```tsx

‎packages/plugin-detail/src/DetailSection.tsx‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,9 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
338338
// the key — a premise the 17.3.0 pin move made false. The spec declares it
339339
// on `RecordDetailsProps.sections[]` and its describe() promises exactly
340340
// this: hiding is the renderer default, an all-empty section then renders
341-
// nothing at all (no heading, no skeleton), and `false` keeps the heading
342-
// and the label skeleton on an all-empty record.
341+
// nothing at all (no heading, no skeleton), and `false` keeps the section
342+
// on an all-empty record — in read-only mode as its heading plus one fold
343+
// row (objectui#13386, `foldAllEmptySection` below).
343344
//
344345
// ⚠️ `=== true`, and the DEFAULT IS NOT HERE. `RecordDetailsRenderer`
345346
// resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section),
@@ -365,11 +366,33 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
365366
const allFieldsEmpty = section.fields.length > 0 && filledCount === 0;
366367
const hideAllEmptySection = section.hideEmpty === true && allFieldsEmpty && !isEditing;
367368

369+
// objectui#13386 (ruling C, batch #7 item 4, maintainer 2026-10-23) — what a
370+
// KEPT all-empty section draws for a reader: its heading plus ONE fold row,
371+
// the same "Show N empty fields" toggle a partly-filled section uses, instead
372+
// of a column of em-dashes. `@objectstack/spec`'s `hideEmpty` describe() now
373+
// states exactly this (objectstack#25511). No key, shape or default moved:
374+
// the hide above still decides WHETHER the section draws, this decides only
375+
// HOW a section that draws looks.
376+
//
377+
// "Kept" is every all-empty section the hide does not take — an authored
378+
// `false`, and an omitted key wherever no default resolves it (`detail-view`,
379+
// the `detail-section` node, the `record:details` direct-`fields` fallback).
380+
// Omitted and `false` already meant the same on those surfaces, and the fold
381+
// keeps them meaning the same, so `!hideAllEmptySection` and not
382+
// `=== false`.
383+
//
384+
// Same two boundaries as the heuristic above, for the same reasons: edit mode
385+
// renders every row as an input (the ruling leaves it unchanged), and a print
386+
// page prints every declared row with no toggle (objectui#12717).
387+
const foldAllEmptySection =
388+
allFieldsEmpty && !hideAllEmptySection && !isEditing && !isPrintPage;
389+
368390
const hideEmptyEffective =
369-
!showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection);
391+
!showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection || foldAllEmptySection);
370392

371-
// Filter out empty fields when the auto-hide heuristic kicked in, or when an
372-
// all-empty section is hiding itself (its early return is below every hook).
393+
// Filter out empty fields when the auto-hide heuristic kicked in, when an
394+
// all-empty section is folded, or when one is hiding itself (its early
395+
// return is below every hook).
373396
const visibleFields = hideEmptyEffective
374397
? section.fields.filter((field) => !isEmptyValue(field))
375398
: section.fields;
@@ -772,7 +795,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
772795

773796
// Hide entire section when all fields are empty AND the user has not asked to
774797
// reveal them. Who decides this is the authored `hideEmpty` (`hideEmptyEffective`
775-
// above, default on, `false` to keep the skeleton); this line is the mechanism
798+
// above, default on, `false` to keep the section); this line is the mechanism
776799
// it reaches, which is why restoring the read there needed no new exit here.
777800
// This early return MUST come AFTER every hook above (including
778801
// the virtual-scroll useEffect) — never before. When a section is all-empty
@@ -782,18 +805,19 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
782805
// than during the previous render"). This is the master-detail drill-in
783806
// crash: navigating account → project reuses this DetailSection fiber, and
784807
// its sections flip from empty to populated. Keeping the guard below all
785-
// hooks makes the hook count invariant.
786-
if (visibleFields.length === 0 && emptyCount === section.fields.length) return null;
808+
// hooks makes the hook count invariant. A FOLDED all-empty section is not
809+
// hidden: it draws its heading and the fold row (objectui#13386).
810+
if (visibleFields.length === 0 && emptyCount === section.fields.length && !foldAllEmptySection) return null;
787811

788812
const renderedFields = visibleCount !== undefined
789813
? layoutFields.slice(0, visibleCount)
790814
: layoutFields;
791815

792-
const showEmptyToggle = emptyCount > 0 && shouldAutoHideEmpty;
816+
const showEmptyToggle = emptyCount > 0 && (shouldAutoHideEmpty || foldAllEmptySection);
793817

794818
const content = (
795819
<>
796-
{isMobile ? (
820+
{renderedFields.length === 0 ? null : isMobile ? (
797821
<div className="flex flex-col divide-y divide-border/60">
798822
{renderedFields.map(renderField)}
799823
</div>
@@ -811,7 +835,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
811835
</div>
812836
)}
813837
{showEmptyToggle && (
814-
<div className="mt-3 -ml-2">
838+
<div className={cn('-ml-2', renderedFields.length > 0 && 'mt-3')}>
815839
<Button
816840
type="button"
817841
variant="ghost"

‎packages/plugin-detail/src/DetailView.tsx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
X,
3737
} from 'lucide-react';
3838
import { DetailSection } from './DetailSection';
39+
import { EmptySectionsFold } from './EmptySectionsFold';
3940
import { DetailTabs } from './DetailTabs';
4041
import { SectionGroup } from './SectionGroup';
4142
import { HeaderHighlight } from './HeaderHighlight';
@@ -1127,6 +1128,20 @@ export const DetailView: React.FC<DetailViewProps> = ({
11271128
);
11281129
}
11291130

1131+
// objectui#13386 — a body whose sections are ALL empty draws one fold row
1132+
// for the whole body (`EmptySectionsFold`). Only when the sections ARE the
1133+
// body: section groups draw fields of their own beside them.
1134+
const foldEmptySections = (rendered: React.ReactNode) =>
1135+
schema.sectionGroups?.length ? rendered : (
1136+
<EmptySectionsFold
1137+
sections={schema.sections ?? []}
1138+
data={{ ...data, ...editedValues }}
1139+
isEditing={isInlineEditing}
1140+
>
1141+
{rendered}
1142+
</EmptySectionsFold>
1143+
);
1144+
11301145
return (
11311146
<TooltipProvider>
11321147
<div className={cn('space-y-6', className)}>
@@ -1909,7 +1924,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
19091924
/>
19101925
))
19111926
)}
1912-
{schema.sections && schema.sections.length > 0 && (
1927+
{schema.sections && schema.sections.length > 0 && foldEmptySections(
19131928
schema.sections.map((section, index) => (
19141929
<SectionBoundary key={index} section={section} index={index}>
19151930
<DetailSection
@@ -2058,7 +2073,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
20582073
{/* Sections */}
20592074
{schema.sections && schema.sections.length > 0 && (
20602075
<div className="space-y-3 sm:space-y-4">
2061-
{schema.sections.map((section, index) => (
2076+
{foldEmptySections(schema.sections.map((section, index) => (
20622077
<SectionBoundary key={index} section={section} index={index}>
20632078
<DetailSection
20642079
section={section}
@@ -2072,7 +2087,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
20722087
dataSource={dataSource}
20732088
/>
20742089
</SectionBoundary>
2075-
))}
2090+
)))}
20762091
</div>
20772092
)}
20782093

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import * as React from 'react';
10+
import { Eye, EyeOff } from 'lucide-react';
11+
import { Button } from '@object-ui/components';
12+
import { usePrintPage } from '@object-ui/react';
13+
import type { DetailViewSection } from '@object-ui/types';
14+
import { hasCellValue } from './emptiness';
15+
import { useDetailTranslation } from './useDetailTranslation';
16+
17+
export interface EmptySectionsFoldProps {
18+
/** The sections the body draws, as handed to each `DetailSection`. */
19+
sections: DetailViewSection[];
20+
/** The record as the sections read it (record plus the inline-edit draft). */
21+
data: Record<string, unknown> | null | undefined;
22+
isEditing: boolean;
23+
/** The rendered sections. */
24+
children: React.ReactNode;
25+
}
26+
27+
/**
28+
* How many empty fields the body would fold into one row, or `0` when it does
29+
* not fold. Exported for the tests that pin the rule.
30+
*
31+
* The body folds when every section that draws anything is all-empty and at
32+
* least TWO of them are kept. A section with no fields draws nothing and an
33+
* all-empty `hideEmpty: true` section hides itself, so neither counts as a
34+
* kept section and neither adds to the count; one kept section is already one
35+
* fold row on its own, so folding it again would only drop its heading.
36+
* Emptiness is `hasCellValue` over `data[name] ?? field.value`, the reading
37+
* `DetailSection` folds by.
38+
*/
39+
export function emptySectionsFoldCount(
40+
sections: DetailViewSection[],
41+
data: Record<string, unknown> | null | undefined,
42+
): number {
43+
// This runs ABOVE every per-section error boundary in `DetailView`, so a
44+
// malformed section must not throw from here and blank the whole body
45+
// (objectui#8497): a section with no field array, or anything that throws
46+
// while reading, turns the fold off, leaving each section to render, or to
47+
// fail visibly, inside its own boundary.
48+
try {
49+
let kept = 0;
50+
let emptyFields = 0;
51+
for (const section of sections) {
52+
if (!Array.isArray(section?.fields)) return 0;
53+
const fields = section.fields;
54+
if (fields.length === 0) continue;
55+
if (fields.some((field) => hasCellValue(data?.[field?.name] ?? field?.value))) return 0;
56+
if (section.hideEmpty === true) continue;
57+
kept += 1;
58+
emptyFields += fields.length;
59+
}
60+
return kept >= 2 ? emptyFields : 0;
61+
} catch {
62+
return 0;
63+
}
64+
}
65+
66+
/**
67+
* The tab-level fold (objectui#13386, ruling C, item 2 of objectui#12618): a
68+
* detail body whose sections are ALL empty draws one "Show N empty fields" row
69+
* for the whole body instead of one folded section after another. Revealing
70+
* it draws the sections as they are, each folded on its own, and a closing
71+
* "Hide empty fields" row folds the body again.
72+
*
73+
* Same boundaries as the section fold in `DetailSection`: edit mode draws
74+
* every section, its fields are the inputs; a print page draws them all, as
75+
* paper cannot toggle (objectui#12717).
76+
*/
77+
export const EmptySectionsFold: React.FC<EmptySectionsFoldProps> = ({
78+
sections,
79+
data,
80+
isEditing,
81+
children,
82+
}) => {
83+
const { t } = useDetailTranslation();
84+
const isPrintPage = usePrintPage() !== null;
85+
const [revealed, setRevealed] = React.useState(false);
86+
87+
const count = isEditing || isPrintPage ? 0 : emptySectionsFoldCount(sections, data);
88+
if (count === 0) return <>{children}</>;
89+
90+
const toggle = (
91+
<div className="-ml-2">
92+
<Button
93+
type="button"
94+
variant="ghost"
95+
size="sm"
96+
onClick={() => setRevealed((r) => !r)}
97+
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
98+
>
99+
{revealed ? (
100+
<EyeOff className="h-3.5 w-3.5 mr-1.5" />
101+
) : (
102+
<Eye className="h-3.5 w-3.5 mr-1.5" />
103+
)}
104+
{revealed
105+
? t('detail.hideEmptyFields', { defaultValue: 'Hide empty fields' })
106+
: t('detail.showEmptyFields', { count, defaultValue: 'Show {{count}} empty fields' })}
107+
</Button>
108+
</div>
109+
);
110+
111+
if (!revealed) return toggle;
112+
return (
113+
<>
114+
{children}
115+
{toggle}
116+
</>
117+
);
118+
};

0 commit comments

Comments
 (0)