diff --git a/.changeset/10485-detail-section-declares-hideempty.md b/.changeset/10485-detail-section-declares-hideempty.md new file mode 100644 index 0000000000..51679d8167 --- /dev/null +++ b/.changeset/10485-detail-section-declares-hideempty.md @@ -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. diff --git a/.changeset/8626-detail-section-authored-node.md b/.changeset/8626-detail-section-authored-node.md index 753cf33138..8d21ea8245 100644 --- a/.changeset/8626-detail-section-authored-node.md +++ b/.changeset/8626-detail-section-authored-node.md @@ -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`. diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index ee2b8dbfc0..32647fde76 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -270,11 +270,13 @@ export const DetailSection: React.FC = ({ // 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. // diff --git a/packages/plugin-detail/src/DetailSectionNode.tsx b/packages/plugin-detail/src/DetailSectionNode.tsx index 2612f9a8c8..d41d873c31 100644 --- a/packages/plugin-detail/src/DetailSectionNode.tsx +++ b/packages/plugin-detail/src/DetailSectionNode.tsx @@ -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. */ @@ -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]; diff --git a/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx b/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx new file mode 100644 index 0000000000..1005655636 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx @@ -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 = {}, 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[0], + ); + +const diagnose = (overrides: Record) => + 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) => + render( + <> + + + , + ); + +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(); + + expect(screen.getByText('Street')).toBeTruthy(); + expect(placeholdersIn(screen.getByText(TITLE))).toBe(1); + }); +}); diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index f9a01c5880..bd23f817f8 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -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.', + }, ], }); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx index 19563c41a6..bef6641990 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx @@ -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 diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 213c3d4f9e..0a77b2c1a8 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -726,12 +726,15 @@ export const RecordDetailsRenderer: React.FC = ({ // // ⭐ 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