@object-ui/app-shell 7.0.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +281 -0
- package/dist/console/AppContent.js +14 -2
- package/dist/console/ai/AiChatPage.js +11 -7
- package/dist/console/ai/LiveCanvas.d.ts +8 -2
- package/dist/console/ai/LiveCanvas.js +6 -4
- package/dist/hooks/useChatConversation.d.ts +30 -0
- package/dist/hooks/useChatConversation.js +63 -0
- package/dist/hooks/useConsoleActionRuntime.js +6 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/layout/ConsoleFloatingChatbot.d.ts +6 -4
- package/dist/layout/ConsoleFloatingChatbot.js +25 -8
- package/dist/layout/ContextSelectors.js +59 -35
- package/dist/layout/agentPicker.d.ts +56 -0
- package/dist/layout/agentPicker.js +40 -0
- package/dist/preview/CommitTimeline.d.ts +15 -0
- package/dist/preview/CommitTimeline.js +82 -0
- package/dist/preview/UnpublishedAppBar.js +11 -7
- package/dist/preview/commitHistory.d.ts +28 -0
- package/dist/preview/commitHistory.js +48 -0
- package/dist/providers/MetadataProvider.js +9 -0
- package/dist/views/FlowRunner.d.ts +2 -30
- package/dist/views/FlowRunner.js +18 -50
- package/dist/views/ScreenView.d.ts +70 -0
- package/dist/views/ScreenView.js +73 -0
- package/dist/views/metadata-admin/DirectoryPage.js +2 -14
- package/dist/views/metadata-admin/JsonSourceEditor.d.ts +3 -1
- package/dist/views/metadata-admin/JsonSourceEditor.js +21 -3
- package/dist/views/metadata-admin/PackagesPage.js +9 -1
- package/dist/views/metadata-admin/ResourceEditPage.js +47 -20
- package/dist/views/metadata-admin/ResourceListPage.js +8 -16
- package/dist/views/metadata-admin/StudioHomePage.js +6 -14
- package/dist/views/metadata-admin/anchors.js +20 -2
- package/dist/views/metadata-admin/i18n.js +88 -2
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.d.ts +2 -2
- package/dist/views/metadata-admin/inspectors/DatasetDefaultInspector.js +122 -8
- package/dist/views/metadata-admin/inspectors/FlowEdgeInspector.js +84 -3
- package/dist/views/metadata-admin/inspectors/FlowNodeInspector.js +67 -2
- package/dist/views/metadata-admin/inspectors/ObjectDefaultInspector.js +5 -4
- package/dist/views/metadata-admin/inspectors/ObjectFieldInspector.js +47 -12
- package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.d.ts +1 -1
- package/dist/views/metadata-admin/inspectors/ReportDefaultInspector.js +60 -2
- package/dist/views/metadata-admin/inspectors/_shared.d.ts +5 -1
- package/dist/views/metadata-admin/inspectors/_shared.js +2 -2
- package/dist/views/metadata-admin/inspectors/datasetFilterCondition.d.ts +24 -0
- package/dist/views/metadata-admin/inspectors/datasetFilterCondition.js +97 -0
- package/dist/views/metadata-admin/inspectors/flow-node-config.js +46 -1
- package/dist/views/metadata-admin/issuePath.d.ts +22 -0
- package/dist/views/metadata-admin/issuePath.js +65 -0
- package/dist/views/metadata-admin/package-scope.d.ts +26 -0
- package/dist/views/metadata-admin/package-scope.js +43 -0
- package/dist/views/metadata-admin/previews/DatasetPreview.js +21 -5
- package/dist/views/metadata-admin/previews/FlowCanvas.d.ts +7 -1
- package/dist/views/metadata-admin/previews/FlowCanvas.js +104 -16
- package/dist/views/metadata-admin/previews/FlowPreview.js +31 -3
- package/dist/views/metadata-admin/previews/FlowSimulatorPanel.js +37 -3
- package/dist/views/metadata-admin/previews/PagePreview.js +112 -3
- package/dist/views/metadata-admin/previews/ScreenPreview.d.ts +38 -0
- package/dist/views/metadata-admin/previews/ScreenPreview.js +61 -0
- package/dist/views/metadata-admin/previews/flow-canvas-layout.d.ts +14 -0
- package/dist/views/metadata-admin/previews/flow-canvas-layout.js +37 -0
- package/dist/views/metadata-admin/previews/flow-canvas-parts.d.ts +9 -1
- package/dist/views/metadata-admin/previews/flow-canvas-parts.js +21 -6
- package/dist/views/metadata-admin/previews/object-fields-io.d.ts +21 -0
- package/dist/views/metadata-admin/previews/object-fields-io.js +37 -2
- package/dist/views/metadata-admin/previews/screen-spec.d.ts +43 -0
- package/dist/views/metadata-admin/previews/screen-spec.js +108 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-types.d.ts +11 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.d.ts +7 -0
- package/dist/views/metadata-admin/previews/simulator/flow-sim-validate.js +72 -0
- package/dist/views/metadata-admin/previews/simulator/flow-simulator.d.ts +32 -3
- package/dist/views/metadata-admin/previews/simulator/flow-simulator.js +119 -9
- package/package.json +38 -38
|
@@ -10,6 +10,7 @@ import * as React from 'react';
|
|
|
10
10
|
import { Play, StepForward, RotateCcw, ChevronRight, AlertTriangle, CircleAlert, Plus, Trash2 } from 'lucide-react';
|
|
11
11
|
import { Button, Input, Label, cn } from '@object-ui/components';
|
|
12
12
|
import { FlowSimulator } from './simulator/flow-simulator';
|
|
13
|
+
import { ScreenPreview } from './ScreenPreview';
|
|
13
14
|
/** Coerce a free-text seed value: number / boolean / JSON object|array / string. */
|
|
14
15
|
function parseSeed(raw) {
|
|
15
16
|
const s = raw.trim();
|
|
@@ -138,6 +139,12 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
|
|
|
138
139
|
// pressing Run again always reflects the new values.
|
|
139
140
|
let sim = simRef.current;
|
|
140
141
|
if (sim && sim.state.status === 'paused') {
|
|
142
|
+
// An approval pause needs an explicit decision (use the branch buttons);
|
|
143
|
+
// blind-resuming would fan out to every branch — so leave it for the user.
|
|
144
|
+
if (sim.state.pausedReason === 'approval') {
|
|
145
|
+
sync();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
141
148
|
sim.resume();
|
|
142
149
|
}
|
|
143
150
|
else {
|
|
@@ -152,9 +159,11 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
|
|
|
152
159
|
sim.step();
|
|
153
160
|
sync();
|
|
154
161
|
};
|
|
155
|
-
const onResume = () =>
|
|
162
|
+
const onResume = () => onDecision();
|
|
163
|
+
/** Continue a paused run; `decision` routes an approval down one out-edge. */
|
|
164
|
+
const onDecision = (decision) => {
|
|
156
165
|
const sim = ensure();
|
|
157
|
-
sim.resume();
|
|
166
|
+
sim.resume(decision ? { decision } : {});
|
|
158
167
|
sim.runToEnd();
|
|
159
168
|
sync();
|
|
160
169
|
};
|
|
@@ -166,5 +175,30 @@ export function FlowSimulatorPanel({ nodes, edges, variables, onRunStateChange }
|
|
|
166
175
|
};
|
|
167
176
|
const status = snapshot?.status ?? 'idle';
|
|
168
177
|
const blocked = (validation?.errors.length ?? 0) > 0;
|
|
169
|
-
|
|
178
|
+
// When the run pauses at a `screen` node, preview the form the end user would
|
|
179
|
+
// see (the shared runtime renderer) instead of just showing "paused".
|
|
180
|
+
const screenPause = React.useMemo(() => {
|
|
181
|
+
if (snapshot?.status !== 'paused' || snapshot.pausedReason !== 'screen' || !snapshot.activeNodeId)
|
|
182
|
+
return null;
|
|
183
|
+
const node = nodes.find((n) => n.id === snapshot.activeNodeId);
|
|
184
|
+
return node ? { node, variables: snapshot.variables } : null;
|
|
185
|
+
}, [snapshot, nodes]);
|
|
186
|
+
// When paused at an approval node, offer its out-edge labels (approve /
|
|
187
|
+
// reject / revise) as decision buttons so the run resumes down ONE branch —
|
|
188
|
+
// letting an author walk a revise loop instead of fanning out to every edge.
|
|
189
|
+
const approvalPause = React.useMemo(() => {
|
|
190
|
+
if (snapshot?.status !== 'paused' || snapshot.pausedReason !== 'approval' || !snapshot.activeNodeId)
|
|
191
|
+
return null;
|
|
192
|
+
const node = nodes.find((n) => n.id === snapshot.activeNodeId);
|
|
193
|
+
if (!node)
|
|
194
|
+
return null;
|
|
195
|
+
const decisions = [
|
|
196
|
+
...new Set(edges
|
|
197
|
+
.filter((e) => e.source === node.id && typeof e.label === 'string' && e.label.trim())
|
|
198
|
+
.map((e) => e.label.trim())),
|
|
199
|
+
];
|
|
200
|
+
return { node, decisions };
|
|
201
|
+
}, [snapshot, nodes, edges]);
|
|
202
|
+
return (_jsxs("div", { className: "flex h-full flex-col text-xs", children: [_jsxs("div", { className: "flex items-center gap-1.5 border-b bg-muted/30 px-3 py-2", children: [_jsxs(Button, { size: "sm", className: "h-7 gap-1 px-2", onClick: onRun, disabled: blocked, children: [_jsx(Play, { className: "h-3.5 w-3.5" }), " Run"] }), _jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onStep, disabled: blocked || status === 'done' || status === 'error', children: [_jsx(StepForward, { className: "h-3.5 w-3.5" }), " Step"] }), status === 'paused' &&
|
|
203
|
+
(approvalPause && approvalPause.decisions.length > 0 ? (_jsx("div", { className: "flex items-center gap-1", children: approvalPause.decisions.map((d) => (_jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2 capitalize", onClick: () => onDecision(d), title: `Resume down the "${d}" branch`, children: [_jsx(ChevronRight, { className: "h-3.5 w-3.5" }), " ", d] }, d))) })) : (_jsxs(Button, { size: "sm", variant: "outline", className: "h-7 gap-1 px-2", onClick: onResume, children: [_jsx(ChevronRight, { className: "h-3.5 w-3.5" }), " Continue"] }))), _jsxs(Button, { size: "sm", variant: "ghost", className: "h-7 gap-1 px-2 text-muted-foreground", onClick: onReset, children: [_jsx(RotateCcw, { className: "h-3.5 w-3.5" }), " Reset"] }), _jsx("span", { className: cn('ml-auto rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase', STATUS_TONE[status === 'idle' || status === 'running' ? 'ok' : status] ?? 'bg-muted'), children: status })] }), _jsxs("div", { className: "min-h-0 flex-1 space-y-3 overflow-auto p-3", children: [validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (_jsxs("div", { className: "space-y-1", children: [validation.errors.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-rose-200 bg-rose-50 px-2 py-1 text-rose-700", children: [_jsx(CircleAlert, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `e${i}`))), validation.warnings.map((d, i) => (_jsxs("div", { className: "flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3 w-3 shrink-0" }), _jsx("span", { children: d.message })] }, `w${i}`)))] })), screenPause && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Screen" }), _jsx(ScreenPreview, { node: screenPause.node, variables: screenPause.variables })] })), inputs.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Inputs" }), inputs.map((v) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { className: "w-24 shrink-0 truncate font-mono text-[11px]", title: v.name, children: v.name }), _jsx(Input, { value: seed[v.name] ?? (v.defaultValue != null ? String(v.defaultValue) : ''), onChange: (e) => setSeed((p) => ({ ...p, [v.name]: e.target.value })), placeholder: v.type ?? 'value', className: "h-7 flex-1 text-xs" })] }, v.name)))] })), _jsxs("section", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5 font-medium text-muted-foreground", children: [_jsx("span", { children: "Set variables" }), _jsxs("button", { type: "button", className: "ml-auto inline-flex items-center gap-0.5 rounded border px-1.5 py-0.5 text-[10px] hover:bg-muted/50", onClick: () => setScratch((p) => [...p, { k: '', v: '' }]), children: [_jsx(Plus, { className: "h-3 w-3" }), " Add"] })] }), scratch.length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "Override or inject any variable (wins over inputs and mocks at start)." })) : (scratch.map((row, i) => (_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx(Input, { value: row.k, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, k: e.target.value } : r))), placeholder: "name", className: "h-7 w-24 shrink-0 font-mono text-[11px]" }), _jsx("span", { className: "text-muted-foreground", children: "=" }), _jsx(Input, { value: row.v, onChange: (e) => setScratch((p) => p.map((r, j) => (j === i ? { ...r, v: e.target.value } : r))), placeholder: "value", className: "h-7 flex-1 text-xs" }), _jsx("button", { type: "button", className: "shrink-0 rounded p-1 text-muted-foreground hover:bg-muted/50 hover:text-rose-600", onClick: () => setScratch((p) => p.filter((_, j) => j !== i)), "aria-label": "Remove variable", children: _jsx(Trash2, { className: "h-3 w-3" }) })] }, i))))] }), mockNodes.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Mock outputs" }), mockNodes.map((m) => (_jsxs("div", { className: "space-y-0.5", children: [_jsxs(Label, { className: "flex items-baseline gap-1.5 text-[11px]", title: m.id, children: [_jsx("span", { className: "truncate font-medium", children: m.label }), _jsx("span", { className: "text-[9px] uppercase text-muted-foreground", children: m.type.replace(/_/g, ' ') }), m.outputs.length > 0 && (_jsxs("span", { className: "truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", m.outputs.join(', ')] }))] }), _jsx(Input, { value: mocks[m.id] ?? '', onChange: (e) => setMocks((p) => ({ ...p, [m.id]: e.target.value })), placeholder: m.type === 'script' && m.outputs.length ? `{ "${m.outputs[0]}": … }` : 'mocked result (JSON)', className: "h-7 w-full font-mono text-[11px]" })] }, m.id)))] })), snapshot && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Variables" }), Object.keys(snapshot.variables).length === 0 ? (_jsx("div", { className: "italic text-muted-foreground", children: "No variables set." })) : (_jsx("ul", { className: "space-y-1", children: Object.entries(snapshot.variables).map(([k, val]) => (_jsxs("li", { className: "flex items-baseline gap-1.5 rounded border bg-background px-1.5 py-1", children: [_jsx("span", { className: "font-mono text-[11px]", children: k }), _jsxs("span", { className: "truncate font-mono text-[10px] text-muted-foreground", children: ["= ", typeof val === 'object' ? JSON.stringify(val) : String(val)] })] }, k))) }))] })), snapshot && snapshot.steps.length > 0 && (_jsxs("section", { className: "space-y-1.5", children: [_jsx("div", { className: "font-medium text-muted-foreground", children: "Timeline" }), _jsx("ol", { className: "space-y-1", children: snapshot.steps.map((s) => (_jsxs("li", { className: "rounded border bg-background p-1.5", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("span", { className: "font-mono text-[10px] text-muted-foreground", children: s.seq + 1 }), _jsx("span", { className: "truncate font-medium", children: s.label }), _jsx("span", { className: "text-[10px] uppercase text-muted-foreground", children: s.type }), _jsx("span", { className: cn('ml-auto rounded px-1 py-0.5 text-[9px] font-semibold uppercase', STATUS_TONE[s.status]), children: s.status })] }), s.note && _jsx("div", { className: "mt-0.5 text-[10px] text-muted-foreground", children: s.note }), s.error && _jsx("div", { className: "mt-0.5 text-[10px] text-rose-600", children: s.error }), s.wrote && (_jsxs("div", { className: "mt-0.5 truncate font-mono text-[10px] text-violet-600", children: ["\u2192 ", Object.keys(s.wrote).join(', ')] })), s.edges && s.edges.length > 0 && (_jsx("ul", { className: "mt-0.5 space-y-0.5", children: s.edges.map((ed) => (_jsxs("li", { className: "space-y-0.5", children: [_jsxs("div", { className: cn('flex items-center gap-1 font-mono text-[10px]', ed.selected ? 'text-sky-700' : 'text-muted-foreground'), children: [_jsx("span", { children: ed.selected ? '▶' : '·' }), _jsx("span", { className: "truncate", children: ed.isDefault ? 'else' : ed.condition }), _jsx("span", { className: cn('ml-auto', ed.error && 'text-rose-600'), children: ed.error ? 'error' : ed.result ? 'true' : 'false' })] }), ed.error && _jsx("div", { className: "pl-3 text-[10px] text-rose-600", children: ed.error })] }, ed.edgeId))) }))] }, s.seq))) })] })), !snapshot && !blocked && (_jsx("p", { className: "italic text-muted-foreground", children: "Press Run to simulate, or Step to walk node by node. Side effects are mocked \u2014 no backend is called." }))] })] }));
|
|
170
204
|
}
|
|
@@ -9,7 +9,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
* threaded in: previews run in a sandbox with no params context.
|
|
10
10
|
*/
|
|
11
11
|
import * as React from 'react';
|
|
12
|
-
import { SchemaRenderer } from '@object-ui/react';
|
|
12
|
+
import { SchemaRenderer, RecordContextProvider } from '@object-ui/react';
|
|
13
|
+
import { buildExpandFields } from '@object-ui/core';
|
|
14
|
+
import { buildDefaultPageSchema } from '@object-ui/plugin-detail';
|
|
13
15
|
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell';
|
|
14
16
|
import { OutlineStrip } from './OutlineStrip';
|
|
15
17
|
import { PageBlockCanvas } from './PageBlockCanvas';
|
|
@@ -88,6 +90,113 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
|
|
|
88
90
|
onPatch({ children: next });
|
|
89
91
|
onSelectionChange?.({ kind: 'block', id: `children[${next.length - 1}]`, label: newBlock.type });
|
|
90
92
|
}, [canEdit, draft, onPatch, onSelectionChange, shape]);
|
|
93
|
+
// ── Record binding ──────────────────────────────────────────────────────
|
|
94
|
+
// A `type: 'record'` page's `record:*` blocks (details / highlights / path /
|
|
95
|
+
// alert) read their data from <RecordContextProvider>. The metadata editor
|
|
96
|
+
// has no record route, so without binding a sample they render the
|
|
97
|
+
// "bind a record to preview" placeholder — i.e. the author designs blind.
|
|
98
|
+
// Fetch a handful of real records of the bound object + its schema and let
|
|
99
|
+
// the author pick which one to preview against (mirrors the runtime
|
|
100
|
+
// RecordDetailView's RecordContextProvider).
|
|
101
|
+
// Match the runtime resolver (usePageAssignment): a record page is keyed by
|
|
102
|
+
// either bare `type: 'record'` (editor draft shape) or `pageType: 'record'`
|
|
103
|
+
// (persisted envelope shape). Both must bind a sample record so record:*
|
|
104
|
+
// blocks render real data.
|
|
105
|
+
const isRecordPage = draft?.type === 'record'
|
|
106
|
+
|| draft?.pageType === 'record';
|
|
107
|
+
const recordObject = isRecordPage ? draft?.object : undefined;
|
|
108
|
+
const [recordSamples, setRecordSamples] = React.useState([]);
|
|
109
|
+
const [recordSchema, setRecordSchema] = React.useState(null);
|
|
110
|
+
const [selectedRecordId, setSelectedRecordId] = React.useState(null);
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
if (!recordObject) {
|
|
113
|
+
setRecordSamples([]);
|
|
114
|
+
setRecordSchema(null);
|
|
115
|
+
setSelectedRecordId(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let cancelled = false;
|
|
119
|
+
(async () => {
|
|
120
|
+
try {
|
|
121
|
+
const opts = { headers: { accept: 'application/json' }, credentials: 'include' };
|
|
122
|
+
// Schema first: it tells us which fields are lookup/master_detail so we
|
|
123
|
+
// can `$expand` them. Without expansion record:details/highlights would
|
|
124
|
+
// show raw foreign-key IDs (e.g. "O4VKrNesnsj2JYMa") instead of display
|
|
125
|
+
// names — the runtime RecordDetailView $expands for exactly this reason.
|
|
126
|
+
const schemaRes = await fetch(`/api/v1/meta/object/${encodeURIComponent(recordObject)}`, opts);
|
|
127
|
+
const schemaJson = await schemaRes.json().catch(() => null);
|
|
128
|
+
const schema = schemaJson?.item ?? schemaJson?.data ?? schemaJson;
|
|
129
|
+
const expand = buildExpandFields(schema?.fields);
|
|
130
|
+
const query = expand.length > 0
|
|
131
|
+
? `?$top=50&$expand=${encodeURIComponent(expand.join(','))}`
|
|
132
|
+
: `?$top=50`;
|
|
133
|
+
const recsRes = await fetch(`/api/v1/data/${encodeURIComponent(recordObject)}${query}`, opts);
|
|
134
|
+
const recsJson = await recsRes.json().catch(() => null);
|
|
135
|
+
// The REST data endpoint returns `{ object, records, total, hasMore }`;
|
|
136
|
+
// tolerate the other common envelopes too.
|
|
137
|
+
const recs = Array.isArray(recsJson?.records) ? recsJson.records
|
|
138
|
+
: Array.isArray(recsJson?.items) ? recsJson.items
|
|
139
|
+
: Array.isArray(recsJson?.data) ? recsJson.data
|
|
140
|
+
: Array.isArray(recsJson) ? recsJson : [];
|
|
141
|
+
if (cancelled)
|
|
142
|
+
return;
|
|
143
|
+
setRecordSamples(recs);
|
|
144
|
+
setRecordSchema(schema);
|
|
145
|
+
// Same id-resolution order as recordIdOf so the initial selection's
|
|
146
|
+
// value matches an <option> even for objects keyed only by `name`.
|
|
147
|
+
setSelectedRecordId((prev) => prev ?? (recs[0]?.id ?? recs[0]?._id ?? recs[0]?.name ?? null));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
if (!cancelled) {
|
|
151
|
+
setRecordSamples([]);
|
|
152
|
+
setRecordSchema(null);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
return () => { cancelled = true; };
|
|
157
|
+
}, [recordObject]);
|
|
158
|
+
const recordIdOf = (r) => r?.id ?? r?._id ?? r?.name;
|
|
159
|
+
const recordLabelOf = (r) => String(r?.name ?? r?.title ?? r?.label ?? r?.subject ?? recordIdOf(r) ?? '(record)');
|
|
160
|
+
const selectedRecord = React.useMemo(() => {
|
|
161
|
+
if (!recordSamples.length)
|
|
162
|
+
return null;
|
|
163
|
+
return recordSamples.find((r) => String(recordIdOf(r)) === String(selectedRecordId)) ?? recordSamples[0];
|
|
164
|
+
}, [recordSamples, selectedRecordId]);
|
|
165
|
+
// ── Slotted record page synthesis ────────────────────────────────────────
|
|
166
|
+
// A `kind: 'slotted'` page carries an empty `regions: []` plus a `slots` map
|
|
167
|
+
// of overrides, so the raw draft renders blank through SchemaRenderer (there
|
|
168
|
+
// are no regions to walk). Mirror the runtime RecordDetailView: synthesize the
|
|
169
|
+
// canonical default page from the bound object's schema and apply the slot
|
|
170
|
+
// overrides via the SAME `buildDefaultPageSchema(objectDef, { slots })` path,
|
|
171
|
+
// so omitted slots fall through to the synthesized header/details/discussion
|
|
172
|
+
// while authored slots (highlights/tabs/…) override in place. Non-slotted
|
|
173
|
+
// pages render their authored schema unchanged.
|
|
174
|
+
const isSlotted = draft?.kind === 'slotted';
|
|
175
|
+
const renderSchema = React.useMemo(() => {
|
|
176
|
+
if (!isSlotted)
|
|
177
|
+
return schema;
|
|
178
|
+
try {
|
|
179
|
+
const slots = draft?.slots ?? {};
|
|
180
|
+
// `recordSchema` arrives async; until then synthesize with no objectDef
|
|
181
|
+
// (structure renders immediately, field-level detail fills in on load).
|
|
182
|
+
return buildDefaultPageSchema(recordSchema ?? undefined, { slots });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return schema;
|
|
186
|
+
}
|
|
187
|
+
}, [isSlotted, schema, draft, recordSchema]);
|
|
188
|
+
// Wrap record-page content in the record context (+ a sample-record picker)
|
|
189
|
+
// so detail/highlights/path/alert blocks render real data. No-op for
|
|
190
|
+
// non-record pages and for record pages with no rows yet (renders the node
|
|
191
|
+
// unchanged so the existing placeholder still shows).
|
|
192
|
+
const withRecordBinding = (node) => {
|
|
193
|
+
if (!recordObject || !selectedRecord)
|
|
194
|
+
return node;
|
|
195
|
+
return (_jsxs(RecordContextProvider, { objectName: recordObject, recordId: recordIdOf(selectedRecord), data: selectedRecord, objectSchema: recordSchema ?? undefined, embedded: true, children: [recordSamples.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 px-3 py-1.5 border-b bg-muted/30 text-xs", children: [_jsx("span", { className: "text-muted-foreground shrink-0", children: "Preview record" }), _jsx("select", { className: "h-7 rounded-md border bg-background px-2 text-xs max-w-[260px]", value: String(selectedRecordId ?? ''), onChange: (e) => setSelectedRecordId(e.target.value), children: recordSamples.map((r) => {
|
|
196
|
+
const id = recordIdOf(r);
|
|
197
|
+
return _jsx("option", { value: String(id), children: recordLabelOf(r) }, String(id));
|
|
198
|
+
}) }), _jsxs("span", { className: "text-muted-foreground/70 shrink-0", children: [recordSamples.length, " sample", recordSamples.length === 1 ? '' : 's'] })] })), node] }));
|
|
199
|
+
};
|
|
91
200
|
// Interface page → always mirror the runtime (InterfaceListPage), in BOTH
|
|
92
201
|
// design and preview modes. These pages are config-driven, not region-
|
|
93
202
|
// composed, so there is nothing to drag on a canvas: the author edits the
|
|
@@ -106,7 +215,7 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
|
|
|
106
215
|
// rename, and add blocks inline. The outline strip becomes
|
|
107
216
|
// redundant in this view.
|
|
108
217
|
if (designMode && shape === 'regions') {
|
|
109
|
-
return (_jsx(PreviewShell, { hint: `page · design`, children: _jsx(PageBlockCanvas, { draft: draft, onPatch: canEdit ? onPatch : undefined, selection: selection ?? null, onSelectionChange: onSelectionChange }) }));
|
|
218
|
+
return (_jsx(PreviewShell, { hint: `page · design`, children: withRecordBinding(_jsx(PageBlockCanvas, { draft: draft, onPatch: canEdit ? onPatch : undefined, selection: selection ?? null, onSelectionChange: onSelectionChange })) }));
|
|
110
219
|
}
|
|
111
|
-
return (_jsx(PreviewShell, { hint: `page${designMode ? ' · design' : ''}`, children: _jsxs(PreviewErrorBoundary, { fallbackHint: "The Page schema is incomplete or references a component that hasn't been registered yet.", children: [designMode && (_jsx(OutlineStrip, { title: tr('engine.inspector.pageBlock.outlineLabel', locale), entries: blockEntries, selectedId: selectedId, onSelect: (e) => onSelectionChange?.({ kind: 'block', id: e.id, label: e.label }), onAdd: canEdit ? handleAddBlock : undefined, addLabel: tr('engine.inspector.add.block', locale) })), _jsx("div", { className: "min-h-[200px] max-h-[70vh] overflow-auto p-4", children: _jsx(SchemaRenderer, { schema:
|
|
220
|
+
return (_jsx(PreviewShell, { hint: `page${designMode ? ' · design' : ''}`, children: _jsxs(PreviewErrorBoundary, { fallbackHint: "The Page schema is incomplete or references a component that hasn't been registered yet.", children: [designMode && (_jsx(OutlineStrip, { title: tr('engine.inspector.pageBlock.outlineLabel', locale), entries: blockEntries, selectedId: selectedId, onSelect: (e) => onSelectionChange?.({ kind: 'block', id: e.id, label: e.label }), onAdd: canEdit ? handleAddBlock : undefined, addLabel: tr('engine.inspector.add.block', locale) })), withRecordBinding(_jsx("div", { className: "min-h-[200px] max-h-[70vh] overflow-auto p-4", children: _jsx(SchemaRenderer, { schema: renderSchema }) }))] }) }));
|
|
112
221
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScreenPreview — live design-time preview of a flow `screen` node, rendered
|
|
3
|
+
* exactly as the end user will see it at runtime.
|
|
4
|
+
*
|
|
5
|
+
* It builds a runtime `ScreenSpec` from the node's authored `config` and hands
|
|
6
|
+
* it to the shared {@link ScreenView} — the SAME renderer the runtime
|
|
7
|
+
* FlowRunner uses — so the preview can never drift from runtime (the
|
|
8
|
+
* design↔runtime divergence #1927 set out to kill). `{var}` references in the
|
|
9
|
+
* title/description are interpolated, and `fields` are gated by their
|
|
10
|
+
* `visibleWhen`, against the supplied `variables` (the flow's declared defaults
|
|
11
|
+
* in the inspector, or the live simulated values when paused in the Debug
|
|
12
|
+
* simulator).
|
|
13
|
+
*
|
|
14
|
+
* Object-form mode is fed the SAME enriched object list the runtime uses
|
|
15
|
+
* (`useMetadata().objects`, which derives inline master-detail `subforms` from
|
|
16
|
+
* `inlineEdit` relationships) so the preview renders those child grids too.
|
|
17
|
+
*
|
|
18
|
+
* Homes: the flow node inspector (live-updates as the config is edited) and the
|
|
19
|
+
* Debug simulator's paused-at-screen state.
|
|
20
|
+
*/
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import { type ScreenPreviewNode } from './screen-spec';
|
|
23
|
+
export type { ScreenPreviewNode } from './screen-spec';
|
|
24
|
+
export interface ScreenPreviewProps {
|
|
25
|
+
/** The screen node to preview. */
|
|
26
|
+
node: ScreenPreviewNode;
|
|
27
|
+
/**
|
|
28
|
+
* Variable values for `{var}` interpolation in the title/description AND for
|
|
29
|
+
* evaluating each field's `visibleWhen`. The inspector passes the flow's
|
|
30
|
+
* declared defaults; the simulator passes the live run state at the pause
|
|
31
|
+
* point. Unknown `{var}` refs stay literal, and fields whose `visibleWhen`
|
|
32
|
+
* can't be decided stay visible — the design preview never hides on missing
|
|
33
|
+
* data.
|
|
34
|
+
*/
|
|
35
|
+
variables?: Record<string, unknown>;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function ScreenPreview({ node, variables, className }: ScreenPreviewProps): React.JSX.Element;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* ScreenPreview — live design-time preview of a flow `screen` node, rendered
|
|
5
|
+
* exactly as the end user will see it at runtime.
|
|
6
|
+
*
|
|
7
|
+
* It builds a runtime `ScreenSpec` from the node's authored `config` and hands
|
|
8
|
+
* it to the shared {@link ScreenView} — the SAME renderer the runtime
|
|
9
|
+
* FlowRunner uses — so the preview can never drift from runtime (the
|
|
10
|
+
* design↔runtime divergence #1927 set out to kill). `{var}` references in the
|
|
11
|
+
* title/description are interpolated, and `fields` are gated by their
|
|
12
|
+
* `visibleWhen`, against the supplied `variables` (the flow's declared defaults
|
|
13
|
+
* in the inspector, or the live simulated values when paused in the Debug
|
|
14
|
+
* simulator).
|
|
15
|
+
*
|
|
16
|
+
* Object-form mode is fed the SAME enriched object list the runtime uses
|
|
17
|
+
* (`useMetadata().objects`, which derives inline master-detail `subforms` from
|
|
18
|
+
* `inlineEdit` relationships) so the preview renders those child grids too.
|
|
19
|
+
*
|
|
20
|
+
* Homes: the flow node inspector (live-updates as the config is edited) and the
|
|
21
|
+
* Debug simulator's paused-at-screen state.
|
|
22
|
+
*/
|
|
23
|
+
import * as React from 'react';
|
|
24
|
+
import { Button, cn } from '@object-ui/components';
|
|
25
|
+
import { useAdapter } from '../../../providers/AdapterProvider';
|
|
26
|
+
import { useMetadata } from '../../../providers/MetadataProvider';
|
|
27
|
+
import { ScreenView, isObjectFormScreen, initialScreenValues } from '../../ScreenView';
|
|
28
|
+
import { buildScreenSpec, interpolate, hiddenFieldCount } from './screen-spec';
|
|
29
|
+
export function ScreenPreview({ node, variables, className }) {
|
|
30
|
+
const adapter = useAdapter();
|
|
31
|
+
const meta = useMetadata();
|
|
32
|
+
const spec = React.useMemo(() => buildScreenSpec(node, variables), [node, variables]);
|
|
33
|
+
const isObjectForm = isObjectFormScreen(spec);
|
|
34
|
+
// Enriched object defs (incl. derived master-detail `subforms`) — the exact
|
|
35
|
+
// list the runtime FlowRunner gets. Only read in object-form mode so a plain
|
|
36
|
+
// field screen never triggers the all-objects fetch.
|
|
37
|
+
const objects = isObjectForm ? meta.objects : undefined;
|
|
38
|
+
const title = interpolate(spec.title, variables);
|
|
39
|
+
const description = interpolate(spec.description, variables);
|
|
40
|
+
const hidden = isObjectForm ? 0 : hiddenFieldCount(node, variables);
|
|
41
|
+
// Reset transient input state when the screen's STRUCTURE changes (fields
|
|
42
|
+
// added/removed/retyped/gated, or object-form target/mode); typing survives a
|
|
43
|
+
// label/title-only edit.
|
|
44
|
+
const structKey = isObjectForm
|
|
45
|
+
? `obj:${spec.objectName}:${spec.mode ?? 'create'}`
|
|
46
|
+
: 'fields:' + spec.fields.map((f) => `${f.name}:${f.type ?? ''}:${f.required ? 1 : 0}`).join('|');
|
|
47
|
+
const empty = !title && !description && !isObjectForm && spec.fields.length === 0 && hidden === 0;
|
|
48
|
+
return (_jsxs("div", { className: cn('overflow-hidden rounded-md border bg-background', className), children: [_jsx("div", { className: "flex items-center gap-1.5 border-b bg-muted/30 px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: "Preview" }), _jsx("div", { className: "max-h-[60vh] overflow-auto p-4", children: empty ? (_jsx("p", { className: "text-sm italic text-muted-foreground", children: "Add a title, description, fields, or an object form to preview this screen." })) : (_jsxs(_Fragment, { children: [title && _jsx("h3", { className: "text-base font-semibold leading-tight", children: title }), description && (_jsx("p", { className: cn('whitespace-pre-line text-sm text-muted-foreground', title && 'mt-1'), children: description })), _jsx(ScreenFormPreview, { spec: spec, adapter: adapter, objects: objects }, structKey), !isObjectForm && hidden > 0 && (_jsxs("p", { className: "mt-3 text-[11px] italic text-muted-foreground", children: [hidden, " field", hidden === 1 ? '' : 's', " hidden by ", hidden === 1 ? 'its' : 'their', " \u201Cvisible when\u201D", ' ', "condition", hidden === 1 ? '' : 's', "."] })), !isObjectForm && spec.fields.length > 0 && (_jsx("div", { className: "mt-4 flex justify-end", children: _jsx(Button, { size: "sm", disabled: true, children: "Submit" }) }))] })) })] }));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Holds the transient field values so the preview is interactive (the author
|
|
52
|
+
* can type into it) while never persisting anything.
|
|
53
|
+
*/
|
|
54
|
+
function ScreenFormPreview({ spec, adapter, objects }) {
|
|
55
|
+
const [values, setValues] = React.useState(() => initialScreenValues(spec));
|
|
56
|
+
return (_jsx(ScreenView, { screen: spec, values: values, onValueChange: (name, v) => setValues((p) => ({ ...p, [name]: v })), dataSource: adapter ?? undefined, objects: objects, objectForm: {
|
|
57
|
+
showSubmit: false,
|
|
58
|
+
showCancel: false,
|
|
59
|
+
noDataSourceMessage: 'Connect to a backend to preview this object form.',
|
|
60
|
+
} }));
|
|
61
|
+
}
|
|
@@ -75,6 +75,20 @@ export declare function topAnchor(p: Point): Point;
|
|
|
75
75
|
export declare function edgePath(from: Point, to: Point): string;
|
|
76
76
|
/** Midpoint of an edge — anchor for the condition label + insert affordance. */
|
|
77
77
|
export declare function edgeMidpoint(from: Point, to: Point): Point;
|
|
78
|
+
/** True for an ADR-0044 declared back-edge (a revise/rework loop's return). */
|
|
79
|
+
export declare function isBackEdge(edge: Pick<FlowEdge, 'type'>): boolean;
|
|
80
|
+
/** Right-center anchor of a node — where a back-edge's return arc attaches. */
|
|
81
|
+
export declare function rightAnchor(p: Point): Point;
|
|
82
|
+
/**
|
|
83
|
+
* Curved return path for a declared back-edge (ADR-0044 revise loop). Unlike a
|
|
84
|
+
* forward edge (top→bottom), a back-edge re-enters an earlier node, so we route
|
|
85
|
+
* it off the right side of both endpoints and bow it out to the right — a
|
|
86
|
+
* distinct return arc that reads as "loop back" rather than crossing the
|
|
87
|
+
* top-to-bottom forward edges.
|
|
88
|
+
*/
|
|
89
|
+
export declare function backEdgePath(from: Point, to: Point): string;
|
|
90
|
+
/** Anchor for a back-edge's label pill — the apex of its return arc. */
|
|
91
|
+
export declare function backEdgeLabelAnchor(from: Point, to: Point): Point;
|
|
78
92
|
/**
|
|
79
93
|
* Stable identity for an edge. Prefers an explicit `edge.id`; otherwise falls
|
|
80
94
|
* back to a `source->target#index` composite so an unsaved edge still has a
|
|
@@ -40,6 +40,12 @@ export function computeLayout(nodes, edges) {
|
|
|
40
40
|
for (const e of edges) {
|
|
41
41
|
if (!byId.has(e.source) || !byId.has(e.target) || e.source === e.target)
|
|
42
42
|
continue;
|
|
43
|
+
// ADR-0044: a declared back-edge (`type: 'back'`) re-enters an earlier node
|
|
44
|
+
// to close a revise loop. Exclude it from layering — exactly as the engine
|
|
45
|
+
// excludes it from DAG validation — so the loop doesn't drag its target
|
|
46
|
+
// node below the wait point. The edge is still drawn (as a return arc).
|
|
47
|
+
if (isBackEdge(e))
|
|
48
|
+
continue;
|
|
43
49
|
if (!outAdj.has(e.source))
|
|
44
50
|
outAdj.set(e.source, []);
|
|
45
51
|
outAdj.get(e.source).push(e.target);
|
|
@@ -167,6 +173,37 @@ export function edgePath(from, to) {
|
|
|
167
173
|
export function edgeMidpoint(from, to) {
|
|
168
174
|
return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
|
|
169
175
|
}
|
|
176
|
+
/** True for an ADR-0044 declared back-edge (a revise/rework loop's return). */
|
|
177
|
+
export function isBackEdge(edge) {
|
|
178
|
+
return edge.type === 'back';
|
|
179
|
+
}
|
|
180
|
+
/** Right-center anchor of a node — where a back-edge's return arc attaches. */
|
|
181
|
+
export function rightAnchor(p) {
|
|
182
|
+
return { x: p.x + NODE_W, y: p.y + NODE_H / 2 };
|
|
183
|
+
}
|
|
184
|
+
/** Horizontal bow of a back-edge's return arc, scaled to the vertical span. */
|
|
185
|
+
function backEdgeBow(from, to) {
|
|
186
|
+
return Math.max(64, Math.abs(to.y - from.y) * 0.35);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Curved return path for a declared back-edge (ADR-0044 revise loop). Unlike a
|
|
190
|
+
* forward edge (top→bottom), a back-edge re-enters an earlier node, so we route
|
|
191
|
+
* it off the right side of both endpoints and bow it out to the right — a
|
|
192
|
+
* distinct return arc that reads as "loop back" rather than crossing the
|
|
193
|
+
* top-to-bottom forward edges.
|
|
194
|
+
*/
|
|
195
|
+
export function backEdgePath(from, to) {
|
|
196
|
+
const bow = backEdgeBow(from, to);
|
|
197
|
+
const c1 = { x: from.x + bow, y: from.y };
|
|
198
|
+
const c2 = { x: to.x + bow, y: to.y };
|
|
199
|
+
return `M ${from.x},${from.y} C ${c1.x},${c1.y} ${c2.x},${c2.y} ${to.x},${to.y}`;
|
|
200
|
+
}
|
|
201
|
+
/** Anchor for a back-edge's label pill — the apex of its return arc. */
|
|
202
|
+
export function backEdgeLabelAnchor(from, to) {
|
|
203
|
+
// The cubic's t=0.5 point sits ~0.75·bow right of the (shared) right edge.
|
|
204
|
+
const bow = backEdgeBow(from, to);
|
|
205
|
+
return { x: Math.max(from.x, to.x) + bow * 0.75, y: (from.y + to.y) / 2 };
|
|
206
|
+
}
|
|
170
207
|
/**
|
|
171
208
|
* Stable identity for an edge. Prefers an explicit `edge.id`; otherwise falls
|
|
172
209
|
* back to a `source->target#index` composite so an unsaved edge still has a
|
|
@@ -66,16 +66,24 @@ export interface NodeCardProps {
|
|
|
66
66
|
runState?: 'active' | 'visited';
|
|
67
67
|
/** Dim nodes not yet reached while a simulation is in progress. */
|
|
68
68
|
dimmed?: boolean;
|
|
69
|
+
/** Structural-validation error highlight (e.g. part of an un-declared cycle). */
|
|
70
|
+
invalid?: boolean;
|
|
69
71
|
onPointerDown?: (e: React.PointerEvent) => void;
|
|
70
72
|
onSelect?: () => void;
|
|
71
73
|
onAppend?: () => void;
|
|
74
|
+
/**
|
|
75
|
+
* ADR-0044: when set (approval nodes without an existing revise loop), render
|
|
76
|
+
* the one-click "add revision loop" affordance — drops a wait node + the
|
|
77
|
+
* `revise` and declared `back` edges in a single gesture.
|
|
78
|
+
*/
|
|
79
|
+
onAddReviseLoop?: () => void;
|
|
72
80
|
}
|
|
73
81
|
/**
|
|
74
82
|
* A single draggable flow node rendered at an absolute canvas coordinate.
|
|
75
83
|
* The card body drives selection + reposition; a dedicated bottom "+" handle
|
|
76
84
|
* (edit mode only) appends a connected child without ambiguity.
|
|
77
85
|
*/
|
|
78
|
-
export declare function NodeCard({ type, label, summary, position, selected, editable, runState, dimmed, onPointerDown, onSelect, onAppend, }: NodeCardProps): React.JSX.Element;
|
|
86
|
+
export declare function NodeCard({ type, label, summary, position, selected, editable, runState, dimmed, invalid, onPointerDown, onSelect, onAppend, onAddReviseLoop, }: NodeCardProps): React.JSX.Element;
|
|
79
87
|
export interface NodePaletteProps {
|
|
80
88
|
locale?: string;
|
|
81
89
|
/** Node types to offer. Defaults to the hardcoded {@link NODE_PALETTE}. */
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
* palette popover. Kept dependency-free and Shadcn-native (Tailwind + lucide).
|
|
7
7
|
*/
|
|
8
8
|
import * as React from 'react';
|
|
9
|
-
import { Code, CircleDot, CircleStop, Diamond, FilePen, FilePlus, FileSearch, FileX, GitFork, Globe, ListChecks, MonitorSmartphone, Play, Plug, Plus, Repeat, ShieldAlert, TimerReset, UserCheck, Variable, Workflow, Zap, } from 'lucide-react';
|
|
9
|
+
import { Code, CircleDot, CircleStop, Diamond, FilePen, FilePlus, FileSearch, FileX, GitFork, Globe, IterationCcw, ListChecks, MonitorSmartphone, Play, Plug, Plus, Repeat, ShieldAlert, TimerReset, UserCheck, Variable, Workflow, Zap, } from 'lucide-react';
|
|
10
10
|
import { cn } from '@object-ui/components';
|
|
11
11
|
import { NODE_W, NODE_H } from './flow-canvas-layout';
|
|
12
12
|
export function nodeIcon(type) {
|
|
@@ -188,6 +188,7 @@ export function nodeTone(type) {
|
|
|
188
188
|
case 'delete_record':
|
|
189
189
|
case 'get_record':
|
|
190
190
|
return TONES.record;
|
|
191
|
+
case 'http':
|
|
191
192
|
case 'http_request':
|
|
192
193
|
case 'connector_action':
|
|
193
194
|
case 'script':
|
|
@@ -237,6 +238,7 @@ export function nodeCategory(type) {
|
|
|
237
238
|
case 'screen':
|
|
238
239
|
case 'user_task':
|
|
239
240
|
return 'Human';
|
|
241
|
+
case 'http':
|
|
240
242
|
case 'http_request':
|
|
241
243
|
case 'connector_action':
|
|
242
244
|
case 'script':
|
|
@@ -265,7 +267,7 @@ export const NODE_PALETTE = [
|
|
|
265
267
|
{ type: 'try_catch', label: 'Try / Catch', hint: 'Protect steps with error handling and retry', category: 'Logic' },
|
|
266
268
|
{ type: 'approval', label: 'Approval', hint: 'Pause for a human decision', category: 'Human' },
|
|
267
269
|
{ type: 'screen', label: 'Screen', hint: 'Collect input from a user', category: 'Human' },
|
|
268
|
-
{ type: '
|
|
270
|
+
{ type: 'http', label: 'HTTP request', hint: 'Call an external API', category: 'Integration' },
|
|
269
271
|
{ type: 'connector_action', label: 'Connector', hint: 'Run an integration action', category: 'Integration' },
|
|
270
272
|
{ type: 'script', label: 'Script', hint: 'Run custom code', category: 'Integration' },
|
|
271
273
|
{ type: 'subflow', label: 'Subflow', hint: 'Invoke another flow', category: 'Flow' },
|
|
@@ -301,6 +303,7 @@ export function defaultNodeExtras(type) {
|
|
|
301
303
|
// Seed a node-model approval: at least one approver + spec defaults. The
|
|
302
304
|
// author wires the out-edges with labels `approve` / `reject`.
|
|
303
305
|
return { config: { approvers: [{ type: 'manager' }], behavior: 'first_response', lockRecord: true } };
|
|
306
|
+
case 'http':
|
|
304
307
|
case 'http_request':
|
|
305
308
|
return { config: { method: 'GET' } };
|
|
306
309
|
case 'script':
|
|
@@ -314,9 +317,13 @@ export function defaultNodeExtras(type) {
|
|
|
314
317
|
* The card body drives selection + reposition; a dedicated bottom "+" handle
|
|
315
318
|
* (edit mode only) appends a connected child without ambiguity.
|
|
316
319
|
*/
|
|
317
|
-
export function NodeCard({ type, label, summary, position, selected, editable, runState, dimmed, onPointerDown, onSelect, onAppend, }) {
|
|
320
|
+
export function NodeCard({ type, label, summary, position, selected, editable, runState, dimmed, invalid, onPointerDown, onSelect, onAppend, onAddReviseLoop, }) {
|
|
318
321
|
const tone = nodeTone(type);
|
|
319
|
-
return (_jsxs("div", {
|
|
322
|
+
return (_jsxs("div", {
|
|
323
|
+
// `group` so the hover-revealed affordances (append "+", revise loop)
|
|
324
|
+
// appear when the pointer is anywhere over the card, not only over the
|
|
325
|
+
// tiny button itself.
|
|
326
|
+
className: "group absolute transition-opacity duration-200", "data-invalid": invalid || undefined, style: { left: position.x, top: position.y, width: NODE_W, height: NODE_H, opacity: dimmed ? 0.35 : 1 }, children: [_jsxs("div", { role: "button", tabIndex: 0, "aria-pressed": selected, onPointerDown: onPointerDown, onClick: (e) => {
|
|
320
327
|
e.stopPropagation();
|
|
321
328
|
onSelect?.();
|
|
322
329
|
}, onKeyDown: (e) => {
|
|
@@ -330,10 +337,18 @@ export function NodeCard({ type, label, summary, position, selected, editable, r
|
|
|
330
337
|
? 'border-emerald-500/70 ring-1 ring-emerald-400/40'
|
|
331
338
|
: selected
|
|
332
339
|
? 'border-primary shadow-md ring-2 ring-primary/30'
|
|
333
|
-
:
|
|
340
|
+
: invalid
|
|
341
|
+
? 'border-destructive ring-2 ring-destructive/50'
|
|
342
|
+
: 'border-border/80'), children: [_jsx("div", { className: cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-transform duration-150 group-hover:scale-105', tone.chip, runState === 'active' && 'animate-pulse'), children: _jsx(NodeTypeIcon, { type: type, className: cn('h-[18px] w-[18px]', tone.icon) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { title: label, className: "truncate text-[13px] font-semibold leading-tight text-foreground", children: label }), _jsxs("div", { className: "mt-1 flex items-baseline gap-1.5 leading-tight", children: [_jsx("span", { className: cn('shrink-0 text-[10px] font-semibold uppercase tracking-[0.08em]', tone.label), children: type }), summary && (_jsx("span", { className: "min-w-0 truncate font-mono text-[10px] text-muted-foreground", title: summary, children: summary }))] })] })] }), editable && type !== 'end' && (_jsx("button", { type: "button", title: "Add connected node", "aria-label": "Add connected node", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
|
|
334
343
|
e.stopPropagation();
|
|
335
344
|
onAppend?.();
|
|
336
|
-
}, className: cn('absolute left-1/2 -bottom-3 z-10 inline-flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors', 'opacity-0 hover:border-primary hover:text-primary group-hover:opacity-100 focus-visible:opacity-100'), children: _jsx(Plus, { className: "h-3.5 w-3.5" }) }))
|
|
345
|
+
}, className: cn('absolute left-1/2 -bottom-3 z-10 inline-flex h-6 w-6 -translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors', 'opacity-0 hover:border-primary hover:text-primary group-hover:opacity-100 focus-visible:opacity-100'), children: _jsx(Plus, { className: "h-3.5 w-3.5" }) })), onAddReviseLoop && (_jsx("button", { type: "button", title: "Add revision loop (send back for revision)", "aria-label": "Add revision loop", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
onAddReviseLoop();
|
|
348
|
+
},
|
|
349
|
+
// Anchored to the right edge — where the back-edge's return arc
|
|
350
|
+
// attaches — and tinted amber to match the rendered back-edge.
|
|
351
|
+
className: cn('absolute -right-3 top-1/2 z-10 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full border bg-background text-amber-600 shadow-sm transition-colors dark:text-amber-400', 'opacity-0 hover:border-amber-500 hover:text-amber-600 group-hover:opacity-100 focus-visible:opacity-100 dark:hover:text-amber-400'), children: _jsx(IterationCcw, { className: "h-3.5 w-3.5" }) }))] }));
|
|
337
352
|
}
|
|
338
353
|
/** Compact popover listing the node types an author can add, grouped by category. */
|
|
339
354
|
export function NodePalette({ items = NODE_PALETTE, onPick, onClose }) {
|
|
@@ -35,6 +35,27 @@ export declare function newField(name: string, type: FieldTypeId, label?: string
|
|
|
35
35
|
export declare function toLabel(name: string): string;
|
|
36
36
|
/** Normalize an arbitrary string into a valid snake_case field name. */
|
|
37
37
|
export declare function toFieldName(raw: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Prefix-stable variant of {@link toFieldName} for *live keystroke* input.
|
|
40
|
+
*
|
|
41
|
+
* The strict `toFieldName` trims a trailing `_`, which makes it impossible
|
|
42
|
+
* to TYPE a multi-word identifier into a controlled input: the field's
|
|
43
|
+
* `onChange` re-normalizes on every keystroke, so the instant the user
|
|
44
|
+
* presses `_` the value is `"repair_"` -> trimmed to `"repair"` -> the
|
|
45
|
+
* underscore vanishes before the next letter arrives, yielding
|
|
46
|
+
* `"repairticket"` instead of `"repair_ticket"`. (Authors of non-Latin
|
|
47
|
+
* locales hit this hardest: their label cannot derive a Latin slug, so
|
|
48
|
+
* they MUST type the identifier by hand.)
|
|
49
|
+
*
|
|
50
|
+
* This variant keeps a single trailing `_` so typing can continue, and
|
|
51
|
+
* returns `''` (not the `'field'` placeholder) on empty input so clearing
|
|
52
|
+
* the box actually clears it. A trailing `_` is itself a valid identifier
|
|
53
|
+
* per the spec ("starts with a letter, may contain letters/digits/`_`"),
|
|
54
|
+
* so no separate commit-time trim is required for correctness; callers
|
|
55
|
+
* that need a canonical form for a *complete* string (label->name
|
|
56
|
+
* derivation, group keys) should keep using strict `toFieldName`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function toFieldNameLoose(raw: string): string;
|
|
38
59
|
/**
|
|
39
60
|
* A declared field group (a.k.a. "section"). Lives at the object's
|
|
40
61
|
* top level as `draft.fieldGroups`; individual fields opt into a group
|
|
@@ -38,9 +38,11 @@ export function indexOfField(view, name) {
|
|
|
38
38
|
/** Build a fresh field definition for the given type. */
|
|
39
39
|
export function newField(name, type, label) {
|
|
40
40
|
const def = { type, label: label ?? toLabel(name) };
|
|
41
|
-
//
|
|
41
|
+
// Picklist-style fields start with no options; the OptionsEditor shows a
|
|
42
|
+
// blank input row locally and only persists rows once they have a value, so
|
|
43
|
+
// an unfilled row never trips the spec's identifier validation.
|
|
42
44
|
if (type === 'select' || type === 'multiselect' || type === 'radio' || type === 'checkboxes') {
|
|
43
|
-
def.options = [
|
|
45
|
+
def.options = [];
|
|
44
46
|
}
|
|
45
47
|
return { name, def };
|
|
46
48
|
}
|
|
@@ -67,6 +69,39 @@ export function toFieldName(raw) {
|
|
|
67
69
|
return `f_${sanitized}`;
|
|
68
70
|
return sanitized;
|
|
69
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Prefix-stable variant of {@link toFieldName} for *live keystroke* input.
|
|
74
|
+
*
|
|
75
|
+
* The strict `toFieldName` trims a trailing `_`, which makes it impossible
|
|
76
|
+
* to TYPE a multi-word identifier into a controlled input: the field's
|
|
77
|
+
* `onChange` re-normalizes on every keystroke, so the instant the user
|
|
78
|
+
* presses `_` the value is `"repair_"` -> trimmed to `"repair"` -> the
|
|
79
|
+
* underscore vanishes before the next letter arrives, yielding
|
|
80
|
+
* `"repairticket"` instead of `"repair_ticket"`. (Authors of non-Latin
|
|
81
|
+
* locales hit this hardest: their label cannot derive a Latin slug, so
|
|
82
|
+
* they MUST type the identifier by hand.)
|
|
83
|
+
*
|
|
84
|
+
* This variant keeps a single trailing `_` so typing can continue, and
|
|
85
|
+
* returns `''` (not the `'field'` placeholder) on empty input so clearing
|
|
86
|
+
* the box actually clears it. A trailing `_` is itself a valid identifier
|
|
87
|
+
* per the spec ("starts with a letter, may contain letters/digits/`_`"),
|
|
88
|
+
* so no separate commit-time trim is required for correctness; callers
|
|
89
|
+
* that need a canonical form for a *complete* string (label->name
|
|
90
|
+
* derivation, group keys) should keep using strict `toFieldName`.
|
|
91
|
+
*/
|
|
92
|
+
export function toFieldNameLoose(raw) {
|
|
93
|
+
const sanitized = raw
|
|
94
|
+
.trim()
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
97
|
+
.replace(/^_+/g, '') // trim leading only -- a trailing `_` must survive
|
|
98
|
+
.replace(/_{2,}/g, '_');
|
|
99
|
+
if (!sanitized)
|
|
100
|
+
return '';
|
|
101
|
+
if (!/^[a-z_]/.test(sanitized))
|
|
102
|
+
return `f_${sanitized}`;
|
|
103
|
+
return sanitized;
|
|
104
|
+
}
|
|
70
105
|
/** Read `draft.fieldGroups` into a normalized, well-typed list. */
|
|
71
106
|
export function readGroups(fieldGroupsInput) {
|
|
72
107
|
if (!Array.isArray(fieldGroupsInput))
|