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
34 changes: 34 additions & 0 deletions .changeset/10485-detail-section-declares-hideempty.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/plugin-detail': minor
---

An authored `detail-section` node now takes `hideEmpty`, and it reaches the section.

`DetailSection` hides a section whose fields are all empty when
`section.hideEmpty` is `true`, but the `detail-section` registration did not
declare `hideEmpty` among its `inputs`, and the node adapter folds only declared
names into the section it hands the component. So an author who wrote
`hideEmpty` on the node was warned `unknown-prop "hideEmpty"` by the JSX-page
compiler's validator — which judges an authored page against these same
`inputs` — and an all-empty section still drew its heading and skeleton.

The registration now declares `{ name: 'hideEmpty', type: 'boolean' }`, and the
adapter folds it like the other declared inputs. On this node, `true` hides an
all-empty section (no heading, no skeleton); omitted or `false`, the section
keeps its heading and label skeleton — the behaviour an unauthored node already
had, now stated in the input's description rather than changed. A non-boolean
`hideEmpty` now draws `type-mismatch` instead of `unknown-prop`.

The omitted default is the node's own. `record:details` still resolves an
omitted `hideEmpty` to `true` on its own authored sections, which never pass
through this node; `detail-view` sections, like this node, apply no default.
What `true` and `false` mean is the same on all three.

**Clause-②: yes** — the authoring surface of `detail-section` widens by one
key, `hideEmpty`, which the renderer already honoured. No other key's verdict
moves: `name`, which the block neither declares nor reads, still draws
`unknown-prop`. No exported symbol is added, removed, renamed or retyped — the
package entry re-exports neither `DetailSectionNode` nor
`DETAIL_SECTION_NODE_INPUTS`. The tag is outside the public block tier, so the
generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` never carried it and
do not change.
8 changes: 8 additions & 0 deletions .changeset/8626-detail-section-authored-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ invalidates none of them.

`DetailSection` itself is byte-identical: every in-repo caller passes
`section={…}` as a direct JSX child and is untouched.

Superseded in this release by objectui#9529 and objectui#10485: the
registration now declares ten flat inputs, not eight — the eight above plus
`icon` (objectui#9529) and then `hideEmpty` (objectui#10485) — and the adapter
folds all ten. The count of eight above describes the tree this entry was
written against; the live list is `DETAIL_SECTION_NODE_INPUTS`, held against
the registration in both directions by the fold-parity row of
`detailSectionAuthoredNode-8626.test.tsx`.
12 changes: 7 additions & 5 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,13 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
// resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section),
// because "the renderer default" in that describe() is the default of the
// renderer the key is DECLARED on. This component also receives sections
// nobody could write the key on — the `record:details` direct-`fields`
// fallback body and the `detail-section` node both synthesize one, and
// neither surface declares `hideEmpty` — and hiding those would be a hide
// with no declarable spelling to ask the skeleton back, which is the exact
// defect upstream declared the key to fix. A truthiness test
// that default must not reach. The `record:details` direct-`fields`
// fallback body synthesizes one nobody could write the key on, and hiding
// it would be a hide with no declarable spelling to ask the skeleton back,
// which is the exact defect upstream declared the key to fix. The
// `detail-section` node declares `hideEmpty` (objectui#10485) but resolves
// no default of its own, so an omitted key keeps the skeleton there too and
// only an authored `true` hides. A truthiness test
// (`!section.hideEmpty`) is banned for the mirror-image reason: it is what
// made an authored `false` indistinguishable from unauthored before #7129.
//
Expand Down
6 changes: 5 additions & 1 deletion packages/plugin-detail/src/DetailSectionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ import { DetailSection, type DetailSectionProps } from './DetailSection';
* is a name that stops reaching `DetailSection` — which is how the ablation
* for objectui#8626 reddens a per-input row.
* `detailSectionAuthoredNode-8626.test.tsx` additionally pins the list against
* the registration's own declared input names in BOTH directions, so a ninth
* the registration's own declared input names in BOTH directions, so a new
* input declared without a fold — the shape of the original defect — reds
* rather than arriving silently inert.
*/
Expand All @@ -100,6 +100,10 @@ export const DETAIL_SECTION_NODE_INPUTS = [
'columns',
'showBorder',
'headerColor',
// objectui#10485: `DetailSection` reads `section.hideEmpty === true` (the
// all-empty hide), so it is declared and folded like the rest. Nothing here
// defaults it: an omitted key stays omitted, and keeps the section.
'hideEmpty',
] as const;

type DetailSectionNodeInput = (typeof DETAIL_SECTION_NODE_INPUTS)[number];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#10485 — an authored `hideEmpty` on a `detail-section` node
* VALIDATES and REACHES `DetailSection`.
*
* ## The gap
*
* `DetailSection` reads `section.hideEmpty === true` (the all-empty hide that
* objectui#8603 restored), but the `detail-section` registration did not
* declare `hideEmpty` among its `inputs`, and `DetailSectionNode` folds only
* the declared names into the `section` it hands the component. So an author
* who wrote `hideEmpty` on the node met both halves of one gap — the same two
* halves objectui#9529 closed for `icon`:
*
* - the JSX-page compiler's validator (a manifest built from
* `getKnownTypes()` plus these `inputs`, the way
* `packages/components/src/renderers/layout/page.tsx` builds it) warned
* `unknown-prop "hideEmpty"`, steering the author away from a key the
* renderer honours; and
* - the fold passed `hideEmpty` on as a host prop, which `DetailSection`
* ignores, so an all-empty section still drew its heading and skeleton.
*
* The triage on the card reuses objectui#9529's ruling (declare a key the
* renderer already honours) and asks for the node's OMITTED default to be
* stated. On this node it is one value: nothing on the node's path resolves a
* default (`record:details` resolves `?? true` on its OWN authored sections,
* which never pass through this node), and `DetailSection` tests `=== true`,
* so an omitted key keeps the all-empty section. The declaration states that;
* it does not change it.
*
* ## What each row reads, and why it is a verdict
*
* - VALIDATES — the node with an authored `hideEmpty` of either polarity draws
* ZERO diagnostics from the live manifest. The declared TYPE is read through
* the same judge: a non-boolean `hideEmpty` draws `type-mismatch` naming the
* key, which only a declared `boolean` input can produce (an undeclared key
* draws `unknown-prop` instead).
* - THE CONTROL IS LIT — `name`, a `DetailViewSection` member this block
* neither declares nor reads, still draws `unknown-prop` from the same
* manifest in the same run. A silenced validator would pass the rows above
* vacuously; it cannot pass this one.
* - REACHES `DetailSection` — measured in the DOM through the real
* `SchemaRenderer` and the real registry, over a record on which every field
* of the section is empty. `true` hides the section: no heading, no
* skeleton. A sibling node in the SAME render, identical but for the key,
* draws its heading and its placeholder, so the absence is a decision about
* `hideEmpty` and not an artefact of the fixture. `false` and an omitted key
* both keep the heading and the placeholder — the node's declared default.
*/

import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';

// Module scope, not a hook (the test-discipline section of AGENTS.md):
// importing the package index executes its registration side-effects, so the
// entry under test is the very one production resolves.
import '../index';

const TAG = 'detail-section';
const TITLE = 'Billing Address';
const CONTROL_TITLE = 'Shipping Address';

/** A minimal authored node: the one required input plus a title to anchor on. */
const authoredNode = (overrides: Record<string, unknown> = {}, title = TITLE) =>
({
type: TAG,
title,
fields: [{ name: 'street', label: 'Street' }],
...overrides,
}) as never;

/** A record on which every field of the section above is empty. */
const EMPTY_RECORD = {};

/** Built the way `page.tsx` builds the JSX-page compiler's manifest. */
const liveManifest = () =>
manifestFromConfigs(
ComponentRegistry.getKnownTypes().map((type) => {
const meta = ComponentRegistry.getMeta(type);
return {
type,
namespace: meta?.namespace,
isContainer: meta?.isContainer,
inputs: meta?.inputs,
};
}) as unknown as Parameters<typeof manifestFromConfigs>[0],
);

const diagnose = (overrides: Record<string, unknown>) =>
validateTree(authoredNode(overrides), liveManifest()).diagnostics.map((d) => ({
code: d.code,
message: d.message,
}));

/** The empty-value placeholder `DetailSection` draws for a field with no value. */
const placeholdersIn = (heading: HTMLElement | null) => {
const card = heading?.closest('.bg-card') ?? null;
return card ? card.querySelectorAll('[title="No value"]').length : 0;
};

/**
* Renders the node under test beside a CONTROL node that is identical except
* for its title and for carrying no `hideEmpty`, over the same all-empty record.
*/
const renderBesideControl = (overrides: Record<string, unknown>) =>
render(
<>
<SchemaRenderer schema={authoredNode(overrides)} data={EMPTY_RECORD} />
<SchemaRenderer schema={authoredNode({}, CONTROL_TITLE)} data={EMPTY_RECORD} />
</>,
);

afterEach(() => {
cleanup();
});

describe('objectui#10485 — an authored detail-section hideEmpty validates', () => {
it('draws no diagnostic for an authored boolean hideEmpty, in either polarity', () => {
expect(diagnose({ hideEmpty: true })).toEqual([]);
expect(diagnose({ hideEmpty: false })).toEqual([]);
});

it('judges the authored hideEmpty as a declared BOOLEAN input', () => {
const found = diagnose({ hideEmpty: 'yes' });
expect(found.map((d) => d.code)).toEqual(['type-mismatch']);
expect(found[0].message).toContain('"hideEmpty"');
});

it('CONTROL: still warns on name, which the block neither declares nor reads', () => {
const found = diagnose({ name: 'billing' });
expect(found.map((d) => d.code)).toEqual(['unknown-prop']);
expect(found[0].message).toContain('"name"');
});
});

describe('objectui#10485 — an authored detail-section hideEmpty reaches DetailSection', () => {
it('`hideEmpty: true` hides an all-empty section: no heading, no skeleton', () => {
renderBesideControl({ hideEmpty: true });

// The lit control: the same all-empty section, without the key, in the
// same render, draws its heading and its placeholder.
const control = screen.getByText(CONTROL_TITLE);
expect(placeholdersIn(control)).toBe(1);

expect(screen.queryByText(TITLE)).toBeNull();
expect(screen.getAllByTitle('No value')).toHaveLength(1);
});

it('`hideEmpty: false` keeps the heading and the label skeleton of an all-empty section', () => {
renderBesideControl({ hideEmpty: false });

expect(placeholdersIn(screen.getByText(TITLE))).toBe(1);
expect(placeholdersIn(screen.getByText(CONTROL_TITLE))).toBe(1);
});

it('an OMITTED hideEmpty keeps the all-empty section too: the node declares no hide by default', () => {
render(<SchemaRenderer schema={authoredNode()} data={EMPTY_RECORD} />);

expect(screen.getByText('Street')).toBeTruthy();
expect(placeholdersIn(screen.getByText(TITLE))).toBe(1);
});
});
20 changes: 20 additions & 0 deletions packages/plugin-detail/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,26 @@ ComponentRegistry.register('detail-section', DetailSectionNode, {
description:
'Header tint, from the design system\'s closed palette: one of `muted`, `muted/50`, `accent`, `primary/10`, `secondary/10`, `destructive/10`. Any other value is refused — `@object-ui/types` parses this key as that same six-member enum (objectui#6594), matching @objectstack/spec\'s strict `record:details` section schema. Omit the key for no tint.',
},
{
/**
* objectui#10485 — declared because `DetailSection` already honours it
* (`section.hideEmpty === true`), the objectui#9529 ruling applied to a
* second key on this node.
*
* The description states THIS node's omitted default, and it is one
* value: nothing on the node's path resolves a default, and
* `DetailSection` tests `=== true`, so an omitted key keeps the
* all-empty section. That matches `detail-view`, which hands its
* sections on unchanged; `record:details` resolves `?? true` on its OWN
* authored sections, which never pass through this node. `true` and
* `false` mean the same on all three. No `defaultValue`: no other input
* of this node carries one.
*/
name: 'hideEmpty',
type: 'boolean',
description:
'When every field in the section is empty, `true` hides the whole section (no heading, no skeleton). Omitted or `false`, an all-empty section keeps its heading and label skeleton. Empty fields in a section that still has a filled one are not governed by this key.',
},
],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@
* section whose fields are ALL empty renders nothing unless the page writes
* `false`. WHERE that default is resolved is the design, and the last describe
* block below is what pins it: `RecordDetailsRenderer` applies `?? true` to an
* authored section and `DetailSection` tests `=== true`, so a section nobody
* could have written the key on — the direct-`fields` fallback body, the
* `detail-section` node — keeps its skeleton. A hide there would be a hide
* with no declarable spelling to ask the skeleton back, which is the defect
* upstream declared the key to fix.
* authored section and `DetailSection` tests `=== true`, so a section this
* renderer did not resolve keeps its skeleton. The direct-`fields` fallback
* body is one nobody could have written the key on; a hide there would be a
* hide with no declarable spelling to ask the skeleton back, which is the
* defect upstream declared the key to fix. The `detail-section` node declares
* the key (objectui#10485) but resolves no default of its own, so an omitted
* key keeps the skeleton there too and only an authored `true` hides.
*
* Deliberately no i18n provider, so the row labels below are rung 2 of the
* label ladder: the object's own DECLARED `label`. They read as field NAMES
Expand Down
15 changes: 9 additions & 6 deletions packages/plugin-detail/src/renderers/record-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -726,12 +726,15 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
//
// ⭐ The default is resolved HERE, on an AUTHORED section, and that
// placement is the whole design. `DetailSection` tests `=== true`, so
// the default reaches exactly the surface that declares the key.
// Sections nobody can write it on stay out: the direct-`fields`
// fallback body below and the `detail-section` node each synthesize a
// section, and a hide there would be one with no declarable spelling
// to ask the skeleton back — the defect upstream declared this key to
// fix, reintroduced one surface over.
// the default reaches exactly the authored sections this renderer
// resolves it on. Every other section stays out. The direct-`fields`
// fallback body below synthesizes a section nobody can write the key
// on, and a hide there would be one with no declarable spelling to
// ask the skeleton back — the defect upstream declared this key to
// fix, reintroduced one surface over. The `detail-section` node
// declares the key (objectui#10485) but applies no default of its
// own, so an omitted one keeps the skeleton there too; only an
// authored `true` hides.
//
// ⚠️ `?? true` is the spelling objectui#7064 removed, and it is back
// deliberately. ⛔ Read that ruling's ground as it was written, not as
Expand Down
Loading