diff --git a/.changeset/11173-retire-page-header-breadcrumb-toggle.md b/.changeset/11173-retire-page-header-breadcrumb-toggle.md new file mode 100644 index 0000000000..559c56a577 --- /dev/null +++ b/.changeset/11173-retire-page-header-breadcrumb-toggle.md @@ -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. diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index c20ab4d7fa..96e6d50dad 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -769,7 +769,9 @@ const ENGINE_STRINGS_EN: Record = { // 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', @@ -3728,8 +3730,8 @@ const ENGINE_STRINGS_ZH: Record = { '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': '标签页', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx index 8e3027bca1..71992bf8b0 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx @@ -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`. }); }); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.pageHeaderBreadcrumb-11173.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.pageHeaderBreadcrumb-11173.test.tsx new file mode 100644 index 0000000000..631f52ecf6 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.pageHeaderBreadcrumb-11173.test.tsx @@ -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): Record { + 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; +} + +function renderInspector( + draft: Record, + locale: 'en-US' | 'zh-CN', + onPatch = vi.fn(), +) { + render( + {}} + readOnly={false} + locale={locale as never} + />, + ); + return onPatch; +} + +/** The block's `properties` as the inspector last committed them. */ +function committedProps(onPatch: ReturnType): Record { + const patch = onPatch.mock.calls.at(-1)![0] as { + regions: Array<{ components: Array<{ properties: Record }> }>; + }; + 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 }); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts index 50eec9b0c2..3719772d9f 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config.test.ts @@ -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', () => { @@ -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). diff --git a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts index ce23c9e4be..73518960b4 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/block-config.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/block-config.ts @@ -398,7 +398,16 @@ export const BLOCK_CONFIG: Record = { // 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' },