@antelopejs/dms-automation 0.0.1
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/LICENSE +190 -0
- package/README.md +95 -0
- package/dist/cluster/bus.d.ts +17 -0
- package/dist/cluster/bus.js +82 -0
- package/dist/cluster/bus.js.map +1 -0
- package/dist/cluster/instance.d.ts +1 -0
- package/dist/cluster/instance.js +6 -0
- package/dist/cluster/instance.js.map +1 -0
- package/dist/cluster/leader.d.ts +7 -0
- package/dist/cluster/leader.js +123 -0
- package/dist/cluster/leader.js.map +1 -0
- package/dist/cluster/mode.d.ts +3 -0
- package/dist/cluster/mode.js +25 -0
- package/dist/cluster/mode.js.map +1 -0
- package/dist/data/procedures-table.d.ts +66 -0
- package/dist/data/procedures-table.js +271 -0
- package/dist/data/procedures-table.js.map +1 -0
- package/dist/data/runs-table.d.ts +23 -0
- package/dist/data/runs-table.js +125 -0
- package/dist/data/runs-table.js.map +1 -0
- package/dist/db/index.d.ts +7 -0
- package/dist/db/index.js +24 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/models/automation_template.model.d.ts +58 -0
- package/dist/db/models/automation_template.model.js +134 -0
- package/dist/db/models/automation_template.model.js.map +1 -0
- package/dist/db/models/index.d.ts +3 -0
- package/dist/db/models/index.js +20 -0
- package/dist/db/models/index.js.map +1 -0
- package/dist/db/models/procedure.model.d.ts +25 -0
- package/dist/db/models/procedure.model.js +28 -0
- package/dist/db/models/procedure.model.js.map +1 -0
- package/dist/db/models/procedure_run.model.d.ts +34 -0
- package/dist/db/models/procedure_run.model.js +39 -0
- package/dist/db/models/procedure_run.model.js.map +1 -0
- package/dist/db/models/stats.model.d.ts +73 -0
- package/dist/db/models/stats.model.js +267 -0
- package/dist/db/models/stats.model.js.map +1 -0
- package/dist/db/models/type_config.model.d.ts +47 -0
- package/dist/db/models/type_config.model.js +51 -0
- package/dist/db/models/type_config.model.js.map +1 -0
- package/dist/db/tables/action_type_config.table.d.ts +7 -0
- package/dist/db/tables/action_type_config.table.js +35 -0
- package/dist/db/tables/action_type_config.table.js.map +1 -0
- package/dist/db/tables/automation_template.table.d.ts +11 -0
- package/dist/db/tables/automation_template.table.js +52 -0
- package/dist/db/tables/automation_template.table.js.map +1 -0
- package/dist/db/tables/index.d.ts +5 -0
- package/dist/db/tables/index.js +22 -0
- package/dist/db/tables/index.js.map +1 -0
- package/dist/db/tables/procedure.table.d.ts +10 -0
- package/dist/db/tables/procedure.table.js +48 -0
- package/dist/db/tables/procedure.table.js.map +1 -0
- package/dist/db/tables/procedure_run.table.d.ts +13 -0
- package/dist/db/tables/procedure_run.table.js +57 -0
- package/dist/db/tables/procedure_run.table.js.map +1 -0
- package/dist/db/tables/trigger_type_config.table.d.ts +7 -0
- package/dist/db/tables/trigger_type_config.table.js +35 -0
- package/dist/db/tables/trigger_type_config.table.js.map +1 -0
- package/dist/implementations/dms-automation/index.d.ts +19 -0
- package/dist/implementations/dms-automation/index.js +50 -0
- package/dist/implementations/dms-automation/index.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +121 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/builder.d.ts +10 -0
- package/dist/pages/builder.js +28 -0
- package/dist/pages/builder.js.map +1 -0
- package/dist/pages/category.d.ts +1 -0
- package/dist/pages/category.js +10 -0
- package/dist/pages/category.js.map +1 -0
- package/dist/pages/index.d.ts +6 -0
- package/dist/pages/index.js +9 -0
- package/dist/pages/index.js.map +1 -0
- package/dist/pages/library.d.ts +10 -0
- package/dist/pages/library.js +28 -0
- package/dist/pages/library.js.map +1 -0
- package/dist/pages/overview.d.ts +10 -0
- package/dist/pages/overview.js +28 -0
- package/dist/pages/overview.js.map +1 -0
- package/dist/pages/procedures.d.ts +10 -0
- package/dist/pages/procedures.js +72 -0
- package/dist/pages/procedures.js.map +1 -0
- package/dist/pages/runs.d.ts +11 -0
- package/dist/pages/runs.js +61 -0
- package/dist/pages/runs.js.map +1 -0
- package/dist/routes/action-types.d.ts +23 -0
- package/dist/routes/action-types.js +80 -0
- package/dist/routes/action-types.js.map +1 -0
- package/dist/routes/data-node-types.d.ts +6 -0
- package/dist/routes/data-node-types.js +36 -0
- package/dist/routes/data-node-types.js.map +1 -0
- package/dist/routes/graphSchema.d.ts +92 -0
- package/dist/routes/graphSchema.js +60 -0
- package/dist/routes/graphSchema.js.map +1 -0
- package/dist/routes/index.d.ts +8 -0
- package/dist/routes/index.js +11 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/routes/node-kinds.d.ts +9 -0
- package/dist/routes/node-kinds.js +35 -0
- package/dist/routes/node-kinds.js.map +1 -0
- package/dist/routes/procedures.d.ts +23 -0
- package/dist/routes/procedures.js +181 -0
- package/dist/routes/procedures.js.map +1 -0
- package/dist/routes/runs.d.ts +7 -0
- package/dist/routes/runs.js +81 -0
- package/dist/routes/runs.js.map +1 -0
- package/dist/routes/stats.d.ts +6 -0
- package/dist/routes/stats.js +35 -0
- package/dist/routes/stats.js.map +1 -0
- package/dist/routes/templates.d.ts +20 -0
- package/dist/routes/templates.js +243 -0
- package/dist/routes/templates.js.map +1 -0
- package/dist/routes/trigger-types.d.ts +24 -0
- package/dist/routes/trigger-types.js +80 -0
- package/dist/routes/trigger-types.js.map +1 -0
- package/dist/routes/typeConfigShared.d.ts +22 -0
- package/dist/routes/typeConfigShared.js +37 -0
- package/dist/routes/typeConfigShared.js.map +1 -0
- package/dist/runtime/builtins/actions/http.d.ts +13 -0
- package/dist/runtime/builtins/actions/http.js +47 -0
- package/dist/runtime/builtins/actions/http.js.map +1 -0
- package/dist/runtime/builtins/actions/index.d.ts +2 -0
- package/dist/runtime/builtins/actions/index.js +7 -0
- package/dist/runtime/builtins/actions/index.js.map +1 -0
- package/dist/runtime/builtins/actions/log.d.ts +13 -0
- package/dist/runtime/builtins/actions/log.js +49 -0
- package/dist/runtime/builtins/actions/log.js.map +1 -0
- package/dist/runtime/builtins/data/boolean.d.ts +2 -0
- package/dist/runtime/builtins/data/boolean.js +39 -0
- package/dist/runtime/builtins/data/boolean.js.map +1 -0
- package/dist/runtime/builtins/data/compare.d.ts +2 -0
- package/dist/runtime/builtins/data/compare.js +29 -0
- package/dist/runtime/builtins/data/compare.js.map +1 -0
- package/dist/runtime/builtins/data/constant.d.ts +2 -0
- package/dist/runtime/builtins/data/constant.js +29 -0
- package/dist/runtime/builtins/data/constant.js.map +1 -0
- package/dist/runtime/builtins/data/index.d.ts +2 -0
- package/dist/runtime/builtins/data/index.js +20 -0
- package/dist/runtime/builtins/data/index.js.map +1 -0
- package/dist/runtime/builtins/data/json.d.ts +2 -0
- package/dist/runtime/builtins/data/json.js +46 -0
- package/dist/runtime/builtins/data/json.js.map +1 -0
- package/dist/runtime/builtins/data/math.d.ts +2 -0
- package/dist/runtime/builtins/data/math.js +53 -0
- package/dist/runtime/builtins/data/math.js.map +1 -0
- package/dist/runtime/builtins/data/object.d.ts +2 -0
- package/dist/runtime/builtins/data/object.js +121 -0
- package/dist/runtime/builtins/data/object.js.map +1 -0
- package/dist/runtime/builtins/data/string.d.ts +2 -0
- package/dist/runtime/builtins/data/string.js +143 -0
- package/dist/runtime/builtins/data/string.js.map +1 -0
- package/dist/runtime/builtins/index.d.ts +17 -0
- package/dist/runtime/builtins/index.js +24 -0
- package/dist/runtime/builtins/index.js.map +1 -0
- package/dist/runtime/builtins/nodes/action.d.ts +1 -0
- package/dist/runtime/builtins/nodes/action.js +36 -0
- package/dist/runtime/builtins/nodes/action.js.map +1 -0
- package/dist/runtime/builtins/nodes/data.d.ts +1 -0
- package/dist/runtime/builtins/nodes/data.js +28 -0
- package/dist/runtime/builtins/nodes/data.js.map +1 -0
- package/dist/runtime/builtins/nodes/delay.d.ts +1 -0
- package/dist/runtime/builtins/nodes/delay.js +41 -0
- package/dist/runtime/builtins/nodes/delay.js.map +1 -0
- package/dist/runtime/builtins/nodes/forRange.d.ts +1 -0
- package/dist/runtime/builtins/nodes/forRange.js +52 -0
- package/dist/runtime/builtins/nodes/forRange.js.map +1 -0
- package/dist/runtime/builtins/nodes/foreach.d.ts +1 -0
- package/dist/runtime/builtins/nodes/foreach.js +31 -0
- package/dist/runtime/builtins/nodes/foreach.js.map +1 -0
- package/dist/runtime/builtins/nodes/groupInput.d.ts +1 -0
- package/dist/runtime/builtins/nodes/groupInput.js +11 -0
- package/dist/runtime/builtins/nodes/groupInput.js.map +1 -0
- package/dist/runtime/builtins/nodes/groupOutput.d.ts +1 -0
- package/dist/runtime/builtins/nodes/groupOutput.js +11 -0
- package/dist/runtime/builtins/nodes/groupOutput.js.map +1 -0
- package/dist/runtime/builtins/nodes/if.d.ts +1 -0
- package/dist/runtime/builtins/nodes/if.js +23 -0
- package/dist/runtime/builtins/nodes/if.js.map +1 -0
- package/dist/runtime/builtins/nodes/parallel.d.ts +1 -0
- package/dist/runtime/builtins/nodes/parallel.js +34 -0
- package/dist/runtime/builtins/nodes/parallel.js.map +1 -0
- package/dist/runtime/builtins/nodes/retry.d.ts +1 -0
- package/dist/runtime/builtins/nodes/retry.js +46 -0
- package/dist/runtime/builtins/nodes/retry.js.map +1 -0
- package/dist/runtime/builtins/nodes/setVariable.d.ts +1 -0
- package/dist/runtime/builtins/nodes/setVariable.js +31 -0
- package/dist/runtime/builtins/nodes/setVariable.js.map +1 -0
- package/dist/runtime/builtins/nodes/switch.d.ts +1 -0
- package/dist/runtime/builtins/nodes/switch.js +33 -0
- package/dist/runtime/builtins/nodes/switch.js.map +1 -0
- package/dist/runtime/builtins/nodes/trigger.d.ts +1 -0
- package/dist/runtime/builtins/nodes/trigger.js +25 -0
- package/dist/runtime/builtins/nodes/trigger.js.map +1 -0
- package/dist/runtime/builtins/nodes/tryCatch.d.ts +1 -0
- package/dist/runtime/builtins/nodes/tryCatch.js +35 -0
- package/dist/runtime/builtins/nodes/tryCatch.js.map +1 -0
- package/dist/runtime/builtins/triggers/cron.d.ts +9 -0
- package/dist/runtime/builtins/triggers/cron.js +52 -0
- package/dist/runtime/builtins/triggers/cron.js.map +1 -0
- package/dist/runtime/builtins/triggers/index.d.ts +2 -0
- package/dist/runtime/builtins/triggers/index.js +12 -0
- package/dist/runtime/builtins/triggers/index.js.map +1 -0
- package/dist/runtime/builtins/triggers/manual.d.ts +3 -0
- package/dist/runtime/builtins/triggers/manual.js +20 -0
- package/dist/runtime/builtins/triggers/manual.js.map +1 -0
- package/dist/runtime/builtins/triggers/webhook.d.ts +14 -0
- package/dist/runtime/builtins/triggers/webhook.js +90 -0
- package/dist/runtime/builtins/triggers/webhook.js.map +1 -0
- package/dist/runtime/executionResult.d.ts +6 -0
- package/dist/runtime/executionResult.js +3 -0
- package/dist/runtime/executionResult.js.map +1 -0
- package/dist/runtime/executor.d.ts +9 -0
- package/dist/runtime/executor.js +300 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/flatten.d.ts +3 -0
- package/dist/runtime/flatten.js +188 -0
- package/dist/runtime/flatten.js.map +1 -0
- package/dist/runtime/nodeKinds.d.ts +64 -0
- package/dist/runtime/nodeKinds.js +43 -0
- package/dist/runtime/nodeKinds.js.map +1 -0
- package/dist/runtime/reconcileDiff.d.ts +9 -0
- package/dist/runtime/reconcileDiff.js +26 -0
- package/dist/runtime/reconcileDiff.js.map +1 -0
- package/dist/runtime/registry.d.ts +19 -0
- package/dist/runtime/registry.js +47 -0
- package/dist/runtime/registry.js.map +1 -0
- package/dist/runtime/resolveInputs.d.ts +14 -0
- package/dist/runtime/resolveInputs.js +64 -0
- package/dist/runtime/resolveInputs.js.map +1 -0
- package/dist/runtime/resolveSubgraph.d.ts +7 -0
- package/dist/runtime/resolveSubgraph.js +15 -0
- package/dist/runtime/resolveSubgraph.js.map +1 -0
- package/dist/runtime/subscriptions.d.ts +16 -0
- package/dist/runtime/subscriptions.js +419 -0
- package/dist/runtime/subscriptions.js.map +1 -0
- package/dist/runtime/subwalk.d.ts +17 -0
- package/dist/runtime/subwalk.js +74 -0
- package/dist/runtime/subwalk.js.map +1 -0
- package/dist/runtime/topo.d.ts +3 -0
- package/dist/runtime/topo.js +75 -0
- package/dist/runtime/topo.js.map +1 -0
- package/dist/runtime/typeOrigin.d.ts +7 -0
- package/dist/runtime/typeOrigin.js +24 -0
- package/dist/runtime/typeOrigin.js.map +1 -0
- package/dist/runtime/validate.d.ts +10 -0
- package/dist/runtime/validate.js +185 -0
- package/dist/runtime/validate.js.map +1 -0
- package/dist/runtime/validateTemplate.d.ts +3 -0
- package/dist/runtime/validateTemplate.js +54 -0
- package/dist/runtime/validateTemplate.js.map +1 -0
- package/dist/types/constants.d.ts +6 -0
- package/dist/types/constants.js +10 -0
- package/dist/types/constants.js.map +1 -0
- package/dist/types/graph.d.ts +51 -0
- package/dist/types/graph.js +23 -0
- package/dist/types/graph.js.map +1 -0
- package/dist/types/pagination.d.ts +8 -0
- package/dist/types/pagination.js +3 -0
- package/dist/types/pagination.js.map +1 -0
- package/dist/types/runLog.d.ts +26 -0
- package/dist/types/runLog.js +3 -0
- package/dist/types/runLog.js.map +1 -0
- package/frontend-vue/.prettierignore +4 -0
- package/frontend-vue/app/components/Actions.vue +141 -0
- package/frontend-vue/app/components/Catalog/TypeConfigForm.vue +166 -0
- package/frontend-vue/app/components/Catalog/TypeList.vue +198 -0
- package/frontend-vue/app/components/DataNodes.vue +259 -0
- package/frontend-vue/app/components/Editor/BuilderPalette.vue +234 -0
- package/frontend-vue/app/components/Editor/GraphCanvas.vue +994 -0
- package/frontend-vue/app/components/Editor/GroupPortEditor.vue +289 -0
- package/frontend-vue/app/components/Editor/JsonField.vue +63 -0
- package/frontend-vue/app/components/Editor/NodeInspector.vue +356 -0
- package/frontend-vue/app/components/Editor/ProcedureList.vue +156 -0
- package/frontend-vue/app/components/Editor/RunsDrawer.vue +312 -0
- package/frontend-vue/app/components/Editor/WiredField.vue +42 -0
- package/frontend-vue/app/components/Editor/nodes/GenericNode.vue +161 -0
- package/frontend-vue/app/components/Editor/nodes/GroupInputNode.vue +19 -0
- package/frontend-vue/app/components/Editor/nodes/GroupNode.vue +200 -0
- package/frontend-vue/app/components/Editor/nodes/GroupOutputNode.vue +19 -0
- package/frontend-vue/app/components/Editor/nodes/GroupSentinel.vue +161 -0
- package/frontend-vue/app/components/Editor/nodes/styles.ts +53 -0
- package/frontend-vue/app/components/Editor.vue +2504 -0
- package/frontend-vue/app/components/Library.vue +67 -0
- package/frontend-vue/app/components/Overview.vue +503 -0
- package/frontend-vue/app/components/RunDetailDrawer.vue +165 -0
- package/frontend-vue/app/components/RunFireTree.vue +170 -0
- package/frontend-vue/app/components/RunTraceDrawer.vue +59 -0
- package/frontend-vue/app/components/RunsStats.vue +126 -0
- package/frontend-vue/app/components/RunsTimelineDisplay.vue +287 -0
- package/frontend-vue/app/components/Templates.vue +663 -0
- package/frontend-vue/app/components/Triggers.vue +137 -0
- package/frontend-vue/app/composables/useAutomationNodeKinds.ts +48 -0
- package/frontend-vue/app/composables/useAutomationRuns.ts +75 -0
- package/frontend-vue/app/composables/useGraphConnect.ts +542 -0
- package/frontend-vue/app/plugins/runs-timeline-display.client.ts +16 -0
- package/frontend-vue/app/utils/automation.ts +51 -0
- package/frontend-vue/dms.frontend.ts +26 -0
- package/frontend-vue/eslint.config.mjs +24 -0
- package/frontend-vue/i18n/locales/dms-automation-en-GB.json +369 -0
- package/frontend-vue/i18n/locales/dms-automation-fr-FR.json +369 -0
- package/frontend-vue/package.json +33 -0
- package/frontend-vue/pnpm-lock.yaml +6483 -0
- package/frontend-vue/pnpm-workspace.yaml +2 -0
- package/package.json +81 -0
|
@@ -0,0 +1,2504 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { useElementBounding } from '@vueuse/core'
|
|
4
|
+
import type { BreadcrumbItem } from '@nuxt/ui'
|
|
5
|
+
import type { NodeKindEntry } from '../composables/useAutomationNodeKinds'
|
|
6
|
+
import { type ListEnvelope, newId, unwrapList } from '../utils/automation'
|
|
7
|
+
import type { GroupPort } from '../composables/useGraphConnect'
|
|
8
|
+
|
|
9
|
+
// Editor mode.
|
|
10
|
+
//
|
|
11
|
+
// 'procedure' (default) — loads /api/automation/procedures,
|
|
12
|
+
// manages the selected procedure list, saves via PUT /procedures/:id, exposes
|
|
13
|
+
// the manual Run-now button.
|
|
14
|
+
// 'template' — loads ONE template via GET /api/automation/templates/:id and
|
|
15
|
+
// edits its `subgraph` directly. The template's `ports` are injected as the
|
|
16
|
+
// root canvas's `parentGroupPorts` so the groupInput/groupOutput sentinels at
|
|
17
|
+
// the root level render the template's outer-face handles. Save goes to
|
|
18
|
+
// PUT /api/automation/templates/:id. Run, procedure list, and enabled toggle
|
|
19
|
+
// are hidden.
|
|
20
|
+
//
|
|
21
|
+
// Mode is resolved from props first (so a host page can hard-wire it) and
|
|
22
|
+
// otherwise from the route query: `?template=<id>` switches into template mode.
|
|
23
|
+
// This keeps the route wiring trivial (the Templates catalog navigates
|
|
24
|
+
// to `/modules/automation/builder?template=<id>`) without needing a
|
|
25
|
+
// dedicated page.
|
|
26
|
+
const props = withDefaults(
|
|
27
|
+
defineProps<{
|
|
28
|
+
mode?: 'procedure' | 'template'
|
|
29
|
+
entityId?: string
|
|
30
|
+
}>(),
|
|
31
|
+
{ mode: undefined, entityId: undefined },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const route = useDmsRoute()
|
|
35
|
+
|
|
36
|
+
// Editor height.
|
|
37
|
+
//
|
|
38
|
+
// The graph canvas must fill the page instead of growing with its content,
|
|
39
|
+
// otherwise the whole page scrolls under a canvas that pans on its own. The
|
|
40
|
+
// dms layout drops page components into plain block containers (UContainer >
|
|
41
|
+
// dms-page-stack), so there is no flex parent whose height we could claim —
|
|
42
|
+
// the height has to come from the viewport.
|
|
43
|
+
//
|
|
44
|
+
// The offset to subtract is measured rather than hardcoded: everything above
|
|
45
|
+
// us (dashboard header, the native page header, the panel's responsive
|
|
46
|
+
// padding) belongs to the dms layer and shifts between its releases and
|
|
47
|
+
// across breakpoints. `100dvh` keeps the viewport half reactive in CSS, so
|
|
48
|
+
// only the offset needs re-measuring. Before the first measurement (SSR,
|
|
49
|
+
// pre-mount) no height is applied and the `min-h-96` floor takes over.
|
|
50
|
+
const editorRoot = ref<HTMLElement | null>(null)
|
|
51
|
+
const { top: editorRootTop } = useElementBounding(editorRoot)
|
|
52
|
+
// Breathing room below the editor, matching the layout panel's bottom padding.
|
|
53
|
+
const EDITOR_BOTTOM_GAP_PX = 24
|
|
54
|
+
const editorHeight = computed(() =>
|
|
55
|
+
editorRootTop.value > 0
|
|
56
|
+
? `calc(100dvh - ${Math.round(editorRootTop.value)}px - ${EDITOR_BOTTOM_GAP_PX}px)`
|
|
57
|
+
: undefined,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const templateQueryId = computed<string | null>(() => {
|
|
61
|
+
const q = route.query.template
|
|
62
|
+
if (typeof q === 'string' && q.length > 0) return q
|
|
63
|
+
if (Array.isArray(q) && typeof q[0] === 'string' && q[0]!.length > 0) return q[0]!
|
|
64
|
+
return null
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const editorMode = computed<'procedure' | 'template'>(() => {
|
|
68
|
+
if (props.mode) return props.mode
|
|
69
|
+
if (templateQueryId.value) return 'template'
|
|
70
|
+
return 'procedure'
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const editorTemplateId = computed<string | null>(() => {
|
|
74
|
+
if (editorMode.value !== 'template') return null
|
|
75
|
+
return props.entityId ?? templateQueryId.value
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
interface GraphNode {
|
|
79
|
+
id: string
|
|
80
|
+
// Node kind is a free-form string keyed off the backend kind registry
|
|
81
|
+
// (`/api/automation/node-kinds`); the few kinds with bespoke Vue
|
|
82
|
+
// components (group / groupInput / groupOutput) are compared as
|
|
83
|
+
// literals where needed.
|
|
84
|
+
kind: string
|
|
85
|
+
typeId?: string
|
|
86
|
+
config: Record<string, unknown>
|
|
87
|
+
position: { x: number; y: number }
|
|
88
|
+
// Group-only extras — present iff kind === 'group'.
|
|
89
|
+
ports?: GroupPort[]
|
|
90
|
+
templateId?: string
|
|
91
|
+
subgraph?: ProcedureGraph
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface TriggerEdge {
|
|
95
|
+
id: string
|
|
96
|
+
from: { node: string; branch?: string }
|
|
97
|
+
// `to.branch` carries the target's trigger-in port name when the target is
|
|
98
|
+
// a group node or groupOutput sentinel — see src/types/graph.ts.
|
|
99
|
+
to: { node: string; branch?: string }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface DataEdge {
|
|
103
|
+
id: string
|
|
104
|
+
from: { node: string; port: string }
|
|
105
|
+
to: { node: string; field: string }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface ProcedureGraph {
|
|
109
|
+
nodes: GraphNode[]
|
|
110
|
+
triggerEdges: TriggerEdge[]
|
|
111
|
+
dataEdges: DataEdge[]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ProcedureRecord {
|
|
115
|
+
_id: string
|
|
116
|
+
name: string
|
|
117
|
+
description: string
|
|
118
|
+
enabled: boolean
|
|
119
|
+
graph: ProcedureGraph
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ProcedureApiRecord {
|
|
123
|
+
_id: string
|
|
124
|
+
name: string
|
|
125
|
+
description?: string
|
|
126
|
+
enabled: boolean
|
|
127
|
+
graph: string | ProcedureGraph
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface JsonSchemaProperty {
|
|
131
|
+
type?: string
|
|
132
|
+
[key: string]: unknown
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface JsonSchema {
|
|
136
|
+
type?: string
|
|
137
|
+
properties?: Record<string, JsonSchemaProperty>
|
|
138
|
+
[key: string]: unknown
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface TriggerType {
|
|
142
|
+
id: string
|
|
143
|
+
name: string
|
|
144
|
+
description?: string
|
|
145
|
+
icon?: string
|
|
146
|
+
configSchema?: JsonSchema
|
|
147
|
+
outputSchema?: JsonSchema
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ActionType {
|
|
151
|
+
id: string
|
|
152
|
+
name: string
|
|
153
|
+
description?: string
|
|
154
|
+
icon?: string
|
|
155
|
+
inputSchema?: JsonSchema
|
|
156
|
+
outputSchema?: JsonSchema
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface DataNodeType {
|
|
160
|
+
id: string
|
|
161
|
+
category: string
|
|
162
|
+
name: string
|
|
163
|
+
description: string
|
|
164
|
+
icon: string
|
|
165
|
+
inputSchema: JsonSchema
|
|
166
|
+
outputSchema: JsonSchema
|
|
167
|
+
// Kept in sync with the canonical DataNodeType (useGraphConnect.ts).
|
|
168
|
+
configSchema?: JsonSchema
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { $authFetch } = useAuthFetch()
|
|
172
|
+
const toast = useToast()
|
|
173
|
+
|
|
174
|
+
const procedures = ref<ProcedureRecord[]>([])
|
|
175
|
+
const selectedId = ref<string | null>(null)
|
|
176
|
+
const showProcedures = ref(false)
|
|
177
|
+
const loading = ref(false)
|
|
178
|
+
const loadError = ref<string | null>(null)
|
|
179
|
+
const saving = ref(false)
|
|
180
|
+
const running = ref(false)
|
|
181
|
+
|
|
182
|
+
// Template-mode state. `templatePorts` holds the OUTER-face ports of the
|
|
183
|
+
// edited template — they're not part of the editable subgraph but they ARE
|
|
184
|
+
// the source of truth for the root-level groupInput/groupOutput sentinels'
|
|
185
|
+
// handle sets. We pipe them through `currentParentGroupPorts` so GraphCanvas
|
|
186
|
+
// renders them identically to how it renders a nested group's sentinels.
|
|
187
|
+
const templatePorts = ref<GroupPort[]>([])
|
|
188
|
+
const templateLoadKey = ref<string | null>(null)
|
|
189
|
+
|
|
190
|
+
const triggerTypes = ref<TriggerType[]>([])
|
|
191
|
+
const actionTypes = ref<ActionType[]>([])
|
|
192
|
+
const dataNodeTypes = ref<DataNodeType[]>([])
|
|
193
|
+
// Per-NodeKind UI metadata served by the backend
|
|
194
|
+
// (`/api/automation/node-kinds`). Drives the editor's GenericNode renderer
|
|
195
|
+
// so the frontend doesn't need to know about specific kinds.
|
|
196
|
+
const nodeKinds = ref<NodeKindEntry[]>([])
|
|
197
|
+
|
|
198
|
+
// Workspace-global templates surfaced in the canvas's node palette.
|
|
199
|
+
// Fetched once on mount alongside the type catalogs. Empty list → palette
|
|
200
|
+
// section hidden (handled inside BuilderPalette).
|
|
201
|
+
//
|
|
202
|
+
// Only the fields the palette + template-instance creation need are kept on
|
|
203
|
+
// the client (id, name, icon, ports). The list endpoint still returns full
|
|
204
|
+
// rows (description, usageCount, hydrated subgraph); the `.map()` below
|
|
205
|
+
// simply discards the extra fields after the fetch.
|
|
206
|
+
interface TemplatePaletteRow {
|
|
207
|
+
_id: string
|
|
208
|
+
name: string
|
|
209
|
+
icon?: string
|
|
210
|
+
ports: GroupPort[]
|
|
211
|
+
}
|
|
212
|
+
interface TemplateListResponse {
|
|
213
|
+
results?: TemplatePaletteRow[]
|
|
214
|
+
items?: TemplatePaletteRow[]
|
|
215
|
+
}
|
|
216
|
+
const templates = ref<TemplatePaletteRow[]>([])
|
|
217
|
+
const templatesLoadError = ref<string | null>(null)
|
|
218
|
+
|
|
219
|
+
async function loadTemplates() {
|
|
220
|
+
try {
|
|
221
|
+
const data = await $authFetch<
|
|
222
|
+
TemplatePaletteRow[] | TemplateListResponse
|
|
223
|
+
>('/api/automation/templates')
|
|
224
|
+
const list = unwrapList<TemplatePaletteRow>(data)
|
|
225
|
+
templates.value = list.map((t) => ({
|
|
226
|
+
_id: t._id,
|
|
227
|
+
name: t.name,
|
|
228
|
+
icon: t.icon,
|
|
229
|
+
ports: Array.isArray(t.ports) ? t.ports : [],
|
|
230
|
+
}))
|
|
231
|
+
templatesLoadError.value = null
|
|
232
|
+
} catch (e) {
|
|
233
|
+
templatesLoadError.value = (e as Error).message ?? 'Failed to load templates'
|
|
234
|
+
templates.value = []
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Hide the palette section while editing a template (self-edit). The
|
|
239
|
+
// server's cycle check would still catch any reference cycle, but blocking
|
|
240
|
+
// it at the UI level keeps the UX cleaner — the user can't even attempt
|
|
241
|
+
// to drop a template (including the one currently open) into itself.
|
|
242
|
+
const palettesTemplates = computed<TemplatePaletteRow[] | undefined>(() =>
|
|
243
|
+
editorMode.value === 'template' ? undefined : templates.value,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const selectedNodeId = ref<string | null>(null)
|
|
247
|
+
// Mirrors GraphCanvas's @selection-change — full set of currently selected
|
|
248
|
+
// node ids in the canvas, sentinel-filtered. Drives the "Group selection"
|
|
249
|
+
// toolbar button (see canGroupSelection / createGroupFromSelection).
|
|
250
|
+
const selectedNodeIds = ref<string[]>([])
|
|
251
|
+
|
|
252
|
+
function onSelectionChange(payload: { ids: string[] }) {
|
|
253
|
+
selectedNodeIds.value = payload.ids
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Subgraph navigation stack. The first entry is always the procedure root;
|
|
257
|
+
// each subsequent entry is a group / template segment the user has entered.
|
|
258
|
+
// Cleared and re-seeded whenever the selected procedure changes (see the
|
|
259
|
+
// watch on selectedId below).
|
|
260
|
+
//
|
|
261
|
+
// kind === 'procedure' — id is the procedure _id, name is its display name.
|
|
262
|
+
// kind === 'group' — id is the group node's id within its parent graph.
|
|
263
|
+
// kind === 'template' — reserved for the dedicated template editor view.
|
|
264
|
+
// Navigation into local groups pushes as 'group';
|
|
265
|
+
// template instances likewise push as 'group' with
|
|
266
|
+
// the read-only signal taken from the node having a
|
|
267
|
+
// templateId, not from the path kind.
|
|
268
|
+
interface SubgraphPathSegment {
|
|
269
|
+
kind: 'procedure' | 'group' | 'template'
|
|
270
|
+
id: string
|
|
271
|
+
name: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const subgraphPath = ref<SubgraphPathSegment[]>([])
|
|
275
|
+
|
|
276
|
+
interface ProcedureSnapshot {
|
|
277
|
+
name: string
|
|
278
|
+
enabled: boolean
|
|
279
|
+
graph: ProcedureGraph
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const graphHistory = ref<ProcedureGraph[]>([])
|
|
283
|
+
const graphHistoryIndex = ref<number>(-1)
|
|
284
|
+
const savedSnapshot = ref<ProcedureSnapshot | null>(null)
|
|
285
|
+
const HISTORY_LIMIT = 100
|
|
286
|
+
|
|
287
|
+
function cloneGraph(g: ProcedureGraph): ProcedureGraph {
|
|
288
|
+
return JSON.parse(JSON.stringify(g))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function takeSnapshot(p: ProcedureRecord): ProcedureSnapshot {
|
|
292
|
+
return { name: p.name, enabled: p.enabled, graph: cloneGraph(p.graph) }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Strip per-node position so pure drag-to-reposition doesn't add an undo
|
|
296
|
+
// step on every pixel. The drag's final position still gets saved by Apply
|
|
297
|
+
// and reverted by Cancel because savedSnapshot keeps the full graph.
|
|
298
|
+
function structuralKey(g: ProcedureGraph): string {
|
|
299
|
+
return JSON.stringify({
|
|
300
|
+
nodes: g.nodes.map(({ position: _pos, ...rest }) => rest),
|
|
301
|
+
triggerEdges: g.triggerEdges,
|
|
302
|
+
dataEdges: g.dataEdges,
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function pushHistory(g: ProcedureGraph) {
|
|
307
|
+
const current = graphHistory.value[graphHistoryIndex.value]
|
|
308
|
+
if (current && structuralKey(current) === structuralKey(g)) return
|
|
309
|
+
graphHistory.value = graphHistory.value.slice(0, graphHistoryIndex.value + 1)
|
|
310
|
+
graphHistory.value.push(cloneGraph(g))
|
|
311
|
+
graphHistoryIndex.value = graphHistory.value.length - 1
|
|
312
|
+
if (graphHistory.value.length > HISTORY_LIMIT) {
|
|
313
|
+
graphHistory.value.shift()
|
|
314
|
+
graphHistoryIndex.value--
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function initHistoryAndSaved() {
|
|
319
|
+
if (selected.value) {
|
|
320
|
+
graphHistory.value = [cloneGraph(selected.value.graph)]
|
|
321
|
+
graphHistoryIndex.value = 0
|
|
322
|
+
savedSnapshot.value = takeSnapshot(selected.value)
|
|
323
|
+
} else {
|
|
324
|
+
graphHistory.value = []
|
|
325
|
+
graphHistoryIndex.value = -1
|
|
326
|
+
savedSnapshot.value = null
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function emptyGraph(): ProcedureGraph {
|
|
331
|
+
return {
|
|
332
|
+
nodes: [
|
|
333
|
+
{
|
|
334
|
+
id: 'trigger-1',
|
|
335
|
+
kind: 'trigger',
|
|
336
|
+
typeId: 'manual',
|
|
337
|
+
config: {},
|
|
338
|
+
position: { x: 100, y: 100 },
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
triggerEdges: [],
|
|
342
|
+
dataEdges: [],
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseGraph(g: string | ProcedureGraph | undefined | null): ProcedureGraph {
|
|
347
|
+
if (!g) return emptyGraph()
|
|
348
|
+
if (typeof g === 'string') {
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(g) as ProcedureGraph
|
|
351
|
+
return parsed && Array.isArray(parsed.nodes) ? parsed : emptyGraph()
|
|
352
|
+
} catch {
|
|
353
|
+
return emptyGraph()
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return g
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalize(r: ProcedureApiRecord): ProcedureRecord {
|
|
360
|
+
return {
|
|
361
|
+
_id: r._id,
|
|
362
|
+
name: r.name,
|
|
363
|
+
description: r.description ?? '',
|
|
364
|
+
enabled: !!r.enabled,
|
|
365
|
+
graph: parseGraph(r.graph),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function loadList() {
|
|
370
|
+
loading.value = true
|
|
371
|
+
loadError.value = null
|
|
372
|
+
try {
|
|
373
|
+
const data = await $authFetch<
|
|
374
|
+
ProcedureApiRecord[] | ListEnvelope<ProcedureApiRecord>
|
|
375
|
+
>('/api/automation/procedures')
|
|
376
|
+
const list = unwrapList<ProcedureApiRecord>(data)
|
|
377
|
+
procedures.value = list.map(normalize)
|
|
378
|
+
if (
|
|
379
|
+
selectedId.value &&
|
|
380
|
+
!procedures.value.some((p) => p._id === selectedId.value)
|
|
381
|
+
) {
|
|
382
|
+
selectedId.value = null
|
|
383
|
+
}
|
|
384
|
+
if (!selectedId.value && procedures.value.length > 0) {
|
|
385
|
+
const first = procedures.value[0]
|
|
386
|
+
selectedId.value = first ? first._id : null
|
|
387
|
+
}
|
|
388
|
+
} catch (e) {
|
|
389
|
+
loadError.value = (e as Error).message ?? 'Failed to load procedures'
|
|
390
|
+
} finally {
|
|
391
|
+
loading.value = false
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function loadTypeCatalogs() {
|
|
396
|
+
try {
|
|
397
|
+
triggerTypes.value = unwrapList<TriggerType>(
|
|
398
|
+
await $authFetch<TriggerType[] | ListEnvelope<TriggerType>>(
|
|
399
|
+
'/api/automation/trigger-types',
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
actionTypes.value = unwrapList<ActionType>(
|
|
403
|
+
await $authFetch<ActionType[] | ListEnvelope<ActionType>>(
|
|
404
|
+
'/api/automation/action-types',
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
dataNodeTypes.value = unwrapList<DataNodeType>(
|
|
408
|
+
await $authFetch<DataNodeType[] | ListEnvelope<DataNodeType>>(
|
|
409
|
+
'/api/automation/data-node-types',
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
nodeKinds.value = await useAutomationNodeKinds().list()
|
|
413
|
+
} catch {
|
|
414
|
+
// Catalogs are optional for editing — silently ignore.
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
interface AutomationTemplateApi {
|
|
419
|
+
_id: string
|
|
420
|
+
name: string
|
|
421
|
+
description?: string
|
|
422
|
+
icon?: string
|
|
423
|
+
ports?: GroupPort[]
|
|
424
|
+
subgraph?: string | ProcedureGraph
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function loadTemplate(id: string) {
|
|
428
|
+
loading.value = true
|
|
429
|
+
loadError.value = null
|
|
430
|
+
try {
|
|
431
|
+
const tpl = await $authFetch<AutomationTemplateApi>(
|
|
432
|
+
`/api/automation/templates/${id}`,
|
|
433
|
+
)
|
|
434
|
+
const subgraph = parseGraph(tpl.subgraph)
|
|
435
|
+
// Adapt the template into the ProcedureRecord shape so the rest of
|
|
436
|
+
// the editor (history, dirty-tracking, sentinel sync, group toolbar)
|
|
437
|
+
// keeps working unchanged. `enabled` is meaningless for templates;
|
|
438
|
+
// pin it to true so the "disabled" warning banner never appears.
|
|
439
|
+
const adapted: ProcedureRecord = {
|
|
440
|
+
_id: tpl._id,
|
|
441
|
+
name: tpl.name,
|
|
442
|
+
description: tpl.description ?? '',
|
|
443
|
+
enabled: true,
|
|
444
|
+
graph: subgraph,
|
|
445
|
+
}
|
|
446
|
+
procedures.value = [adapted]
|
|
447
|
+
selectedId.value = adapted._id
|
|
448
|
+
templatePorts.value = Array.isArray(tpl.ports)
|
|
449
|
+
? tpl.ports.map((p) => ({ ...p }))
|
|
450
|
+
: []
|
|
451
|
+
templateLoadKey.value = id
|
|
452
|
+
} catch (e) {
|
|
453
|
+
loadError.value = (e as Error).message ?? 'Failed to load template'
|
|
454
|
+
procedures.value = []
|
|
455
|
+
selectedId.value = null
|
|
456
|
+
templatePorts.value = []
|
|
457
|
+
} finally {
|
|
458
|
+
loading.value = false
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (editorMode.value === 'template') {
|
|
463
|
+
const id = editorTemplateId.value
|
|
464
|
+
if (id) {
|
|
465
|
+
await loadTemplate(id)
|
|
466
|
+
} else {
|
|
467
|
+
loadError.value = 'No template id provided'
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
await loadList()
|
|
471
|
+
}
|
|
472
|
+
await loadTypeCatalogs()
|
|
473
|
+
await loadTemplates()
|
|
474
|
+
|
|
475
|
+
const selected = computed<ProcedureRecord | null>(
|
|
476
|
+
() => procedures.value.find((p) => p._id === selectedId.value) ?? null,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
// `watch(selectedId, …)` (declared below) doesn't fire for the value
|
|
480
|
+
// assigned during the awaits above, so seed history + savedSnapshot once
|
|
481
|
+
// for the auto-selected procedure. Must come after `selected` is declared
|
|
482
|
+
// — initHistoryAndSaved() reads selected.value.
|
|
483
|
+
initHistoryAndSaved()
|
|
484
|
+
seedSubgraphPath()
|
|
485
|
+
|
|
486
|
+
function seedSubgraphPath() {
|
|
487
|
+
const p = selected.value
|
|
488
|
+
if (!p) {
|
|
489
|
+
subgraphPath.value = []
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
// In template mode the root segment is the template itself (we reuse the
|
|
493
|
+
// 'template' kind already declared on SubgraphPathSegment). The breadcrumb
|
|
494
|
+
// renders it with the i-ph-link icon, which is exactly what we want for
|
|
495
|
+
// "Templates ▸ <name>".
|
|
496
|
+
if (editorMode.value === 'template') {
|
|
497
|
+
subgraphPath.value = [
|
|
498
|
+
{ kind: 'template', id: p._id, name: p.name || 'Template' },
|
|
499
|
+
]
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
subgraphPath.value = [
|
|
503
|
+
{ kind: 'procedure', id: p._id, name: p.name || 'Procedure' },
|
|
504
|
+
]
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Walk the procedure graph following subgraphPath. Returns the full chain
|
|
508
|
+
// of (segment, containerGraph, groupNode|null) so currentGraph and
|
|
509
|
+
// currentParentGroupPorts can be derived from one traversal without
|
|
510
|
+
// re-walking.
|
|
511
|
+
//
|
|
512
|
+
// If a path segment can't be resolved (e.g. the group node was deleted in
|
|
513
|
+
// the outer graph), traversal stops at the last valid level — the caller
|
|
514
|
+
// is expected to also watch the stale path and trim it (see the watch on
|
|
515
|
+
// `selected.graph`).
|
|
516
|
+
interface SubgraphFrame {
|
|
517
|
+
segment: SubgraphPathSegment
|
|
518
|
+
graph: ProcedureGraph
|
|
519
|
+
// The group node that owns `graph` (i.e. graph === groupNode.subgraph).
|
|
520
|
+
// Null for the procedure root.
|
|
521
|
+
groupNode: GraphNode | null
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const subgraphFrames = computed<SubgraphFrame[]>(() => {
|
|
525
|
+
const p = selected.value
|
|
526
|
+
if (!p) return []
|
|
527
|
+
const path = subgraphPath.value
|
|
528
|
+
if (path.length === 0) return []
|
|
529
|
+
const frames: SubgraphFrame[] = [
|
|
530
|
+
{ segment: path[0]!, graph: p.graph, groupNode: null },
|
|
531
|
+
]
|
|
532
|
+
for (let i = 1; i < path.length; i++) {
|
|
533
|
+
const seg = path[i]!
|
|
534
|
+
const parent = frames[i - 1]!
|
|
535
|
+
const node = parent.graph.nodes.find(
|
|
536
|
+
(n) => n.id === seg.id && n.kind === 'group',
|
|
537
|
+
)
|
|
538
|
+
if (!node) break
|
|
539
|
+
// Local group → inline subgraph. Template instance → no inline
|
|
540
|
+
// subgraph on the client, so we can't navigate in. The
|
|
541
|
+
// double-click handler guards against pushing a template-instance
|
|
542
|
+
// segment in the first place; this is a safety net.
|
|
543
|
+
const sub = node.subgraph
|
|
544
|
+
if (!sub) break
|
|
545
|
+
frames.push({ segment: seg, graph: sub, groupNode: node })
|
|
546
|
+
}
|
|
547
|
+
return frames
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const currentGraph = computed<ProcedureGraph | null>(() => {
|
|
551
|
+
const frames = subgraphFrames.value
|
|
552
|
+
if (frames.length === 0) return null
|
|
553
|
+
return frames[frames.length - 1]!.graph
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// The parent group node whose subgraph we're currently viewing — null at
|
|
557
|
+
// the procedure root.
|
|
558
|
+
const currentParentGroup = computed<GraphNode | null>(() => {
|
|
559
|
+
const frames = subgraphFrames.value
|
|
560
|
+
if (frames.length <= 1) return null
|
|
561
|
+
return frames[frames.length - 1]!.groupNode
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const currentParentGroupPorts = computed<GroupPort[] | undefined>(() => {
|
|
565
|
+
const g = currentParentGroup.value
|
|
566
|
+
if (g) return Array.isArray(g.ports) ? g.ports : []
|
|
567
|
+
// Root level in template mode: the template's own `ports` are the
|
|
568
|
+
// outer-face of the edited subgraph. Inject them so the root-level
|
|
569
|
+
// groupInput / groupOutput sentinels render the right handles.
|
|
570
|
+
if (editorMode.value === 'template') return templatePorts.value
|
|
571
|
+
return undefined
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// Read-only mode kicks in when viewing the inner subgraph of a template
|
|
575
|
+
// instance (a group node carrying `templateId`). The fork action lets
|
|
576
|
+
// the user promote it to a local group; until then we just show a
|
|
577
|
+
// banner and disable mutations.
|
|
578
|
+
const isCurrentGraphReadOnly = computed<boolean>(() => {
|
|
579
|
+
const g = currentParentGroup.value
|
|
580
|
+
if (!g) return false
|
|
581
|
+
return Boolean(g.templateId)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
const breadcrumbItems = computed<BreadcrumbItem[]>(() =>
|
|
585
|
+
subgraphPath.value.map((seg, idx) => ({
|
|
586
|
+
label: seg.name,
|
|
587
|
+
icon:
|
|
588
|
+
seg.kind === 'procedure'
|
|
589
|
+
? 'i-ph-flow-arrow'
|
|
590
|
+
: seg.kind === 'template'
|
|
591
|
+
? 'i-ph-link'
|
|
592
|
+
: 'i-ph-package',
|
|
593
|
+
// onSelect fires for clicks; popping the path to `idx + 1` keeps
|
|
594
|
+
// the clicked segment and discards everything deeper.
|
|
595
|
+
onSelect: (ev: Event) => {
|
|
596
|
+
ev.preventDefault()
|
|
597
|
+
if (idx < subgraphPath.value.length - 1) {
|
|
598
|
+
subgraphPath.value = subgraphPath.value.slice(0, idx + 1)
|
|
599
|
+
// Clear node selection — the old selected node id may not
|
|
600
|
+
// exist in the new current graph.
|
|
601
|
+
selectedNodeId.value = null
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
})),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
// If the user deletes a group node from an outer graph while their path
|
|
608
|
+
// points into its subgraph, prune the path back to the deepest valid
|
|
609
|
+
// frame. Cheap because subgraphFrames already does the traversal.
|
|
610
|
+
watch(
|
|
611
|
+
() => subgraphFrames.value.length,
|
|
612
|
+
(framesLen) => {
|
|
613
|
+
if (framesLen > 0 && framesLen < subgraphPath.value.length) {
|
|
614
|
+
subgraphPath.value = subgraphPath.value.slice(0, framesLen)
|
|
615
|
+
selectedNodeId.value = null
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
// Keep the procedure-root breadcrumb label in sync with renames.
|
|
621
|
+
watch(
|
|
622
|
+
() => selected.value?.name,
|
|
623
|
+
(name) => {
|
|
624
|
+
const first = subgraphPath.value[0]
|
|
625
|
+
if (first && first.kind === 'procedure' && name) first.name = name
|
|
626
|
+
},
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
function exitSubgraph() {
|
|
630
|
+
subgraphPath.value = subgraphPath.value.slice(0, -1)
|
|
631
|
+
selectedNodeId.value = null
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function onGraphUpdate(g: ProcedureGraph) {
|
|
635
|
+
if (!selected.value) return
|
|
636
|
+
const path = subgraphPath.value
|
|
637
|
+
if (path.length <= 1) {
|
|
638
|
+
// At procedure root — write straight through, same as before
|
|
639
|
+
// subgraph navigation existed.
|
|
640
|
+
selected.value.graph = g
|
|
641
|
+
pushHistory(g)
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
// At a deeper level — `g` is the subgraph of the deepest group node.
|
|
645
|
+
// Walk the path from the outer procedure graph down, cloning the
|
|
646
|
+
// chain so Vue's reactivity sees a fresh top-level graph (history
|
|
647
|
+
// keys structuralKey() off node identity at every level).
|
|
648
|
+
const rootGraph = cloneGraph(selected.value.graph)
|
|
649
|
+
let containerNodes = rootGraph.nodes
|
|
650
|
+
for (let i = 1; i < path.length; i++) {
|
|
651
|
+
const seg = path[i]!
|
|
652
|
+
const idx = containerNodes.findIndex(
|
|
653
|
+
(n) => n.id === seg.id && n.kind === 'group',
|
|
654
|
+
)
|
|
655
|
+
if (idx === -1) {
|
|
656
|
+
// Path was stale — bail out without mutating; the watch on
|
|
657
|
+
// subgraphFrames.length will prune the path.
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
// Replace the node at idx with a shallow clone so we can mutate
|
|
661
|
+
// its `subgraph` without aliasing back into the original (the
|
|
662
|
+
// outer graph already came from cloneGraph above, so its node
|
|
663
|
+
// objects are fresh too — but the inner subgraph reference
|
|
664
|
+
// chain needs explicit substitution).
|
|
665
|
+
const cloned: GraphNode = { ...containerNodes[idx]! }
|
|
666
|
+
containerNodes[idx] = cloned
|
|
667
|
+
if (i === path.length - 1) {
|
|
668
|
+
cloned.subgraph = g
|
|
669
|
+
} else {
|
|
670
|
+
// Mid-chain — keep walking, ensuring each intermediate
|
|
671
|
+
// subgraph is also a fresh object for the next iteration's
|
|
672
|
+
// nodes array.
|
|
673
|
+
if (!cloned.subgraph) return
|
|
674
|
+
cloned.subgraph = {
|
|
675
|
+
...cloned.subgraph,
|
|
676
|
+
nodes: [...cloned.subgraph.nodes],
|
|
677
|
+
}
|
|
678
|
+
containerNodes = cloned.subgraph.nodes
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
selected.value.graph = rootGraph
|
|
682
|
+
pushHistory(rootGraph)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const canUndo = computed<boolean>(
|
|
686
|
+
() => graphHistoryIndex.value > 0,
|
|
687
|
+
)
|
|
688
|
+
const canRedo = computed<boolean>(
|
|
689
|
+
() => graphHistoryIndex.value < graphHistory.value.length - 1,
|
|
690
|
+
)
|
|
691
|
+
const isDirty = computed<boolean>(() => {
|
|
692
|
+
if (!selected.value || !savedSnapshot.value) return false
|
|
693
|
+
return (
|
|
694
|
+
JSON.stringify(takeSnapshot(selected.value)) !==
|
|
695
|
+
JSON.stringify(savedSnapshot.value)
|
|
696
|
+
)
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
function onUndo() {
|
|
700
|
+
if (!canUndo.value || !selected.value) return
|
|
701
|
+
graphHistoryIndex.value--
|
|
702
|
+
const g = graphHistory.value[graphHistoryIndex.value]
|
|
703
|
+
if (g) selected.value.graph = cloneGraph(g)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function onRedo() {
|
|
707
|
+
if (!canRedo.value || !selected.value) return
|
|
708
|
+
graphHistoryIndex.value++
|
|
709
|
+
const g = graphHistory.value[graphHistoryIndex.value]
|
|
710
|
+
if (g) selected.value.graph = cloneGraph(g)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function onCancel() {
|
|
714
|
+
if (!selected.value || !savedSnapshot.value) return
|
|
715
|
+
const snap = savedSnapshot.value
|
|
716
|
+
selected.value.name = snap.name
|
|
717
|
+
selected.value.enabled = snap.enabled
|
|
718
|
+
selected.value.graph = cloneGraph(snap.graph)
|
|
719
|
+
pushHistory(selected.value.graph)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const selectedNode = computed<GraphNode | null>(() => {
|
|
723
|
+
const id = selectedNodeId.value
|
|
724
|
+
if (!id) return null
|
|
725
|
+
// Lookup is scoped to whichever graph the canvas is currently
|
|
726
|
+
// bound to, not always the procedure root — otherwise selecting a
|
|
727
|
+
// node inside a subgraph would resolve to null.
|
|
728
|
+
const g = currentGraph.value
|
|
729
|
+
if (!g) return null
|
|
730
|
+
return g.nodes.find((n) => n.id === id) ?? null
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
const selectedNodeTriggerType = computed<TriggerType | null>(() => {
|
|
734
|
+
const n = selectedNode.value
|
|
735
|
+
if (!n || n.kind !== 'trigger') return null
|
|
736
|
+
return triggerTypes.value.find((t) => t.id === n.typeId) ?? null
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
const selectedNodeActionType = computed<ActionType | null>(() => {
|
|
740
|
+
const n = selectedNode.value
|
|
741
|
+
if (!n || n.kind !== 'action') return null
|
|
742
|
+
return actionTypes.value.find((a) => a.id === n.typeId) ?? null
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
function onSelectNode(id: string | null) {
|
|
746
|
+
selectedNodeId.value = id
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// The node palette lives in the left BuilderPalette sidebar; its add events are
|
|
750
|
+
// forwarded to the canvas' exposed helpers via this template ref.
|
|
751
|
+
const graphCanvasRef = ref<{
|
|
752
|
+
addNode: (kind: string, typeId?: string) => void
|
|
753
|
+
addTemplateInstance: (template: { _id: string; name: string; icon?: string; ports: GroupPort[] }) => void
|
|
754
|
+
} | null>(null)
|
|
755
|
+
|
|
756
|
+
// Runs panel (toolbar button + right slideover). Kept so onRun can refresh the
|
|
757
|
+
// list and its trigger badge once a run has been queued.
|
|
758
|
+
const runsDrawerRef = ref<{ reload: () => void } | null>(null)
|
|
759
|
+
|
|
760
|
+
function onPaletteAddNode(payload: { kind: string; typeId?: string }) {
|
|
761
|
+
graphCanvasRef.value?.addNode(payload.kind, payload.typeId)
|
|
762
|
+
}
|
|
763
|
+
function onPaletteAddTemplate(template: { _id: string; name: string; icon?: string; ports: GroupPort[] }) {
|
|
764
|
+
graphCanvasRef.value?.addTemplateInstance(template)
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function onProcedurePicked(id: string | null) {
|
|
768
|
+
selectedId.value = id
|
|
769
|
+
if (id) showProcedures.value = false
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
watch(selectedId, () => {
|
|
773
|
+
selectedNodeId.value = null
|
|
774
|
+
initHistoryAndSaved()
|
|
775
|
+
seedSubgraphPath()
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
// Re-fetch when the route's `?template=<id>` changes between templates while
|
|
779
|
+
// the editor is mounted. Editor is mounted once per route navigation so this
|
|
780
|
+
// is mostly a guard for in-place query updates (e.g. catalog "Edit" buttons
|
|
781
|
+
// using router.push).
|
|
782
|
+
watch(templateQueryId, async (id) => {
|
|
783
|
+
if (editorMode.value !== 'template') return
|
|
784
|
+
if (!id || id === templateLoadKey.value) return
|
|
785
|
+
await loadTemplate(id)
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
function onGroupEnter(payload: { id: string }) {
|
|
789
|
+
const g = currentGraph.value
|
|
790
|
+
if (!g) return
|
|
791
|
+
const node = g.nodes.find((n) => n.id === payload.id && n.kind === 'group')
|
|
792
|
+
if (!node) return
|
|
793
|
+
// Template instances have no inline subgraph — the frame walker would
|
|
794
|
+
// stop at this segment and the watcher would prune it back. Bail early so
|
|
795
|
+
// the path doesn't briefly include a doomed segment.
|
|
796
|
+
if (node.templateId && !node.subgraph) return
|
|
797
|
+
const name =
|
|
798
|
+
(node.config?.name as string | undefined) ?? 'Group'
|
|
799
|
+
subgraphPath.value = [
|
|
800
|
+
...subgraphPath.value,
|
|
801
|
+
{ kind: 'group', id: node.id, name },
|
|
802
|
+
]
|
|
803
|
+
selectedNodeId.value = null
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// "Enter group" affordance — true iff the currently selected node is a group
|
|
807
|
+
// (local, or a template instance with a resolved subgraph). Read-only state
|
|
808
|
+
// is not consulted: entering a group from a read-only view is allowed.
|
|
809
|
+
// Drives the toolbar button, the inspector button, and the Tab keybinding.
|
|
810
|
+
const canEnterGroup = computed<boolean>(() => {
|
|
811
|
+
const n = selectedNode.value
|
|
812
|
+
if (!n || n.kind !== 'group') return false
|
|
813
|
+
// Template instances need a resolved subgraph to navigate into; without
|
|
814
|
+
// one we can't render the inner view.
|
|
815
|
+
if (n.templateId && !n.subgraph) return false
|
|
816
|
+
return true
|
|
817
|
+
})
|
|
818
|
+
|
|
819
|
+
function enterSelectedGroup() {
|
|
820
|
+
const n = selectedNode.value
|
|
821
|
+
if (!n || n.kind !== 'group') return
|
|
822
|
+
onGroupEnter({ id: n.id })
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function onUpdateNode(updated: GraphNode) {
|
|
826
|
+
const g = currentGraph.value
|
|
827
|
+
if (!g) return
|
|
828
|
+
const nextGraph: ProcedureGraph = {
|
|
829
|
+
...g,
|
|
830
|
+
nodes: g.nodes.map((n) => (n.id === updated.id ? updated : n)),
|
|
831
|
+
}
|
|
832
|
+
onGraphUpdate(nextGraph)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Detect port renames between two port arrays of the same group node.
|
|
836
|
+
//
|
|
837
|
+
// Two-pass matching, scoped per (kind, direction):
|
|
838
|
+
// 1. Ports present in BOTH arrays under the same (kind, direction, name) are
|
|
839
|
+
// considered identical — no rename.
|
|
840
|
+
// 2. Of the remaining old/new ports, pair them positionally within each
|
|
841
|
+
// (kind, direction) bucket. Each pair is a rename (old.name → new.name).
|
|
842
|
+
// Unpaired old-only ports are true deletions; unpaired new-only ports are
|
|
843
|
+
// true additions.
|
|
844
|
+
//
|
|
845
|
+
// This is heuristic but the GroupPortEditor emits one mutation per UX action,
|
|
846
|
+
// so each ports:update typically carries a single delta and the positional
|
|
847
|
+
// pairing is unambiguous.
|
|
848
|
+
interface PortRename {
|
|
849
|
+
kind: 'data' | 'trigger'
|
|
850
|
+
direction: 'in' | 'out'
|
|
851
|
+
oldName: string
|
|
852
|
+
newName: string
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function detectPortRenames(
|
|
856
|
+
oldPorts: GroupPort[],
|
|
857
|
+
newPorts: GroupPort[],
|
|
858
|
+
): PortRename[] {
|
|
859
|
+
const renames: PortRename[] = []
|
|
860
|
+
const buckets: Array<{ kind: 'data' | 'trigger'; direction: 'in' | 'out' }> = [
|
|
861
|
+
{ kind: 'data', direction: 'in' },
|
|
862
|
+
{ kind: 'data', direction: 'out' },
|
|
863
|
+
{ kind: 'trigger', direction: 'in' },
|
|
864
|
+
{ kind: 'trigger', direction: 'out' },
|
|
865
|
+
]
|
|
866
|
+
for (const b of buckets) {
|
|
867
|
+
const oldInBucket = oldPorts.filter(
|
|
868
|
+
(p) => p.kind === b.kind && p.direction === b.direction,
|
|
869
|
+
)
|
|
870
|
+
const newInBucket = newPorts.filter(
|
|
871
|
+
(p) => p.kind === b.kind && p.direction === b.direction,
|
|
872
|
+
)
|
|
873
|
+
const newNames = new Set(newInBucket.map((p) => p.name))
|
|
874
|
+
const oldNames = new Set(oldInBucket.map((p) => p.name))
|
|
875
|
+
// Ports that exist in both arrays with the same name didn't change.
|
|
876
|
+
const oldOnly = oldInBucket.filter((p) => !newNames.has(p.name))
|
|
877
|
+
const newOnly = newInBucket.filter((p) => !oldNames.has(p.name))
|
|
878
|
+
const pairCount = Math.min(oldOnly.length, newOnly.length)
|
|
879
|
+
for (let i = 0; i < pairCount; i++) {
|
|
880
|
+
renames.push({
|
|
881
|
+
kind: b.kind,
|
|
882
|
+
direction: b.direction,
|
|
883
|
+
oldName: oldOnly[i]!.name,
|
|
884
|
+
newName: newOnly[i]!.name,
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return renames
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Rewrite edge endpoints that referenced the renamed port names. Mutates the
|
|
892
|
+
// provided edge arrays in place.
|
|
893
|
+
//
|
|
894
|
+
// `nodeId` is the node owning the renamed ports — either the outer group node
|
|
895
|
+
// (for the outer graph's edges) or a sentinel node id inside the subgraph (for
|
|
896
|
+
// the inner edges). The direction of the rename determines which edge end is
|
|
897
|
+
// affected:
|
|
898
|
+
// • data/in → edge.to.field when edge.to.node === nodeId
|
|
899
|
+
// • data/out → edge.from.port when edge.from.node === nodeId
|
|
900
|
+
// • trigger/in → edge.to.branch when edge.to.node === nodeId
|
|
901
|
+
// • trigger/out → edge.from.branch when edge.from.node === nodeId
|
|
902
|
+
//
|
|
903
|
+
// For the inner subgraph, the sentinels invert the direction (an inbound port
|
|
904
|
+
// on the group is an OUTBOUND handle on the groupInput sentinel and vice
|
|
905
|
+
// versa). Callers pass `invertDirection: true` so a group-level "in" rename is
|
|
906
|
+
// applied to the sentinel's "out" handles, etc.
|
|
907
|
+
function applyPortRenamesToEdges(
|
|
908
|
+
triggerEdges: TriggerEdge[],
|
|
909
|
+
dataEdges: DataEdge[],
|
|
910
|
+
nodeId: string,
|
|
911
|
+
renames: PortRename[],
|
|
912
|
+
invertDirection: boolean,
|
|
913
|
+
): void {
|
|
914
|
+
for (const r of renames) {
|
|
915
|
+
const effectiveDirection = invertDirection
|
|
916
|
+
? r.direction === 'in'
|
|
917
|
+
? 'out'
|
|
918
|
+
: 'in'
|
|
919
|
+
: r.direction
|
|
920
|
+
if (r.kind === 'data') {
|
|
921
|
+
if (effectiveDirection === 'out') {
|
|
922
|
+
for (const e of dataEdges) {
|
|
923
|
+
if (e.from.node === nodeId && e.from.port === r.oldName) {
|
|
924
|
+
e.from.port = r.newName
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
for (const e of dataEdges) {
|
|
929
|
+
if (e.to.node === nodeId && e.to.field === r.oldName) {
|
|
930
|
+
e.to.field = r.newName
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
} else {
|
|
935
|
+
if (effectiveDirection === 'out') {
|
|
936
|
+
for (const e of triggerEdges) {
|
|
937
|
+
if (e.from.node === nodeId && e.from.branch === r.oldName) {
|
|
938
|
+
e.from.branch = r.newName
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
} else {
|
|
942
|
+
for (const e of triggerEdges) {
|
|
943
|
+
if (e.to.node === nodeId && e.to.branch === r.oldName) {
|
|
944
|
+
e.to.branch = r.newName
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Make sure the group node's local subgraph has the sentinels and that no
|
|
953
|
+
// inner edge references a port that was just removed. The sentinels are
|
|
954
|
+
// auto-managed (the user never creates them by hand); their handle set is
|
|
955
|
+
// derived at render time from the parent group's `ports` array (see
|
|
956
|
+
// GraphCanvas.vue `parent-group-ports` prop plumbing), so this
|
|
957
|
+
// function only owns four responsibilities:
|
|
958
|
+
// 1. Ensure a single groupInput and a single groupOutput exist.
|
|
959
|
+
// 2. Drop data edges whose source/target port no longer exists.
|
|
960
|
+
// 3. Drop trigger edges whose source branch (originating at groupInput)
|
|
961
|
+
// no longer exists.
|
|
962
|
+
// 4. Drop trigger edges into groupOutput whose target port (`to.branch`)
|
|
963
|
+
// no longer exists.
|
|
964
|
+
function syncSentinels(group: GraphNode): void {
|
|
965
|
+
if (!group.subgraph) return
|
|
966
|
+
|
|
967
|
+
let gi = group.subgraph.nodes.find((n) => n.kind === 'groupInput')
|
|
968
|
+
let go = group.subgraph.nodes.find((n) => n.kind === 'groupOutput')
|
|
969
|
+
|
|
970
|
+
if (!gi) {
|
|
971
|
+
gi = {
|
|
972
|
+
id: newId(),
|
|
973
|
+
kind: 'groupInput',
|
|
974
|
+
config: {},
|
|
975
|
+
position: { x: 0, y: 0 },
|
|
976
|
+
}
|
|
977
|
+
group.subgraph.nodes.push(gi)
|
|
978
|
+
}
|
|
979
|
+
if (!go) {
|
|
980
|
+
go = {
|
|
981
|
+
id: newId(),
|
|
982
|
+
kind: 'groupOutput',
|
|
983
|
+
config: {},
|
|
984
|
+
position: { x: 600, y: 0 },
|
|
985
|
+
}
|
|
986
|
+
group.subgraph.nodes.push(go)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const ports = Array.isArray(group.ports) ? group.ports : []
|
|
990
|
+
const validDataIn = new Set(
|
|
991
|
+
ports
|
|
992
|
+
.filter((p) => p.kind === 'data' && p.direction === 'in')
|
|
993
|
+
.map((p) => p.name),
|
|
994
|
+
)
|
|
995
|
+
const validDataOut = new Set(
|
|
996
|
+
ports
|
|
997
|
+
.filter((p) => p.kind === 'data' && p.direction === 'out')
|
|
998
|
+
.map((p) => p.name),
|
|
999
|
+
)
|
|
1000
|
+
const validTriggerIn = new Set(
|
|
1001
|
+
ports
|
|
1002
|
+
.filter((p) => p.kind === 'trigger' && p.direction === 'in')
|
|
1003
|
+
.map((p) => p.name),
|
|
1004
|
+
)
|
|
1005
|
+
const validTriggerOut = new Set(
|
|
1006
|
+
ports
|
|
1007
|
+
.filter((p) => p.kind === 'trigger' && p.direction === 'out')
|
|
1008
|
+
.map((p) => p.name),
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
const giId = gi.id
|
|
1012
|
+
const goId = go.id
|
|
1013
|
+
|
|
1014
|
+
group.subgraph.dataEdges = group.subgraph.dataEdges.filter((e) => {
|
|
1015
|
+
if (e.from.node === giId && !validDataIn.has(e.from.port)) return false
|
|
1016
|
+
if (e.to.node === goId && !validDataOut.has(e.to.field)) return false
|
|
1017
|
+
return true
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
group.subgraph.triggerEdges = group.subgraph.triggerEdges.filter((e) => {
|
|
1021
|
+
// trigger edges OUT OF groupInput carry the port name as `from.branch`
|
|
1022
|
+
// (the same convention if/switch use for named branches).
|
|
1023
|
+
if (e.from.node === giId) {
|
|
1024
|
+
const branch = e.from.branch ?? 'main'
|
|
1025
|
+
if (!validTriggerIn.has(branch)) return false
|
|
1026
|
+
}
|
|
1027
|
+
// trigger edges INTO groupOutput carry the target port name on
|
|
1028
|
+
// `to.branch`. If the port has been removed from the group's
|
|
1029
|
+
// declarations, drop the edge.
|
|
1030
|
+
if (e.to.node === goId && e.to.branch !== undefined) {
|
|
1031
|
+
if (!validTriggerOut.has(e.to.branch)) return false
|
|
1032
|
+
}
|
|
1033
|
+
return true
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function onPortsUpdate(payload: { nodeId: string; ports: GroupPort[] }) {
|
|
1038
|
+
const g = currentGraph.value
|
|
1039
|
+
if (!g) return
|
|
1040
|
+
const idx = g.nodes.findIndex(
|
|
1041
|
+
(n) => n.id === payload.nodeId && n.kind === 'group',
|
|
1042
|
+
)
|
|
1043
|
+
if (idx === -1) return
|
|
1044
|
+
const existing = g.nodes[idx]!
|
|
1045
|
+
// Template instances don't own a local subgraph — their `ports` come from
|
|
1046
|
+
// the template definition and are read-only on the instance. Defensive:
|
|
1047
|
+
// silently no-op if someone managed to invoke the editor on one.
|
|
1048
|
+
if (existing.templateId) return
|
|
1049
|
+
|
|
1050
|
+
// Detect renames against the PREVIOUS ports array BEFORE we replace it.
|
|
1051
|
+
// Pure additions and pure deletions yield no renames; syncSentinels still
|
|
1052
|
+
// handles deletions by dropping orphaned inner edges, and the outer graph
|
|
1053
|
+
// has no edges for added ports yet, so neither needs port-name rewrites.
|
|
1054
|
+
const oldPorts = Array.isArray(existing.ports) ? existing.ports : []
|
|
1055
|
+
const renames = detectPortRenames(oldPorts, payload.ports)
|
|
1056
|
+
|
|
1057
|
+
// Build a deep-cloned group node so syncSentinels can mutate its subgraph
|
|
1058
|
+
// freely without aliasing into the current graph's state. The outer
|
|
1059
|
+
// onUpdateNode/onGraphUpdate path expects a fresh node reference.
|
|
1060
|
+
const clonedGroup: GraphNode = {
|
|
1061
|
+
...existing,
|
|
1062
|
+
ports: payload.ports.map((p) => ({ ...p })),
|
|
1063
|
+
subgraph: existing.subgraph
|
|
1064
|
+
? (JSON.parse(JSON.stringify(existing.subgraph)) as ProcedureGraph)
|
|
1065
|
+
: undefined,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Cascade renames into the INNER subgraph before syncSentinels runs — if
|
|
1069
|
+
// we waited until after, syncSentinels would treat the old port names
|
|
1070
|
+
// still on inner edges as references to deleted ports and drop them.
|
|
1071
|
+
if (renames.length > 0 && clonedGroup.subgraph) {
|
|
1072
|
+
const gi = clonedGroup.subgraph.nodes.find((n) => n.kind === 'groupInput')
|
|
1073
|
+
const go = clonedGroup.subgraph.nodes.find((n) => n.kind === 'groupOutput')
|
|
1074
|
+
// Group-level "in" ports surface as groupInput's OUT handles, and
|
|
1075
|
+
// group-level "out" ports surface as groupOutput's IN handles. We pass
|
|
1076
|
+
// invertDirection=true and target the right sentinel per direction.
|
|
1077
|
+
const inRenames = renames.filter((r) => r.direction === 'in')
|
|
1078
|
+
const outRenames = renames.filter((r) => r.direction === 'out')
|
|
1079
|
+
if (gi && inRenames.length > 0) {
|
|
1080
|
+
applyPortRenamesToEdges(
|
|
1081
|
+
clonedGroup.subgraph.triggerEdges,
|
|
1082
|
+
clonedGroup.subgraph.dataEdges,
|
|
1083
|
+
gi.id,
|
|
1084
|
+
inRenames,
|
|
1085
|
+
true,
|
|
1086
|
+
)
|
|
1087
|
+
}
|
|
1088
|
+
if (go && outRenames.length > 0) {
|
|
1089
|
+
applyPortRenamesToEdges(
|
|
1090
|
+
clonedGroup.subgraph.triggerEdges,
|
|
1091
|
+
clonedGroup.subgraph.dataEdges,
|
|
1092
|
+
go.id,
|
|
1093
|
+
outRenames,
|
|
1094
|
+
true,
|
|
1095
|
+
)
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
syncSentinels(clonedGroup)
|
|
1100
|
+
|
|
1101
|
+
// Cascade renames into the OUTER graph's edges (where this group node is
|
|
1102
|
+
// an endpoint). We do this by walking the current graph, cloning the
|
|
1103
|
+
// triggerEdges / dataEdges arrays, applying the renames, then going
|
|
1104
|
+
// through onGraphUpdate so history + dirty tracking pick it up. We can't
|
|
1105
|
+
// rely on onUpdateNode for outer-edge changes because it only replaces
|
|
1106
|
+
// the node, not the edges.
|
|
1107
|
+
if (renames.length > 0) {
|
|
1108
|
+
const nextOuter: ProcedureGraph = {
|
|
1109
|
+
...g,
|
|
1110
|
+
nodes: g.nodes.map((n, i) => (i === idx ? clonedGroup : n)),
|
|
1111
|
+
triggerEdges: g.triggerEdges.map((e) => ({
|
|
1112
|
+
...e,
|
|
1113
|
+
from: { ...e.from },
|
|
1114
|
+
to: { ...e.to },
|
|
1115
|
+
})),
|
|
1116
|
+
dataEdges: g.dataEdges.map((e) => ({
|
|
1117
|
+
...e,
|
|
1118
|
+
from: { ...e.from },
|
|
1119
|
+
to: { ...e.to },
|
|
1120
|
+
})),
|
|
1121
|
+
}
|
|
1122
|
+
applyPortRenamesToEdges(
|
|
1123
|
+
nextOuter.triggerEdges,
|
|
1124
|
+
nextOuter.dataEdges,
|
|
1125
|
+
payload.nodeId,
|
|
1126
|
+
renames,
|
|
1127
|
+
false,
|
|
1128
|
+
)
|
|
1129
|
+
onGraphUpdate(nextOuter)
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
onUpdateNode(clonedGroup)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function onUnwireField(payload: { field: string }) {
|
|
1137
|
+
const g = currentGraph.value
|
|
1138
|
+
if (!g || !selectedNode.value) return
|
|
1139
|
+
const nodeId = selectedNode.value.id
|
|
1140
|
+
const nextGraph: ProcedureGraph = {
|
|
1141
|
+
...g,
|
|
1142
|
+
dataEdges: g.dataEdges.filter(
|
|
1143
|
+
(e) => !(e.to.node === nodeId && e.to.field === payload.field),
|
|
1144
|
+
),
|
|
1145
|
+
}
|
|
1146
|
+
onGraphUpdate(nextGraph)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Inspector "Delete node" button — same outcome as pressing Delete/Backspace
|
|
1150
|
+
// on the canvas: drop the node plus every trigger/data edge that touches it.
|
|
1151
|
+
function onDeleteNode(payload: { id: string }) {
|
|
1152
|
+
const g = currentGraph.value
|
|
1153
|
+
if (!g || isCurrentGraphReadOnly.value) return
|
|
1154
|
+
const id = payload.id
|
|
1155
|
+
const nextGraph: ProcedureGraph = {
|
|
1156
|
+
...g,
|
|
1157
|
+
nodes: g.nodes.filter((n) => n.id !== id),
|
|
1158
|
+
triggerEdges: g.triggerEdges.filter(
|
|
1159
|
+
(e) => e.from.node !== id && e.to.node !== id,
|
|
1160
|
+
),
|
|
1161
|
+
dataEdges: g.dataEdges.filter(
|
|
1162
|
+
(e) => e.from.node !== id && e.to.node !== id,
|
|
1163
|
+
),
|
|
1164
|
+
}
|
|
1165
|
+
if (selectedNodeId.value === id) selectedNodeId.value = null
|
|
1166
|
+
onGraphUpdate(nextGraph)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ---------------------------------------------------------------------------
|
|
1170
|
+
// Create-group-from-selection
|
|
1171
|
+
// ---------------------------------------------------------------------------
|
|
1172
|
+
// Visible when the user has selected one or more eligible nodes in the
|
|
1173
|
+
// current canvas. "Eligible" rules out kinds that can't legally live inside
|
|
1174
|
+
// a group's subgraph in v1:
|
|
1175
|
+
// • `trigger` — triggers must live at the top level of a procedure.
|
|
1176
|
+
// • `groupInput` / `groupOutput` — sentinels are auto-managed and only
|
|
1177
|
+
// valid inside their owning group.
|
|
1178
|
+
// Selection containing multiple groups IS allowed: nesting an existing
|
|
1179
|
+
// group inside a new one is just a regular subgraph node move; the v1
|
|
1180
|
+
// validator doesn't forbid it, and the executor's depth cap catches
|
|
1181
|
+
// runaway recursion.
|
|
1182
|
+
//
|
|
1183
|
+
// Also gated on:
|
|
1184
|
+
// • An editable graph (not the read-only template-instance view).
|
|
1185
|
+
// • A real procedure being open (selected.value).
|
|
1186
|
+
const canGroupSelection = computed<boolean>(() => {
|
|
1187
|
+
if (!selected.value) return false
|
|
1188
|
+
if (isCurrentGraphReadOnly.value) return false
|
|
1189
|
+
const ids = selectedNodeIds.value
|
|
1190
|
+
if (ids.length === 0) return false
|
|
1191
|
+
const g = currentGraph.value
|
|
1192
|
+
if (!g) return false
|
|
1193
|
+
const idSet = new Set(ids)
|
|
1194
|
+
for (const n of g.nodes) {
|
|
1195
|
+
if (!idSet.has(n.id)) continue
|
|
1196
|
+
if (n.kind === 'trigger') return false
|
|
1197
|
+
if (n.kind === 'groupInput' || n.kind === 'groupOutput') return false
|
|
1198
|
+
}
|
|
1199
|
+
return true
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
// Friendly default port names. Each cut edge maps to a desired name based on
|
|
1203
|
+
// the user-facing label on the cut side; if two edges land on the same name
|
|
1204
|
+
// (e.g. two inside actions both have an `input` field), we suffix `-2`, `-3`
|
|
1205
|
+
// per (kind, direction). The user can rename later in the GroupPortEditor.
|
|
1206
|
+
//
|
|
1207
|
+
// Port name picks:
|
|
1208
|
+
// data-in → target node's input field name
|
|
1209
|
+
// data-out → source node's output port name
|
|
1210
|
+
// trigger-in → source branch label if present, else 'main'
|
|
1211
|
+
// trigger-out → source branch label if present, else 'main'
|
|
1212
|
+
//
|
|
1213
|
+
// The "main" default for non-branching trigger cuts is intentional: the
|
|
1214
|
+
// GroupNode / sentinel renderers place a trigger port named "main" on the
|
|
1215
|
+
// title bar (mirroring ordinary trigger/action nodes), so a typical group
|
|
1216
|
+
// wrapping linear flow looks just like a single action — title-bar in/out.
|
|
1217
|
+
// Branch-labeled ports (if.then, switch.<case>, …) render in the body.
|
|
1218
|
+
function desiredPortNameForTrigger(branch: string | undefined): string {
|
|
1219
|
+
return branch && branch.length > 0 ? branch : 'main'
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Dedup against an already-declared set scoped to (kind, direction). The set
|
|
1223
|
+
// is mutated as new names are reserved.
|
|
1224
|
+
function uniqueName(desired: string, taken: Set<string>): string {
|
|
1225
|
+
if (!taken.has(desired)) {
|
|
1226
|
+
taken.add(desired)
|
|
1227
|
+
return desired
|
|
1228
|
+
}
|
|
1229
|
+
let i = 2
|
|
1230
|
+
while (taken.has(`${desired}-${i}`)) i++
|
|
1231
|
+
const final = `${desired}-${i}`
|
|
1232
|
+
taken.add(final)
|
|
1233
|
+
return final
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
interface NewGroupPlan {
|
|
1237
|
+
groupId: string
|
|
1238
|
+
groupPorts: GroupPort[]
|
|
1239
|
+
// Sentinel node ids — the planner generates them up front so its inner
|
|
1240
|
+
// edges reference real ids; the caller installs the sentinel nodes
|
|
1241
|
+
// under these ids.
|
|
1242
|
+
groupInputId: string
|
|
1243
|
+
groupOutputId: string
|
|
1244
|
+
// Edges to add to the outer graph (replacements for cut edges).
|
|
1245
|
+
outerTriggerEdges: TriggerEdge[]
|
|
1246
|
+
outerDataEdges: DataEdge[]
|
|
1247
|
+
// Edges to add inside the new subgraph (connecting sentinels to the
|
|
1248
|
+
// inner endpoints of the cut edges).
|
|
1249
|
+
innerTriggerEdges: TriggerEdge[]
|
|
1250
|
+
innerDataEdges: DataEdge[]
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Build a JsonSchema property bag from a kind's declared static port
|
|
1254
|
+
// list — used as the fallback when no type-registry schema applies.
|
|
1255
|
+
function staticPortsToSchema(
|
|
1256
|
+
ports: ReadonlyArray<{ name: string; type?: string }>,
|
|
1257
|
+
): JsonSchema {
|
|
1258
|
+
const properties: Record<string, JsonSchemaProperty> = {}
|
|
1259
|
+
for (const p of ports) {
|
|
1260
|
+
properties[p.name] = p.type ? { type: p.type } : {}
|
|
1261
|
+
}
|
|
1262
|
+
return { properties }
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function findSchemaForDataSourcePort(nodeId: string, port: string): JsonSchema | undefined {
|
|
1266
|
+
const g = currentGraph.value
|
|
1267
|
+
if (!g) return undefined
|
|
1268
|
+
const node = g.nodes.find((n) => n.id === nodeId)
|
|
1269
|
+
if (!node) return undefined
|
|
1270
|
+
// `group` carries its ports on the node itself, not in the kind registry.
|
|
1271
|
+
if (node.kind === 'group') {
|
|
1272
|
+
const p = (node.ports ?? []).find(
|
|
1273
|
+
(pp) => pp.kind === 'data' && pp.direction === 'out' && pp.name === port,
|
|
1274
|
+
)
|
|
1275
|
+
return p?.schema as JsonSchema | undefined
|
|
1276
|
+
}
|
|
1277
|
+
const ui = nodeKinds.value.find((k) => k.kind === node.kind)?.meta.ui
|
|
1278
|
+
if (!ui) return undefined
|
|
1279
|
+
let outputSchema: JsonSchema | undefined
|
|
1280
|
+
if (ui.typeRegistry && ui.dataOutsFromTypeSchema) {
|
|
1281
|
+
const key = ui.dataOutsFromTypeSchema
|
|
1282
|
+
if (ui.typeRegistry === 'triggers') {
|
|
1283
|
+
const t = triggerTypes.value.find((tt) => tt.id === node.typeId) as
|
|
1284
|
+
| Record<string, JsonSchema | undefined>
|
|
1285
|
+
| undefined
|
|
1286
|
+
outputSchema = t?.[key]
|
|
1287
|
+
} else if (ui.typeRegistry === 'actions') {
|
|
1288
|
+
const a = actionTypes.value.find((aa) => aa.id === node.typeId) as
|
|
1289
|
+
| Record<string, JsonSchema | undefined>
|
|
1290
|
+
| undefined
|
|
1291
|
+
outputSchema = a?.[key]
|
|
1292
|
+
} else {
|
|
1293
|
+
const d = dataNodeTypes.value.find((dd) => dd.id === node.typeId) as
|
|
1294
|
+
| Record<string, JsonSchema | undefined>
|
|
1295
|
+
| undefined
|
|
1296
|
+
outputSchema = d?.[key]
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (!outputSchema && ui.staticDataOuts.length > 0) {
|
|
1300
|
+
outputSchema = staticPortsToSchema(ui.staticDataOuts)
|
|
1301
|
+
}
|
|
1302
|
+
const prop = outputSchema?.properties?.[port]
|
|
1303
|
+
if (!prop) return undefined
|
|
1304
|
+
return prop as JsonSchema
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
function findSchemaForDataTargetField(nodeId: string, field: string): JsonSchema | undefined {
|
|
1308
|
+
const g = currentGraph.value
|
|
1309
|
+
if (!g) return undefined
|
|
1310
|
+
const node = g.nodes.find((n) => n.id === nodeId)
|
|
1311
|
+
if (!node) return undefined
|
|
1312
|
+
if (node.kind === 'group') {
|
|
1313
|
+
const p = (node.ports ?? []).find(
|
|
1314
|
+
(pp) => pp.kind === 'data' && pp.direction === 'in' && pp.name === field,
|
|
1315
|
+
)
|
|
1316
|
+
return p?.schema as JsonSchema | undefined
|
|
1317
|
+
}
|
|
1318
|
+
const ui = nodeKinds.value.find((k) => k.kind === node.kind)?.meta.ui
|
|
1319
|
+
if (!ui) return undefined
|
|
1320
|
+
let inputSchema: JsonSchema | undefined
|
|
1321
|
+
if (ui.typeRegistry && ui.dataInsFromTypeSchema) {
|
|
1322
|
+
const key = ui.dataInsFromTypeSchema
|
|
1323
|
+
if (ui.typeRegistry === 'actions') {
|
|
1324
|
+
const a = actionTypes.value.find((aa) => aa.id === node.typeId) as
|
|
1325
|
+
| Record<string, JsonSchema | undefined>
|
|
1326
|
+
| undefined
|
|
1327
|
+
inputSchema = a?.[key]
|
|
1328
|
+
} else if (ui.typeRegistry === 'dataNodes') {
|
|
1329
|
+
const d = dataNodeTypes.value.find((dd) => dd.id === node.typeId) as
|
|
1330
|
+
| Record<string, JsonSchema | undefined>
|
|
1331
|
+
| undefined
|
|
1332
|
+
inputSchema = d?.[key]
|
|
1333
|
+
} else {
|
|
1334
|
+
const t = triggerTypes.value.find((tt) => tt.id === node.typeId) as
|
|
1335
|
+
| Record<string, JsonSchema | undefined>
|
|
1336
|
+
| undefined
|
|
1337
|
+
inputSchema = t?.[key]
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (!inputSchema && ui.staticDataIns.length > 0) {
|
|
1341
|
+
inputSchema = staticPortsToSchema(ui.staticDataIns)
|
|
1342
|
+
}
|
|
1343
|
+
const prop = inputSchema?.properties?.[field]
|
|
1344
|
+
if (!prop) return undefined
|
|
1345
|
+
return prop as JsonSchema
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function planNewGroup(
|
|
1349
|
+
g: ProcedureGraph,
|
|
1350
|
+
selection: Set<string>,
|
|
1351
|
+
groupId: string,
|
|
1352
|
+
): NewGroupPlan {
|
|
1353
|
+
const ports: GroupPort[] = []
|
|
1354
|
+
const outerTriggerEdges: TriggerEdge[] = []
|
|
1355
|
+
const outerDataEdges: DataEdge[] = []
|
|
1356
|
+
const innerTriggerEdges: TriggerEdge[] = []
|
|
1357
|
+
const innerDataEdges: DataEdge[] = []
|
|
1358
|
+
|
|
1359
|
+
// Per (kind, direction) name reservations — keeps dedup scoped so a
|
|
1360
|
+
// data-in "input" and a data-out "output" can coexist even if a
|
|
1361
|
+
// hypothetical trigger-in shared the same desired label.
|
|
1362
|
+
const takenDataIn = new Set<string>()
|
|
1363
|
+
const takenDataOut = new Set<string>()
|
|
1364
|
+
const takenTriggerIn = new Set<string>()
|
|
1365
|
+
const takenTriggerOut = new Set<string>()
|
|
1366
|
+
|
|
1367
|
+
// We need sentinel ids that match the ones syncSentinels will (re)use.
|
|
1368
|
+
// Generate them up front so the inner edges below can reference them;
|
|
1369
|
+
// then we splat them into the subgraph and let syncSentinels see them
|
|
1370
|
+
// already in place.
|
|
1371
|
+
const groupInputId = newId()
|
|
1372
|
+
const groupOutputId = newId()
|
|
1373
|
+
|
|
1374
|
+
for (const e of g.triggerEdges) {
|
|
1375
|
+
const fromIn = selection.has(e.from.node)
|
|
1376
|
+
const toIn = selection.has(e.to.node)
|
|
1377
|
+
if (fromIn === toIn) continue // both inside or both outside → not cut
|
|
1378
|
+
if (fromIn && !toIn) {
|
|
1379
|
+
// Cut: source inside, target outside → declare trigger-out port.
|
|
1380
|
+
const name = uniqueName(
|
|
1381
|
+
desiredPortNameForTrigger(e.from.branch),
|
|
1382
|
+
takenTriggerOut,
|
|
1383
|
+
)
|
|
1384
|
+
ports.push({ kind: 'trigger', direction: 'out', name })
|
|
1385
|
+
// Outer: new edge from the OUTER group node's trigger-out handle
|
|
1386
|
+
// to the original target node. `from.branch` IS the port name —
|
|
1387
|
+
// matches how connect parses `group-trigger-out-<port>`.
|
|
1388
|
+
outerTriggerEdges.push({
|
|
1389
|
+
id: newId(),
|
|
1390
|
+
from: { node: groupId, branch: name },
|
|
1391
|
+
to: { node: e.to.node },
|
|
1392
|
+
})
|
|
1393
|
+
// Inner: original source node fires into groupOutput. Source-side
|
|
1394
|
+
// branch is preserved verbatim (for if/switch routing); target
|
|
1395
|
+
// port name lives on `to.branch` so the renderer can pick the
|
|
1396
|
+
// right `groupOutput-trigger-in-<port>` handle.
|
|
1397
|
+
innerTriggerEdges.push({
|
|
1398
|
+
id: newId(),
|
|
1399
|
+
from: { node: e.from.node, branch: e.from.branch },
|
|
1400
|
+
to: { node: groupOutputId, branch: name },
|
|
1401
|
+
})
|
|
1402
|
+
} else {
|
|
1403
|
+
// Cut: source outside, target inside → declare trigger-in port.
|
|
1404
|
+
const name = uniqueName(
|
|
1405
|
+
desiredPortNameForTrigger(e.from.branch),
|
|
1406
|
+
takenTriggerIn,
|
|
1407
|
+
)
|
|
1408
|
+
ports.push({ kind: 'trigger', direction: 'in', name })
|
|
1409
|
+
// Outer: original source node fires into OUTER group node's
|
|
1410
|
+
// trigger-in handle. Port name lives on `to.branch` (rendered as
|
|
1411
|
+
// `group-trigger-in-<port>` via the renderer).
|
|
1412
|
+
outerTriggerEdges.push({
|
|
1413
|
+
id: newId(),
|
|
1414
|
+
from: { node: e.from.node, branch: e.from.branch },
|
|
1415
|
+
to: { node: groupId, branch: name },
|
|
1416
|
+
})
|
|
1417
|
+
// Inner: groupInput fires into original target. Port name lives
|
|
1418
|
+
// on `from.branch` — matches the syncSentinels filter for
|
|
1419
|
+
// trigger edges out of groupInput.
|
|
1420
|
+
innerTriggerEdges.push({
|
|
1421
|
+
id: newId(),
|
|
1422
|
+
from: { node: groupInputId, branch: name },
|
|
1423
|
+
to: { node: e.to.node },
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
for (const e of g.dataEdges) {
|
|
1429
|
+
const fromIn = selection.has(e.from.node)
|
|
1430
|
+
const toIn = selection.has(e.to.node)
|
|
1431
|
+
if (fromIn === toIn) continue
|
|
1432
|
+
if (fromIn && !toIn) {
|
|
1433
|
+
// Cut: source inside, target outside → declare data-out port.
|
|
1434
|
+
const name = uniqueName(e.from.port, takenDataOut)
|
|
1435
|
+
ports.push({
|
|
1436
|
+
kind: 'data',
|
|
1437
|
+
direction: 'out',
|
|
1438
|
+
name,
|
|
1439
|
+
schema: findSchemaForDataSourcePort(e.from.node, e.from.port),
|
|
1440
|
+
})
|
|
1441
|
+
// Outer: group node's data-out → original target field.
|
|
1442
|
+
outerDataEdges.push({
|
|
1443
|
+
id: newId(),
|
|
1444
|
+
from: { node: groupId, port: name },
|
|
1445
|
+
to: { node: e.to.node, field: e.to.field },
|
|
1446
|
+
})
|
|
1447
|
+
// Inner: original source → groupOutput's data-in port (name).
|
|
1448
|
+
innerDataEdges.push({
|
|
1449
|
+
id: newId(),
|
|
1450
|
+
from: { node: e.from.node, port: e.from.port },
|
|
1451
|
+
to: { node: groupOutputId, field: name },
|
|
1452
|
+
})
|
|
1453
|
+
} else {
|
|
1454
|
+
// Cut: source outside, target inside → declare data-in port.
|
|
1455
|
+
const name = uniqueName(e.to.field, takenDataIn)
|
|
1456
|
+
ports.push({
|
|
1457
|
+
kind: 'data',
|
|
1458
|
+
direction: 'in',
|
|
1459
|
+
name,
|
|
1460
|
+
schema: findSchemaForDataTargetField(e.to.node, e.to.field),
|
|
1461
|
+
})
|
|
1462
|
+
// Outer: original source → group node's data-in port.
|
|
1463
|
+
outerDataEdges.push({
|
|
1464
|
+
id: newId(),
|
|
1465
|
+
from: { node: e.from.node, port: e.from.port },
|
|
1466
|
+
to: { node: groupId, field: name },
|
|
1467
|
+
})
|
|
1468
|
+
// Inner: groupInput's data-out → original target field.
|
|
1469
|
+
innerDataEdges.push({
|
|
1470
|
+
id: newId(),
|
|
1471
|
+
from: { node: groupInputId, port: name },
|
|
1472
|
+
to: { node: e.to.node, field: e.to.field },
|
|
1473
|
+
})
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
groupId,
|
|
1479
|
+
groupPorts: ports,
|
|
1480
|
+
groupInputId,
|
|
1481
|
+
groupOutputId,
|
|
1482
|
+
outerTriggerEdges,
|
|
1483
|
+
outerDataEdges,
|
|
1484
|
+
innerTriggerEdges,
|
|
1485
|
+
innerDataEdges,
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function createGroupFromSelection() {
|
|
1490
|
+
if (!canGroupSelection.value) return
|
|
1491
|
+
const g = currentGraph.value
|
|
1492
|
+
if (!g) return
|
|
1493
|
+
const ids = selectedNodeIds.value.slice()
|
|
1494
|
+
if (ids.length === 0) return
|
|
1495
|
+
|
|
1496
|
+
// Default name; window.prompt is intentionally lo-fi for v1. A modal
|
|
1497
|
+
// could replace this later, but it keeps the change scope tight.
|
|
1498
|
+
const inputName =
|
|
1499
|
+
typeof window !== 'undefined'
|
|
1500
|
+
? (window.prompt('Group name', 'Group') ?? '').trim() || 'Group'
|
|
1501
|
+
: 'Group'
|
|
1502
|
+
|
|
1503
|
+
const selection = new Set(ids)
|
|
1504
|
+
const groupId = newId()
|
|
1505
|
+
|
|
1506
|
+
const plan = planNewGroup(g, selection, groupId)
|
|
1507
|
+
|
|
1508
|
+
// Position the new group at the centroid of the selection. We also
|
|
1509
|
+
// shift the selected nodes' positions into the subgraph's coordinate
|
|
1510
|
+
// space: subtract the selection min, then add a left-margin so the
|
|
1511
|
+
// groupInput sentinel can sit at x ≈ 0.
|
|
1512
|
+
const SUBGRAPH_LEFT_MARGIN = 220 // groupInput sits at x = 0 with room
|
|
1513
|
+
const SUBGRAPH_RIGHT_MARGIN = 220
|
|
1514
|
+
const selectedNodes = g.nodes.filter((n) => selection.has(n.id))
|
|
1515
|
+
const xs = selectedNodes.map((n) => n.position.x)
|
|
1516
|
+
const ys = selectedNodes.map((n) => n.position.y)
|
|
1517
|
+
const minX = xs.length ? Math.min(...xs) : 0
|
|
1518
|
+
const maxX = xs.length ? Math.max(...xs) : 0
|
|
1519
|
+
const minY = ys.length ? Math.min(...ys) : 0
|
|
1520
|
+
const maxY = ys.length ? Math.max(...ys) : 0
|
|
1521
|
+
const centerX = xs.length ? (minX + maxX) / 2 : 200
|
|
1522
|
+
const centerY = ys.length ? (minY + maxY) / 2 : 200
|
|
1523
|
+
|
|
1524
|
+
const innerNodes: GraphNode[] = selectedNodes.map((n) => ({
|
|
1525
|
+
...JSON.parse(JSON.stringify(n)),
|
|
1526
|
+
position: {
|
|
1527
|
+
x: n.position.x - minX + SUBGRAPH_LEFT_MARGIN,
|
|
1528
|
+
y: n.position.y - minY,
|
|
1529
|
+
},
|
|
1530
|
+
}))
|
|
1531
|
+
|
|
1532
|
+
const innerHeight = Math.max(maxY - minY, 0)
|
|
1533
|
+
const innerWidth = Math.max(maxX - minX, 0)
|
|
1534
|
+
|
|
1535
|
+
const groupInputNode: GraphNode = {
|
|
1536
|
+
id: plan.groupInputId,
|
|
1537
|
+
kind: 'groupInput',
|
|
1538
|
+
config: {},
|
|
1539
|
+
position: { x: 0, y: innerHeight / 2 },
|
|
1540
|
+
}
|
|
1541
|
+
const groupOutputNode: GraphNode = {
|
|
1542
|
+
id: plan.groupOutputId,
|
|
1543
|
+
kind: 'groupOutput',
|
|
1544
|
+
config: {},
|
|
1545
|
+
position: {
|
|
1546
|
+
x: innerWidth + SUBGRAPH_LEFT_MARGIN + SUBGRAPH_RIGHT_MARGIN,
|
|
1547
|
+
y: innerHeight / 2,
|
|
1548
|
+
},
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Inner edges fully inside the selection — keep them verbatim (re-id
|
|
1552
|
+
// not required since we keep node ids stable; ids only need to be
|
|
1553
|
+
// unique within their containing graph and these are all from the
|
|
1554
|
+
// outer graph we just stripped them from).
|
|
1555
|
+
const innerOriginalTriggerEdges = g.triggerEdges.filter(
|
|
1556
|
+
(e) => selection.has(e.from.node) && selection.has(e.to.node),
|
|
1557
|
+
)
|
|
1558
|
+
const innerOriginalDataEdges = g.dataEdges.filter(
|
|
1559
|
+
(e) => selection.has(e.from.node) && selection.has(e.to.node),
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
const newGroupNode: GraphNode = {
|
|
1563
|
+
id: groupId,
|
|
1564
|
+
kind: 'group',
|
|
1565
|
+
config: { name: inputName },
|
|
1566
|
+
position: { x: centerX, y: centerY },
|
|
1567
|
+
ports: plan.groupPorts,
|
|
1568
|
+
subgraph: {
|
|
1569
|
+
nodes: [...innerNodes, groupInputNode, groupOutputNode],
|
|
1570
|
+
triggerEdges: [...innerOriginalTriggerEdges, ...plan.innerTriggerEdges],
|
|
1571
|
+
dataEdges: [...innerOriginalDataEdges, ...plan.innerDataEdges],
|
|
1572
|
+
},
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Build the new outer graph: drop the selected nodes + every edge that
|
|
1576
|
+
// touches them (cut OR fully-inside), then add the new group + the
|
|
1577
|
+
// replacement outer edges.
|
|
1578
|
+
const remainingOuterNodes = g.nodes.filter((n) => !selection.has(n.id))
|
|
1579
|
+
const remainingOuterTriggerEdges = g.triggerEdges.filter(
|
|
1580
|
+
(e) => !selection.has(e.from.node) && !selection.has(e.to.node),
|
|
1581
|
+
)
|
|
1582
|
+
const remainingOuterDataEdges = g.dataEdges.filter(
|
|
1583
|
+
(e) => !selection.has(e.from.node) && !selection.has(e.to.node),
|
|
1584
|
+
)
|
|
1585
|
+
|
|
1586
|
+
const nextGraph: ProcedureGraph = {
|
|
1587
|
+
nodes: [...remainingOuterNodes, newGroupNode],
|
|
1588
|
+
triggerEdges: [...remainingOuterTriggerEdges, ...plan.outerTriggerEdges],
|
|
1589
|
+
dataEdges: [...remainingOuterDataEdges, ...plan.outerDataEdges],
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Clear stale selection — the original ids no longer exist in the
|
|
1593
|
+
// outer graph. Select the newly-created group.
|
|
1594
|
+
selectedNodeIds.value = []
|
|
1595
|
+
selectedNodeId.value = groupId
|
|
1596
|
+
|
|
1597
|
+
onGraphUpdate(nextGraph)
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const canRun = computed<boolean>(() => {
|
|
1601
|
+
const g = selected.value?.graph
|
|
1602
|
+
if (!g) return false
|
|
1603
|
+
return g.nodes.some(
|
|
1604
|
+
(n) => n.kind === 'trigger' && n.typeId === 'manual',
|
|
1605
|
+
)
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
const procedureName = computed<string>({
|
|
1609
|
+
get: () => selected.value?.name ?? '',
|
|
1610
|
+
set: (v: string) => {
|
|
1611
|
+
if (selected.value) selected.value.name = v
|
|
1612
|
+
},
|
|
1613
|
+
})
|
|
1614
|
+
|
|
1615
|
+
const procedureEnabled = computed<boolean>({
|
|
1616
|
+
get: () => selected.value?.enabled ?? false,
|
|
1617
|
+
set: (v: boolean) => {
|
|
1618
|
+
if (selected.value) selected.value.enabled = v
|
|
1619
|
+
},
|
|
1620
|
+
})
|
|
1621
|
+
|
|
1622
|
+
// Triggers other than `manual` only fire when the procedure is enabled and
|
|
1623
|
+
// saved (reconcile activates them); manual still works via Run-now regardless.
|
|
1624
|
+
const hasInactiveTriggers = computed<boolean>(() => {
|
|
1625
|
+
const g = selected.value?.graph
|
|
1626
|
+
if (!g || selected.value?.enabled) return false
|
|
1627
|
+
return g.nodes.some(
|
|
1628
|
+
(n) => n.kind === 'trigger' && n.typeId && n.typeId !== 'manual',
|
|
1629
|
+
)
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
async function onCreate() {
|
|
1633
|
+
try {
|
|
1634
|
+
const body = {
|
|
1635
|
+
name: 'New procedure',
|
|
1636
|
+
description: '',
|
|
1637
|
+
enabled: false,
|
|
1638
|
+
graph: emptyGraph(),
|
|
1639
|
+
}
|
|
1640
|
+
const res = await $authFetch<{ _id: string }>(
|
|
1641
|
+
'/api/automation/procedures',
|
|
1642
|
+
{ method: 'POST', body },
|
|
1643
|
+
)
|
|
1644
|
+
const created: ProcedureRecord = {
|
|
1645
|
+
_id: res._id,
|
|
1646
|
+
name: body.name,
|
|
1647
|
+
description: body.description,
|
|
1648
|
+
enabled: body.enabled,
|
|
1649
|
+
graph: body.graph,
|
|
1650
|
+
}
|
|
1651
|
+
procedures.value = [created, ...procedures.value]
|
|
1652
|
+
selectedId.value = created._id
|
|
1653
|
+
toast.add({
|
|
1654
|
+
title: 'Procedure created',
|
|
1655
|
+
color: 'success',
|
|
1656
|
+
icon: 'i-ph-check-circle',
|
|
1657
|
+
})
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
toast.add({
|
|
1660
|
+
title: 'Failed to create procedure',
|
|
1661
|
+
description: (e as Error).message,
|
|
1662
|
+
color: 'error',
|
|
1663
|
+
icon: 'i-ph-warning',
|
|
1664
|
+
})
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function onDelete(id: string) {
|
|
1669
|
+
try {
|
|
1670
|
+
await $authFetch(`/api/automation/procedures/${id}`, {
|
|
1671
|
+
method: 'DELETE',
|
|
1672
|
+
})
|
|
1673
|
+
procedures.value = procedures.value.filter((p) => p._id !== id)
|
|
1674
|
+
if (selectedId.value === id) {
|
|
1675
|
+
const first = procedures.value[0]
|
|
1676
|
+
selectedId.value = first ? first._id : null
|
|
1677
|
+
}
|
|
1678
|
+
toast.add({
|
|
1679
|
+
title: 'Procedure deleted',
|
|
1680
|
+
color: 'success',
|
|
1681
|
+
icon: 'i-ph-check-circle',
|
|
1682
|
+
})
|
|
1683
|
+
} catch (e) {
|
|
1684
|
+
toast.add({
|
|
1685
|
+
title: 'Failed to delete procedure',
|
|
1686
|
+
description: (e as Error).message,
|
|
1687
|
+
color: 'error',
|
|
1688
|
+
icon: 'i-ph-warning',
|
|
1689
|
+
})
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function onSave() {
|
|
1694
|
+
if (!selected.value) return
|
|
1695
|
+
saving.value = true
|
|
1696
|
+
const current = selected.value
|
|
1697
|
+
try {
|
|
1698
|
+
if (editorMode.value === 'template') {
|
|
1699
|
+
// Template save — the PUT route's Zod requires name + ports +
|
|
1700
|
+
// subgraph and re-runs validateGraph + cycle detection. We send
|
|
1701
|
+
// the in-memory state verbatim; description/icon are not edited
|
|
1702
|
+
// from the procedure-style editor chrome yet (catalog page owns
|
|
1703
|
+
// metadata edits) so we preserve whatever was loaded.
|
|
1704
|
+
const body = {
|
|
1705
|
+
name: current.name,
|
|
1706
|
+
description: current.description,
|
|
1707
|
+
icon: 'i-ph-package',
|
|
1708
|
+
ports: templatePorts.value.map((p) => ({ ...p })),
|
|
1709
|
+
subgraph: current.graph,
|
|
1710
|
+
}
|
|
1711
|
+
await $authFetch(`/api/automation/templates/${current._id}`, {
|
|
1712
|
+
method: 'PUT',
|
|
1713
|
+
body,
|
|
1714
|
+
})
|
|
1715
|
+
savedSnapshot.value = takeSnapshot(current)
|
|
1716
|
+
toast.add({
|
|
1717
|
+
title: 'Template saved',
|
|
1718
|
+
description: current.name,
|
|
1719
|
+
color: 'success',
|
|
1720
|
+
icon: 'i-ph-check-circle',
|
|
1721
|
+
})
|
|
1722
|
+
return
|
|
1723
|
+
}
|
|
1724
|
+
const body = {
|
|
1725
|
+
name: current.name,
|
|
1726
|
+
description: current.description,
|
|
1727
|
+
enabled: current.enabled,
|
|
1728
|
+
graph: current.graph,
|
|
1729
|
+
}
|
|
1730
|
+
await $authFetch(`/api/automation/procedures/${current._id}`, {
|
|
1731
|
+
method: 'PUT',
|
|
1732
|
+
body,
|
|
1733
|
+
})
|
|
1734
|
+
savedSnapshot.value = takeSnapshot(current)
|
|
1735
|
+
toast.add({
|
|
1736
|
+
title: 'Procedure saved',
|
|
1737
|
+
description: current.name,
|
|
1738
|
+
color: 'success',
|
|
1739
|
+
icon: 'i-ph-check-circle',
|
|
1740
|
+
})
|
|
1741
|
+
} catch (e) {
|
|
1742
|
+
toast.add({
|
|
1743
|
+
title:
|
|
1744
|
+
editorMode.value === 'template'
|
|
1745
|
+
? 'Failed to save template'
|
|
1746
|
+
: 'Failed to save procedure',
|
|
1747
|
+
description: (e as Error).message,
|
|
1748
|
+
color: 'error',
|
|
1749
|
+
icon: 'i-ph-warning',
|
|
1750
|
+
})
|
|
1751
|
+
} finally {
|
|
1752
|
+
saving.value = false
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// ---------------------------------------------------------------------------
|
|
1757
|
+
// Save-as-template
|
|
1758
|
+
// ---------------------------------------------------------------------------
|
|
1759
|
+
// Visible when the user has selected a single local group (kind === 'group',
|
|
1760
|
+
// no templateId). On submit:
|
|
1761
|
+
// 1. POST /api/automation/templates with { name, description, icon, ports,
|
|
1762
|
+
// subgraph } from the local group.
|
|
1763
|
+
// 2. On success: mutate the in-graph node — set templateId = res._id, drop
|
|
1764
|
+
// subgraph. The save flow (onGraphUpdate → onSave by user) propagates.
|
|
1765
|
+
// 3. On failure (Zod or template cycle): render the error body inline in
|
|
1766
|
+
// the modal; the graph is NOT touched.
|
|
1767
|
+
const selectedLocalGroupNode = computed<GraphNode | null>(() => {
|
|
1768
|
+
const n = selectedNode.value
|
|
1769
|
+
if (!n || n.kind !== 'group') return null
|
|
1770
|
+
if (n.templateId) return null
|
|
1771
|
+
return n
|
|
1772
|
+
})
|
|
1773
|
+
|
|
1774
|
+
const canSaveAsTemplate = computed<boolean>(() => {
|
|
1775
|
+
if (!selected.value) return false
|
|
1776
|
+
if (isCurrentGraphReadOnly.value) return false
|
|
1777
|
+
return selectedLocalGroupNode.value !== null
|
|
1778
|
+
})
|
|
1779
|
+
|
|
1780
|
+
const saveAsTemplateOpen = ref<boolean>(false)
|
|
1781
|
+
const saveAsTemplateName = ref<string>('')
|
|
1782
|
+
const saveAsTemplateDescription = ref<string>('')
|
|
1783
|
+
const saveAsTemplateIcon = ref<string>('i-ph-package')
|
|
1784
|
+
const saveAsTemplateSubmitting = ref<boolean>(false)
|
|
1785
|
+
const saveAsTemplateError = ref<string | null>(null)
|
|
1786
|
+
|
|
1787
|
+
function openSaveAsTemplate() {
|
|
1788
|
+
const n = selectedLocalGroupNode.value
|
|
1789
|
+
if (!n) return
|
|
1790
|
+
saveAsTemplateName.value =
|
|
1791
|
+
(n.config?.name as string | undefined) ?? 'Template'
|
|
1792
|
+
saveAsTemplateDescription.value = ''
|
|
1793
|
+
saveAsTemplateIcon.value = 'i-ph-package'
|
|
1794
|
+
saveAsTemplateError.value = null
|
|
1795
|
+
saveAsTemplateSubmitting.value = false
|
|
1796
|
+
saveAsTemplateOpen.value = true
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Render the 400 body from src/routes/templates.ts:
|
|
1800
|
+
// - Zod failure → { error: 'invalid body', issues: ZodIssue[] }
|
|
1801
|
+
// - validateGraph failure → { error: 'invalid graph', issues: string[] }
|
|
1802
|
+
// - cycle → { error: 'template reference cycle', cycle: string[] }
|
|
1803
|
+
// Other errors (network, 401, 500) fall back to the message string.
|
|
1804
|
+
function formatTemplateError(err: unknown): string {
|
|
1805
|
+
const data = (err as { data?: unknown; message?: string } | undefined)?.data as
|
|
1806
|
+
| {
|
|
1807
|
+
error?: string
|
|
1808
|
+
issues?: Array<{ path?: Array<string | number>; message?: string } | string>
|
|
1809
|
+
cycle?: string[]
|
|
1810
|
+
}
|
|
1811
|
+
| undefined
|
|
1812
|
+
if (data && typeof data === 'object') {
|
|
1813
|
+
const head = data.error ?? 'Request failed'
|
|
1814
|
+
if (Array.isArray(data.issues) && data.issues.length > 0) {
|
|
1815
|
+
const lines = data.issues.map((iss) => {
|
|
1816
|
+
if (typeof iss === 'string') return iss
|
|
1817
|
+
const path = Array.isArray(iss.path) ? iss.path.join('.') : ''
|
|
1818
|
+
const msg = iss.message ?? 'invalid'
|
|
1819
|
+
return path ? `${path}: ${msg}` : msg
|
|
1820
|
+
})
|
|
1821
|
+
return `${head}\n${lines.join('\n')}`
|
|
1822
|
+
}
|
|
1823
|
+
if (Array.isArray(data.cycle) && data.cycle.length > 0) {
|
|
1824
|
+
return `${head}: ${data.cycle.join(' → ')}`
|
|
1825
|
+
}
|
|
1826
|
+
return head
|
|
1827
|
+
}
|
|
1828
|
+
return (err as Error).message ?? 'Failed to save template'
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
function convertLocalGroupToTemplateInstance(
|
|
1832
|
+
nodeId: string,
|
|
1833
|
+
templateId: string,
|
|
1834
|
+
): void {
|
|
1835
|
+
const g = currentGraph.value
|
|
1836
|
+
if (!g) return
|
|
1837
|
+
const existing = g.nodes.find((n) => n.id === nodeId && n.kind === 'group')
|
|
1838
|
+
if (!existing) return
|
|
1839
|
+
const updated: GraphNode = { ...existing, templateId }
|
|
1840
|
+
delete updated.subgraph
|
|
1841
|
+
onUpdateNode(updated)
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
async function submitSaveAsTemplate() {
|
|
1845
|
+
const n = selectedLocalGroupNode.value
|
|
1846
|
+
if (!n) return
|
|
1847
|
+
const name = saveAsTemplateName.value.trim()
|
|
1848
|
+
if (!name) {
|
|
1849
|
+
saveAsTemplateError.value = 'Name is required'
|
|
1850
|
+
return
|
|
1851
|
+
}
|
|
1852
|
+
if (!n.subgraph) {
|
|
1853
|
+
saveAsTemplateError.value = 'Group has no subgraph to save'
|
|
1854
|
+
return
|
|
1855
|
+
}
|
|
1856
|
+
saveAsTemplateSubmitting.value = true
|
|
1857
|
+
saveAsTemplateError.value = null
|
|
1858
|
+
try {
|
|
1859
|
+
const body = {
|
|
1860
|
+
name,
|
|
1861
|
+
description: saveAsTemplateDescription.value.trim(),
|
|
1862
|
+
icon: saveAsTemplateIcon.value.trim() || 'i-ph-package',
|
|
1863
|
+
ports: Array.isArray(n.ports) ? n.ports : [],
|
|
1864
|
+
subgraph: n.subgraph,
|
|
1865
|
+
}
|
|
1866
|
+
const created = await $authFetch<{ _id: string }>(
|
|
1867
|
+
'/api/automation/templates',
|
|
1868
|
+
{ method: 'POST', body },
|
|
1869
|
+
)
|
|
1870
|
+
convertLocalGroupToTemplateInstance(n.id, created._id)
|
|
1871
|
+
saveAsTemplateOpen.value = false
|
|
1872
|
+
toast.add({
|
|
1873
|
+
title: 'Template saved',
|
|
1874
|
+
description: name,
|
|
1875
|
+
color: 'success',
|
|
1876
|
+
icon: 'i-ph-check-circle',
|
|
1877
|
+
})
|
|
1878
|
+
} catch (e) {
|
|
1879
|
+
saveAsTemplateError.value = formatTemplateError(e)
|
|
1880
|
+
} finally {
|
|
1881
|
+
saveAsTemplateSubmitting.value = false
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// ---------------------------------------------------------------------------
|
|
1886
|
+
// Fork-to-local-group
|
|
1887
|
+
// ---------------------------------------------------------------------------
|
|
1888
|
+
// Visible when a template-instance group node is selected — i.e. kind === 'group'
|
|
1889
|
+
// AND templateId is set. The fork is the escape hatch from the live-link
|
|
1890
|
+
// model: it converts the instance back to a local group by deep-cloning the
|
|
1891
|
+
// template's current subgraph into the node and dropping templateId.
|
|
1892
|
+
//
|
|
1893
|
+
// Selection rules:
|
|
1894
|
+
// • Allowed even from the read-only inner-subgraph view of a template
|
|
1895
|
+
// instance — this is the "Fork to edit" CTA flow. The action mutates
|
|
1896
|
+
// the OUTER graph (the procedure root or whichever container holds
|
|
1897
|
+
// the template instance), not the inner subgraph being viewed.
|
|
1898
|
+
// • To make that work, the selection target for the fork is the deepest
|
|
1899
|
+
// currentParentGroup when we're inside a template-instance subgraph,
|
|
1900
|
+
// OR the selectedNode otherwise.
|
|
1901
|
+
//
|
|
1902
|
+
// The deep-clone uses a JSON parse/stringify round-trip: structuredClone
|
|
1903
|
+
// throws on Vue's reactive proxies (see the inline note at the clone site).
|
|
1904
|
+
//
|
|
1905
|
+
// Edge case: if the template was deleted between selection and confirm,
|
|
1906
|
+
// GET /:id returns 404 → we surface that as an error in the modal so the
|
|
1907
|
+
// user can dismiss and stop trying.
|
|
1908
|
+
interface ForkTarget {
|
|
1909
|
+
// The group node to convert (the template instance).
|
|
1910
|
+
node: GraphNode
|
|
1911
|
+
// The graph in which that node lives. May be the procedure root or any
|
|
1912
|
+
// outer subgraph (when the user is viewing the inner read-only graph of
|
|
1913
|
+
// an instance, the instance node lives one frame up).
|
|
1914
|
+
containerGraph: ProcedureGraph
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
const forkTarget = computed<ForkTarget | null>(() => {
|
|
1918
|
+
if (!selected.value) return null
|
|
1919
|
+
const frames = subgraphFrames.value
|
|
1920
|
+
// Case A: user is INSIDE a template-instance subgraph (read-only view).
|
|
1921
|
+
// The instance node is the parent group; its container is the previous
|
|
1922
|
+
// frame's graph.
|
|
1923
|
+
if (isCurrentGraphReadOnly.value && frames.length >= 2) {
|
|
1924
|
+
const parent = frames[frames.length - 1]!.groupNode
|
|
1925
|
+
const container = frames[frames.length - 2]!.graph
|
|
1926
|
+
if (parent && parent.kind === 'group' && parent.templateId) {
|
|
1927
|
+
return { node: parent, containerGraph: container }
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
// Case B: user has a template-instance node selected in the current
|
|
1931
|
+
// (editable) graph.
|
|
1932
|
+
const sel = selectedNode.value
|
|
1933
|
+
const g = currentGraph.value
|
|
1934
|
+
if (sel && g && sel.kind === 'group' && sel.templateId) {
|
|
1935
|
+
return { node: sel, containerGraph: g }
|
|
1936
|
+
}
|
|
1937
|
+
return null
|
|
1938
|
+
})
|
|
1939
|
+
|
|
1940
|
+
const canForkToLocal = computed<boolean>(() => forkTarget.value !== null)
|
|
1941
|
+
|
|
1942
|
+
const forkOpen = ref<boolean>(false)
|
|
1943
|
+
const forkSubmitting = ref<boolean>(false)
|
|
1944
|
+
const forkError = ref<string | null>(null)
|
|
1945
|
+
|
|
1946
|
+
function openFork() {
|
|
1947
|
+
if (!canForkToLocal.value) return
|
|
1948
|
+
forkError.value = null
|
|
1949
|
+
forkSubmitting.value = false
|
|
1950
|
+
forkOpen.value = true
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
interface FetchedTemplate {
|
|
1954
|
+
_id: string
|
|
1955
|
+
name: string
|
|
1956
|
+
description: string
|
|
1957
|
+
icon: string
|
|
1958
|
+
ports: GroupPort[]
|
|
1959
|
+
subgraph: ProcedureGraph
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
// Replace the template instance node with a forked local copy. Mirrors the
|
|
1963
|
+
// shape of convertLocalGroupToTemplateInstance but in reverse: drops
|
|
1964
|
+
// templateId, installs subgraph from the fetched template, and writes back
|
|
1965
|
+
// through onUpdateNode / onGraphUpdate (so history + dirty-snapshot stay
|
|
1966
|
+
// consistent). The replacement uses a fresh node object so Vue reactivity
|
|
1967
|
+
// sees a real change.
|
|
1968
|
+
function convertTemplateInstanceToLocal(
|
|
1969
|
+
target: ForkTarget,
|
|
1970
|
+
template: FetchedTemplate,
|
|
1971
|
+
): void {
|
|
1972
|
+
const { node, containerGraph } = target
|
|
1973
|
+
const idx = containerGraph.nodes.findIndex(
|
|
1974
|
+
(n) => n.id === node.id && n.kind === 'group',
|
|
1975
|
+
)
|
|
1976
|
+
if (idx === -1) return
|
|
1977
|
+
const updated: GraphNode = {
|
|
1978
|
+
...node,
|
|
1979
|
+
// Deep-clone the template's subgraph so subsequent edits to the
|
|
1980
|
+
// template (or to this fork) don't alias each other. JSON round-trip
|
|
1981
|
+
// strips Vue's reactive proxy — structuredClone throws on proxies.
|
|
1982
|
+
subgraph: JSON.parse(JSON.stringify(template.subgraph)) as ProcedureGraph,
|
|
1983
|
+
// Preserve the local ports if the instance carries them (it should
|
|
1984
|
+
// — palette drops clone template.ports onto the instance); fall back
|
|
1985
|
+
// to the template's port list otherwise.
|
|
1986
|
+
ports: Array.isArray(node.ports) && node.ports.length > 0
|
|
1987
|
+
? node.ports.map((p) => ({ ...p }))
|
|
1988
|
+
: template.ports.map((p) => ({ ...p })),
|
|
1989
|
+
}
|
|
1990
|
+
delete updated.templateId
|
|
1991
|
+
|
|
1992
|
+
// If the user was inside the template instance's subgraph (Case A in
|
|
1993
|
+
// forkTarget), pop the read-only path segment first so the canvas
|
|
1994
|
+
// re-binds to the (now editable) outer graph. The user can re-enter
|
|
1995
|
+
// the local subgraph fresh via dblclick if they want.
|
|
1996
|
+
if (isCurrentGraphReadOnly.value && subgraphPath.value.length > 1) {
|
|
1997
|
+
subgraphPath.value = subgraphPath.value.slice(0, -1)
|
|
1998
|
+
selectedNodeId.value = updated.id
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Use onUpdateNode — it walks subgraphPath and rewrites the correct
|
|
2002
|
+
// container graph (root or any depth). Because we just popped above
|
|
2003
|
+
// when needed, currentGraph now points at containerGraph.
|
|
2004
|
+
onUpdateNode(updated)
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function formatForkError(err: unknown): string {
|
|
2008
|
+
const status = (err as { statusCode?: number; status?: number } | undefined)
|
|
2009
|
+
const code = status?.statusCode ?? status?.status
|
|
2010
|
+
if (code === 404) {
|
|
2011
|
+
return 'This template no longer exists. It may have been deleted by another user.'
|
|
2012
|
+
}
|
|
2013
|
+
const data = (err as { data?: { error?: string } } | undefined)?.data
|
|
2014
|
+
if (data?.error) return data.error
|
|
2015
|
+
return (err as Error).message ?? 'Failed to fork template instance'
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
async function submitFork() {
|
|
2019
|
+
const target = forkTarget.value
|
|
2020
|
+
if (!target) return
|
|
2021
|
+
const templateId = target.node.templateId
|
|
2022
|
+
if (!templateId) return
|
|
2023
|
+
forkSubmitting.value = true
|
|
2024
|
+
forkError.value = null
|
|
2025
|
+
try {
|
|
2026
|
+
const tpl = await $authFetch<FetchedTemplate>(
|
|
2027
|
+
`/api/automation/templates/${templateId}`,
|
|
2028
|
+
)
|
|
2029
|
+
convertTemplateInstanceToLocal(target, tpl)
|
|
2030
|
+
forkOpen.value = false
|
|
2031
|
+
toast.add({
|
|
2032
|
+
title: 'Forked to local group',
|
|
2033
|
+
description:
|
|
2034
|
+
(target.node.config?.name as string | undefined) ?? tpl.name,
|
|
2035
|
+
color: 'success',
|
|
2036
|
+
icon: 'i-ph-check-circle',
|
|
2037
|
+
})
|
|
2038
|
+
} catch (e) {
|
|
2039
|
+
forkError.value = formatForkError(e)
|
|
2040
|
+
} finally {
|
|
2041
|
+
forkSubmitting.value = false
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// Tab / Shift+Tab / Escape keybindings for subgraph navigation. Tab enters
|
|
2046
|
+
// the selected group (if eligible); Shift+Tab and Escape pop one level out.
|
|
2047
|
+
// We ignore the key when focus is in an editable field (input, textarea,
|
|
2048
|
+
// contenteditable, USelect popovers) so typing a literal Tab in the
|
|
2049
|
+
// inspector still works.
|
|
2050
|
+
function isEditableTarget(target: EventTarget | null): boolean {
|
|
2051
|
+
if (!(target instanceof HTMLElement)) return false
|
|
2052
|
+
const tag = target.tagName
|
|
2053
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
2054
|
+
if (target.isContentEditable) return true
|
|
2055
|
+
return false
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function onKeyDown(ev: KeyboardEvent) {
|
|
2059
|
+
if (isEditableTarget(ev.target)) return
|
|
2060
|
+
// Modal / drawer open → don't hijack keys.
|
|
2061
|
+
if (saveAsTemplateOpen.value || forkOpen.value || showProcedures.value) return
|
|
2062
|
+
if (ev.key === 'Tab' && !ev.shiftKey) {
|
|
2063
|
+
if (!canEnterGroup.value) return
|
|
2064
|
+
ev.preventDefault()
|
|
2065
|
+
enterSelectedGroup()
|
|
2066
|
+
return
|
|
2067
|
+
}
|
|
2068
|
+
if ((ev.key === 'Tab' && ev.shiftKey) || ev.key === 'Escape') {
|
|
2069
|
+
if (subgraphPath.value.length <= 1) return
|
|
2070
|
+
ev.preventDefault()
|
|
2071
|
+
exitSubgraph()
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
onMounted(() => {
|
|
2076
|
+
if (typeof window === 'undefined') return
|
|
2077
|
+
window.addEventListener('keydown', onKeyDown)
|
|
2078
|
+
})
|
|
2079
|
+
onBeforeUnmount(() => {
|
|
2080
|
+
if (typeof window === 'undefined') return
|
|
2081
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
2082
|
+
})
|
|
2083
|
+
|
|
2084
|
+
async function onRun() {
|
|
2085
|
+
if (!selected.value) return
|
|
2086
|
+
running.value = true
|
|
2087
|
+
const id = selected.value._id
|
|
2088
|
+
try {
|
|
2089
|
+
const res = await $authFetch<{ runId: string }>(
|
|
2090
|
+
`/api/automation/procedures/${id}/run`,
|
|
2091
|
+
{ method: 'POST', body: {} },
|
|
2092
|
+
)
|
|
2093
|
+
toast.add({
|
|
2094
|
+
title: 'Run started',
|
|
2095
|
+
description: res.runId ? `runId: ${res.runId}` : id,
|
|
2096
|
+
color: 'success',
|
|
2097
|
+
icon: 'i-ph-play-circle',
|
|
2098
|
+
})
|
|
2099
|
+
// Reflect the freshly-queued run in the runs panel and its trigger badge.
|
|
2100
|
+
runsDrawerRef.value?.reload()
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
toast.add({
|
|
2103
|
+
title: 'Failed to start run',
|
|
2104
|
+
description: (e as Error).message,
|
|
2105
|
+
color: 'error',
|
|
2106
|
+
icon: 'i-ph-warning',
|
|
2107
|
+
})
|
|
2108
|
+
} finally {
|
|
2109
|
+
running.value = false
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
</script>
|
|
2113
|
+
|
|
2114
|
+
<template>
|
|
2115
|
+
<!--
|
|
2116
|
+
The page (src/pages/builder.ts) renders the native dms header
|
|
2117
|
+
(icon / title / description), so the editor doesn't repeat one here.
|
|
2118
|
+
Height comes from `editorHeight` (see above); the `min-h-0` / `flex-1`
|
|
2119
|
+
chain below passes it down to the canvas, and `min-h-96` keeps the
|
|
2120
|
+
canvas usable on short viewports at the cost of scrolling there.
|
|
2121
|
+
-->
|
|
2122
|
+
<div ref="editorRoot" class="flex min-h-96 flex-col gap-4" :style="{ height: editorHeight }">
|
|
2123
|
+
<UAlert
|
|
2124
|
+
v-if="loadError"
|
|
2125
|
+
color="error"
|
|
2126
|
+
variant="subtle"
|
|
2127
|
+
icon="i-ph-warning"
|
|
2128
|
+
:title="
|
|
2129
|
+
editorMode === 'template'
|
|
2130
|
+
? $t('dms_automation.editor.loadTemplateError')
|
|
2131
|
+
: $t('dms_automation.editor.loadProceduresError')
|
|
2132
|
+
"
|
|
2133
|
+
:description="loadError"
|
|
2134
|
+
/>
|
|
2135
|
+
|
|
2136
|
+
<DmsCard :padded="false">
|
|
2137
|
+
<div class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
|
|
2138
|
+
<UButton
|
|
2139
|
+
v-if="editorMode === 'procedure'"
|
|
2140
|
+
icon="i-ph-list-bullets"
|
|
2141
|
+
variant="outline"
|
|
2142
|
+
color="neutral"
|
|
2143
|
+
@click="showProcedures = true"
|
|
2144
|
+
>
|
|
2145
|
+
{{ $t('dms_automation.editor.procedures') }}
|
|
2146
|
+
</UButton>
|
|
2147
|
+
<UInput
|
|
2148
|
+
v-if="selected"
|
|
2149
|
+
v-model="procedureName"
|
|
2150
|
+
:placeholder="
|
|
2151
|
+
editorMode === 'template'
|
|
2152
|
+
? $t('dms_automation.editor.templateName')
|
|
2153
|
+
: $t('dms_automation.editor.procedureName')
|
|
2154
|
+
"
|
|
2155
|
+
class="min-w-[200px] flex-1"
|
|
2156
|
+
/>
|
|
2157
|
+
<div
|
|
2158
|
+
v-if="selected && editorMode === 'procedure'"
|
|
2159
|
+
class="flex items-center gap-2"
|
|
2160
|
+
>
|
|
2161
|
+
<span class="text-sm text-muted">{{ $t('dms_automation.editor.enabled') }}</span>
|
|
2162
|
+
<USwitch v-model="procedureEnabled" />
|
|
2163
|
+
</div>
|
|
2164
|
+
<UButton
|
|
2165
|
+
v-if="selected && editorMode === 'procedure'"
|
|
2166
|
+
color="success"
|
|
2167
|
+
icon="i-ph-play"
|
|
2168
|
+
variant="solid"
|
|
2169
|
+
:disabled="!canRun"
|
|
2170
|
+
:loading="running"
|
|
2171
|
+
@click="onRun"
|
|
2172
|
+
>
|
|
2173
|
+
{{ $t('dms_automation.editor.runNow') }}
|
|
2174
|
+
</UButton>
|
|
2175
|
+
<DmsAutomationRunsDrawer
|
|
2176
|
+
v-if="selected && editorMode === 'procedure'"
|
|
2177
|
+
ref="runsDrawerRef"
|
|
2178
|
+
:procedure-id="selectedId"
|
|
2179
|
+
:procedure-name="procedureName"
|
|
2180
|
+
/>
|
|
2181
|
+
<div
|
|
2182
|
+
v-if="!selected && editorMode === 'procedure'"
|
|
2183
|
+
class="text-sm text-dimmed"
|
|
2184
|
+
>
|
|
2185
|
+
{{ $t('dms_automation.editor.noProcedure') }}
|
|
2186
|
+
</div>
|
|
2187
|
+
<div
|
|
2188
|
+
v-if="!selected && editorMode === 'template'"
|
|
2189
|
+
class="text-sm text-dimmed"
|
|
2190
|
+
>
|
|
2191
|
+
{{ $t('dms_automation.editor.templateNotLoaded') }}
|
|
2192
|
+
</div>
|
|
2193
|
+
<UButton
|
|
2194
|
+
class="ml-auto"
|
|
2195
|
+
color="neutral"
|
|
2196
|
+
variant="outline"
|
|
2197
|
+
icon="i-ph-arrows-clockwise"
|
|
2198
|
+
:loading="loading"
|
|
2199
|
+
@click="
|
|
2200
|
+
editorMode === 'template' && editorTemplateId
|
|
2201
|
+
? loadTemplate(editorTemplateId)
|
|
2202
|
+
: loadList()
|
|
2203
|
+
"
|
|
2204
|
+
>
|
|
2205
|
+
{{ $t('dms_automation.common.refresh') }}
|
|
2206
|
+
</UButton>
|
|
2207
|
+
</div>
|
|
2208
|
+
</DmsCard>
|
|
2209
|
+
|
|
2210
|
+
<div v-if="selected" class="flex min-h-0 flex-1 flex-col gap-4">
|
|
2211
|
+
<UAlert
|
|
2212
|
+
v-if="hasInactiveTriggers && editorMode === 'procedure'"
|
|
2213
|
+
color="warning"
|
|
2214
|
+
variant="subtle"
|
|
2215
|
+
icon="i-ph-warning"
|
|
2216
|
+
:title="$t('dms_automation.editor.disabledTitle')"
|
|
2217
|
+
:description="$t('dms_automation.editor.disabledDescription')"
|
|
2218
|
+
:actions="[
|
|
2219
|
+
{
|
|
2220
|
+
label: $t('dms_automation.editor.enableAndSave'),
|
|
2221
|
+
color: 'warning',
|
|
2222
|
+
variant: 'solid',
|
|
2223
|
+
loading: saving,
|
|
2224
|
+
onClick: () => {
|
|
2225
|
+
procedureEnabled = true
|
|
2226
|
+
onSave()
|
|
2227
|
+
},
|
|
2228
|
+
},
|
|
2229
|
+
]"
|
|
2230
|
+
/>
|
|
2231
|
+
<div
|
|
2232
|
+
v-if="subgraphPath.length > 1"
|
|
2233
|
+
class="flex items-center justify-between gap-3 rounded-lg border border-default bg-elevated px-3 py-2"
|
|
2234
|
+
>
|
|
2235
|
+
<UBreadcrumb
|
|
2236
|
+
:items="breadcrumbItems"
|
|
2237
|
+
:ui="{ linkLabel: 'first-letter:uppercase' }"
|
|
2238
|
+
/>
|
|
2239
|
+
<UButton
|
|
2240
|
+
v-if="subgraphPath.length > 1"
|
|
2241
|
+
size="xs"
|
|
2242
|
+
variant="ghost"
|
|
2243
|
+
color="neutral"
|
|
2244
|
+
icon="i-ph-arrow-up-left"
|
|
2245
|
+
@click="exitSubgraph"
|
|
2246
|
+
>
|
|
2247
|
+
{{ $t('dms_automation.editor.exitSubgraph') }}
|
|
2248
|
+
</UButton>
|
|
2249
|
+
</div>
|
|
2250
|
+
<UAlert
|
|
2251
|
+
v-if="isCurrentGraphReadOnly"
|
|
2252
|
+
color="info"
|
|
2253
|
+
variant="subtle"
|
|
2254
|
+
icon="i-ph-link"
|
|
2255
|
+
:title="$t('dms_automation.editor.linkedTitle')"
|
|
2256
|
+
:description="$t('dms_automation.editor.linkedDescription')"
|
|
2257
|
+
:actions="canForkToLocal ? [
|
|
2258
|
+
{
|
|
2259
|
+
label: $t('dms_automation.editor.forkToEdit'),
|
|
2260
|
+
color: 'info',
|
|
2261
|
+
variant: 'solid',
|
|
2262
|
+
icon: 'i-ph-git-fork',
|
|
2263
|
+
onClick: openFork,
|
|
2264
|
+
},
|
|
2265
|
+
] : []"
|
|
2266
|
+
/>
|
|
2267
|
+
<div class="flex min-h-0 flex-1 gap-4">
|
|
2268
|
+
<DmsAutomationBuilderPalette
|
|
2269
|
+
v-if="currentGraph && !isCurrentGraphReadOnly"
|
|
2270
|
+
:node-kinds="nodeKinds"
|
|
2271
|
+
:trigger-types="triggerTypes"
|
|
2272
|
+
:action-types="actionTypes"
|
|
2273
|
+
:data-node-types="dataNodeTypes"
|
|
2274
|
+
:templates="palettesTemplates"
|
|
2275
|
+
class="w-60 shrink-0"
|
|
2276
|
+
@add-node="onPaletteAddNode"
|
|
2277
|
+
@add-template="onPaletteAddTemplate"
|
|
2278
|
+
/>
|
|
2279
|
+
<div class="h-full min-w-0 flex-1">
|
|
2280
|
+
<ClientOnly>
|
|
2281
|
+
<DmsAutomationGraphCanvas
|
|
2282
|
+
v-if="currentGraph"
|
|
2283
|
+
ref="graphCanvasRef"
|
|
2284
|
+
:graph="currentGraph"
|
|
2285
|
+
:trigger-types="triggerTypes"
|
|
2286
|
+
:action-types="actionTypes"
|
|
2287
|
+
:data-node-types="dataNodeTypes"
|
|
2288
|
+
:node-kinds="nodeKinds"
|
|
2289
|
+
:can-undo="canUndo"
|
|
2290
|
+
:can-redo="canRedo"
|
|
2291
|
+
:is-dirty="isDirty"
|
|
2292
|
+
:applying="saving"
|
|
2293
|
+
:parent-group-ports="currentParentGroupPorts"
|
|
2294
|
+
:read-only="isCurrentGraphReadOnly"
|
|
2295
|
+
@update:graph="onGraphUpdate"
|
|
2296
|
+
@select:node="onSelectNode"
|
|
2297
|
+
@selection-change="onSelectionChange"
|
|
2298
|
+
@undo="onUndo"
|
|
2299
|
+
@redo="onRedo"
|
|
2300
|
+
@cancel="onCancel"
|
|
2301
|
+
@apply="onSave"
|
|
2302
|
+
@group:enter="onGroupEnter"
|
|
2303
|
+
>
|
|
2304
|
+
<template
|
|
2305
|
+
v-if="canGroupSelection || canSaveAsTemplate || canForkToLocal || canEnterGroup"
|
|
2306
|
+
#groupActions
|
|
2307
|
+
>
|
|
2308
|
+
<UButton
|
|
2309
|
+
v-if="canEnterGroup"
|
|
2310
|
+
size="sm"
|
|
2311
|
+
color="primary"
|
|
2312
|
+
icon="i-ph-arrow-square-in"
|
|
2313
|
+
variant="ghost"
|
|
2314
|
+
@click="enterSelectedGroup"
|
|
2315
|
+
>
|
|
2316
|
+
{{ $t('dms_automation.editor.enterGroup') }}
|
|
2317
|
+
</UButton>
|
|
2318
|
+
<UButton
|
|
2319
|
+
v-if="canGroupSelection"
|
|
2320
|
+
size="sm"
|
|
2321
|
+
color="primary"
|
|
2322
|
+
icon="i-ph-package"
|
|
2323
|
+
variant="ghost"
|
|
2324
|
+
@click="createGroupFromSelection"
|
|
2325
|
+
>
|
|
2326
|
+
{{ $t('dms_automation.editor.groupSelection', { n: selectedNodeIds.length }) }}
|
|
2327
|
+
</UButton>
|
|
2328
|
+
<UButton
|
|
2329
|
+
v-if="canSaveAsTemplate"
|
|
2330
|
+
size="sm"
|
|
2331
|
+
color="primary"
|
|
2332
|
+
icon="i-ph-floppy-disk"
|
|
2333
|
+
variant="ghost"
|
|
2334
|
+
@click="openSaveAsTemplate"
|
|
2335
|
+
>
|
|
2336
|
+
{{ $t('dms_automation.editor.saveAsTemplate') }}
|
|
2337
|
+
</UButton>
|
|
2338
|
+
<UButton
|
|
2339
|
+
v-if="canForkToLocal"
|
|
2340
|
+
size="sm"
|
|
2341
|
+
color="primary"
|
|
2342
|
+
icon="i-ph-git-fork"
|
|
2343
|
+
variant="ghost"
|
|
2344
|
+
@click="openFork"
|
|
2345
|
+
>
|
|
2346
|
+
{{ $t('dms_automation.editor.forkToLocal') }}
|
|
2347
|
+
</UButton>
|
|
2348
|
+
</template>
|
|
2349
|
+
<template v-if="selectedNode" #inspector>
|
|
2350
|
+
<DmsAutomationNodeInspector
|
|
2351
|
+
:node="selectedNode"
|
|
2352
|
+
:data-edges="selected.graph.dataEdges"
|
|
2353
|
+
:trigger-type="selectedNodeTriggerType"
|
|
2354
|
+
:action-type="selectedNodeActionType"
|
|
2355
|
+
:trigger-types="triggerTypes"
|
|
2356
|
+
:action-types="actionTypes"
|
|
2357
|
+
:data-node-types="dataNodeTypes"
|
|
2358
|
+
:node-kinds="nodeKinds"
|
|
2359
|
+
:read-only="isCurrentGraphReadOnly"
|
|
2360
|
+
@update:node="onUpdateNode"
|
|
2361
|
+
@unwire="onUnwireField"
|
|
2362
|
+
@ports:update="onPortsUpdate"
|
|
2363
|
+
@group:enter="onGroupEnter"
|
|
2364
|
+
@delete:node="onDeleteNode"
|
|
2365
|
+
/>
|
|
2366
|
+
</template>
|
|
2367
|
+
</DmsAutomationGraphCanvas>
|
|
2368
|
+
<template #fallback>
|
|
2369
|
+
<div
|
|
2370
|
+
class="flex h-full items-center justify-center rounded-lg border border-default text-sm text-dimmed"
|
|
2371
|
+
>
|
|
2372
|
+
{{ $t('dms_automation.editor.loading') }}
|
|
2373
|
+
</div>
|
|
2374
|
+
</template>
|
|
2375
|
+
</ClientOnly>
|
|
2376
|
+
</div>
|
|
2377
|
+
</div>
|
|
2378
|
+
</div>
|
|
2379
|
+
|
|
2380
|
+
<USlideover
|
|
2381
|
+
v-if="editorMode === 'procedure'"
|
|
2382
|
+
v-model:open="showProcedures"
|
|
2383
|
+
side="left"
|
|
2384
|
+
:title="$t('dms_automation.editor.procedures')"
|
|
2385
|
+
>
|
|
2386
|
+
<template #body>
|
|
2387
|
+
<DmsAutomationProcedureList
|
|
2388
|
+
:procedures="procedures"
|
|
2389
|
+
:model-value="selectedId"
|
|
2390
|
+
@update:model-value="onProcedurePicked"
|
|
2391
|
+
@create="onCreate"
|
|
2392
|
+
@delete="onDelete"
|
|
2393
|
+
/>
|
|
2394
|
+
</template>
|
|
2395
|
+
</USlideover>
|
|
2396
|
+
|
|
2397
|
+
<UModal
|
|
2398
|
+
v-model:open="saveAsTemplateOpen"
|
|
2399
|
+
:title="$t('dms_automation.editor.saveTemplateModal.title')"
|
|
2400
|
+
>
|
|
2401
|
+
<template #body>
|
|
2402
|
+
<div class="flex flex-col gap-3">
|
|
2403
|
+
<UFormField
|
|
2404
|
+
:label="$t('dms_automation.editor.saveTemplateModal.name')"
|
|
2405
|
+
required
|
|
2406
|
+
>
|
|
2407
|
+
<UInput
|
|
2408
|
+
v-model="saveAsTemplateName"
|
|
2409
|
+
:placeholder="$t('dms_automation.editor.saveTemplateModal.namePlaceholder')"
|
|
2410
|
+
class="w-full"
|
|
2411
|
+
:disabled="saveAsTemplateSubmitting"
|
|
2412
|
+
/>
|
|
2413
|
+
</UFormField>
|
|
2414
|
+
<UFormField
|
|
2415
|
+
:label="$t('dms_automation.editor.saveTemplateModal.description')"
|
|
2416
|
+
>
|
|
2417
|
+
<UTextarea
|
|
2418
|
+
v-model="saveAsTemplateDescription"
|
|
2419
|
+
:placeholder="$t('dms_automation.editor.saveTemplateModal.descriptionPlaceholder')"
|
|
2420
|
+
class="w-full"
|
|
2421
|
+
:rows="3"
|
|
2422
|
+
:disabled="saveAsTemplateSubmitting"
|
|
2423
|
+
/>
|
|
2424
|
+
</UFormField>
|
|
2425
|
+
<UFormField
|
|
2426
|
+
:label="$t('dms_automation.editor.saveTemplateModal.icon')"
|
|
2427
|
+
:help="$t('dms_automation.editor.saveTemplateModal.iconHelp')"
|
|
2428
|
+
>
|
|
2429
|
+
<UInput
|
|
2430
|
+
v-model="saveAsTemplateIcon"
|
|
2431
|
+
placeholder="i-ph-package"
|
|
2432
|
+
class="w-full"
|
|
2433
|
+
:disabled="saveAsTemplateSubmitting"
|
|
2434
|
+
/>
|
|
2435
|
+
</UFormField>
|
|
2436
|
+
<UAlert
|
|
2437
|
+
v-if="saveAsTemplateError"
|
|
2438
|
+
color="error"
|
|
2439
|
+
variant="subtle"
|
|
2440
|
+
icon="i-ph-warning"
|
|
2441
|
+
:title="$t('dms_automation.editor.saveTemplateModal.error')"
|
|
2442
|
+
:description="saveAsTemplateError"
|
|
2443
|
+
/>
|
|
2444
|
+
</div>
|
|
2445
|
+
</template>
|
|
2446
|
+
<template #footer>
|
|
2447
|
+
<div class="flex w-full justify-end gap-2">
|
|
2448
|
+
<UButton
|
|
2449
|
+
:label="$t('dms_automation.editor.saveTemplateModal.cancel')"
|
|
2450
|
+
variant="outline"
|
|
2451
|
+
color="neutral"
|
|
2452
|
+
:disabled="saveAsTemplateSubmitting"
|
|
2453
|
+
@click="saveAsTemplateOpen = false"
|
|
2454
|
+
/>
|
|
2455
|
+
<UButton
|
|
2456
|
+
:label="$t('dms_automation.editor.saveTemplateModal.submit')"
|
|
2457
|
+
color="primary"
|
|
2458
|
+
:loading="saveAsTemplateSubmitting"
|
|
2459
|
+
@click="submitSaveAsTemplate"
|
|
2460
|
+
/>
|
|
2461
|
+
</div>
|
|
2462
|
+
</template>
|
|
2463
|
+
</UModal>
|
|
2464
|
+
|
|
2465
|
+
<UModal
|
|
2466
|
+
v-model:open="forkOpen"
|
|
2467
|
+
:title="$t('dms_automation.editor.forkModal.title')"
|
|
2468
|
+
>
|
|
2469
|
+
<template #body>
|
|
2470
|
+
<div class="flex flex-col gap-3">
|
|
2471
|
+
<p class="text-sm text-toned">
|
|
2472
|
+
{{ $t('dms_automation.editor.forkModal.body') }}
|
|
2473
|
+
</p>
|
|
2474
|
+
<UAlert
|
|
2475
|
+
v-if="forkError"
|
|
2476
|
+
color="error"
|
|
2477
|
+
variant="subtle"
|
|
2478
|
+
icon="i-ph-warning"
|
|
2479
|
+
:title="$t('dms_automation.editor.forkModal.error')"
|
|
2480
|
+
:description="forkError"
|
|
2481
|
+
/>
|
|
2482
|
+
</div>
|
|
2483
|
+
</template>
|
|
2484
|
+
<template #footer>
|
|
2485
|
+
<div class="flex w-full justify-end gap-2">
|
|
2486
|
+
<UButton
|
|
2487
|
+
:label="$t('dms_automation.editor.forkModal.cancel')"
|
|
2488
|
+
variant="outline"
|
|
2489
|
+
color="neutral"
|
|
2490
|
+
:disabled="forkSubmitting"
|
|
2491
|
+
@click="forkOpen = false"
|
|
2492
|
+
/>
|
|
2493
|
+
<UButton
|
|
2494
|
+
:label="$t('dms_automation.editor.forkModal.submit')"
|
|
2495
|
+
color="primary"
|
|
2496
|
+
icon="i-ph-git-fork"
|
|
2497
|
+
:loading="forkSubmitting"
|
|
2498
|
+
@click="submitFork"
|
|
2499
|
+
/>
|
|
2500
|
+
</div>
|
|
2501
|
+
</template>
|
|
2502
|
+
</UModal>
|
|
2503
|
+
</div>
|
|
2504
|
+
</template>
|