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
56 changes: 56 additions & 0 deletions .changeset/record-details-sections-name-anchor-3819.md
Original file line number Diff line number Diff line change
@@ -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.<object>._sections.<name>.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.
Original file line number Diff line number Diff line change
@@ -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.<object>._sections.<name>.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<string, unknown>>): Record<string, unknown> {
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<string, unknown>;
}

function renderInspector(draft: Record<string, unknown>, onPatch = vi.fn()) {
render(
<PageBlockInspector
type="page"
name="contact_record"
draft={draft}
selection={{ kind: 'block', id: BLOCK_PATH }}
onPatch={onPatch}
onClearSelection={() => {}}
readOnly={false}
locale={'en-US' as never}
/>,
);
return onPatch;
}

/** The sections array as the inspector last committed it. */
function committedSections(onPatch: ReturnType<typeof vi.fn>): Array<Record<string, unknown>> {
const patch = onPatch.mock.calls.at(-1)![0] as any;
return patch.regions[0].components[0].properties.sections as Array<Record<string, unknown>>;
}

/**
* The section-name boxes, in section order. Located by the placeholder because
* `InspectorTextField` renders its `<Label>` unassociated with the `<input>`
* (no `htmlFor`/`id`), so `getByLabelText` cannot reach it — and locating by
* placeholder doubles as proof the snake_case hint reaches the DOM, which is
* the only convention affordance this field has.
*/
const nameBoxes = () => screen.getAllByPlaceholderText(/snake_case/i) as HTMLInputElement[];
/** The label box of section #1 — "Contact info" in these fixtures. */
const labelBox = () => screen.getByDisplayValue('Contact info') as HTMLInputElement;

/* ───────────────────────── the box exists and commits ───────────────────── */

describe('PageBlockInspector — record:details sections expose the i18n anchor (#3819)', () => {
it('renders one name box per section', () => {
renderInspector(
pageDraft([
{ label: 'Contact info', fields: ['first_name'] },
{ label: 'Address', fields: ['city'] },
]),
);
expect(nameBoxes()).toHaveLength(2);
});

it('typing a name commits it to `properties.sections[i].name`', () => {
const onPatch = renderInspector(pageDraft([{ label: 'Contact info', columns: 2, fields: ['first_name'] }]));

fireEvent.change(nameBoxes()[0], { target: { value: 'contact_info' } });

const [section] = committedSections(onPatch);
expect(section.name).toBe('contact_info');
// The write must be a merge, not a replacement: the array branch commits
// `{ ...itemObj, [n]: v }`, and a section that lost its `fields` to gain a
// name would render nothing at all.
expect(section).toMatchObject({ label: 'Contact info', columns: 2, fields: ['first_name'] });
});

it('names the right section when several exist', () => {
const onPatch = renderInspector(
pageDraft([
{ label: 'Contact info', fields: ['first_name'] },
{ label: 'Address', fields: ['city'] },
]),
);

fireEvent.change(nameBoxes()[1], { target: { value: 'address' } });

const sections = committedSections(onPatch);
expect(sections[1].name).toBe('address');
expect(sections[0]).not.toHaveProperty('name');
});

it('the committed name satisfies the renderer guard that reaches the i18n lookup', () => {
// `record-details.tsx` gates the translated read on `s.name && objectName`.
// Before this field existed the guard could never be satisfied from Studio;
// this asserts the authored value is what makes it truthy, and spells out
// the key the convention then resolves.
const onPatch = renderInspector(pageDraft([{ label: 'Contact info', fields: ['first_name'] }]));
fireEvent.change(nameBoxes()[0], { target: { value: 'contact_info' } });

const [section] = committedSections(onPatch);
expect(typeof section.name === 'string' && section.name.length > 0).toBe(true);
expect(`objects.contact._sections.${section.name}.label`).toBe(
'objects.contact._sections.contact_info.label',
);
});

it('a freshly added section offers an empty name box', () => {
// Start from a page that already has one section so the new entry is
// rendered by the same pass — `onPatch` is a spy, not a state setter, so the
// pushed item is not fed back into this render.
const onPatch = renderInspector(pageDraft([{ label: 'Contact info', fields: ['first_name'] }]));

fireEvent.click(screen.getByText('Add section'));

// The array branch pushes a bare `{}`, so the new entry carries no keys —
// in particular no `name` derived from anything.
expect(committedSections(onPatch)).toEqual([
{ label: 'Contact info', fields: ['first_name'] },
{},
]);
// And the box the author needs is really on screen for the existing entry.
// Asserted here too so this case fails — rather than passing vacuously —
// if the itemField is ever dropped again.
expect(nameBoxes()[0].value).toBe('');
});
});

/* ─────────── sub-question ②: no backfill onto pre-existing sections ─────── */

/**
* A section authored before this field existed has a `label` and no `name`.
* Opening it must leave the anchor EMPTY rather than seeding it from the label.
*
* Backfilling would be actively harmful, and silently so: `label` may already
* be a localized string (or an inline `{ en, 'zh-CN' }` map — `record-details`
* runs it through `pickLocalized`). Deriving an anchor from it freezes one
* locale's prose into the key that is supposed to be locale-INDEPENDENT, and
* because the renderer falls back to the authored label when a translation
* misses, the damage renders identically to the bug — invisible until someone
* adds a second locale.
*/
describe('PageBlockInspector — an existing label-only section is not backfilled (#3819)', () => {
const legacy = () => pageDraft([{ label: 'Contact info', columns: 2, fields: ['first_name'] }]);

it('opens the name box empty and leaves the label alone', () => {
renderInspector(legacy());

expect(nameBoxes()[0].value).toBe('');
expect(labelBox().value).toBe('Contact info');
});

it('rendering the inspector writes nothing by itself', () => {
// The inspector is read-through: `renderField` only writes from a commit
// handler. A backfill would have to patch during render — which would also
// mark an untouched page dirty just for being opened.
const onPatch = renderInspector(legacy());
expect(onPatch).not.toHaveBeenCalled();
});

it('keeps the section nameless until the author types one', () => {
const onPatch = renderInspector(legacy());

// Touch a neighbouring field: any commit re-writes the whole item object,
// which is exactly where an accidental derived `name` would appear.
fireEvent.change(labelBox(), { target: { value: 'Contact details' } });

const [section] = committedSections(onPatch);
expect(section.label).toBe('Contact details');
expect(section).not.toHaveProperty('name');
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { PageComponentType } from '@objectstack/spec/ui';
import { PageComponentType, RecordDetailsProps } from '@objectstack/spec/ui';
import { BLOCK_CONFIG, blockHasConfig } from '../block-config';
import { BLOCK_TYPE_META, PALETTE_EXCLUSIONS } from '../block-types';

Expand Down Expand Up @@ -50,6 +50,86 @@ describe('block-config', () => {
});
});

/**
* `record:details.sections` ↔ the spec's own section-entry shape (#3819).
*
* The designer offered `label` / `columns` / `fields` and silently omitted
* `name` — which the spec describes as the section's i18n ANCHOR ("resolves
* `objects.<object>._sections.<name>.label`; a nameless section renders its
* authored label in every locale") and which the renderer really reads
* (`record-details.tsx`: `s.name && objectName ? sectionLabel(objectName,
* s.name, …)`). Every section Studio produced was therefore untranslatable by
* construction, and carried an upstream `translation-section-name-missing`
* diagnostic no designer control could clear.
*
* The coverage half is DERIVED from `RecordDetailsProps`, not hand-listed, for
* the reason the palette suite below states: a hand-written
* `expect(name).toBeDefined()` pins today's gap closed but stays green the next
* time the spec grows a section key the inspector never learns to author.
*/
describe('record:details sections ↔ spec section-entry coverage (#3819)', () => {
/** The spec's authorable keys for one `sections[]` entry, read off the Zod shape. */
const specSectionKeys: string[] = (() => {
const props = RecordDetailsProps as unknown as { shape?: Record<string, unknown> };
const sections = props.shape?.sections as { _def?: Record<string, any> } | undefined;
// sections: ZodOptional< ZodArray< ZodObject > > — unwrap to the element object.
let node: any = sections;
for (let i = 0; i < 6 && node; i++) {
const def = node._def ?? {};
if (def.innerType) { node = def.innerType; continue; }
if (def.element) { node = def.element; continue; }
break;
}
const shape = node?.shape;
return shape ? Object.keys(shape) : [];
})();

/** The inspector's item editors for one section. */
const sectionsField = BLOCK_CONFIG['record:details'].find((f) => f.name === 'sections') as
| { kind: 'array'; itemFields: Array<{ name: string; label: string; kind: string; placeholder?: string }> }
| undefined;

it('reads a non-empty section-entry shape from the spec', () => {
// Guards the derivation itself: a spec refactor that moves the shape must
// fail here loudly rather than turn the coverage assertion into a no-op
// that passes over an empty list.
expect(specSectionKeys, 'could not read RecordDetailsProps.sections[] shape').not.toEqual([]);
expect(specSectionKeys).toContain('name');
});

it('exposes an editor for every key the spec declares on a section', () => {
expect(sectionsField?.kind).toBe('array');
const authored = (sectionsField?.itemFields ?? []).map((f) => f.name);
const missing = specSectionKeys.filter((k) => !authored.includes(k));
// If this fails: the spec declares a section key the block designer gives
// authors no way to write. Add the itemField — a key that only source-mode
// editing can reach is a key Studio-built pages structurally cannot carry.
expect(missing, 'section keys with no designer control').toEqual([]);
});

it('the `name` editor is a text box carrying the snake_case convention', () => {
const nameField = sectionsField?.itemFields.find((f) => f.name === 'name');
expect(nameField, 'record:details sections must expose the i18n anchor `name`').toBeDefined();
expect(nameField!.kind).toBe('text');
// `BlockPropField` has no description/pattern affordance, so the
// placeholder is the only place the snake_case convention can be stated —
// the same argument the json-placeholder test below makes.
expect(nameField!.placeholder, '`name` needs a placeholder stating snake_case').toMatch(/snake_case/);
// The label must say what the box is FOR. A bare "Name" next to "Label"
// reads as a second display string, which is how an author ends up typing
// a heading into the anchor.
expect(nameField!.label).toMatch(/i18n/i);
});

it('lists `name` before `label` — the entry identity comes first', () => {
// Matches `page:tabs` (`key`) and `page:accordion` (`value`), where the
// stable identifier precedes the human label.
const order = (sectionsField?.itemFields ?? []).map((f) => f.name);
expect(order.indexOf('name')).toBeGreaterThanOrEqual(0);
expect(order.indexOf('name')).toBeLessThan(order.indexOf('label'));
});
});

/**
* Palette coverage ↔ spec `PageComponentType` (#2943).
*
Expand Down
Loading
Loading