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
17 changes: 17 additions & 0 deletions .changeset/11173-retire-page-header-breadcrumb-toggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the page designer stops offering the retired `page:header` breadcrumb toggle (objectui#11173)

The Studio page designer's properties panel offered a "Show breadcrumb" toggle on the
canonical `page:header` block, which wrote `breadcrumb` into the stored page. The
header renderer no longer reads that key (objectui#11166), and objectstack#20758
retires `PageHeaderProps.breadcrumb` in the spec through ADR-0087, after which the
platform refuses it by name. The toggle is gone from `BLOCK_CONFIG['page:header']`,
and its label key `engine.inspector.pageBlock.field.page:header.breadcrumb` left
both the en and zh tables, the same shape the header's `icon` field took.

A page that already carries `breadcrumb` is not rewritten by the designer: it opens,
the key shows under Advanced by its raw name, and the next save keeps it. The one
strip belongs to the spec's ADR-0087 conversion on load, with its notice.
8 changes: 5 additions & 3 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,9 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
// 17.0.0-rc.6). Their only reader was the canonical `page:header` icon field
// removed from `previews/block-config.ts`; a key kept past its field is dead
// vocabulary that the next author reads as a live surface. objectui#3829.
'engine.inspector.pageBlock.field.page:header.breadcrumb': 'Show breadcrumb',
// `…field.page:header.breadcrumb` left the same way with the designer's
// breadcrumb toggle (objectui#11173; the spec key retires through
// objectstack#20758).
'engine.inspector.pageBlock.field.page:card.title': 'Title',
'engine.inspector.pageBlock.field.page:card.bordered': 'Bordered',
'engine.inspector.pageBlock.field.page:tabs.items': 'Tabs',
Expand Down Expand Up @@ -3728,8 +3730,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.page:header.subtitle': '副标题',
// `…field.page:header.icon` and its placeholder retired with the spec key —
// see the matching note in the `en` table above. Removed from BOTH tables in
// the same edit so the two key sets stay identical.
'engine.inspector.pageBlock.field.page:header.breadcrumb': '显示面包屑',
// the same edit so the two key sets stay identical. `…field.page:header.breadcrumb`
// likewise left both tables with its toggle (objectui#11173).
'engine.inspector.pageBlock.field.page:card.title': '标题',
'engine.inspector.pageBlock.field.page:card.bordered': '显示边框',
'engine.inspector.pageBlock.field.page:tabs.items': '标签页',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ describe('PageBlockInspector PROPERTIES labels follow the locale (#3913)', () =>
expect(screen.getByText('属性')).toBeTruthy(); // chrome, already worked
expect(screen.getByText('标题')).toBeTruthy(); // contents, the fix
expect(screen.getByText('副标题')).toBeTruthy();
expect(screen.getByText('显示面包屑')).toBeTruthy();
expect(screen.queryByText('Subtitle')).toBeNull();
expect(screen.queryByText('Show breadcrumb')).toBeNull();
// The breadcrumb toggle's label pair left with the toggle (objectui#11173);
// its absence is pinned in `PageBlockInspector.pageHeaderBreadcrumb-11173.test.tsx`.
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11173 — the `page:header` panel on screen after the designer's
* `breadcrumb` toggle left `BLOCK_CONFIG['page:header']`.
*
* The sibling `previews/__tests__/block-config.test.ts` pins the TABLE (the
* curated field set is `title` / `subtitle`). This file pins the two facts only
* a real render and a real commit can answer:
*
* - the panel offers no breadcrumb control, in either locale;
* - a page that ALREADY carries `breadcrumb` (a released designer build wrote
* it) opens, and its next save keeps the key. That is the triage ruling on
* the card: no strip in the designer. objectstack#20758's ADR-0087
* conversion is the one strip, on load, with its notice; a second strip
* here would be a second mechanism and would hide that notice. So
* `RETIRED_BLOCK_PROP_KEYS` has no `page:header` entry, and the round-trip
* case below is the control that goes red if one is added.
*
* FIXTURE DISCIPLINE (#3216's method, as in the sibling suites): the page is
* authored the way a user does and fed through `PageSchema.parse`, so the
* fixture cannot drift from the spec. `PageSchema` does not judge a block's
* `properties` (the sibling `PageBlockInspector.retiredBlockProps.test.tsx`
* header records that measurement), so the stored fixture keeps parsing when a
* later spec pin tombstones the key.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { PageSchema } from '@objectstack/spec/ui';

// objectui#4697 — see PageBlockInspector.i18n.test.tsx for the full mechanism;
// same STABLE stub, short-circuiting useObjectFields/useObjectOptions's
// mount-time fetch instead of letting it escape to the real network. No
// assertion here reads the fetched object list or its fields.
const state = vi.hoisted(() => ({
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
}));
vi.mock('../useMetadata', () => ({
useMetadataClient: () => state.metadataClient,
}));

import { PageBlockInspector } from './PageBlockInspector';

afterEach(cleanup);

const BLOCK_PATH = 'regions[0].components[0]';

/** A record page carrying one `page:header` block with the given properties. */
function pageDraft(properties: Record<string, unknown>): Record<string, unknown> {
return PageSchema.parse({
name: 'account_record',
label: 'Account',
type: 'record',
object: 'account',
template: 'default',
regions: [{ name: 'main', components: [{ type: 'page:header', id: 'b1', properties }] }],
}) as unknown as Record<string, unknown>;
}

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

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

const LABELS = {
'en-US': { title: 'Title', subtitle: 'Subtitle' },
'zh-CN': { title: '标题', subtitle: '副标题' },
} as const;

describe('page:header inspector · no breadcrumb toggle (objectui#11173)', () => {
it.each(['en-US', 'zh-CN'] as const)('%s: offers title and subtitle, and no toggle at all', (locale) => {
renderInspector(pageDraft({}), locale);
// Non-vacuity: the curated section IS rendering, so the zeroes below are a
// reading about the toggle rather than about an empty panel.
expect(screen.getByLabelText(LABELS[locale].title)).toBeTruthy();
expect(screen.getByLabelText(LABELS[locale].subtitle)).toBeTruthy();
// The toggle was the header's only boolean control, so no checkbox is left.
expect(screen.queryAllByRole('checkbox')).toEqual([]);
// Neither label spelling, and no raw key standing in for a missing one.
expect(screen.queryByText('Show breadcrumb')).toBeNull();
expect(screen.queryByText('显示面包屑')).toBeNull();
expect(document.body.textContent ?? '').not.toContain('engine.inspector.pageBlock.');
});
});

describe('page:header inspector · a stored `breadcrumb` round-trips (objectui#11173)', () => {
/** A block saved by a released build, while the toggle was still offered. */
const stored = () => pageDraft({ title: 'Account', breadcrumb: true });

it('opens: the stored key stays visible, in Advanced under its raw name', () => {
renderInspector(stored(), 'en-US');
expect((screen.getByLabelText('Title') as HTMLInputElement).value).toBe('Account');
// No longer curated, so the generic editor shows it; it was not dropped on
// read, which is what a strip would have done.
const box = screen.getByLabelText('breadcrumb') as HTMLInputElement;
expect(box.type).toBe('checkbox');
expect(box.checked).toBe(true);
});

it('saves: an edit to another field keeps `breadcrumb` in the committed document', () => {
const onPatch = renderInspector(stored(), 'en-US');
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Customer' } });
// Everything the block carried rides out; only the edited field moved.
expect(committedProps(onPatch)).toEqual({ title: 'Customer', breadcrumb: true });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,8 @@ describe('page:header `icon` — the designer field retired with the spec key (#
// POSITIVE half, for the reason the location dropdown states: without it the
// negative pin below passes just as happily on a deleted panel.
it('still offers the canonical header fields the renderer does implement', () => {
expect(fieldNames()).toEqual(['title', 'subtitle', 'breadcrumb']);
// `breadcrumb` left this list with objectui#11173 — see the next describe.
expect(fieldNames()).toEqual(['title', 'subtitle']);
});

it('does NOT offer the retired `icon` field', () => {
Expand Down Expand Up @@ -446,6 +447,45 @@ describe('page:header `icon` — the designer field retired with the spec key (#
});
});

/**
* `page:header.breadcrumb` — the second canonical header toggle retired under
* the rule the `icon` describe above states (objectui#11173).
*
* The canonical `page:header` renderer stopped reading the key (objectui#11166),
* and objectstack#20758 retires `PageHeaderProps.breadcrumb` through ADR-0087.
* A designer that kept offering the toggle would author a key nothing reads,
* and — once objectui pins a spec release carrying that tombstone — one the
* platform refuses BY NAME. The producer leaves first, so the pin bump does not
* find it standing. When that bump lands, the `icon` describe's tombstone probe
* is the pattern for a third assertion here.
*
* NOT a strip, deliberately (triage ruling on the card): `RETIRED_BLOCK_PROP_KEYS`
* gains no `page:header` entry, because the spec's ADR-0087 conversion is the
* one strip and a second one here would hide its notice. The stored-page half —
* a page already carrying `breadcrumb: true` opens, and its next save keeps the
* key — is a fact about a real commit, so it is pinned on screen in
* `inspectors/PageBlockInspector.pageHeaderBreadcrumb-11173.test.tsx`.
*/
describe('page:header `breadcrumb` — the designer toggle retired with its reader (objectui#11173)', () => {
const fieldNames = () => BLOCK_CONFIG['page:header'].map((f) => f.name);

// The POSITIVE half is the `icon` describe's field-set pin above; it is the
// non-vacuity control for this negative one as well.
it('does NOT offer the retired `breadcrumb` toggle', () => {
expect(fieldNames().length, 'field list is empty — the pin would be vacuous').toBeGreaterThan(0);
expect(fieldNames()).not.toContain('breadcrumb');
});

// The i18n side, as for `icon`: a label kept past its field is dead
// vocabulary, so BOTH locale tables lost it. `t()` returns the key unchanged
// on a miss.
it('has no leftover translation for the retired toggle in either locale', () => {
const key = 'engine.inspector.pageBlock.field.page:header.breadcrumb';
expect(t(key, 'en-US')).toBe(key);
expect(t(key, 'zh-CN')).toBe(key);
});
});

/**
* `page:accordion` `title` / items `value` — designer inputs no renderer
* reads (#5212).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,16 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
// would let the designer author metadata that fails to parse, which is
// strictly worse than the silent drop it replaced. Pinned negatively in
// `__tests__/block-config.test.ts`. objectui#3829.
{ name: 'breadcrumb', label: 'engine.inspector.pageBlock.field.page:header.breadcrumb', kind: 'boolean' },
//
// No `breadcrumb` toggle either, for the same reason one step earlier
// (objectui#11173): the canonical `page:header` renderer stopped reading it
// (objectui#11166), and objectstack#20758 retires `PageHeaderProps.breadcrumb`
// through ADR-0087. The toggle would go on authoring a key nothing reads
// now and the platform refuses by name once that release is pinned.
// Deliberately NOT added to `RETIRED_BLOCK_PROP_KEYS` below: a stored
// `breadcrumb` round-trips untouched, because the spec's ADR-0087
// conversion is the one strip (with its notice) and a second one here
// would hide that notice.
],
'page:card': [
{ name: 'title', label: 'engine.inspector.pageBlock.field.page:card.title', kind: 'text' },
Expand Down
Loading