@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
|
@@ -40,10 +40,12 @@ const FLOW_NODE_CONFIG = {
|
|
|
40
40
|
ref: { kind: 'object' },
|
|
41
41
|
placeholder: 'crm_lead',
|
|
42
42
|
help: 'Target object for record / scheduled-scan triggers.',
|
|
43
|
+
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
|
|
43
44
|
}),
|
|
44
45
|
cfg('condition', 'Entry condition', 'expression', {
|
|
45
46
|
placeholder: 'status == "qualifying" && previous.status != "qualifying"',
|
|
46
47
|
help: 'CEL predicate — the flow runs only when this is true. Leave empty to run on every event.',
|
|
48
|
+
showWhen: { field: 'triggerType', equals: ['record-after-create', 'record-after-update', 'record-before-update', 'record-after-delete', 'record-change', 'schedule', 'webhook', 'event'] },
|
|
47
49
|
}),
|
|
48
50
|
cfg('cron', 'Cron schedule', 'text', {
|
|
49
51
|
placeholder: '0 7 * * *',
|
|
@@ -169,9 +171,19 @@ const FLOW_NODE_CONFIG = {
|
|
|
169
171
|
}),
|
|
170
172
|
{ id: 'timeoutMs', path: ['timeoutMs'], label: 'Timeout (ms)', kind: 'number', placeholder: '30000' },
|
|
171
173
|
],
|
|
174
|
+
// Screen — collect input (a flat `fields` list) OR render an object's full
|
|
175
|
+
// create/edit form (`objectName`, master-detail). `title`/`description`
|
|
176
|
+
// head the screen (description interpolates {var}); `waitForInput` forces a
|
|
177
|
+
// pause on a field-less message/confirmation screen. All optional and shown
|
|
178
|
+
// together so neither a message screen nor an object-form step needs JSON.
|
|
172
179
|
screen: [
|
|
180
|
+
cfg('title', 'Title', 'text', { placeholder: 'Request a discount', help: 'Heading shown above the screen.' }),
|
|
181
|
+
cfg('description', 'Description', 'textarea', {
|
|
182
|
+
placeholder: 'Enter the deal amount and the discount you want.',
|
|
183
|
+
help: 'Body text. Interpolates {var} references (e.g. {approval_path}).',
|
|
184
|
+
}),
|
|
173
185
|
cfg('fields', 'Fields', 'objectList', {
|
|
174
|
-
help: '
|
|
186
|
+
help: 'Input fields collected on this screen. Leave empty for a message-only screen.',
|
|
175
187
|
columns: [
|
|
176
188
|
{ key: 'name', label: 'Name', kind: 'text', placeholder: 'discount' },
|
|
177
189
|
{ key: 'label', label: 'Label', kind: 'text', placeholder: 'Discount %' },
|
|
@@ -180,6 +192,29 @@ const FLOW_NODE_CONFIG = {
|
|
|
180
192
|
{ key: 'visibleWhen', label: 'Visible when', kind: 'expression', placeholder: 'stage == "review"' },
|
|
181
193
|
],
|
|
182
194
|
}),
|
|
195
|
+
cfg('waitForInput', 'Wait for input', 'boolean', {
|
|
196
|
+
help: 'Pause to show this screen even with no fields (a message / confirmation). A field-less screen with this off is a server pass-through.',
|
|
197
|
+
}),
|
|
198
|
+
cfg('objectName', 'Object form', 'reference', {
|
|
199
|
+
ref: { kind: 'object' },
|
|
200
|
+
placeholder: 'crm_account',
|
|
201
|
+
help: 'Render this object\u2019s full create/edit form (incl. master-detail) instead of a flat field list.',
|
|
202
|
+
}),
|
|
203
|
+
cfg('idVariable', 'Saved-record variable', 'text', {
|
|
204
|
+
placeholder: 'account_id',
|
|
205
|
+
help: 'Object form only: variable bound to the saved record\u2019s id, for later steps.',
|
|
206
|
+
}),
|
|
207
|
+
cfg('mode', 'Form mode', 'select', {
|
|
208
|
+
options: [
|
|
209
|
+
{ value: 'create', label: 'Create' },
|
|
210
|
+
{ value: 'edit', label: 'Edit' },
|
|
211
|
+
],
|
|
212
|
+
defaultValue: 'create',
|
|
213
|
+
help: 'Object form only.',
|
|
214
|
+
}),
|
|
215
|
+
cfg('defaults', 'Form defaults', 'keyValue', {
|
|
216
|
+
help: 'Object form only: prefilled values (e.g. account \u2192 {account_id}).',
|
|
217
|
+
}),
|
|
183
218
|
],
|
|
184
219
|
// Approval node (ADR-0019). The node opens an approval request on entry,
|
|
185
220
|
// suspends the run, and resumes down its `approve` / `reject` out-edge once a
|
|
@@ -260,6 +295,15 @@ const FLOW_NODE_CONFIG = {
|
|
|
260
295
|
},
|
|
261
296
|
{ id: 'escalation.escalateTo', path: ['config', 'escalation', 'escalateTo'], label: 'Escalate to', kind: 'reference', ref: { kind: 'role' }, placeholder: 'user id / role / manager level', showWhen: { field: 'escalation.enabled', equals: ['true'] } },
|
|
262
297
|
{ id: 'escalation.notifySubmitter', path: ['config', 'escalation', 'notifySubmitter'], label: 'Notify submitter', kind: 'boolean', showWhen: { field: 'escalation.enabled', equals: ['true'] } },
|
|
298
|
+
// ADR-0044 send-back-for-revision guard. Surfaces from the engine's
|
|
299
|
+
// published configSchema when online; this hardcoded copy keeps it visible
|
|
300
|
+
// offline / on an older backend. Only meaningful once the node has a
|
|
301
|
+
// `revise` out-edge (author one via the canvas "add revision loop").
|
|
302
|
+
cfg('maxRevisions', 'Max revisions', 'number', {
|
|
303
|
+
placeholder: '3',
|
|
304
|
+
defaultValue: '3',
|
|
305
|
+
help: 'Max send-backs for revision before the request auto-rejects (0 disables send-back). Needs a "revise" out-edge to take effect.',
|
|
306
|
+
}),
|
|
263
307
|
],
|
|
264
308
|
wait: [
|
|
265
309
|
at('waitEventConfig', 'eventType', 'Wait for', 'select', {
|
|
@@ -368,6 +412,7 @@ const FLOW_NODE_CONFIG = {
|
|
|
368
412
|
*/
|
|
369
413
|
const TYPE_ALIASES = {
|
|
370
414
|
action: 'legacy_action',
|
|
415
|
+
http: 'http_request',
|
|
371
416
|
branch: 'decision',
|
|
372
417
|
gateway: 'decision',
|
|
373
418
|
condition: 'decision',
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Render a (possibly nested) validation issue path into a human-readable trail
|
|
10
|
+
* that names the offending element. A Zod issue on a dashboard widget arrives as
|
|
11
|
+
* a dot-joined path like `widgets.2.layout`; shown as just its head field
|
|
12
|
+
* ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This
|
|
13
|
+
* turns it into "Widgets → priority_split → layout" by resolving each array
|
|
14
|
+
* index to the item's stable identity (id/name/title) from the draft value.
|
|
15
|
+
*
|
|
16
|
+
* @param headLabel resolved human label for the first segment (caller knows the
|
|
17
|
+
* form/schema labels).
|
|
18
|
+
* @param path dot-joined issue path (e.g. `widgets.2.layout`).
|
|
19
|
+
* @param rootValue the draft object the path indexes into (used to resolve an
|
|
20
|
+
* array index to the item's identity).
|
|
21
|
+
*/
|
|
22
|
+
export declare function describeIssuePath(headLabel: string, path: string, rootValue: unknown): string;
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Render a (possibly nested) validation issue path into a human-readable trail
|
|
10
|
+
* that names the offending element. A Zod issue on a dashboard widget arrives as
|
|
11
|
+
* a dot-joined path like `widgets.2.layout`; shown as just its head field
|
|
12
|
+
* ("Widgets") the author can't tell WHICH widget or sub-field is at fault. This
|
|
13
|
+
* turns it into "Widgets → priority_split → layout" by resolving each array
|
|
14
|
+
* index to the item's stable identity (id/name/title) from the draft value.
|
|
15
|
+
*
|
|
16
|
+
* @param headLabel resolved human label for the first segment (caller knows the
|
|
17
|
+
* form/schema labels).
|
|
18
|
+
* @param path dot-joined issue path (e.g. `widgets.2.layout`).
|
|
19
|
+
* @param rootValue the draft object the path indexes into (used to resolve an
|
|
20
|
+
* array index to the item's identity).
|
|
21
|
+
*/
|
|
22
|
+
export function describeIssuePath(headLabel, path, rootValue) {
|
|
23
|
+
const segments = path.split('.');
|
|
24
|
+
if (segments.length <= 1)
|
|
25
|
+
return headLabel;
|
|
26
|
+
const parts = [headLabel];
|
|
27
|
+
let cursor = asRecord(rootValue)?.[segments[0]];
|
|
28
|
+
for (let i = 1; i < segments.length; i++) {
|
|
29
|
+
const seg = segments[i];
|
|
30
|
+
if (/^\d+$/.test(seg)) {
|
|
31
|
+
const idx = Number(seg);
|
|
32
|
+
const item = Array.isArray(cursor) ? cursor[idx] : undefined;
|
|
33
|
+
// 1-based index reads naturally for non-developers ("#1" not "#0").
|
|
34
|
+
parts.push(itemIdentity(item) ?? `#${idx + 1}`);
|
|
35
|
+
cursor = item;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
parts.push(seg);
|
|
39
|
+
cursor = asRecord(cursor)?.[seg];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return parts.join(' → ');
|
|
43
|
+
}
|
|
44
|
+
/** Best-effort stable identity of an array item, resolving an I18nLabel object
|
|
45
|
+
* ({ key, defaultValue }) to its string. Returns undefined when none usable. */
|
|
46
|
+
function itemIdentity(item) {
|
|
47
|
+
const o = asRecord(item);
|
|
48
|
+
if (!o)
|
|
49
|
+
return undefined;
|
|
50
|
+
for (const k of ['id', 'name', 'key', 'title', 'label']) {
|
|
51
|
+
const v = o[k];
|
|
52
|
+
if (typeof v === 'string' && v.trim())
|
|
53
|
+
return v;
|
|
54
|
+
const nested = asRecord(v);
|
|
55
|
+
if (nested) {
|
|
56
|
+
const s = nested.defaultValue ?? nested.key;
|
|
57
|
+
if (typeof s === 'string' && s.trim())
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function asRecord(v) {
|
|
64
|
+
return v && typeof v === 'object' ? v : undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel "package" id for this environment's runtime, DB-authored metadata —
|
|
3
|
+
* items with no code-package binding (`package_id IS NULL`). The metadata
|
|
4
|
+
* list/get API treats `?package=sys_metadata` as exactly that local scope on
|
|
5
|
+
* READ, and a WRITE under it persists `package_id = null` (matching the
|
|
6
|
+
* server's runtime-only provenance, see framework #2252).
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: a self-hosted, metadata-customizable environment is
|
|
9
|
+
* single-tenant — there is no "org" dimension here; the real axis is
|
|
10
|
+
* code-package vs. runtime (DB-authored). Before this scope, the package
|
|
11
|
+
* selector only listed code packages, so metadata authored at runtime
|
|
12
|
+
* (`package_id = null`) was filtered out of every code-package view and became
|
|
13
|
+
* un-navigable (the route redirected to "new"). Surfacing the local scope as a
|
|
14
|
+
* first-class, always-present selector entry makes it discoverable and editable.
|
|
15
|
+
*/
|
|
16
|
+
export declare const LOCAL_PACKAGE_ID = "sys_metadata";
|
|
17
|
+
/**
|
|
18
|
+
* Build the Studio package-scope options from the raw `package` metadata list.
|
|
19
|
+
* Filters out system/cloud-scoped packages and appends a stable
|
|
20
|
+
* "Local / Custom (this environment)" scope so runtime metadata authored here
|
|
21
|
+
* is always selectable/visible — even when zero items exist yet.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildPackageScopeOptions(rawList: unknown[] | null | undefined): {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
}[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
import { detectLocale, t } from './i18n';
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel "package" id for this environment's runtime, DB-authored metadata —
|
|
5
|
+
* items with no code-package binding (`package_id IS NULL`). The metadata
|
|
6
|
+
* list/get API treats `?package=sys_metadata` as exactly that local scope on
|
|
7
|
+
* READ, and a WRITE under it persists `package_id = null` (matching the
|
|
8
|
+
* server's runtime-only provenance, see framework #2252).
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: a self-hosted, metadata-customizable environment is
|
|
11
|
+
* single-tenant — there is no "org" dimension here; the real axis is
|
|
12
|
+
* code-package vs. runtime (DB-authored). Before this scope, the package
|
|
13
|
+
* selector only listed code packages, so metadata authored at runtime
|
|
14
|
+
* (`package_id = null`) was filtered out of every code-package view and became
|
|
15
|
+
* un-navigable (the route redirected to "new"). Surfacing the local scope as a
|
|
16
|
+
* first-class, always-present selector entry makes it discoverable and editable.
|
|
17
|
+
*/
|
|
18
|
+
export const LOCAL_PACKAGE_ID = 'sys_metadata';
|
|
19
|
+
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
|
|
20
|
+
/**
|
|
21
|
+
* Build the Studio package-scope options from the raw `package` metadata list.
|
|
22
|
+
* Filters out system/cloud-scoped packages and appends a stable
|
|
23
|
+
* "Local / Custom (this environment)" scope so runtime metadata authored here
|
|
24
|
+
* is always selectable/visible — even when zero items exist yet.
|
|
25
|
+
*/
|
|
26
|
+
export function buildPackageScopeOptions(rawList) {
|
|
27
|
+
const rows = (rawList ?? [])
|
|
28
|
+
.map((raw) => {
|
|
29
|
+
const item = raw && typeof raw === 'object' && 'item' in raw ? raw.item : raw;
|
|
30
|
+
const m = (item?.manifest ?? item ?? {});
|
|
31
|
+
return {
|
|
32
|
+
id: m.id,
|
|
33
|
+
scope: m.scope,
|
|
34
|
+
name: m.name || m.id,
|
|
35
|
+
};
|
|
36
|
+
})
|
|
37
|
+
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
|
|
38
|
+
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
const opts = rows.map((p) => ({ id: p.id, name: p.name }));
|
|
40
|
+
// Append (never default) so the existing first-code-package default is
|
|
41
|
+
// preserved; the user opts into the local scope explicitly.
|
|
42
|
+
return [...opts, { id: LOCAL_PACKAGE_ID, name: t('engine.package.local', detectLocale()) }];
|
|
43
|
+
}
|
|
@@ -77,13 +77,29 @@ export function DatasetPreview({ draft }) {
|
|
|
77
77
|
const resultObject = state.status === 'ok' ? state.object : undefined;
|
|
78
78
|
const { measureField, headerLabel } = buildDatasetFieldHelpers(resultFields, resultObject, fieldLabel);
|
|
79
79
|
const columns = [...dimensionNames, ...measureNames];
|
|
80
|
+
// A ratio/percent measure (format like `0.0%`) on the same axis as a
|
|
81
|
+
// magnitude measure (currency in the hundred-thousands) renders as an
|
|
82
|
+
// invisible sliver. When the selection MIXES the two scales, plot the ratio
|
|
83
|
+
// measures as a line on a secondary (right) Y axis via the `combo` chart —
|
|
84
|
+
// bars (magnitude) keep the left axis. Same-scale selections stay a plain bar.
|
|
85
|
+
const isRatioMeasure = (m) => {
|
|
86
|
+
const f = measureField(m)?.format;
|
|
87
|
+
return typeof f === 'string' && f.includes('%');
|
|
88
|
+
};
|
|
89
|
+
const ratioMeasures = measureNames.filter(isRatioMeasure);
|
|
90
|
+
const mixedScale = ratioMeasures.length > 0 && ratioMeasures.length < measureNames.length;
|
|
80
91
|
return (_jsx(PreviewShell, { hint: `dataset · ${objectName}${dimensionNames.length ? ' · by ' + dimensionNames.join(', ') : ''}`, children: _jsxs("div", { className: "p-3 space-y-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("button", { type: "button", onClick: () => void run(), disabled: state.status === 'loading', className: "inline-flex items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50", children: [state.status === 'loading'
|
|
81
92
|
? _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" })
|
|
82
|
-
: _jsx(BarChart3, { className: "h-3.5 w-3.5" }), "Run preview"] }), _jsxs("span", { className: "text-[11px] text-muted-foreground", children: [measureNames.length, " measure", measureNames.length === 1 ? '' : 's', " \u00B7 ", dimensionNames.length, " dimension", dimensionNames.length === 1 ? '' : 's'] })] }), state.status === 'error' && (_jsxs("div", { role: "alert", className: "flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive", children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5 mt-0.5 shrink-0" }), _jsx("span", { className: "break-words", children: state.error })] })), state.status === 'ok' && state.rows.length === 0 && (_jsx(PreviewEmptyState, { icon: _jsx(BarChart3, { className: "h-8 w-8" }), title: "No rows", description: "The dataset returned no rows for the current scope." })), state.rows.length > 0 && dimensionNames.length >= 1 && (_jsx(PreviewErrorBoundary, { fallbackHint: "Couldn't render the chart for this result \u2014 the table below still shows the data.", children: _jsx(React.Suspense, { fallback: _jsx("div", { className: "h-[260px] flex items-center justify-center text-xs text-muted-foreground", children: _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) }), children:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
: _jsx(BarChart3, { className: "h-3.5 w-3.5" }), "Run preview"] }), _jsxs("span", { className: "text-[11px] text-muted-foreground", children: [measureNames.length, " measure", measureNames.length === 1 ? '' : 's', " \u00B7 ", dimensionNames.length, " dimension", dimensionNames.length === 1 ? '' : 's'] })] }), state.status === 'error' && (_jsxs("div", { role: "alert", className: "flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive", children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5 mt-0.5 shrink-0" }), _jsx("span", { className: "break-words", children: state.error })] })), state.status === 'ok' && state.rows.length === 0 && (_jsx(PreviewEmptyState, { icon: _jsx(BarChart3, { className: "h-8 w-8" }), title: "No rows", description: "The dataset returned no rows for the current scope." })), state.rows.length > 0 && dimensionNames.length >= 1 && (_jsx(PreviewErrorBoundary, { fallbackHint: "Couldn't render the chart for this result \u2014 the table below still shows the data.", children: _jsx(React.Suspense, { fallback: _jsx("div", { className: "h-[260px] flex items-center justify-center text-xs text-muted-foreground", children: _jsx(Loader2, { className: "h-4 w-4 animate-spin" }) }), children: _jsxs("div", { className: "rounded-md border p-2", children: [_jsx(ChartRenderer, { schema: {
|
|
94
|
+
data: state.rows,
|
|
95
|
+
xAxisKey: dimensionNames[0],
|
|
96
|
+
chartType: mixedScale ? 'combo' : 'bar',
|
|
97
|
+
series: measureNames.map((m) => ({
|
|
98
|
+
dataKey: m,
|
|
99
|
+
label: headerLabel(m),
|
|
100
|
+
chartType: mixedScale ? (isRatioMeasure(m) ? 'line' : 'bar') : 'bar',
|
|
101
|
+
})),
|
|
102
|
+
} }), mixedScale && (_jsxs("p", { className: "mt-1 px-1 text-[10px] text-muted-foreground", children: ["Ratio measures (", ratioMeasures.map(headerLabel).join(', '), ") use the right axis."] }))] }) }) })), state.rows.length > 0 && (_jsx("div", { className: "overflow-auto max-h-[60vh] rounded-md border", children: _jsxs("table", { className: "w-full text-xs", children: [_jsx("thead", { className: "bg-muted/40", children: _jsx("tr", { children: columns.map((c) => (_jsx("th", { className: "px-2 py-1.5 text-left font-medium whitespace-nowrap", children: headerLabel(c) }, c))) }) }), _jsx("tbody", { children: state.rows.map((row, i) => (_jsx("tr", { className: "border-t", children: columns.map((c) => (_jsx("td", { className: "px-2 py-1 tabular-nums whitespace-nowrap", children: measureNames.includes(c)
|
|
87
103
|
? formatMeasure(row[c], measureField(c)?.format, measureField(c)?.currency)
|
|
88
104
|
: formatDimensionValue(row[c]) }, c))) }, i))) })] }) }))] }) }));
|
|
89
105
|
}
|
|
@@ -35,9 +35,15 @@ export interface FlowCanvasProps {
|
|
|
35
35
|
visitedNodeIds?: string[];
|
|
36
36
|
/** Simulation overlay: ids of edges that were traversed. */
|
|
37
37
|
traversedEdgeIds?: string[];
|
|
38
|
+
/** Structural-validation: node ids to paint with a red error ring. */
|
|
39
|
+
invalidNodeIds?: string[];
|
|
40
|
+
/** Structural-validation: edges (keyed `${source}->${target}`) to paint red. */
|
|
41
|
+
invalidEdges?: ReadonlySet<string>;
|
|
42
|
+
/** Structural-validation error messages shown in an inline canvas banner. */
|
|
43
|
+
validationErrors?: string[];
|
|
38
44
|
onSelect: (node: FlowNode | null) => void;
|
|
39
45
|
/** Select an edge (its `edgeKey`), or clear selection with `null`. */
|
|
40
46
|
onSelectEdge?: (edge: FlowEdge | null, key: string) => void;
|
|
41
47
|
onPatch?: (partial: Record<string, unknown>) => void;
|
|
42
48
|
}
|
|
43
|
-
export declare function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, onSelectEdge, onPatch, }: FlowCanvasProps): React.JSX.Element;
|
|
49
|
+
export declare function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, invalidNodeIds, invalidEdges, validationErrors, onSelect, onSelectEdge, onPatch, }: FlowCanvasProps): React.JSX.Element;
|
|
@@ -21,17 +21,17 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
* `onPatch(partial)` and the host merges + persists.
|
|
22
22
|
*/
|
|
23
23
|
import * as React from 'react';
|
|
24
|
-
import { Maximize2, Plus, ZoomIn, ZoomOut } from 'lucide-react';
|
|
24
|
+
import { AlertTriangle, Maximize2, Plus, ZoomIn, ZoomOut } from 'lucide-react';
|
|
25
25
|
import { cn } from '@object-ui/components';
|
|
26
26
|
import { uniqueId, appendArray, spliceArray } from '../inspectors/_shared';
|
|
27
27
|
import { t as tr } from '../i18n';
|
|
28
|
-
import { computeLayout, diagramSize, bottomAnchor, topAnchor, edgePath, edgeMidpoint, edgeKey, conditionText, } from './flow-canvas-layout';
|
|
28
|
+
import { computeLayout, diagramSize, bottomAnchor, topAnchor, rightAnchor, edgePath, edgeMidpoint, backEdgePath, backEdgeLabelAnchor, isBackEdge, edgeKey, conditionText, } from './flow-canvas-layout';
|
|
29
29
|
import { NodeCard, NodePalette, defaultNodeLabel, defaultNodeExtras } from './flow-canvas-parts';
|
|
30
30
|
import { useFlowNodePalette } from './useFlowNodePalette';
|
|
31
31
|
const MIN_ZOOM = 0.4;
|
|
32
32
|
const MAX_ZOOM = 1.6;
|
|
33
33
|
const DRAG_THRESHOLD = 4;
|
|
34
|
-
export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, onSelect, onSelectEdge, onPatch, }) {
|
|
34
|
+
export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, selectedEdgeId, locale, activeNodeId, visitedNodeIds, traversedEdgeIds, invalidNodeIds, invalidEdges, validationErrors, onSelect, onSelectEdge, onPatch, }) {
|
|
35
35
|
const viewportRef = React.useRef(null);
|
|
36
36
|
const [zoom, setZoom] = React.useState(1);
|
|
37
37
|
const [pan, setPan] = React.useState({ x: 0, y: 0 });
|
|
@@ -51,6 +51,7 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
|
|
|
51
51
|
// Simulation overlay sets (display-only; never drives engine behavior).
|
|
52
52
|
const visitedSet = React.useMemo(() => new Set(visitedNodeIds ?? []), [visitedNodeIds]);
|
|
53
53
|
const traversedSet = React.useMemo(() => new Set(traversedEdgeIds ?? []), [traversedEdgeIds]);
|
|
54
|
+
const invalidNodeSet = React.useMemo(() => new Set(invalidNodeIds ?? []), [invalidNodeIds]);
|
|
54
55
|
const simRunning = (visitedNodeIds?.length ?? 0) > 0 || !!activeNodeId;
|
|
55
56
|
const positionOf = React.useCallback((id) => {
|
|
56
57
|
if (dragPos && dragPos.id === id)
|
|
@@ -88,6 +89,28 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
|
|
|
88
89
|
source: opts.from,
|
|
89
90
|
target: id,
|
|
90
91
|
};
|
|
92
|
+
// When the source is a decision, carry its matching branch (by order:
|
|
93
|
+
// the k-th out-edge takes the k-th branch) onto the new edge so it
|
|
94
|
+
// actually routes. The decision's config.conditions are otherwise
|
|
95
|
+
// disconnected from the edges, leaving every branch unconditional.
|
|
96
|
+
const fromNode = nodes.find((n) => n.id === opts.from);
|
|
97
|
+
if (fromNode?.type === 'decision') {
|
|
98
|
+
const branches = Array.isArray(fromNode.config?.conditions)
|
|
99
|
+
? fromNode.config.conditions
|
|
100
|
+
: [];
|
|
101
|
+
const outCount = edges.filter((e) => e.source === opts.from).length;
|
|
102
|
+
const branch = branches[outCount];
|
|
103
|
+
if (branch && typeof branch === 'object') {
|
|
104
|
+
const expr = typeof branch.expression === 'string' ? branch.expression.trim() : '';
|
|
105
|
+
const label = typeof branch.label === 'string' ? branch.label.trim() : '';
|
|
106
|
+
if (label)
|
|
107
|
+
newEdge.label = label;
|
|
108
|
+
if (expr === 'true')
|
|
109
|
+
newEdge.isDefault = true;
|
|
110
|
+
else if (expr)
|
|
111
|
+
newEdge.condition = expr;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
91
114
|
patch.edges = appendArray(edges, newEdge);
|
|
92
115
|
}
|
|
93
116
|
onPatch(patch);
|
|
@@ -124,6 +147,49 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
|
|
|
124
147
|
onPatch({ nodes: appendArray(nodes, newNode), edges: appendArray(nextEdges, secondSegment) });
|
|
125
148
|
onSelect(newNode);
|
|
126
149
|
}, [edges, nodes, onPatch, onSelect, positionOf]);
|
|
150
|
+
/**
|
|
151
|
+
* ADR-0044 one-click "add revision loop": drop a signal `wait` node plus the
|
|
152
|
+
* two edges that form a send-back-for-revision loop on an approval node —
|
|
153
|
+
* a `revise` out-edge to the wait point, and a declared `back`-edge closing
|
|
154
|
+
* the loop (resubmit re-enters the approval node as round N+1). Reproduces the
|
|
155
|
+
* canonical `showcase_budget_approval` shape in a single gesture. The wait
|
|
156
|
+
* node is left unpinned so the layered auto-layout slots it among the
|
|
157
|
+
* approval node's other branches.
|
|
158
|
+
*/
|
|
159
|
+
const addReviseLoop = React.useCallback((approvalId) => {
|
|
160
|
+
if (!onPatch)
|
|
161
|
+
return;
|
|
162
|
+
if (!nodes.some((n) => n.id === approvalId))
|
|
163
|
+
return;
|
|
164
|
+
const waitId = uniqueId('node', nodes.map((n) => n.id).filter(Boolean));
|
|
165
|
+
const waitNode = {
|
|
166
|
+
id: waitId,
|
|
167
|
+
type: 'wait',
|
|
168
|
+
label: 'Awaiting Revision',
|
|
169
|
+
// Signal-flavored wait: the submitter's resubmit signal resumes the run.
|
|
170
|
+
waitEventConfig: { eventType: 'signal', signalName: 'revision', onTimeout: 'fail' },
|
|
171
|
+
};
|
|
172
|
+
const existingEdgeIds = edges.map((e) => e.id).filter(Boolean);
|
|
173
|
+
const reviseId = uniqueId('edge', existingEdgeIds);
|
|
174
|
+
const backId = uniqueId('edge', [...existingEdgeIds, reviseId]);
|
|
175
|
+
const reviseEdge = { id: reviseId, source: approvalId, target: waitId, label: 'revise' };
|
|
176
|
+
const backEdge = { id: backId, source: waitId, target: approvalId, label: 'resubmit', type: 'back' };
|
|
177
|
+
onPatch({
|
|
178
|
+
nodes: appendArray(nodes, waitNode),
|
|
179
|
+
edges: appendArray(appendArray(edges, reviseEdge), backEdge),
|
|
180
|
+
});
|
|
181
|
+
onSelect(waitNode);
|
|
182
|
+
}, [edges, nodes, onPatch, onSelect]);
|
|
183
|
+
// Approval nodes that already declare a `revise` out-edge — used to hide the
|
|
184
|
+
// "add revision loop" affordance once a loop exists (avoid duplicates).
|
|
185
|
+
const reviseLoopSources = React.useMemo(() => {
|
|
186
|
+
const s = new Set();
|
|
187
|
+
for (const e of edges) {
|
|
188
|
+
if (typeof e.label === 'string' && e.label.trim().toLowerCase() === 'revise')
|
|
189
|
+
s.add(e.source);
|
|
190
|
+
}
|
|
191
|
+
return s;
|
|
192
|
+
}, [edges]);
|
|
127
193
|
const deleteNode = React.useCallback((id) => {
|
|
128
194
|
if (!onPatch)
|
|
129
195
|
return;
|
|
@@ -220,7 +286,7 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
|
|
|
220
286
|
}
|
|
221
287
|
}, [deleteNode, editable, selectedId]);
|
|
222
288
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
223
|
-
return (_jsxs("div", { className: "relative h-full min-h-[320px] w-full overflow-hidden", children: [_jsxs("div", { className: "absolute right-2 top-2 z-30 flex items-center gap-1.5", children: [editable && (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setPaletteOpen((v) => !v), className: "inline-flex items-center gap-1.5 rounded-lg border bg-background/90 px-2.5 py-1.5 text-xs font-medium shadow-sm backdrop-blur-sm transition-colors hover:border-primary/50 hover:bg-accent hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), tr('engine.inspector.add.node', locale)] }), paletteOpen && (_jsx(NodePalette, { locale: locale, items: paletteItems, onClose: () => setPaletteOpen(false), onPick: (type) => addNode(type, { from: selectedId ?? undefined }) }))] })), _jsxs("div", { className: "flex items-center rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm", children: [_jsx("button", { type: "button", title: "Zoom out", "aria-label": "Zoom out", onClick: () => setZoom((z) => clampZoom(z - 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomOut, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "w-10 text-center text-[11px] tabular-nums text-muted-foreground", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { type: "button", title: "Zoom in", "aria-label": "Zoom in", onClick: () => setZoom((z) => clampZoom(z + 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomIn, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", title: "Fit to view", "aria-label": "Fit to view", onClick: fitToView, className: "inline-flex h-7 w-7 items-center justify-center border-l text-muted-foreground hover:text-foreground", children: _jsx(Maximize2, { className: "h-3.5 w-3.5" }) })] })] }), _jsx("div", { ref: viewportRef, tabIndex: 0, role: "application", "aria-label": "Flow canvas", onKeyDown: onKeyDown, onPointerDown: onBgPointerDown, onPointerMove: (e) => {
|
|
289
|
+
return (_jsxs("div", { className: "relative h-full min-h-[320px] w-full overflow-hidden", children: [validationErrors && validationErrors.length > 0 && (_jsxs("div", { className: "absolute left-2 top-2 z-30 max-w-[min(60%,420px)] space-y-1", children: [validationErrors.slice(0, 3).map((msg, i) => (_jsxs("div", { role: "alert", className: "flex items-start gap-1.5 rounded-lg border border-destructive/40 bg-destructive/10 px-2.5 py-1.5 text-[11px] leading-snug text-destructive shadow-sm backdrop-blur-sm", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }), _jsx("span", { children: msg })] }, i))), validationErrors.length > 3 && (_jsxs("div", { className: "px-2.5 text-[10px] text-destructive/80", children: ["+", validationErrors.length - 3, " more\u2026"] }))] })), _jsxs("div", { className: "absolute right-2 top-2 z-30 flex items-center gap-1.5", children: [editable && (_jsxs("div", { className: "relative", children: [_jsxs("button", { type: "button", onClick: () => setPaletteOpen((v) => !v), className: "inline-flex items-center gap-1.5 rounded-lg border bg-background/90 px-2.5 py-1.5 text-xs font-medium shadow-sm backdrop-blur-sm transition-colors hover:border-primary/50 hover:bg-accent hover:text-foreground", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), tr('engine.inspector.add.node', locale)] }), paletteOpen && (_jsx(NodePalette, { locale: locale, items: paletteItems, onClose: () => setPaletteOpen(false), onPick: (type) => addNode(type, { from: selectedId ?? undefined }) }))] })), _jsxs("div", { className: "flex items-center rounded-lg border bg-background/90 shadow-sm backdrop-blur-sm", children: [_jsx("button", { type: "button", title: "Zoom out", "aria-label": "Zoom out", onClick: () => setZoom((z) => clampZoom(z - 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomOut, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "w-10 text-center text-[11px] tabular-nums text-muted-foreground", children: [Math.round(zoom * 100), "%"] }), _jsx("button", { type: "button", title: "Zoom in", "aria-label": "Zoom in", onClick: () => setZoom((z) => clampZoom(z + 0.15)), className: "inline-flex h-7 w-7 items-center justify-center text-muted-foreground hover:text-foreground", children: _jsx(ZoomIn, { className: "h-3.5 w-3.5" }) }), _jsx("button", { type: "button", title: "Fit to view", "aria-label": "Fit to view", onClick: fitToView, className: "inline-flex h-7 w-7 items-center justify-center border-l text-muted-foreground hover:text-foreground", children: _jsx(Maximize2, { className: "h-3.5 w-3.5" }) })] })] }), _jsx("div", { ref: viewportRef, tabIndex: 0, role: "application", "aria-label": "Flow canvas", onKeyDown: onKeyDown, onPointerDown: onBgPointerDown, onPointerMove: (e) => {
|
|
224
290
|
onBgPointerMove(e);
|
|
225
291
|
onNodePointerMove(e);
|
|
226
292
|
}, onPointerUp: (e) => {
|
|
@@ -238,44 +304,66 @@ export function FlowCanvas({ nodes, edges, editable, designMode, selectedId, sel
|
|
|
238
304
|
width: size.width,
|
|
239
305
|
height: size.height,
|
|
240
306
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
241
|
-
}, children: [_jsxs("svg", { className: "pointer-events-none absolute left-0 top-0 overflow-visible", width: size.width, height: size.height, children: [
|
|
307
|
+
}, children: [_jsxs("svg", { className: "pointer-events-none absolute left-0 top-0 overflow-visible", width: size.width, height: size.height, children: [_jsxs("defs", { children: [_jsx("marker", { id: "flow-arrow", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-muted-foreground/55" }) }), _jsx("marker", { id: "flow-arrow-back", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-amber-500/80" }) }), _jsx("marker", { id: "flow-arrow-error", viewBox: "0 0 10 10", refX: "8", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 0 L 10 5 L 0 10 z", className: "fill-destructive" }) })] }), edges.map((edge, i) => {
|
|
242
308
|
const sp = layout.get(edge.source);
|
|
243
309
|
const tp = layout.get(edge.target);
|
|
244
310
|
if (!sp || !tp)
|
|
245
311
|
return null;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
312
|
+
// ADR-0044 back-edges (revise loop) re-enter an earlier node, so
|
|
313
|
+
// they attach to the right side of both endpoints and render as a
|
|
314
|
+
// dashed amber return arc — visually distinct from the forward
|
|
315
|
+
// top-to-bottom flow.
|
|
316
|
+
const back = isBackEdge(edge);
|
|
317
|
+
// Structural-validation error (e.g. part of an un-declared cycle).
|
|
318
|
+
// Back-edges are excluded from cycle detection, so they're never invalid.
|
|
319
|
+
const invalid = !back && !!invalidEdges?.has(`${edge.source}->${edge.target}`);
|
|
320
|
+
const sPos = dragPos?.id === edge.source ? positionOf(edge.source) : sp;
|
|
321
|
+
const tPos = dragPos?.id === edge.target ? positionOf(edge.target) : tp;
|
|
322
|
+
const from = back ? rightAnchor(sPos) : bottomAnchor(sPos);
|
|
323
|
+
const to = back ? rightAnchor(tPos) : topAnchor(tPos);
|
|
324
|
+
const labelPos = back ? backEdgeLabelAnchor(from, to) : edgeMidpoint(from, to);
|
|
249
325
|
const cond = conditionText(edge.condition);
|
|
250
326
|
const branchLabel = edge.isDefault ? 'else' : cond ? `if ${cond}` : edge.label;
|
|
251
327
|
const eid = edgeKey(edge, i);
|
|
252
328
|
const traversed = traversedSet.has(eid);
|
|
253
329
|
const selected = selectedEdgeId === eid;
|
|
254
|
-
const d = edgePath(from, to);
|
|
330
|
+
const d = back ? backEdgePath(from, to) : edgePath(from, to);
|
|
255
331
|
// Edges are selectable in design mode; the host opens the edge
|
|
256
332
|
// inspector. A wide transparent hit-path widens the click target
|
|
257
333
|
// beyond the 1.5px visible stroke without altering the visuals.
|
|
258
334
|
const selectable = designMode && !!onSelectEdge;
|
|
259
|
-
return (_jsxs("g", { children: [_jsx("path", { d: d, strokeLinecap: "round", className: cn('fill-none transition-[stroke] duration-150', traversed
|
|
335
|
+
return (_jsxs("g", { "data-invalid": invalid || undefined, children: [_jsx("path", { d: d, strokeLinecap: "round", strokeDasharray: back ? '5 4' : undefined, className: cn('fill-none transition-[stroke] duration-150', traversed
|
|
260
336
|
? 'stroke-sky-500'
|
|
261
337
|
: selected
|
|
262
338
|
? 'stroke-primary'
|
|
263
|
-
:
|
|
264
|
-
? 'stroke-
|
|
265
|
-
:
|
|
339
|
+
: invalid
|
|
340
|
+
? 'stroke-destructive'
|
|
341
|
+
: back
|
|
342
|
+
? 'stroke-amber-500/70'
|
|
343
|
+
: simRunning
|
|
344
|
+
? 'stroke-muted-foreground/20'
|
|
345
|
+
: 'stroke-muted-foreground/40'), strokeWidth: traversed || selected || invalid ? 2.5 : 1.75, markerEnd: invalid ? 'url(#flow-arrow-error)' : back ? 'url(#flow-arrow-back)' : 'url(#flow-arrow)' }), selectable && (_jsx("path", { d: d, className: "pointer-events-auto cursor-pointer fill-none stroke-transparent", strokeWidth: 14, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
|
|
266
346
|
e.stopPropagation();
|
|
267
347
|
onSelectEdge(edge, eid);
|
|
268
|
-
}, children: _jsx("title", { children: `${edge.source} → ${edge.target}` }) })), branchLabel && (_jsx("foreignObject", { x:
|
|
348
|
+
}, children: _jsx("title", { children: invalid ? `${edge.source} → ${edge.target} — part of an un-declared cycle; mark the edge that closes the loop as a back-edge` : back ? `${edge.source} ↩ ${edge.target} (back-edge)` : `${edge.source} → ${edge.target}` }) })), branchLabel && (_jsx("foreignObject", { x: labelPos.x - 60, y: labelPos.y - 11, width: 120, height: 22, className: cn(selectable && 'pointer-events-auto'), children: _jsx("div", { className: "flex justify-center", children: _jsx("span", { onPointerDown: selectable ? (e) => e.stopPropagation() : undefined, onClick: selectable ? (e) => { e.stopPropagation(); onSelectEdge(edge, eid); } : undefined, className: cn('max-w-full truncate rounded-full border bg-background/95 px-2 py-0.5 text-[10px] font-medium shadow-sm backdrop-blur-sm transition-colors', selectable && 'cursor-pointer hover:border-primary/60', selected
|
|
349
|
+
? 'border-primary text-primary'
|
|
350
|
+
: invalid
|
|
351
|
+
? 'border-destructive/60 text-destructive'
|
|
352
|
+
: back
|
|
353
|
+
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
|
354
|
+
: 'border-border text-muted-foreground'), children: branchLabel }) }) })), editable && !back && (_jsx("foreignObject", {
|
|
269
355
|
// Sit the insert handle at the edge midpoint, but slide it
|
|
270
356
|
// to the right of the branch-label pill when one is present
|
|
271
357
|
// so the two don't stack on the same spot.
|
|
272
|
-
x: branchLabel ?
|
|
358
|
+
x: branchLabel ? labelPos.x + 66 : labelPos.x - 11, y: labelPos.y - 11, width: 22, height: 22, className: "pointer-events-auto", children: _jsx("button", { type: "button", title: "Insert node here", "aria-label": "Insert node here", onPointerDown: (e) => e.stopPropagation(), onClick: (e) => {
|
|
273
359
|
e.stopPropagation();
|
|
274
360
|
insertOnEdge(edge);
|
|
275
361
|
}, className: "inline-flex h-[22px] w-[22px] items-center justify-center rounded-full border bg-background/90 text-muted-foreground opacity-50 shadow-sm backdrop-blur-sm transition-all hover:scale-110 hover:border-primary hover:bg-background hover:text-primary hover:opacity-100 focus-visible:opacity-100", children: _jsx(Plus, { className: "h-3 w-3" }) }) }))] }, edge.id || `${edge.source}-${edge.target}-${i}`));
|
|
276
362
|
})] }), nodes.map((node) => {
|
|
277
363
|
const runState = activeNodeId === node.id ? 'active' : visitedSet.has(node.id) ? 'visited' : undefined;
|
|
278
|
-
return (_jsx(NodeCard, { id: node.id, type: node.type, label: node.label || node.id, summary: nodeSummary(node), position: positionOf(node.id), selected: selectedId === node.id, editable: editable, runState: runState, dimmed: simRunning && !runState, onPointerDown: onNodePointerDown(node.id), onSelect: () => designMode && onSelect(node), onAppend: () => addNode('create_record', { from: node.id })
|
|
364
|
+
return (_jsx(NodeCard, { id: node.id, type: node.type, label: node.label || node.id, summary: nodeSummary(node), position: positionOf(node.id), selected: selectedId === node.id, editable: editable, runState: runState, dimmed: simRunning && !runState, onPointerDown: onNodePointerDown(node.id), onSelect: () => designMode && onSelect(node), onAppend: () => addNode('create_record', { from: node.id }), onAddReviseLoop: editable && node.type === 'approval' && !reviseLoopSources.has(node.id)
|
|
365
|
+
? () => addReviseLoop(node.id)
|
|
366
|
+
: undefined, invalid: invalidNodeSet.has(node.id) }, node.id));
|
|
279
367
|
})] }) })] }));
|
|
280
368
|
}
|
|
281
369
|
/** One-line config summary shown on the node card (best-effort, type-aware). */
|
|
@@ -25,10 +25,13 @@ import { t as tr } from '../i18n';
|
|
|
25
25
|
import { FlowCanvas } from './FlowCanvas';
|
|
26
26
|
import { FlowSimulatorPanel } from './FlowSimulatorPanel';
|
|
27
27
|
import { FlowRunsPanel } from './FlowRunsPanel';
|
|
28
|
+
import { validateFlowDraft } from './simulator/flow-sim-validate';
|
|
28
29
|
export function FlowPreview({ draft, editing, selection, onSelectionChange, onPatch, locale }) {
|
|
29
30
|
const d = draft;
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
// Memoized so hook deps (validation memo, handleAddNode) get a stable array
|
|
32
|
+
// reference across renders instead of a fresh `[]`/cast each time.
|
|
33
|
+
const nodes = React.useMemo(() => (Array.isArray(d.nodes) ? d.nodes : []), [d.nodes]);
|
|
34
|
+
const edges = React.useMemo(() => (Array.isArray(d.edges) ? d.edges : []), [d.edges]);
|
|
32
35
|
const variables = Array.isArray(d.variables) ? d.variables : [];
|
|
33
36
|
const designMode = !!(editing && onSelectionChange);
|
|
34
37
|
const canEdit = designMode && !!onPatch;
|
|
@@ -38,6 +41,31 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
|
|
|
38
41
|
const [showVars, setShowVars] = React.useState(true);
|
|
39
42
|
const [showRuns, setShowRuns] = React.useState(false);
|
|
40
43
|
const [runHL, setRunHL] = React.useState(null);
|
|
44
|
+
// Continuous structural validation surfaced INLINE on the canvas (ADR-0044):
|
|
45
|
+
// an un-declared cycle (and other structural errors) paints the offending
|
|
46
|
+
// edges/nodes red and shows a banner — so the author sees it without opening
|
|
47
|
+
// the Debug panel. Same `validateFlowDraft` the simulator preflight uses.
|
|
48
|
+
const { invalidNodeIds, invalidEdges, validationErrors } = React.useMemo(() => {
|
|
49
|
+
const v = validateFlowDraft(nodes, edges);
|
|
50
|
+
const nodeSet = new Set();
|
|
51
|
+
const edgeSet = new Set();
|
|
52
|
+
for (const diag of v.errors) {
|
|
53
|
+
if (diag.nodeId)
|
|
54
|
+
nodeSet.add(diag.nodeId);
|
|
55
|
+
// A cycle error carries its closing node path → mark each hop's edge red.
|
|
56
|
+
if (diag.cycle) {
|
|
57
|
+
for (let i = 0; i < diag.cycle.length - 1; i++) {
|
|
58
|
+
nodeSet.add(diag.cycle[i]);
|
|
59
|
+
edgeSet.add(`${diag.cycle[i]}->${diag.cycle[i + 1]}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
invalidNodeIds: [...nodeSet],
|
|
65
|
+
invalidEdges: edgeSet,
|
|
66
|
+
validationErrors: v.errors.map((diag) => diag.message),
|
|
67
|
+
};
|
|
68
|
+
}, [nodes, edges]);
|
|
41
69
|
const handleAddNode = React.useCallback(() => {
|
|
42
70
|
if (!canEdit)
|
|
43
71
|
return;
|
|
@@ -76,7 +104,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa
|
|
|
76
104
|
}, className: 'inline-flex items-center gap-1 rounded border px-2 py-0.5 text-[11px] font-medium transition-colors ' +
|
|
77
105
|
(showDebug
|
|
78
106
|
? 'border-sky-500 bg-sky-50 text-sky-700'
|
|
79
|
-
: 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(Bug, { className: "h-3 w-3" }), " Debug"] })] })] }), _jsx("div", { className: "flex-1 min-h-0", children: _jsx(FlowCanvas, { nodes: nodes, edges: edges, editable: canEdit, designMode: designMode, selectedId: selectedId, selectedEdgeId: selectedEdgeId, locale: locale, activeNodeId: runHL?.activeNodeId ?? null, visitedNodeIds: runHL?.visitedNodeIds, traversedEdgeIds: runHL?.traversedEdgeIds, onSelect: (n) => n
|
|
107
|
+
: 'border-border text-muted-foreground hover:bg-muted/50 hover:text-foreground'), children: [_jsx(Bug, { className: "h-3 w-3" }), " Debug"] })] })] }), _jsx("div", { className: "flex-1 min-h-0", children: _jsx(FlowCanvas, { nodes: nodes, edges: edges, editable: canEdit, designMode: designMode, selectedId: selectedId, selectedEdgeId: selectedEdgeId, locale: locale, activeNodeId: runHL?.activeNodeId ?? null, visitedNodeIds: runHL?.visitedNodeIds, traversedEdgeIds: runHL?.traversedEdgeIds, invalidNodeIds: invalidNodeIds, invalidEdges: invalidEdges, validationErrors: validationErrors, onSelect: (n) => n
|
|
80
108
|
? onSelectionChange?.({ kind: 'node', id: n.id, label: n.label || n.id })
|
|
81
109
|
: onSelectionChange?.(null), onSelectEdge: (e, key) => e
|
|
82
110
|
? onSelectionChange?.({ kind: 'edge', id: key, label: `${e.source} → ${e.target}` })
|