work on fixing group drag
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,103 @@ function formatUiLabel(text) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseProxyHandle(handleId) {
|
||||
const text = String(handleId || '');
|
||||
if (!text.startsWith('group-proxy::')) return null;
|
||||
const parts = text.split('::');
|
||||
if (parts.length < 5) return null;
|
||||
return {
|
||||
direction: parts[1],
|
||||
nodeId: parts[2],
|
||||
type: parts[3],
|
||||
realHandle: decodeURIComponent(parts.slice(4).join('::')),
|
||||
};
|
||||
}
|
||||
|
||||
function GroupNode({ id, data }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
const proxyInputs = Array.isArray(data.proxyInputs) ? data.proxyInputs : [];
|
||||
const proxyOutputs = Array.isArray(data.proxyOutputs) ? data.proxyOutputs : [];
|
||||
const childCount = Number(data.childCount) || 0;
|
||||
const collapsed = !!data.collapsed;
|
||||
const maxRows = Math.max(proxyInputs.length, proxyOutputs.length, collapsed ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className={`custom-node group-node ${collapsed ? 'group-node-collapsed' : 'group-node-expanded'}`}>
|
||||
<div className="node-title drag-handle group-node-title">
|
||||
<button
|
||||
type="button"
|
||||
className="group-toggle group-toggle-collapse nodrag"
|
||||
onClick={() => ctx.onToggleGroupCollapse?.(id)}
|
||||
title={collapsed ? 'expand group' : 'collapse group'}
|
||||
>
|
||||
{collapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span className="node-title-main">{formatUiLabel(data.label || 'group')}</span>
|
||||
<div className="group-node-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="group-toggle nodrag"
|
||||
onClick={() => ctx.onUngroup?.(id)}
|
||||
title="ungroup"
|
||||
>
|
||||
ungroup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="node-body">
|
||||
{collapsed ? (
|
||||
<>
|
||||
{Array.from({ length: maxRows }, (_, index) => {
|
||||
const input = proxyInputs[index];
|
||||
const output = proxyOutputs[index];
|
||||
return (
|
||||
<div className="io-row" key={`group-io-${index}`}>
|
||||
<div className="io-left">
|
||||
{input && (
|
||||
<>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={input.handleId}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[input.type] || 'var(--fallback-type)' }}
|
||||
/>
|
||||
<span className="io-label">{formatUiLabel(input.label || input.name)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="io-right">
|
||||
{output && (
|
||||
<>
|
||||
<span className="io-label">{formatUiLabel(output.label || output.name)}</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={output.handleId}
|
||||
className="typed-handle"
|
||||
style={{ background: TYPE_COLORS[output.type] || 'var(--fallback-type)' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="group-node-summary">{childCount} nodes</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="group-node-workspace">
|
||||
<div className="group-node-workspace-label">workflow group</div>
|
||||
<div className="group-node-summary">{childCount} nodes</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class PreviewBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -390,6 +487,8 @@ function getSourceTypeForInput(store, nodeId, inputName) {
|
||||
const targetHandle = `input::${inputName}::`;
|
||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||
if (!edge?.sourceHandle) return null;
|
||||
const proxy = parseProxyHandle(edge.sourceHandle);
|
||||
if (proxy) return proxy.type || null;
|
||||
const parts = edge.sourceHandle.split('::');
|
||||
return parts[2] || null;
|
||||
}
|
||||
@@ -405,8 +504,11 @@ function getConnectedOutputInfo(store, nodeId, inputName) {
|
||||
const targetHandle = `input::${inputName}::`;
|
||||
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
|
||||
if (!edge?.sourceHandle) return null;
|
||||
const sourceNode = store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
|
||||
const slot = Number.parseInt(edge.sourceHandle.split('::')[1], 10);
|
||||
const proxy = parseProxyHandle(edge.sourceHandle);
|
||||
const sourceNodeId = proxy?.nodeId || edge.source;
|
||||
const sourceHandle = proxy?.realHandle || edge.sourceHandle;
|
||||
const sourceNode = store.nodeLookup?.get(sourceNodeId) || store.nodes?.find((n) => n.id === sourceNodeId) || null;
|
||||
const slot = Number.parseInt(sourceHandle.split('::')[1], 10);
|
||||
if (!sourceNode || !Number.isInteger(slot)) return null;
|
||||
return {
|
||||
path: sourceNode.data?.definition?.output_paths?.[slot] || null,
|
||||
@@ -751,6 +853,9 @@ function NodeTable({ rows }) {
|
||||
|
||||
function CustomNode({ id, data }) {
|
||||
const ctx = useContext(NodeContext);
|
||||
if (data.className === 'Group') {
|
||||
return <GroupNode id={id} data={data} />;
|
||||
}
|
||||
const def = data.definition;
|
||||
const scalarDisplay = formatScalarDisplay(data.scalarValue);
|
||||
const processingTimeText = formatProcessingTime(data.processingTimeMs);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DATA_TYPES } from './constants';
|
||||
import { DATA_TYPES } from './constants.js';
|
||||
|
||||
function getInputName(handleId) {
|
||||
return handleId.split('::')[1];
|
||||
@@ -8,11 +8,24 @@ function getOutputSlot(handleId) {
|
||||
return parseInt(handleId.split('::')[1], 10);
|
||||
}
|
||||
|
||||
function resolveExecutionEdge(edge) {
|
||||
const original = edge?.data?.groupProxyOriginal;
|
||||
if (!original) return edge;
|
||||
return {
|
||||
...edge,
|
||||
source: original.source || edge.source,
|
||||
sourceHandle: original.sourceHandle || edge.sourceHandle,
|
||||
target: original.target || edge.target,
|
||||
targetHandle: original.targetHandle || edge.targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
export function getConnectedNodeIds(edges) {
|
||||
const connectedNodeIds = new Set();
|
||||
for (const edge of edges) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
connectedNodeIds.add(resolved.source);
|
||||
connectedNodeIds.add(resolved.target);
|
||||
}
|
||||
return connectedNodeIds;
|
||||
}
|
||||
@@ -53,6 +66,7 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
if (!runnableNodeIds.has(node.id)) continue;
|
||||
|
||||
const { className, definition, widgetValues, runtimeValues } = node.data;
|
||||
if (className === 'Group') continue;
|
||||
if (!definition) continue;
|
||||
if (excludeManualTrigger && definition.manual_trigger) continue;
|
||||
|
||||
@@ -72,7 +86,9 @@ export function serializeExecutionGraph(nodes, edges, { excludeManualTrigger = f
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = edges.filter((edge) => edge.target === node.id);
|
||||
const incoming = edges
|
||||
.map(resolveExecutionEdge)
|
||||
.filter((edge) => edge.target === node.id);
|
||||
for (const edge of incoming) {
|
||||
const inputName = getInputName(edge.targetHandle);
|
||||
const outputSlot = getOutputSlot(edge.sourceHandle);
|
||||
@@ -97,12 +113,15 @@ export function hasBlockingAutoRunInput(node, edges) {
|
||||
const required = def.input.required || {};
|
||||
for (const [name, spec] of Object.entries(required)) {
|
||||
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
|
||||
const hiddenByConnectedInput = (() => {
|
||||
const raw = opts?.hide_when_input_connected;
|
||||
if (!raw) return false;
|
||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||
return inputs.some((inputName) => edges.some(
|
||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === String(inputName)
|
||||
const hiddenByConnectedInput = (() => {
|
||||
const raw = opts?.hide_when_input_connected;
|
||||
if (!raw) return false;
|
||||
const inputs = Array.isArray(raw) ? raw : [raw];
|
||||
return inputs.some((inputName) => edges.some(
|
||||
(edge) => {
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle) === String(inputName);
|
||||
}
|
||||
));
|
||||
})();
|
||||
|
||||
@@ -114,7 +133,10 @@ export function hasBlockingAutoRunInput(node, edges) {
|
||||
}
|
||||
if (!DATA_TYPES.has(type)) continue;
|
||||
const hasEdge = edges.some(
|
||||
(edge) => edge.target === node.id && getInputName(edge.targetHandle) === name
|
||||
(edge) => {
|
||||
const resolved = resolveExecutionEdge(edge);
|
||||
return resolved.target === node.id && getInputName(resolved.targetHandle) === name;
|
||||
}
|
||||
);
|
||||
if (!hasEdge) return true;
|
||||
}
|
||||
|
||||
@@ -18,13 +18,52 @@ function clonePlainObject(value) {
|
||||
return cloneValue(value) || {};
|
||||
}
|
||||
|
||||
function collectSelectedNodeIds(nodes, nodeIds) {
|
||||
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
|
||||
if (selectedIdSet.size === 0) return selectedIdSet;
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
const parentId = node?.parentId ? String(node.parentId) : null;
|
||||
const nodeId = String(node?.id);
|
||||
if (parentId && selectedIdSet.has(parentId) && !selectedIdSet.has(nodeId)) {
|
||||
selectedIdSet.add(nodeId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectedIdSet;
|
||||
}
|
||||
|
||||
function extractExtraData(data) {
|
||||
const source = data || {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([key]) => ![
|
||||
'label',
|
||||
'className',
|
||||
'widgetValues',
|
||||
'runtimeValues',
|
||||
'definition',
|
||||
'previewImage',
|
||||
'tableRows',
|
||||
'meshData',
|
||||
'overlay',
|
||||
'scalarValue',
|
||||
'processingTimeMs',
|
||||
'warning',
|
||||
].includes(key)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNodeClipboardPayloadForIds(
|
||||
nodes,
|
||||
edges,
|
||||
nodeIds,
|
||||
{ includeIncomingExternalEdges = false } = {},
|
||||
) {
|
||||
const selectedIdSet = new Set((Array.isArray(nodeIds) ? nodeIds : []).map((id) => String(id)));
|
||||
const selectedIdSet = collectSelectedNodeIds(nodes, nodeIds);
|
||||
const selectedNodes = Array.isArray(nodes)
|
||||
? nodes.filter((node) => selectedIdSet.has(String(node.id)))
|
||||
: [];
|
||||
@@ -50,12 +89,18 @@ export function buildNodeClipboardPayloadForIds(
|
||||
x: Number(node.position?.x) || 0,
|
||||
y: Number(node.position?.y) || 0,
|
||||
},
|
||||
...(node.className ? { className: node.className } : {}),
|
||||
...(node.parentId ? { parentId: String(node.parentId) } : {}),
|
||||
...(node.extent ? { extent: node.extent } : {}),
|
||||
...(node.hidden ? { hidden: true } : {}),
|
||||
...(node.style ? { style: cloneValue(node.style) } : {}),
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
className: node.data?.className || '',
|
||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||
extraData: clonePlainObject(extractExtraData(node.data)),
|
||||
},
|
||||
})),
|
||||
edges: capturedEdges.map((edge) => ({
|
||||
@@ -64,15 +109,19 @@ export function buildNodeClipboardPayloadForIds(
|
||||
target: String(edge.target),
|
||||
targetHandle: edge.targetHandle,
|
||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||
...(edge.hidden ? { hidden: true } : {}),
|
||||
...(edge.data ? { data: cloneValue(edge.data) } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeClipboardPayload(nodes, edges) {
|
||||
const selectedIds = Array.isArray(nodes)
|
||||
? nodes.filter((node) => node?.selected).map((node) => String(node.id))
|
||||
const selectedNodes = Array.isArray(nodes)
|
||||
? nodes.filter((node) => node?.selected)
|
||||
: [];
|
||||
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds);
|
||||
const selectedIds = selectedNodes.map((node) => String(node.id));
|
||||
const includeIncomingExternalEdges = selectedNodes.some((node) => node?.data?.className === 'Group');
|
||||
return buildNodeClipboardPayloadForIds(nodes, edges, selectedIds, { includeIncomingExternalEdges });
|
||||
}
|
||||
|
||||
export function parseNodeClipboardPayload(text) {
|
||||
@@ -111,10 +160,15 @@ export function instantiateNodeClipboardPayload(
|
||||
return {
|
||||
id: newId,
|
||||
type: node.type || 'custom',
|
||||
className: node.className,
|
||||
position: {
|
||||
x: (Number(node.position?.x) || 0) + (Number(offset?.x) || 0),
|
||||
y: (Number(node.position?.y) || 0) + (Number(offset?.y) || 0),
|
||||
},
|
||||
...(node.parentId ? { parentId: idMap.get(String(node.parentId)) || String(node.parentId) } : {}),
|
||||
...(node.extent ? { extent: node.extent } : {}),
|
||||
...(node.hidden ? { hidden: true } : {}),
|
||||
...(node.style ? { style: cloneValue(node.style) } : {}),
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
selected: true,
|
||||
data: {
|
||||
@@ -122,6 +176,7 @@ export function instantiateNodeClipboardPayload(
|
||||
className,
|
||||
widgetValues: clonePlainObject(node.data?.widgetValues),
|
||||
runtimeValues: clonePlainObject(node.data?.runtimeValues),
|
||||
...(clonePlainObject(node.data?.extraData)),
|
||||
definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
@@ -147,6 +202,8 @@ export function instantiateNodeClipboardPayload(
|
||||
targetHandle: edge.targetHandle,
|
||||
selected: false,
|
||||
...(edge.style ? { style: { ...edge.style } } : {}),
|
||||
...(edge.hidden ? { hidden: true } : {}),
|
||||
...(edge.data ? { data: cloneValue(edge.data) } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -236,8 +236,104 @@ html, body, #root {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-node {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 220px;
|
||||
resize: none;
|
||||
border-style: dashed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 41, 59, 0.82), rgba(15, 23, 42, 0.72));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
|
||||
inset 0 1px 18px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.group-node-title {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.group-node-title .node-title-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.group-node-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.group-toggle {
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: var(--text-heading);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.group-toggle-collapse {
|
||||
min-width: 24px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.group-node-summary {
|
||||
padding: 6px 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.group-node .node-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.group-node-expanded .node-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.group-node-workspace {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.16), rgba(15, 23, 42, 0.34));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(15, 23, 42, 0.12),
|
||||
inset 0 12px 28px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.group-node-workspace-label {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
color: rgba(148, 163, 184, 0.58);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.group-node-expanded .group-node-summary {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 8px;
|
||||
border-top: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Let React Flow node wrapper fit to the custom-node's size */
|
||||
.react-flow__node-custom {
|
||||
.react-flow__node-custom:not(.group-shell) {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toBlob } from 'html-to-image';
|
||||
import { CANVAS_COLORS } from './constants';
|
||||
import { CANVAS_COLORS } from './constants.js';
|
||||
|
||||
export const OVERLAY_CAPTURE_SELECTORS = [
|
||||
'.lineplot-overlay',
|
||||
|
||||
@@ -40,18 +40,26 @@ export function hydrateWorkflowState(data, defs = {}) {
|
||||
return {
|
||||
...node,
|
||||
type: node.type || 'custom',
|
||||
className: node.className,
|
||||
parentId: node.parentId,
|
||||
extent: node.extent,
|
||||
hidden: !!node.hidden,
|
||||
style: node.style,
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
...node.data,
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
widgetValues: sanitizeWidgetValues(node.data?.widgetValues, definition),
|
||||
runtimeValues: {},
|
||||
runtimeValues: node.data?.runtimeValues || {},
|
||||
...(node.data?.extraData || {}),
|
||||
definition,
|
||||
previewImage: null,
|
||||
tableRows: null,
|
||||
meshData: null,
|
||||
overlay: null,
|
||||
scalarValue: null,
|
||||
processingTimeMs: null,
|
||||
warning: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
export function serializeWorkflowState(nodes, edges) {
|
||||
const compactObject = (value) => {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const entries = Object.entries(value);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||
};
|
||||
const getExtraData = (data) => compactObject(Object.fromEntries(
|
||||
Object.entries(data || {}).filter(([key]) => ![
|
||||
'label',
|
||||
'className',
|
||||
'widgetValues',
|
||||
'runtimeValues',
|
||||
'definition',
|
||||
'previewImage',
|
||||
'tableRows',
|
||||
'meshData',
|
||||
'overlay',
|
||||
'scalarValue',
|
||||
'processingTimeMs',
|
||||
'warning',
|
||||
].includes(key))
|
||||
));
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: node.type || 'custom',
|
||||
position: node.position,
|
||||
...(node.className ? { className: node.className } : {}),
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
...(node.extent ? { extent: node.extent } : {}),
|
||||
...(node.hidden ? { hidden: true } : {}),
|
||||
...(node.style ? { style: node.style } : {}),
|
||||
dragHandle: node.dragHandle || '.drag-handle',
|
||||
data: {
|
||||
label: node.data?.label || node.data?.className || 'Node',
|
||||
className: node.data?.className || '',
|
||||
widgetValues: node.data?.widgetValues || {},
|
||||
...(compactObject(node.data?.runtimeValues) ? { runtimeValues: compactObject(node.data?.runtimeValues) } : {}),
|
||||
...(getExtraData(node.data) ? { extraData: getExtraData(node.data) } : {}),
|
||||
output: node.data?.definition?.output || [],
|
||||
output_name: node.data?.definition?.output_name || [],
|
||||
},
|
||||
@@ -21,6 +50,8 @@ export function serializeWorkflowState(nodes, edges) {
|
||||
target: edge.target,
|
||||
targetHandle: edge.targetHandle,
|
||||
...(edge.style ? { style: edge.style } : {}),
|
||||
...(edge.hidden ? { hidden: true } : {}),
|
||||
...(edge.data ? { data: edge.data } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +192,73 @@ test('serializeExecutionGraph allows a singleton ImageDemo graph so previews can
|
||||
});
|
||||
});
|
||||
|
||||
test('serializeExecutionGraph ignores group shells and resolves collapsed proxy edges back to child endpoints', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '1',
|
||||
data: {
|
||||
className: 'Image',
|
||||
definition: {
|
||||
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: { filename: 'scan.gwy' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
data: {
|
||||
className: 'Group',
|
||||
definition: null,
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
parentId: '10',
|
||||
hidden: true,
|
||||
data: {
|
||||
className: 'PreviewImage',
|
||||
definition: {
|
||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
||||
manual_trigger: false,
|
||||
},
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges = [
|
||||
{
|
||||
source: '1',
|
||||
sourceHandle: 'output::0::DATA_FIELD',
|
||||
target: '10',
|
||||
targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD',
|
||||
data: {
|
||||
groupProxyOwner: '10',
|
||||
groupProxyOriginal: {
|
||||
target: '2',
|
||||
targetHandle: 'input::field::DATA_FIELD',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const prompt = serializeExecutionGraph(nodes, edges);
|
||||
|
||||
assert.deepEqual(prompt, {
|
||||
'1': {
|
||||
class_type: 'Image',
|
||||
inputs: { filename: 'scan.gwy' },
|
||||
},
|
||||
'2': {
|
||||
class_type: 'PreviewImage',
|
||||
inputs: { field: ['1', 0] },
|
||||
},
|
||||
});
|
||||
assert.equal('10' in prompt, false);
|
||||
});
|
||||
|
||||
test('getAutoRunnableNodes ignores disconnected nodes when deciding what can auto-run', () => {
|
||||
const nodes = [
|
||||
{ id: '1', data: { definition: {}, widgetValues: {} } },
|
||||
|
||||
@@ -265,3 +265,28 @@ test('clipboard payload deep-copies local widget and runtime fields', () => {
|
||||
assert.equal(payload.nodes[0].data.widgetValues.markup_shapes[0].points[0], 0.1);
|
||||
assert.equal(payload.nodes[0].data.runtimeValues.camera.azimuth, 15);
|
||||
});
|
||||
|
||||
test('clipboard payload preserves wrapper class names for group shells', () => {
|
||||
const payload = buildNodeClipboardPayloadForIds(
|
||||
[
|
||||
{
|
||||
id: '50',
|
||||
type: 'custom',
|
||||
className: 'group-shell',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'group',
|
||||
className: 'Group',
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
['50'],
|
||||
);
|
||||
|
||||
const instantiated = instantiateNodeClipboardPayload(payload, {}, 80);
|
||||
|
||||
assert.equal(payload.nodes[0].className, 'group-shell');
|
||||
assert.equal(instantiated.nodes[0].className, 'group-shell');
|
||||
});
|
||||
|
||||
@@ -226,3 +226,26 @@ test('hydrateWorkflowState clears saved folder selections on shared workflows',
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['PATH']);
|
||||
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['path']);
|
||||
});
|
||||
|
||||
test('workflow serialization preserves wrapper class names for group shells', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: '31',
|
||||
type: 'custom',
|
||||
className: 'group-shell',
|
||||
position: { x: 5, y: 15 },
|
||||
style: { width: 420, height: 260 },
|
||||
data: {
|
||||
label: 'group',
|
||||
className: 'Group',
|
||||
widgetValues: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const serialized = serializeWorkflowState(nodes, []);
|
||||
const hydrated = hydrateWorkflowState(serialized, {});
|
||||
|
||||
assert.equal(serialized.nodes[0].className, 'group-shell');
|
||||
assert.equal(hydrated.nodes[0].className, 'group-shell');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user