diff --git a/.changeset/record-details-sections-name-anchor-3819.md b/.changeset/record-details-sections-name-anchor-3819.md new file mode 100644 index 0000000000..96c6a5f658 --- /dev/null +++ b/.changeset/record-details-sections-name-anchor-3819.md @@ -0,0 +1,56 @@ +--- +"@object-ui/app-shell": patch +--- + +`record:details` section editor now offers the `name` i18n anchor + +The page block inspector's `record:details` → Sections editor exposed +`label` / `columns` / `fields` and silently omitted `name`. That key is not +decoration: it is the section heading's i18n anchor. `plugin-detail`'s +`record-details` renderer resolves the heading through +`objects.._sections..label` and falls back to the authored string +whenever `name` is absent — + +``` +const translatedTitle = s.name && objectName + ? sectionLabel(objectName, s.name, rawTitle ?? s.name) + : rawTitle; +``` + +— so every section built in Studio was untranslatable by construction: one +authored string in every locale, plus an upstream +`translation-section-name-missing` diagnostic the author had no control to +clear. The key was reachable only by hand-editing source, which is precisely +what a designer exists to avoid. + +The new `Name (i18n key)` text box sits first in each section entry, matching +`page:tabs` / `page:accordion` where the stable identifier precedes the human +label. Its placeholder carries the snake_case convention, because +`BlockPropField` has no description or pattern affordance — the same reason the +suite already requires every `json` field to carry a shape placeholder. + +Two authoring decisions, both deliberate and both pinned: + +**The anchor is never derived from `label`.** `InspectorTextField` does expose an +`onBlur` hook for deriving a dependent field, and the block-config renderer +deliberately leaves it unwired here. A label may already be localized prose — or +an inline `{ en, 'zh-CN' }` map, which `record-details` runs through +`pickLocalized` — and seeding an anchor from it freezes one locale's text into +the one value that must stay locale-independent. Worse, it would be invisible: +the renderer falls back to the authored label when a translation misses, so a +wrongly-derived anchor renders exactly like the bug it was meant to fix, until +someone adds a second locale. + +**Sections authored before this field existed are not backfilled.** They open +with the anchor box empty and their `label` untouched; nothing is written until +the author types. This needs no code — the inspector is read-through, writing +only from a commit handler — and the alternative would mark an untouched page +dirty merely for being opened. + +No validation was added. `BlockPropField` has no pattern/validate capability, +and inventing one for a single field is out of scope; the placeholder states the +convention and the upstream lint rule remains the enforcement point. + +Coverage for this block's section entry is now derived from the spec's own +`RecordDetailsProps` shape rather than hand-listed, so the next section key the +spec grows fails loudly here instead of quietly never reaching the designer. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx new file mode 100644 index 0000000000..21fefd12ad --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx @@ -0,0 +1,222 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#3819 — the `record:details` block inspector must let an author write + * a section's `name`, because `name` is the section heading's i18n ANCHOR. + * + * The renderer resolves the heading through + * `objects.._sections..label` (`plugin-detail`'s + * `record-details.tsx`: `s.name && objectName ? sectionLabel(objectName, s.name, + * rawTitle ?? s.name) : rawTitle`; key convention in + * `i18n/useObjectLabel.ts`). While the inspector offered only + * `label`/`columns`/`fields`, every section built in Studio fell into the + * `: rawTitle` branch forever — one authored string in every locale — and + * carried an upstream `translation-section-name-missing` diagnostic its author + * had no control to clear. + * + * ## Why this is an interaction test and not only a config-face assertion + * + * `BLOCK_CONFIG` is data; what authors get is whatever `PageBlockInspector`'s + * recursive `renderField` does with it. The array branch renders `itemFields` + * against a per-item read/write pair, so "the entry exists in the table" and + * "the box is on screen and its value lands in `properties.sections[i]`" are + * different facts. These drive the real component and read the committed patch. + * + * ## NOT pinned here, deliberately: schema rejection + * + * Unlike #3229's `visibleWhen` (where the page schema is `.strict()` and the + * old key made drafts unsavable), this surface is PERMISSIVE — verified against + * the pinned `@objectstack/spec@17.0.0-rc.5`: `PageSchema.parse` does not + * validate `properties` against `RecordDetailsProps` at all, and + * `RecordDetailsProps` itself accepts an unknown key inside a `sections[]` + * entry. So a nameless section always parsed; it just could never be + * translated. Asserting "the committed draft parses" would therefore be a + * green light that means nothing — the reachability of the KEY is the fact + * under test. + * + * FIXTURE DISCIPLINE (#3216's method, as in the sibling visibleWhen suite): the + * page is authored the way a user does and fed through `PageSchema.parse`, so + * the fixture cannot drift from the spec. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { PageSchema } from '@objectstack/spec/ui'; +import { PageBlockInspector } from './PageBlockInspector'; + +afterEach(cleanup); + +/** Selection id for the single block in the fixture page. */ +const BLOCK_PATH = 'regions[0].components[0]'; + +/** A record page carrying one `record:details` block with the given sections. */ +function pageDraft(sections: Array>): Record { + return PageSchema.parse({ + name: 'contact_record', + label: 'Contact', + type: 'record', + object: 'contact', + template: 'default', + regions: [ + { + name: 'main', + components: [{ type: 'record:details', id: 'b1', properties: { sections } }], + }, + ], + }) as unknown as Record; +} + +function renderInspector(draft: Record, onPatch = vi.fn()) { + render( + {}} + readOnly={false} + locale={'en-US' as never} + />, + ); + return onPatch; +} + +/** The sections array as the inspector last committed it. */ +function committedSections(onPatch: ReturnType): Array> { + const patch = onPatch.mock.calls.at(-1)![0] as any; + return patch.regions[0].components[0].properties.sections as Array>; +} + +/** + * The section-name boxes, in section order. Located by the placeholder because + * `InspectorTextField` renders its `