Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
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
21 changes: 21 additions & 0 deletions .changeset/12476-page-template-slots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@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`.

The renames the spec names: `full-width` → `default`; on `default` and `three-column`, `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are meant as a grid.

`@object-ui/components` now needs `@objectstack/spec` 18.0.0-next.2 or later: the renderer reads the spec's `PAGE_TEMPLATE_SLOTS`, which next.1 does not carry, so the package's declared range moves from `^18.0.0-next.1` to `^18.0.0-next.2`.

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.
4 changes: 2 additions & 2 deletions .changeset/23610-record-page-seed-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ A new record page seeded in Studio is saved with the `default` layout template i

`@objectstack/spec` closes `PageSchema.template` to four layout names — `default`, `header-sidebar-main`, `three-column`, `dashboard` — and retires `full-width` by name, naming `default` as its replacement; the save path refuses a page that still carries it.

- **`buildDefaultPageSchema` seeds `template: 'default'`.** Studio's "new record page" seed takes its template from this synthesis, so with the old value the first save of a new record page was refused. The renderer maps `default` and `full-width` to the same `FullWidthTemplate`, so a seeded page lays out exactly as before.
- **`buildDefaultPageSchema` seeds `template: 'default'`.** Studio's "new record page" seed takes its template from this synthesis, so with the old value the first save of a new record page was refused. `full-width` and `default` were drawn by the same renderer component, which `default` still names, so a seeded page lays out exactly as before.
- **`PageNodeSchema.template` is `SpecPage['template']`.** The member was re-declared as `string` over the spec's inherited one; once the spec closes the set, that widening is a TypeScript error (TS2430) on the interface. Against today's spec, whose member is still `string`, the type is unchanged.
- **The zod mirror's `template` is the spec's member too.** `PageNodeSchema` in the zod face declared `template: z.string()`, which accepts every name; once the spec closes the set it measures wider than the TS face, so `full-width` would still pass `objectui validate`. It now takes the spec's own member (defaults stripped, as for every imported member), and against today's spec it accepts exactly what it accepted before.

The renderer still recognises `full-width`, so pages already stored with it keep rendering.
A page already stored with `full-width` keeps rendering because the platform rewrites it to `default` on read (the spec's conversion `page-template-full-width-to-default`). The renderer itself no longer recognises `full-width` (objectui#12476).
2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@object-ui/react-runtime": "workspace:*",
"@object-ui/sdui-parser": "workspace:*",
"@object-ui/types": "workspace:*",
"@objectstack/spec": "^18.0.0-next.1",
"@objectstack/spec": "^18.0.0-next.2",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
Expand Down
163 changes: 163 additions & 0 deletions packages/components/src/__tests__/page-template-slots-12476.test.tsx
Original file line number Diff line number Diff line change
@@ -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 };
}

function region(name: string) {
return { name, components: [tile(`in-${name}`)] };
}

function renderPage(schema: unknown) {
return render(
<ActionProvider>
<SchemaRenderer schema={schema as never} />
</ActionProvider>,
);
}

/** 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}'`);
},
);
});
Loading
Loading