From 5737745237e5d69e1ee20b1d86138570305231b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:06:17 +0000 Subject: [PATCH 1/3] fix(components): a slotted page template draws only the regions it places, and full-width retires (objectui#12476) The default, header-sidebar-main and three-column templates no longer stack a region they do not place under main; the page shows a refusal naming the region and the template's slots. The template registry is keyed by the spec's PAGE_TEMPLATE_SLOTS and drops full-width; an unregistered template draws a refusal instead of the pageType layout. dashboard keeps its open grid; the pageType dispatch is unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KduCFcSEActo1Znrb6DLqw --- .changeset/12476-page-template-slots.md | 17 ++ .../page-template-slots-12476.test.tsx | 163 ++++++++++++++++++ .../components/src/renderers/layout/page.tsx | 148 +++++++++++++--- .../layout-default-jsdoc-7361.test.ts | 3 +- 4 files changed, 304 insertions(+), 27 deletions(-) create mode 100644 .changeset/12476-page-template-slots.md create mode 100644 packages/components/src/__tests__/page-template-slots-12476.test.tsx diff --git a/.changeset/12476-page-template-slots.md b/.changeset/12476-page-template-slots.md new file mode 100644 index 0000000000..7167a636f2 --- /dev/null +++ b/.changeset/12476-page-template-slots.md @@ -0,0 +1,17 @@ +--- +'@object-ui/components': minor +--- + +A page laid out by a slotted template draws only the regions that template places, and says so on the page for any other (objectui#12476, maintainer ruling A on objectstack-ai/objectstack#23610). + +Clause-②: no (narrowing) + +`@objectstack/spec` 18 declares each template's slots (`PAGE_TEMPLATE_SLOTS`): `default` and `three-column` place header, sidebar, main, aside and footer, `header-sidebar-main` places header, sidebar and main, and `dashboard` places header and footer with every other region a grid cell. It refuses, at `os validate` and at save, a region its template does not place, and the retired `full-width` template. + +The `page` renderer used to stack such a region under `main` at full width, ignoring its `width`, so a `left_sidebar` on a `three-column` page drew as one column. On the `default`, `header-sidebar-main` and `three-column` templates that region is now drawn nowhere and is not moved into another slot. Where it used to be stacked, the page shows a refusal that names the region and the slots the template places. A page row stored before the spec refusal is still served unchanged, so this is what it shows until the region is renamed. + +`template: 'full-width'` no longer resolves. It was a second name for `default`, drawn by the same component. A served stored row is rewritten to `default` on read by the platform. A page node that still names it, or any template the registry does not hold, draws a refusal that names the template instead of falling back to the page-type layout. The registry is now keyed by the spec's template set, so a template the spec adds or retires fails to compile here. + +Unchanged: the placed regions draw as before, each on its 12-column block grid (objectui#12624), with the narrow-region compact list (objectui#12703). `dashboard` keeps its open grid. A page that names no template takes the `pageType` layout, which still stacks an unplaced region under `main`. + +No export, prop, type or language-pack key is added or removed. The narrowing is in what the renderer draws: `PageNodeSchema.regions[].name` is a string in `@object-ui/types`, and the renderer no longer draws a name its template does not place. diff --git a/packages/components/src/__tests__/page-template-slots-12476.test.tsx b/packages/components/src/__tests__/page-template-slots-12476.test.tsx new file mode 100644 index 0000000000..38200a1220 --- /dev/null +++ b/packages/components/src/__tests__/page-template-slots-12476.test.tsx @@ -0,0 +1,163 @@ +/** + * 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. + * + * A slotted template draws only the regions it places (objectui#12476, + * maintainer ruling A on objectstack#23610). + * + * `@objectstack/spec` 18 declares each template's slots (`PAGE_TEMPLATE_SLOTS`) + * and refuses, at `os validate` and at save, a region its template does not + * place, and the retired `full-width` template. The page renderer used to stack + * such a region under `main` at full width. It no longer draws it anywhere and + * says so on the page instead; it never moves it into another slot. The + * `dashboard` template keeps its open grid, and a page that names no template + * (the `pageType` dispatch) is not judged here. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import { ActionProvider, SchemaRenderer } from '@object-ui/react'; +// Module scope, not a hook: registers PageRenderer and `statistic` +// (AGENTS.md §测试纪律). +import '../renderers'; + +function tile(id: string) { + return { type: 'statistic', id, label: id, value: 1 } as any; +} + +function region(name: string) { + return { name, components: [tile(`in-${name}`)] }; +} + +function renderPage(schema: unknown) { + return render( + + + , + ); +} + +/** The region element and its 12-column block grid (objectui#12624). */ +function expectDrawnOnGrid(container: HTMLElement, name: string) { + const el = container.querySelector(`[data-region="${name}"]`); + expect(el, `region ${name}`).not.toBeNull(); + expect(el!.classList.contains('grid-cols-12')).toBe(true); + expect(container.textContent).toContain(`in-${name}`); +} + +function refusals(container: HTMLElement, kind: string) { + return Array.from(container.querySelectorAll(`[data-page-refusal="${kind}"]`)); +} + +afterEach(() => cleanup()); + +/** Each slotted template, its slots, and a name it does not place. */ +const SLOTTED: Array<[template: string, slots: string[], unplaced: string[]]> = [ + ['default', ['header', 'sidebar', 'main', 'aside', 'footer'], ['left_sidebar', 'content']], + ['header-sidebar-main', ['header', 'sidebar', 'main'], ['aside', 'footer', 'right_sidebar']], + ['three-column', ['header', 'sidebar', 'main', 'aside', 'footer'], ['left_sidebar', 'right_sidebar']], +]; + +describe('a slotted template does not stack a region it does not place (objectui#12476)', () => { + it.each(SLOTTED)('%s: an unplaced region is drawn nowhere, and the page refuses it by name', (template, slots, unplaced) => { + const { container } = renderPage({ + type: 'page', + template, + regions: [region('main'), ...unplaced.map(region)], + }); + for (const name of unplaced) { + expect(container.querySelector(`[data-region="${name}"]`), name).toBeNull(); + expect(container.textContent).not.toContain(`in-${name}`); + } + const [refusal, ...more] = refusals(container, 'unplaced-region'); + expect(more).toHaveLength(0); + expect(refusal).toBeDefined(); + const items = Array.from(refusal.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(items).toHaveLength(unplaced.length); + unplaced.forEach((name, i) => { + expect(items[i]).toContain(`'${name}'`); + expect(items[i]).toContain(`'${template}' places only ${slots.join(', ')}`); + }); + // The page still draws what the template places. + expectDrawnOnGrid(container, 'main'); + }); + + it('default: a page with no placed region draws only the refusal', () => { + const { container } = renderPage({ + type: 'page', + template: 'default', + regions: [region('kpis'), region('charts')], + }); + expect(container.querySelector('[data-region]')).toBeNull(); + expect(refusals(container, 'unplaced-region')).toHaveLength(1); + expect(refusals(container, 'unplaced-region')[0].querySelectorAll('li')).toHaveLength(2); + }); +}); + +describe('CONTROLS — what each template places still draws as before', () => { + it.each(SLOTTED)('%s: every placed slot draws on its block grid, with no refusal', (template, slots) => { + const { container } = renderPage({ type: 'page', template, regions: slots.map(region) }); + for (const name of slots) expectDrawnOnGrid(container, name); + expect(container.querySelector('[data-page-refusal]')).toBeNull(); + }); + + it('region names place case-insensitively, as the spec matches them', () => { + const { container } = renderPage({ + type: 'page', + template: 'three-column', + regions: [region('Main'), region('ASIDE')], + }); + expectDrawnOnGrid(container, 'Main'); + expectDrawnOnGrid(container, 'ASIDE'); + expect(container.querySelector('[data-page-refusal]')).toBeNull(); + }); + + it('dashboard keeps its open grid: every non-header, non-footer region is a grid cell', () => { + const { container } = renderPage({ + type: 'page', + template: 'dashboard', + regions: [region('header'), region('kpis'), region('charts'), region('main'), region('footer')], + }); + const cells = container.querySelector('[data-template="dashboard"] .md\\:grid-cols-2')!; + expect(cells).not.toBeNull(); + expect(Array.from(cells.children).map((c) => c.getAttribute('data-region'))).toEqual(['kpis', 'charts', 'main']); + for (const name of ['header', 'kpis', 'charts', 'main', 'footer']) expectDrawnOnGrid(container, name); + expect(container.querySelector('[data-page-refusal]')).toBeNull(); + }); + + it.each(['record', 'home', 'app', 'utility'])( + 'a %s page that names no template still stacks an unnamed-slot region under main (the pageType dispatch)', + (pageType) => { + const { container } = renderPage({ + type: 'page', + pageType, + regions: [region('main'), region('region_2')], + }); + expectDrawnOnGrid(container, 'main'); + expectDrawnOnGrid(container, 'region_2'); + expect(container.querySelector('[data-page-refusal]')).toBeNull(); + }, + ); +}); + +describe("the retired `full-width` template no longer resolves (objectui#12476)", () => { + it.each(['full-width', 'two-column', 'constructor'])( + '`template: %s` draws a refusal, not the page-type layout', + (template) => { + const { container } = renderPage({ + type: 'page', + pageType: 'home', + template, + regions: [region('main')], + }); + expect(container.querySelector('[data-region]')).toBeNull(); + expect(container.querySelector('[data-page-layout]')).toBeNull(); + const [refusal] = refusals(container, 'unregistered-template'); + expect(refusal).toBeDefined(); + expect(refusal.textContent).toContain(`'${template}'`); + }, + ); +}); diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index dad6a91a96..c464da3060 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -27,6 +27,7 @@ import { import { ComponentRegistry, toDomProps } from '@object-ui/core'; import { useDisplayLocale } from '@object-ui/i18n'; import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser'; +import { PAGE_TEMPLATE_SLOTS, type PageTemplate } from '@objectstack/spec/ui'; import { ReactKindPage } from './react-page'; import { PRINT_PAGE_ATTRIBUTE, PRINT_RUNNING_ATTRIBUTE, buildPagePrintStylesheet, isPagePrintDeclaration } from './page-print'; import { cn } from '../../lib/utils'; @@ -91,7 +92,7 @@ function findRegion(regions: PageNodeRegion[] | undefined, name: string): PageNo } /** Get all regions that are NOT in the named set */ -function getRemainingRegions(regions: PageNodeRegion[] | undefined, exclude: string[]): PageNodeRegion[] { +function getRemainingRegions(regions: PageNodeRegion[] | undefined, exclude: readonly string[]): PageNodeRegion[] { if (!regions) return []; const lowerSet = new Set(exclude.map((n) => n.toLowerCase())); return regions.filter((r) => !lowerSet.has(r.name?.toLowerCase() ?? '')); @@ -291,32 +292,115 @@ const RunningRegions: React.FC<{ ); }; +// --------------------------------------------------------------------------- +// Layout refusals — a template shape the spec refuses (objectui#12476) +// --------------------------------------------------------------------------- + +/** A template that places named slots only: every one but `dashboard`'s open grid. */ +type SlottedTemplate = Exclude; + +const TEMPLATE_NAMES = Object.keys(PAGE_TEMPLATE_SLOTS).map((name) => `'${name}'`).join(', '); + +/** A visible refusal in place of what the page asked for, styled like the html tier's compile error. */ +const PageLayoutRefusal: React.FC<{ kind: string; title: string; items: string[] }> = ({ kind, title, items }) => ( +
+
{title}
+
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+
+); + +/** + * The regions a slotted template does not place (`PAGE_TEMPLATE_SLOTS`). + * + * They are not drawn, and not moved into another slot: the page says so where + * they used to be stacked, under `main` (objectui#12476). The spec refuses such + * a region at `os validate` and at save (`checkPageRegionPlacement`); a row + * stored before that refusal is served unchanged, so this is what it shows + * until its region is renamed. + */ +const UnplacedRegionsRefusal: React.FC<{ regions: PageNodeRegion[]; template: SlottedTemplate }> = ({ + regions, + template, +}) => { + if (regions.length === 0) return null; + const slots = PAGE_TEMPLATE_SLOTS[template].join(', '); + return ( + + `Region '${region.name ?? `#${idx}`}': '${template}' places only ${slots}. Rename it to one of those slots, ` + + `or choose the 'dashboard' template, which lays every other region out as a grid cell.`, + )} + /> + ); +}; + +/** A template name the registry does not hold: the page is not laid out by another layout instead. */ +const UnregisteredTemplateRefusal: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => ( + +); + // --------------------------------------------------------------------------- // RegionLayout — structured layout with named slots // --------------------------------------------------------------------------- +/** + * The `default` template's body, and the `pageType` layouts' body. + * + * Under `template` (the `default` template) a region the template does not + * place is refused on screen, never stacked (objectui#12476): the spec refuses + * it at `os validate` and at save (`checkPageRegionPlacement`). Without + * `template` (a page that names no template and takes the `pageType` + * dispatch, or a page app-shell builds in code) such a region is still + * stacked under `main`. + */ const RegionLayout: React.FC<{ regions: PageNodeRegion[]; pageType?: string; className?: string; -}> = ({ regions, pageType, className }) => { + template?: SlottedTemplate; +}> = ({ regions, pageType, className, template }) => { const header = findRegion(regions, 'header'); const sidebar = findRegion(regions, 'sidebar'); const main = findRegion(regions, 'main'); const aside = findRegion(regions, 'aside'); const footer = findRegion(regions, 'footer'); - // Remaining regions that don't match named slots → append below main - const extras = getRemainingRegions(regions, ['header', 'sidebar', 'main', 'aside', 'footer']); + const remaining = getRemainingRegions(regions, ['header', 'sidebar', 'main', 'aside', 'footer']); + // Remaining regions that don't match named slots → append below main, + // on a page with no template only. + const extras = template ? [] : remaining; + const unplaced = template ? remaining : []; - // If there's no named layout structure, just stack everything const hasStructure = header || sidebar || main || aside || footer; if (!hasStructure) { + // If there's no named layout structure, just stack everything — unless a + // template lays the page out, which places none of these regions. return (
- {regions.map((region, idx) => ( - - ))} + {template ? ( + + ) : ( + regions.map((region, idx) => ( + + )) + )}
); } @@ -350,6 +434,7 @@ const RegionLayout: React.FC<{ {extras.map((region, idx) => ( ))} + {template && } {aside && ( @@ -390,10 +475,10 @@ const FlatContent: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => { // Template layouts — predefined layout templates // --------------------------------------------------------------------------- -/** Template: full-width single column */ -const FullWidthTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => { +/** Template: default — header and footer spanning, sidebar + main + aside between */ +const DefaultTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => { if (schema.regions && schema.regions.length > 0) { - return ; + return ; } return ; }; @@ -406,7 +491,7 @@ const HeaderSidebarMainTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schem const header = findRegion(regions, 'header'); const sidebar = findRegion(regions, 'sidebar'); const main = findRegion(regions, 'main'); - const extras = getRemainingRegions(regions, ['header', 'sidebar', 'main']); + const unplaced = getRemainingRegions(regions, PAGE_TEMPLATE_SLOTS['header-sidebar-main']); return (
@@ -419,9 +504,7 @@ const HeaderSidebarMainTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schem )}
{main && } - {extras.map((region, idx) => ( - - ))} +
@@ -439,7 +522,7 @@ const ThreeColumnTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schema }) = const main = findRegion(regions, 'main'); const aside = findRegion(regions, 'aside'); const footer = findRegion(regions, 'footer'); - const extras = getRemainingRegions(regions, ['header', 'sidebar', 'main', 'aside', 'footer']); + const unplaced = getRemainingRegions(regions, PAGE_TEMPLATE_SLOTS['three-column']); return (
@@ -455,9 +538,7 @@ const ThreeColumnTemplate: React.FC<{ schema: PageNodeSchema }> = ({ schema }) = )}
{main && } - {extras.map((region, idx) => ( - - ))} +
{aside && (