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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
46 changes: 46 additions & 0 deletions .changeset/7064-empty-section-default.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
---
'@object-ui/plugin-detail': minor
---

**Behaviour change.** `record:details` no longer forces `hideEmpty` on the
sections it synthesizes, so a sparse record keeps its section skeleton instead
of collapsing. Applications relying on the old auto-hide of *unauthored*
sections will now see headings, field labels and empty-value placeholders where
rows used to vanish. This is the loud-over-silent direction, ruled by the
maintainer on 2026-08-31: an empty detail body is a platform concern, and a
metadata application should not have to author its way out of one.

`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic — *"If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide — the labels themselves are useful
as a structural skeleton"* — and the forced default overrode exactly the case
that sentence reserves. On a hand-created record whole sections disappeared and
the body collapsed to a couple of rows; seeded demo data hid it. Every
application then had to hand-write `hideEmpty: false` per section to stop
looking broken, which is per-app tax for a platform defect. The renderer now
passes the authored value through untouched and lets the heuristic own the
default.

What changes, precisely:

- an **all-empty** section renders its heading, every field label and one
empty-value placeholder per field (it used to render nothing at all);
- a **small** partly-empty section — below `DetailSection`'s auto-hide
threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty
rows;
- a **large** mostly-empty section with at least one filled row still
auto-hides, with the "Show N empty fields" toggle unchanged: the
label-graveyard guard is intact and this is not a return to dense-by-default;
- empty rows are now visible while inline-editing a section, so an unwritten
field can be filled in place.

What does **not** change: an authored `hideEmpty` keeps its exact former
meaning. `hideEmpty: true` remains the explicit opt-in to hiding, and
`hideEmpty: false` remains what it always was — "not `true`", not an override
of the auto-hide heuristic (measured, and pinned as pre-existing).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(`PagePreview`) binds a real sample record, so a `record:details` block over a
sparse sample now previews the skeleton rather than a collapsed body. No
application metadata needs editing — that is the point of the change.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
/**
* 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.
*/

/**
* `record:details` — who owns the empty-section default (objectui#7064).
*
* `RecordDetailsRenderer` used to map every authored section with
* `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case
* `DetailSection`'s own heuristic explicitly reserves:
*
* "If a section is entirely empty (e.g., loading state, brand-new record),
* do NOT auto-hide — the labels themselves are useful as a structural
* skeleton."
*
* With the force in place an all-empty section took `DetailSection`'s
* all-fields-hidden early return instead, so a hand-created record lost whole
* sections and collapsed to a two-row body, and every application had to
* hand-write `hideEmpty: false` per section to stop looking broken — per-app
* tax for a platform concern (maintainer ruling 2026-08-31).
*
* The renderer now passes the authored value through untouched. These pins
* hold both halves of that contract:
* - the UNAUTHORED default is the heuristic's, not the renderer's;
* - an AUTHORED value keeps its exact former meaning.
*
* Deliberately no i18n provider: `fieldLabel` falls back to the value the
* renderer hands it, which for the spec's bare-string section fields is the
* field NAME. So the "labels" a skeleton shows here read as field names — the
* same DOM nodes a translated app fills with translated labels.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import * as React from 'react';
import { RecordContextProvider } from '@object-ui/react';
import { RecordDetailsRenderer } from '../record-details';

/**
* No `name` / `title` / `subject` / `display_name` key anywhere: the renderer
* drops the page-H1 title field from the body (`titleCandidates`), which would
* make an absence assertion below pass for the wrong reason.
*/
const objectSchema = {
fields: {
industry: { type: 'text', label: 'Industry' },
stage: { type: 'text', label: 'Stage' },
amount: { type: 'text', label: 'Amount' },
close_date: { type: 'text', label: 'Close Date' },
next_step: { type: 'text', label: 'Next Step' },
},
};

/** A hand-created sparse record: one filled field, everything else unwritten. */
const sparseData = { industry: 'Manufacturing' };

const renderDetails = (schema: Record<string, unknown>, data: Record<string, unknown> = sparseData) =>
render(
<RecordContextProvider
objectName="crm_opportunity"
recordId="O1"
data={data}
objectSchema={objectSchema}
>
<RecordDetailsRenderer schema={schema as any} />
</RecordContextProvider>,
);

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const emptyPlaceholders = () => screen.queryAllByTitle('No value');

describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => {
it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] },
],
});

// The heading survives — the whole section used to disappear here.
expect(screen.getByText('Deal Terms')).toBeInTheDocument();

// Every field keeps its row, so the record reads as a structure waiting to
// be filled rather than as a blank page.
for (const label of ['stage', 'amount', 'close_date', 'next_step']) {
expect(screen.getByText(label)).toBeInTheDocument();
}
expect(emptyPlaceholders()).toHaveLength(4);
});

it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => {
// 2 fields, 1 empty: under DetailSection's minimum field count in both the
// desktop (4) and mobile (3) variant, so the auto-hide heuristic never
// fires and the empty row is shown. Under the old forced default this row
// was hidden. This is the second half of the user-visible behaviour change
// the changeset names — it is not limited to all-empty sections.
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'] },
],
});

expect(screen.getByText('Summary')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => {
// 4 fields, 3 empty, 1 filled — at/above both threshold variants
// (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so
// `shouldAutoHideEmpty` still fires exactly as before. Flipping the
// unauthored default did NOT turn populated pages into label graveyards;
// it only stopped overriding the all-empty case the heuristic reserves.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
// …and the user-facing escape hatch is offered for the rows it hid.
expect(screen.getByRole('button', { name: /empty fields/i })).toBeInTheDocument();
});
});

describe('record:details — an AUTHORED `hideEmpty` keeps its exact former meaning (#7064)', () => {
it('`hideEmpty: true` still hides an all-empty section entirely', () => {
renderDetails({
sections: [
{ name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true },
// CONTROL: a sibling section that MUST render, so the absences below
// are a decision by `hideEmpty` and not a render that never happened.
{ name: 'firmographics', label: 'Firmographics', fields: ['industry'] },
],
});

expect(screen.getByText('Firmographics')).toBeInTheDocument();
expect(screen.getByText('Manufacturing')).toBeInTheDocument();

expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: true` still hides the empty rows of a partly-filled section', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});

it('`hideEmpty: false` shows the empty rows the heuristic would not have hidden anyway', () => {
renderDetails({
sections: [
{ name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: false },
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.getByText('stage')).toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(1);
});

it('MEASURED, not endorsed: `hideEmpty: false` is "not true", NOT an override of the auto-hide heuristic', () => {
// `DetailSection` computes `shouldAutoHideEmpty` from `!section.hideEmpty`,
// so an authored `false` is indistinguishable from an unauthored section
// and the heuristic still hides empty rows once the thresholds are met.
// This is PRE-EXISTING and is NOT changed by #7064 — under the old forced
// default the same fixture took the same path, because `?? true` preserved
// an authored `false` too. Pinned so a future reader can see that the flip
// left this precedence exactly where it found it; whether `false` SHOULD
// become a hard override is a separate contract question.
renderDetails({
sections: [
{
name: 'deal_terms',
label: 'Deal Terms',
fields: ['industry', 'stage', 'amount', 'close_date'],
hideEmpty: false,
},
],
});

expect(screen.getByText('Manufacturing')).toBeInTheDocument();
expect(screen.queryByText('stage')).not.toBeInTheDocument();
expect(emptyPlaceholders()).toHaveLength(0);
});
});
27 changes: 21 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -202,12 +202,27 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
// flat sections stay borderless so the page chrome alone provides
// containment. Authors can override explicitly via `showBorder`.
showBorder: s.showBorder ?? (translatedTitle ? true : false),
// Phase N: default to hide-empty so pages don't render as label
// graveyards on first load. Authors can opt back in to showing
// empty rows by setting `hideEmpty: false` explicitly. The
// "显示 N 个空字段" toggle in DetailSection still works as the
// user-facing escape hatch.
hideEmpty: s.hideEmpty ?? true,
// Deliberately NOT defaulted. The authored value passes through
// verbatim, so an UNAUTHORED section reaches DetailSection as
// `undefined` and that component's own stated heuristic decides:
// auto-hide empty rows only while the section still has at least one
// filled row, and never on an all-empty section — there the labels
// ARE the structural skeleton a sparse or brand-new record needs.
//
// This slot used to force `s.hideEmpty ?? true`, which overrode
// exactly the case that heuristic reserves: a hand-created record
// collapsed to a two-row body and whole sections vanished, and every
// app had to hand-write `hideEmpty: false` per section to stop looking
// broken. That is per-app tax for a platform concern (maintainer
// ruling 2026-08-31: this is a platform problem; metadata
// applications should not have to think about these details).
//
// An AUTHORED value is still honoured exactly as before —
// `hideEmpty: true` remains the explicit opt-in to hiding, and
// `hideEmpty: false` the explicit opt-out. Only the unauthored
// default flips. DetailSection's "Show N empty fields" toggle remains
// the user-facing escape hatch wherever the heuristic does hide rows.
hideEmpty: s.hideEmpty,
fields: dropHidden(normaliseList(filterList(s.fields))),
});
})
Expand Down
Loading