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
9 changes: 9 additions & 0 deletions .changeset/11933-preview-page-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

Studio's page previews draw the page's own kind, as the running app does (objectui#11933): the live preview beside an `html` or `react` page's source, and the Interfaces page canvas in Run mode.

Both previews wrote the page's kind into the node `type` only. `PageRenderer` reads the kind from `pageType`, so it fell back to a record page: an `app` or `home` page previewed at the record width and without its title heading, while the running app drew it with both. The two previews and the running app's page view now build the `{ type, pageType }` pair with one shared builder, so each preview draws a page's width and title heading as the app does. A `record` page previews as before, and an empty page draft still shows the "add components" message. The running app's page view renders exactly as before.

Nothing is added to the package entry: no export, prop, type member or language-pack key.
9 changes: 7 additions & 2 deletions packages/app-shell/src/views/PageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime.j
import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js';
import { InterfaceListPage } from './InterfaceListPage.js';
import { pageRecordActionsValue } from './pageRecordActions.js';
import { pageKindNode } from './pageKindNode.js';

/**
* After a successful page-level action, declare the change on the
Expand Down Expand Up @@ -182,8 +183,12 @@ export function PageView() {
// package, so its module graph cannot reach this file: gutting
// this mapping leaves it green, and deleting a registration
// turns it red. Both directions were measured on objectui#9718.
type: (page as any).type || 'page',
pageType: (page as any).type,
//
// The pair is built by `pageKindNode`, the ONE builder this
// writer shares with Studio's page previews (objectui#11933):
// they used to write `type` alone, so they drew an app or
// home page as a record page.
...pageKindNode(page),
// `context` is built here, never read off the page: `PageSchema`
// refuses a page-level `context` key, so no parsed page can
// carry one (objectui#9673). Written after `...page`, it also
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* 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.
*/

/**
* objectui#11933 — Studio's Run-mode page canvas draws the page's own KIND.
*
* `PagePreview` renders a region-composed page "using the runtime
* SchemaRenderer so authors see exactly what end-users would see". Out of
* design mode (the Studio Interfaces canvas in Run mode) it wrote the page's
* kind into the node `type` and nowhere else,
* while the running app (`PageView`) also copies it onto `pageType` — the key
* `PageRenderer` reads for the page's width, its `data-page-type` and whether
* it draws the implicit title heading. So an `app` or `home` page drew as a
* record page (record width, no title). It now spreads the builder `PageView`
* uses, `pageKindNode`.
*
* Everything below renders through the REAL `SchemaRenderer`, the real
* `ComponentRegistry` and the real `PageRenderer`. Widths are compared, never
* written down: the classes are `PageRenderer`'s choice, and what this card
* owns is only that a non-record page does not get the record one.
*
* - an `app` and a `home` page draw the title heading and a width that is not
* the record width;
* - CONTROL: a `record` page draws exactly as an untyped page does — the
* renderer's own record default, which is what every page drew here before;
* - CONTROL: an empty draft, typed or not, still draws no page but the
* "add components" message. The renderer's node now carries two keys the
* draft did not write, and neither counts as content.
*
* The source-page preview's half is `SourcePageEditor.pageKind-11933` beside
* this file; the running app's half is `page-kind-writing-end-9718`.
*/

import * as React from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from '@testing-library/react';
// Registers `PageRenderer` under each page kind and the blocks the region
// renders. Module scope, not a hook (AGENTS.md's flaky test discipline).
import '@object-ui/components';
import { PagePreview } from './PagePreview';
import { t } from '../i18n';

const LABEL = 'Team handbook';

interface Drawn {
pageType: string | null;
/** The `max-w-*` classes on the page's content column. */
width: string;
/** The text of every level-one heading the page drew. */
headings: string[];
}

/** Render the Run-mode canvas (no design mode) over one region page document. */
function runMode(doc: Record<string, unknown>): Drawn {
cleanup();
const { container } = render(
<PagePreview
type="page"
name="team_handbook"
draft={{
name: 'team_handbook',
label: LABEL,
regions: [{ name: 'main', components: [{ type: 'container' }] }],
...doc,
}}
/>,
);

// Firing controls: the page renderer was reached and drew its region layout.
// Without them every reading below could be a reading of something else.
const page = container.querySelector<HTMLElement>('[data-page-type]');
expect(page, 'the canvas drew no PageRenderer at all; nothing below would be measured').not.toBeNull();
expect(page!.querySelector('[data-page-layout]'), 'the page drew no region layout').not.toBeNull();

const column = page!.firstElementChild as HTMLElement | null;
const width = (column?.className ?? '')
.split(/\s+/)
.filter((c) => c.startsWith('max-w-'))
.join(' ');
expect(width, 'the page content column carries no max-width class to compare').not.toBe('');

return {
pageType: page!.getAttribute('data-page-type'),
width,
headings: [...container.querySelectorAll('h1')].map((h) => h.textContent ?? ''),
};
}

afterEach(() => cleanup());

describe('objectui#11933 — the Run-mode page canvas draws the page as its own kind', () => {
it('CONTROL: a record page draws as before, the renderer\'s record default', () => {
const record = runMode({ type: 'record' });
const untyped = runMode({});

expect(record.pageType).toBe('record');
// An untyped document carries no kind, so the renderer falls back to its
// record default — which is what EVERY page drew on this canvas before.
expect(untyped.pageType).toBe('record');
expect(record.width).toBe(untyped.width);
// A record page leaves its title to `page:header`, so it draws no implicit one.
expect(record.headings).toEqual([]);
expect(untyped.headings).toEqual([]);
});

it.each(['app', 'home'])('a page of kind %s draws its title heading and not the record width', (kind) => {
const recordWidth = runMode({ type: 'record' }).width;
const drawn = runMode({ type: kind });

expect(
drawn.pageType,
`the canvas must hand PageRenderer the page kind '${kind}' on pageType, as the running app does`,
).toBe(kind);
expect(drawn.headings).toEqual([LABEL]);
expect(
drawn.width,
`a '${kind}' page drew at the record width (${recordWidth}); the running app draws it at its own`,
).not.toBe(recordWidth);
});

it.each([{}, { type: 'app' }])('CONTROL: an empty draft %j still shows the add-components message', (draft) => {
cleanup();
const { container } = render(<PagePreview type="page" name="empty" draft={draft} />);

expect(container.querySelector('[data-page-type]')).toBeNull();
expect(container.textContent).toContain(t('engine.pagePreview.addComponents'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ import type { MetadataPreviewProps } from '../preview-registry.js';
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js';
import { OutlineStrip } from './OutlineStrip.js';
import { SourcePageEditor } from './SourcePageEditor.js';
import { pageKindNode } from '../../pageKindNode.js';
import { PageBlockCanvas } from './PageBlockCanvas.js';
import { InterfaceListPage } from '../../InterfaceListPage.js';
import { t as tr, tFormat } from '../i18n.js';

interface Block { type?: string; id?: string; children?: Block[]; [k: string]: unknown }

export function PagePreview({ draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps) {
const schema = React.useMemo(() => {
// SchemaRenderer needs a `type` discriminator. Page schemas may
// omit it (Page is the implicit type at this metadata level), so
// we inject it if missing while preserving any explicit override.
const t = (draft as { type?: string }).type ?? 'page';
return { ...(draft as Record<string, unknown>), type: t };
}, [draft]);
const schema = React.useMemo(
// SchemaRenderer needs a `type` discriminator (`'page'` when the draft
// declares no kind), and PageRenderer reads the page's KIND off
// `pageType` for its width and title heading — both written by the one
// builder the running app's PageView uses. Writing `type` alone drew an
// app or home page as a record page in the Run-mode canvas
// (objectui#11933).
() => ({ ...(draft as Record<string, unknown>), ...pageKindNode(draft as { type?: string }) }),
[draft],
);

const designMode = !!(editing && onSelectionChange);
const canEdit = designMode && !!onPatch;
Expand Down Expand Up @@ -324,8 +328,10 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
}

// Empty draft → no preview; but if we're in design mode show the
// canvas so users can author from scratch.
if (!schema || Object.keys(schema).length <= 1) {
// canvas so users can author from scratch. "Empty" is the DRAFT's own
// keys, a `type` aside: the keys `schema` adds for the renderer are not
// content (objectui#11933).
if (!schema || Object.keys(draft as Record<string, unknown>).every((k) => k === 'type')) {
return (
<PreviewShell hint={`page${designMode ? ' · design' : ''}`}>
{designMode && shape === 'regions' ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* 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.
*/

/**
* objectui#11933 — a source page's live preview draws the page's own KIND.
*
* `SourcePageEditor`'s preview is "a live preview rendered through the runtime
* SchemaRenderer". It wrote the page's kind into the node `type` and nowhere
* else, while the running app (`PageView`) also copies it onto `pageType` — the
* key `PageRenderer` reads for the page's width, its `data-page-type` and
* whether it draws the implicit title heading. So Studio drew an `app` or
* `home` page as a record page (record width, no title) while the running app
* drew it with both. Both writers now spread one builder, `pageKindNode`.
*
* Everything below renders through the REAL `SchemaRenderer`, the real
* `ComponentRegistry` and the real `PageRenderer` the preview uses — no mock
* stands in for the renderer. Widths are compared, never written down: the
* classes are `PageRenderer`'s choice, and what this card owns is only that a
* non-record page does not get the record one.
*
* - an `app` and a `home` source page preview with the title heading and a
* width that is not the record width;
* - every region-composed page kind reaches the renderer as itself;
* - CONTROL: a `record` page previews exactly as an untyped page does — the
* renderer's own record default, which is what every source page drew before.
*
* The running app's half of the pair is pinned by `page-kind-writing-end-9718`
* in `views/__tests__`, kind by kind; this file does not re-assert it.
*/

import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { PageTypeSchema } from '@objectstack/spec/ui';
// Registers `PageRenderer` under each page kind and the html tier's blocks the
// preview compiles the source to. Module scope, not a hook (AGENTS.md's flaky
// test discipline).
import '@object-ui/components';

// The editor half is not under test (the canvas mounts the preview half only),
// but the component asks the Monaco loader either way. Offline, as in the
// sibling source-page tests, so no CDN script is requested.
vi.mock('@monaco-editor/react', () => {
const Editor = () => null;
return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } };
});

import { SourcePageEditor } from './SourcePageEditor';

const LABEL = 'Team handbook';
const BODY = 'Welcome to the team.';

/**
* Kinds that are interface mode in the running app (`PageView` short-circuits
* them to `InterfaceListPage`), so no `PageRenderer` registration answers for
* them. The same split `page-kind-writing-end-9718` reads.
*/
const INTERFACE_MODE_KINDS = ['list'] as const;

function regionComposedKinds(): string[] {
// `PageTypeSchema` is a lazy schema; `.options` is the enum's member list.
const kinds = [...(PageTypeSchema as unknown as { options: readonly string[] }).options];
return kinds.filter((kind) => !(INTERFACE_MODE_KINDS as readonly string[]).includes(kind));
}

interface Drawn {
pageType: string | null;
/** The `max-w-*` classes on the page's content column. */
width: string;
/** The text of every level-one heading the page drew. */
headings: string[];
}

/** Render the Studio canvas's preview half over one source page document. */
function preview(doc: Record<string, unknown>): Drawn {
cleanup();
const { container } = render(
<SourcePageEditor
mode="preview"
readOnly
draft={{ name: 'team_handbook', label: LABEL, kind: 'html', source: `<p>${BODY}</p>`, ...doc }}
/>,
);

// Firing controls: the page renderer was reached, and the source compiled.
// A compile error replaces the body with an error panel, and a missing page
// element means the preview drew something else — either would make every
// reading below a reading of the wrong thing.
const page = container.querySelector<HTMLElement>('[data-page-type]');
expect(page, 'the preview drew no PageRenderer at all; nothing below would be measured').not.toBeNull();
expect(container.textContent).not.toContain('failed to compile');
expect(container.textContent).toContain(BODY);

const column = page!.firstElementChild as HTMLElement | null;
const width = (column?.className ?? '')
.split(/\s+/)
.filter((c) => c.startsWith('max-w-'))
.join(' ');
expect(width, 'the page content column carries no max-width class to compare').not.toBe('');

return {
pageType: page!.getAttribute('data-page-type'),
width,
headings: [...container.querySelectorAll('h1')].map((h) => h.textContent ?? ''),
};
}

afterEach(() => cleanup());

describe('objectui#11933 — a source page previews as its own page kind', () => {
it('CONTROL: a record page previews as before, the renderer\'s record default', () => {
const record = preview({ type: 'record' });
const untyped = preview({});

expect(record.pageType).toBe('record');
// An untyped document carries no kind, so the renderer falls back to its
// record default — which is what EVERY source page drew before this fix.
expect(untyped.pageType).toBe('record');
expect(record.width).toBe(untyped.width);
// A record page leaves its title to `page:header`, so it draws no implicit one.
expect(record.headings).toEqual([]);
expect(untyped.headings).toEqual([]);
});

it.each(['app', 'home'])('a page of kind %s previews with its title heading and not the record width', (kind) => {
const recordWidth = preview({ type: 'record' }).width;
const drawn = preview({ type: kind });

expect(
drawn.pageType,
`the preview must hand PageRenderer the page kind '${kind}' on pageType, as the running app does`,
).toBe(kind);
expect(drawn.headings).toEqual([LABEL]);
expect(
drawn.width,
`a '${kind}' page previewed at the record width (${recordWidth}); the running app draws it at its own`,
).not.toBe(recordWidth);
});

it('every region-composed page kind reaches the renderer as itself', () => {
const kinds = regionComposedKinds();
// Firing control: a green below is a reading, not an empty loop.
expect(kinds.length).toBeGreaterThan(0);

for (const kind of kinds) {
expect(preview({ type: kind }).pageType, `page kind '${kind}'`).toBe(kind);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Skeleton } from '@object-ui/components';
import { SchemaRenderer } from '@object-ui/react';
import { PreviewShell, PreviewErrorBoundary } from './PreviewShell.js';
import { useMonacoFallback } from '../useMonacoFallback.js';
import { pageKindNode } from '../../pageKindNode.js';
import { t as tr } from '../i18n.js';

// Lazy for the same reason as JsonSourceEditor — Monaco's core is ~3MB and
Expand Down Expand Up @@ -150,8 +151,14 @@ export function SourcePageEditor({
onPatch?.({ source: v });
};

// The page's KIND rides two keys, as the running app hands it (`PageView`):
// the node `type` the registry dispatches on and the `pageType` PageRenderer
// reads for the page width and the title heading. Writing `type` alone drew
// every source page as a record page — record width, no title — whatever
// its kind (objectui#11933). One shared builder writes both keys, for the
// running app and for both Studio page previews.
const previewSchema = React.useMemo(
() => ({ ...(draft as Record<string, unknown>), type: (draft as { type?: string }).type ?? 'page' }),
() => ({ ...(draft as Record<string, unknown>), ...pageKindNode(draft as { type?: string }) }),
[draft],
);

Expand Down
Loading
Loading