Skip to content

Commit e885db3

Browse files
authored
fix(studio): edit kind:'html'/'react' pages as source (no more crash) (#2109)
* fix(studio): edit kind:'html'/'react' pages as source, not the design canvas A source page (ADR-0080/0081) IS a `source` string with no `regions`/`children`, so the structured design canvas does not apply and was crashing on it. PagePreview now detects kind html/react and renders a new SourcePageEditor — a code editor for the `source` field (Monaco, JSX/TSX) beside a live runtime preview, patching draft.source on edit. dev/sdui-source-editor-preview verifies it (no crash; code + live interactive preview). * chore(dev): add Task Desk (drawer + modal) to the scenarios harness Covers the popup-edit patterns — a slide-out drawer to edit a row and a centered modal to create — both wrapping the real ObjectForm in author-built React overlays. Adds showcase_task schema/data to the in-memory adapter. Browser-verified. * fix(dev): pass required type/name props to PagePreview in source-editor harness (tsc) * fix(app-shell): drop 3 unused imports surfaced by console tsc (TS6133) The source-editor harness deep-imports PagePreview, pulling these app-shell metadata-admin source files into the console build's strict tsc — surfacing pre-existing dead imports (React in color-variant-field + OutlineStrip, Heading1 in block-types). Removed. Fixes Bundle Analysis. --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent d88c8ec commit e885db3

9 files changed

Lines changed: 295 additions & 4 deletions

File tree

apps/console/dev/scenario-sources.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,71 @@ function Page() {
249249
</div>
250250
);
251251
}`;
252+
253+
export const taskdeskSource = `
254+
function Page() {
255+
const adapter = useAdapter();
256+
const [editId, setEditId] = React.useState(null); // drawer (edit existing)
257+
const [creating, setCreating] = React.useState(false); // modal (create new)
258+
const [reload, setReload] = React.useState(0);
259+
260+
const closeAll = () => { setEditId(null); setCreating(false); };
261+
const afterWrite = () => { closeAll(); setReload((k) => k + 1); };
262+
263+
// Close overlays on Escape — the kind of detail a real app needs.
264+
React.useEffect(() => {
265+
const onKey = (e) => { if (e.key === 'Escape') closeAll(); };
266+
window.addEventListener('keydown', onKey);
267+
return () => window.removeEventListener('keydown', onKey);
268+
}, []);
269+
270+
return (
271+
<div className="mx-auto max-w-5xl space-y-5 p-8">
272+
<header className="flex items-center justify-between">
273+
<div>
274+
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Task Desk</h1>
275+
<p className="mt-1 text-sm text-slate-500">Click a task to edit in a <strong>drawer</strong>; create one in a <strong>modal</strong> — both wrap the real <code>&lt;ObjectForm&gt;</code>.</p>
276+
</div>
277+
<button onClick={() => setCreating(true)} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-500">+ New task</button>
278+
</header>
279+
280+
<div className="rounded-xl border border-slate-200 bg-white p-2">
281+
<ListView key={reload} objectName="showcase_task"
282+
fields={['title', 'assignee', 'status', 'priority']}
283+
navigation={{ mode: 'none' }} onRowClick={(r) => setEditId(r.id)} />
284+
</div>
285+
286+
{/* Drawer — edit an existing task */}
287+
{editId ? (
288+
<div className="fixed inset-0 z-40">
289+
<div className="absolute inset-0 bg-slate-900/30" onClick={closeAll} />
290+
<div className="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-2xl">
291+
<div className="flex items-center justify-between border-b border-slate-200 px-5 py-4">
292+
<h2 className="text-base font-semibold text-slate-900">Edit task</h2>
293+
<button onClick={closeAll} className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600" aria-label="Close">✕</button>
294+
</div>
295+
<div className="flex-1 overflow-y-auto p-5">
296+
<ObjectForm key={'edit:' + editId + ':' + reload} objectName="showcase_task" mode="edit"
297+
recordId={editId} onSuccess={afterWrite} onCancel={closeAll} />
298+
</div>
299+
</div>
300+
</div>
301+
) : null}
302+
303+
{/* Modal — create a new task */}
304+
{creating ? (
305+
<div className="fixed inset-0 z-40 flex items-center justify-center p-4">
306+
<div className="absolute inset-0 bg-slate-900/40" onClick={closeAll} />
307+
<div className="relative w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl">
308+
<div className="mb-4 flex items-center justify-between">
309+
<h2 className="text-lg font-semibold text-slate-900">New task</h2>
310+
<button onClick={closeAll} className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600" aria-label="Close">✕</button>
311+
</div>
312+
<ObjectForm key={'new:' + reload} objectName="showcase_task" mode="create"
313+
onSuccess={afterWrite} onCancel={closeAll} />
314+
</div>
315+
</div>
316+
) : null}
317+
</div>
318+
);
319+
}`;

apps/console/dev/sdui-scenarios-preview.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import React from 'react';
1414
import { createRoot } from 'react-dom/client';
1515
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
1616
import { I18nProvider } from '@object-ui/i18n';
17-
import { triageSource, cockpitSource, invoiceSource } from './scenario-sources';
17+
import { triageSource, cockpitSource, invoiceSource, taskdeskSource } from './scenario-sources';
1818

1919
// ---------------------------------------------------------------------------
2020
// In-memory multi-object adapter (just enough for ListView + ObjectForm)
@@ -45,6 +45,12 @@ const SCHEMAS: Record<string, any> = {
4545
health: { ...SELECT([['green', 'Green'], ['yellow', 'Yellow'], ['red', 'Red']]), label: 'Health' },
4646
budget: { type: 'currency', label: 'Budget' }, owner: { type: 'text', label: 'Owner' },
4747
} },
48+
showcase_task: { name: 'showcase_task', label: 'Task', fields: {
49+
title: { type: 'text', label: 'Title', required: true }, assignee: { type: 'text', label: 'Assignee' },
50+
status: { ...SELECT([['backlog', 'Backlog'], ['todo', 'To Do'], ['in_progress', 'In Progress'], ['in_review', 'In Review'], ['done', 'Done']]), label: 'Status' },
51+
priority: { ...SELECT([['low', 'Low'], ['medium', 'Medium'], ['high', 'High'], ['urgent', 'Urgent']]), label: 'Priority' },
52+
estimate_hours: { type: 'number', label: 'Estimate (h)' },
53+
} },
4854
};
4955
const store: Record<string, any[]> = {
5056
showcase_account: [
@@ -72,6 +78,11 @@ const store: Record<string, any[]> = {
7278
{ id: 'p2', name: 'Billing Revamp', account: 'a1', status: 'planned', health: 'yellow', budget: 80000, owner: 'Sam Ortiz' },
7379
{ id: 'p3', name: 'Mobile App v2', account: 'a3', status: 'active', health: 'red', budget: 210000, owner: 'Priya N.' },
7480
],
81+
showcase_task: [
82+
{ id: 't1', title: 'Wire up SSO', assignee: 'Dana Lee', status: 'in_progress', priority: 'high', estimate_hours: 8 },
83+
{ id: 't2', title: 'Migrate billing schema', assignee: 'Sam Ortiz', status: 'todo', priority: 'urgent', estimate_hours: 13 },
84+
{ id: 't3', title: 'Polish onboarding', assignee: 'Priya N.', status: 'backlog', priority: 'medium', estimate_hours: 5 },
85+
],
7586
};
7687
let nextId = 100;
7788

@@ -99,6 +110,7 @@ const SCENARIOS: { key: string; label: string; source: string }[] = [
99110
{ key: 'triage', label: 'Inquiry Triage', source: triageSource },
100111
{ key: 'cockpit', label: 'Account Cockpit', source: cockpitSource },
101112
{ key: 'invoice', label: 'Invoice Console', source: invoiceSource },
113+
{ key: 'taskdesk', label: 'Task Desk', source: taskdeskSource },
102114
];
103115

104116
function Harness() {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<!doctype html>
2+
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
3+
<title>Studio source-page editor (kind:'react'/'html')</title></head>
4+
<body><div id="root" style="height:100vh"></div><script type="module" src="./sdui-source-editor-preview.tsx"></script></body></html>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/* Verifies the Studio editor fix: opening a kind:'react'/'html' page shows the
2+
* SourcePageEditor (code + live preview) instead of crashing on the design
3+
* canvas. Mounts the real PagePreview with a react-page draft. Dev-only. */
4+
import '../src/index.css';
5+
import '@object-ui/components';
6+
import React from 'react';
7+
import { createRoot } from 'react-dom/client';
8+
import { SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
9+
import { I18nProvider } from '@object-ui/i18n';
10+
import { PagePreview } from '../../../packages/app-shell/src/views/metadata-admin/previews/PagePreview';
11+
12+
const initialReact = `function Page() {
13+
const [n, setN] = React.useState(0);
14+
return (
15+
<div className="p-10">
16+
<h1 className="text-3xl font-bold text-slate-900">Hello from source</h1>
17+
<p className="mt-1 text-slate-500">This page is edited as source, not a region tree.</p>
18+
<button onClick={() => setN(n + 1)} className="mt-5 rounded-lg bg-indigo-600 px-5 py-2.5 font-semibold text-white hover:bg-indigo-500">Count {n}</button>
19+
</div>
20+
);
21+
}`;
22+
23+
function Harness() {
24+
const [draft, setDraft] = React.useState<Record<string, unknown>>({
25+
type: 'page', kind: 'react', name: 'demo_source_page', label: 'Demo', source: initialReact,
26+
});
27+
return (
28+
<div className="flex h-screen flex-col">
29+
<div className="border-b bg-white px-4 py-2 text-sm font-semibold">Studio · editing a kind:'react' page</div>
30+
<div className="min-h-0 flex-1">
31+
<PagePreview
32+
type="page"
33+
name="demo_source_page"
34+
draft={draft as never}
35+
editing
36+
selection={null as never}
37+
onSelectionChange={() => {}}
38+
onPatch={(patch: Record<string, unknown>) => setDraft((d) => ({ ...d, ...patch }))}
39+
locale={'en' as never}
40+
/>
41+
</div>
42+
</div>
43+
);
44+
}
45+
46+
createRoot(document.getElementById('root')!).render(
47+
<React.StrictMode>
48+
<I18nProvider>
49+
<AdapterCtx.Provider value={null}>
50+
<SchemaRendererProvider dataSource={{}}>
51+
<Harness />
52+
</SchemaRendererProvider>
53+
</AdapterCtx.Provider>
54+
</I18nProvider>
55+
</React.StrictMode>,
56+
);

packages/app-shell/src/views/metadata-admin/color-variant-field.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
* field looks and behaves the same.
1212
*/
1313

14-
import * as React from 'react';
1514
import { cn } from '@object-ui/components';
1615

1716
export interface ColorVariant { value: string; label: string; css: string }

packages/app-shell/src/views/metadata-admin/previews/OutlineStrip.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
* outside design mode.
1313
*/
1414

15-
import * as React from 'react';
1615
import { Plus } from 'lucide-react';
1716
import { cn } from '@object-ui/components';
1817

packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { buildDefaultPageSchema } from '@object-ui/plugin-detail';
1616
import type { MetadataPreviewProps } from '../preview-registry';
1717
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell';
1818
import { OutlineStrip } from './OutlineStrip';
19+
import { SourcePageEditor } from './SourcePageEditor';
1920
import { PageBlockCanvas } from './PageBlockCanvas';
2021
import { InterfaceListPage } from '../../InterfaceListPage';
2122
import { t as tr } from '../i18n';
@@ -234,6 +235,21 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
234235
);
235236
}
236237

238+
// Source page (ADR-0080/0081) → `kind:'html'`/`'react'` pages are a `source`
239+
// string, not a region tree. The design canvas does not apply (and would
240+
// choke on the absent regions/children), so edit the source directly with a
241+
// live preview. This is the fix for the editor crashing on these pages.
242+
const pageKind = (draft as { kind?: string }).kind;
243+
if (pageKind === 'react' || pageKind === 'html') {
244+
return (
245+
<SourcePageEditor
246+
draft={draft as Record<string, unknown>}
247+
onPatch={canEdit ? onPatch : undefined}
248+
readOnly={!canEdit}
249+
/>
250+
);
251+
}
252+
237253
// Empty draft → no preview; but if we're in design mode show the
238254
// canvas so users can author from scratch.
239255
if (!schema || Object.keys(schema).length <= 1) {
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* SourcePageEditor — the Studio editor surface for `kind:'html'` and
9+
* `kind:'react'` pages (ADR-0080/0081). These pages ARE a `source` string
10+
* (JSX/HTML or real React), not a region tree — so the structured design
11+
* canvas does not apply (and would choke on the missing `regions`/`children`).
12+
* Instead we present a code editor for the `source` field beside a live preview
13+
* rendered through the runtime SchemaRenderer. Edits patch `draft.source`.
14+
*
15+
* Mirrors JsonSourceEditor's Monaco + textarea-fallback + theme handling, but
16+
* edits ONE field (the source) instead of the whole-record JSON, with a
17+
* JSX/TSX language mode.
18+
*/
19+
20+
import * as React from 'react';
21+
import { Skeleton } from '@object-ui/components';
22+
import { SchemaRenderer } from '@object-ui/react';
23+
import { PreviewShell, PreviewErrorBoundary } from './PreviewShell';
24+
25+
const LazyMonaco = React.lazy(async () => {
26+
const mod = await import('@monaco-editor/react');
27+
return { default: mod.default };
28+
});
29+
30+
export interface SourcePageEditorProps {
31+
draft: Record<string, unknown>;
32+
/** Patch the draft; undefined in read-only mode. */
33+
onPatch?: (patch: Record<string, unknown>) => void;
34+
readOnly?: boolean;
35+
fallbackDelayMs?: number;
36+
}
37+
38+
export function SourcePageEditor({ draft, onPatch, readOnly, fallbackDelayMs = 4000 }: SourcePageEditorProps) {
39+
const kind = (draft as { kind?: string }).kind === 'react' ? 'react' : 'html';
40+
const source = typeof draft.source === 'string' ? (draft.source as string) : '';
41+
42+
const [text, setText] = React.useState(source);
43+
const lastCommittedRef = React.useRef(source);
44+
const containerRef = React.useRef<HTMLDivElement | null>(null);
45+
46+
// Sync from upstream (Reset / inspector edits) without clobbering keystrokes.
47+
React.useEffect(() => {
48+
if (source !== lastCommittedRef.current) {
49+
setText(source);
50+
lastCommittedRef.current = source;
51+
}
52+
}, [source]);
53+
54+
// Monaco-unavailable fallback (headless / CSP) → plain textarea.
55+
const [monacoUnavailable, setMonacoUnavailable] = React.useState(false);
56+
React.useEffect(() => {
57+
if (monacoUnavailable) return;
58+
const id = setTimeout(() => {
59+
const el = containerRef.current;
60+
if (!el || !el.querySelector('.view-line')) setMonacoUnavailable(true);
61+
}, fallbackDelayMs);
62+
return () => clearTimeout(id);
63+
}, [monacoUnavailable, fallbackDelayMs]);
64+
65+
const [theme, setTheme] = React.useState<'vs-dark' | 'light'>(() =>
66+
typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ? 'vs-dark' : 'light',
67+
);
68+
React.useEffect(() => {
69+
if (typeof document === 'undefined') return;
70+
const root = document.documentElement;
71+
const update = () => setTheme(root.classList.contains('dark') ? 'vs-dark' : 'light');
72+
const obs = new MutationObserver(update);
73+
obs.observe(root, { attributes: true, attributeFilter: ['class'] });
74+
return () => obs.disconnect();
75+
}, []);
76+
77+
const handleChange = (next: string | undefined) => {
78+
const v = next ?? '';
79+
setText(v);
80+
lastCommittedRef.current = v;
81+
onPatch?.({ source: v });
82+
};
83+
84+
const previewSchema = React.useMemo(
85+
() => ({ ...(draft as Record<string, unknown>), type: (draft as { type?: string }).type ?? 'page' }),
86+
[draft],
87+
);
88+
89+
return (
90+
<PreviewShell hint={`page · ${kind} source`}>
91+
<div className="grid h-full grid-cols-1 divide-y divide-border lg:grid-cols-2 lg:divide-x lg:divide-y-0">
92+
{/* Code editor */}
93+
<div ref={containerRef} className="h-full min-h-[260px] overflow-hidden bg-background">
94+
{monacoUnavailable ? (
95+
<textarea
96+
value={text}
97+
onChange={(e) => handleChange(e.target.value)}
98+
readOnly={readOnly}
99+
spellCheck={false}
100+
aria-label="Page source"
101+
className="h-full w-full resize-none bg-background p-3 font-mono text-xs leading-relaxed outline-none"
102+
/>
103+
) : (
104+
<React.Suspense fallback={<Skeleton className="h-full w-full" />}>
105+
<LazyMonaco
106+
value={text}
107+
language="typescript"
108+
path={kind === 'react' ? 'page.tsx' : 'page.html.tsx'}
109+
theme={theme}
110+
onChange={handleChange}
111+
options={{
112+
readOnly,
113+
minimap: { enabled: false },
114+
fontSize: 12,
115+
lineNumbers: 'on',
116+
scrollBeyondLastLine: false,
117+
automaticLayout: true,
118+
folding: true,
119+
wordWrap: 'on',
120+
tabSize: 2,
121+
scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 },
122+
}}
123+
/>
124+
</React.Suspense>
125+
)}
126+
</div>
127+
{/* Live preview through the real runtime */}
128+
<div className="h-full min-h-[260px] overflow-auto bg-muted/20">
129+
<PreviewErrorBoundary fallbackHint="The page source threw while rendering — fix the code on the left.">
130+
<SchemaRenderer schema={previewSchema as never} />
131+
</PreviewErrorBoundary>
132+
</div>
133+
</div>
134+
</PreviewShell>
135+
);
136+
}
137+
138+
export default SourcePageEditor;

packages/app-shell/src/views/metadata-admin/previews/block-types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
*/
1212

1313
import {
14-
Heading1,
1514
PanelTop,
1615
PanelBottom,
1716
PanelLeft,

0 commit comments

Comments
 (0)