Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/13386-fold-all-empty-section.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions content/docs/guide/print-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
26 changes: 26 additions & 0 deletions packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 35 additions & 11 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -338,8 +338,9 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
// 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),
Expand All @@ -365,11 +366,33 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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;
Expand Down Expand Up @@ -772,7 +795,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({

// 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
Expand All @@ -782,18 +805,19 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
// 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 ? (
<div className="flex flex-col divide-y divide-border/60">
{renderedFields.map(renderField)}
</div>
Expand All @@ -811,7 +835,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
</div>
)}
{showEmptyToggle && (
<div className="mt-3 -ml-2">
<div className={cn('-ml-2', renderedFields.length > 0 && 'mt-3')}>
<Button
type="button"
variant="ghost"
Expand Down
21 changes: 18 additions & 3 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
X,
} from 'lucide-react';
import { DetailSection } from './DetailSection';
import { EmptySectionsFold } from './EmptySectionsFold';
import { DetailTabs } from './DetailTabs';
import { SectionGroup } from './SectionGroup';
import { HeaderHighlight } from './HeaderHighlight';
Expand Down Expand Up @@ -1127,6 +1128,20 @@ export const DetailView: React.FC<DetailViewProps> = ({
);
}

// objectui#13386 — a body whose sections are ALL empty draws one fold row
// for the whole body (`EmptySectionsFold`). Only when the sections ARE the
// body: section groups draw fields of their own beside them.
const foldEmptySections = (rendered: React.ReactNode) =>
schema.sectionGroups?.length ? rendered : (
<EmptySectionsFold
sections={schema.sections ?? []}
data={{ ...data, ...editedValues }}
isEditing={isInlineEditing}
>
{rendered}
</EmptySectionsFold>
);

return (
<TooltipProvider>
<div className={cn('space-y-6', className)}>
Expand Down Expand Up @@ -1909,7 +1924,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
/>
))
)}
{schema.sections && schema.sections.length > 0 && (
{schema.sections && schema.sections.length > 0 && foldEmptySections(
schema.sections.map((section, index) => (
<SectionBoundary key={index} section={section} index={index}>
<DetailSection
Expand Down Expand Up @@ -2058,7 +2073,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
{/* Sections */}
{schema.sections && schema.sections.length > 0 && (
<div className="space-y-3 sm:space-y-4">
{schema.sections.map((section, index) => (
{foldEmptySections(schema.sections.map((section, index) => (
<SectionBoundary key={index} section={section} index={index}>
<DetailSection
section={section}
Expand All @@ -2072,7 +2087,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
dataSource={dataSource}
/>
</SectionBoundary>
))}
)))}
</div>
)}

Expand Down
118 changes: 118 additions & 0 deletions packages/plugin-detail/src/EmptySectionsFold.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* 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.
*/

import * as React from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Button } from '@object-ui/components';
import { usePrintPage } from '@object-ui/react';
import type { DetailViewSection } from '@object-ui/types';
import { hasCellValue } from './emptiness';
import { useDetailTranslation } from './useDetailTranslation';

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: Record<string, unknown> | null | undefined;
isEditing: boolean;
/** The rendered sections. */
children: React.ReactNode;
}

/**
* How many empty fields the body would fold into one row, or `0` when it does
* not fold. Exported for the tests that pin the rule.
*
* The body folds when every section that draws anything is all-empty and at
* least TWO of them are kept. A section with no fields draws nothing and an
* all-empty `hideEmpty: true` section hides itself, so neither counts as a
* kept section and neither adds to the count; one kept section is already one
* fold row on its own, so folding it again would only drop its heading.
* Emptiness is `hasCellValue` over `data[name] ?? field.value`, the reading
* `DetailSection` folds by.
*/
export function emptySectionsFoldCount(
sections: DetailViewSection[],
data: Record<string, unknown> | 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
// 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;
}
}

/**
* The tab-level fold (objectui#13386, ruling C, item 2 of objectui#12618): a
* detail body whose sections are ALL empty draws one "Show N empty fields" row
* for the whole body instead of one folded section after another. Revealing
* it draws the sections as they are, each folded on its own, and a closing
* "Hide empty fields" row folds the body again.
*
* Same boundaries as the section fold in `DetailSection`: edit mode draws
* every section, its fields are the inputs; a print page draws them all, as
* paper cannot toggle (objectui#12717).
*/
export const EmptySectionsFold: React.FC<EmptySectionsFoldProps> = ({
sections,
data,
isEditing,
children,
}) => {
const { t } = useDetailTranslation();
const isPrintPage = usePrintPage() !== null;
const [revealed, setRevealed] = React.useState(false);

const count = isEditing || isPrintPage ? 0 : emptySectionsFoldCount(sections, data);
if (count === 0) return <>{children}</>;

const toggle = (
<div className="-ml-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setRevealed((r) => !r)}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{revealed ? (
<EyeOff className="h-3.5 w-3.5 mr-1.5" />
) : (
<Eye className="h-3.5 w-3.5 mr-1.5" />
)}
{revealed
? t('detail.hideEmptyFields', { defaultValue: 'Hide empty fields' })
: t('detail.showEmptyFields', { count, defaultValue: 'Show {{count}} empty fields' })}
</Button>
</div>
);

if (!revealed) return toggle;
return (
<>
{children}
{toggle}
</>
);
};
Loading
Loading