feat(canvas): implement edge insertion reflow and enhance connection validation
- Introduced a new CSS transition for edge insertion reflowing to improve visual feedback during node adjustments. - Enhanced the connection validation logic to include options for optimistic edges, ensuring better handling of edge creation scenarios. - Updated the canvas connection drop menu to support additional templates and improved edge insertion handling. - Refactored edge insertion logic to accommodate local node position adjustments during reflow operations. - Added tests for new edge insertion features and connection validation improvements.
This commit is contained in:
@@ -26,6 +26,8 @@ const latestHandlersRef: {
|
||||
type HarnessProps = {
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
liveNodes?: RFNode[];
|
||||
liveEdges?: RFEdge[];
|
||||
runBatchRemoveNodesMutation: ReturnType<typeof vi.fn>;
|
||||
runCreateEdgeMutation: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@@ -33,6 +35,11 @@ type HarnessProps = {
|
||||
function HookHarness(props: HarnessProps) {
|
||||
const deletingNodeIds = useRef(new Set<string>());
|
||||
const [, setAssetBrowserTargetNodeId] = useState<string | null>(null);
|
||||
const nodesRef = useRef<RFNode[]>(props.liveNodes ?? props.nodes);
|
||||
const edgesRef = useRef<RFEdge[]>(props.liveEdges ?? props.edges);
|
||||
|
||||
nodesRef.current = props.liveNodes ?? props.nodes;
|
||||
edgesRef.current = props.liveEdges ?? props.edges;
|
||||
|
||||
const handlers = useCanvasDeleteHandlers({
|
||||
t: ((key: string, values?: Record<string, unknown>) =>
|
||||
@@ -40,6 +47,8 @@ function HookHarness(props: HarnessProps) {
|
||||
canvasId: asCanvasId("canvas-1"),
|
||||
nodes: props.nodes,
|
||||
edges: props.edges,
|
||||
nodesRef,
|
||||
edgesRef,
|
||||
deletingNodeIds,
|
||||
setAssetBrowserTargetNodeId,
|
||||
runBatchRemoveNodesMutation: props.runBatchRemoveNodesMutation,
|
||||
@@ -57,10 +66,14 @@ function HookHarness(props: HarnessProps) {
|
||||
describe("useCanvasDeleteHandlers", () => {
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleInfoSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
afterEach(async () => {
|
||||
latestHandlersRef.current = null;
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy?.mockRestore();
|
||||
consoleInfoSpy?.mockRestore();
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
@@ -132,4 +145,298 @@ describe("useCanvasDeleteHandlers", () => {
|
||||
targetHandle: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs bridge payload details when bridge edge creation fails", async () => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const bridgeError = new Error("Render accepts only image input");
|
||||
const runCreateEdgeMutation = vi.fn(async () => {
|
||||
throw bridgeError;
|
||||
});
|
||||
|
||||
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-color",
|
||||
type: "color-adjust",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const renderNode: RFNode = { id: "node-render", type: "render", position: { x: 400, y: 0 }, data: {} };
|
||||
|
||||
const edges: RFEdge[] = [
|
||||
{ id: "edge-in", source: "node-image", target: "node-color" },
|
||||
{ id: "edge-out", source: "node-color", target: "node-render" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [imageNode, deletedNode, renderNode],
|
||||
edges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"[Canvas] bridge edge create failed",
|
||||
expect.objectContaining({
|
||||
canvasId: "canvas-1",
|
||||
deletedNodeIds: ["node-color"],
|
||||
bridgeCreate: {
|
||||
sourceNodeId: "node-image",
|
||||
targetNodeId: "node-render",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
},
|
||||
error: bridgeError,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips invalid bridge edges that violate the connection policy", async () => {
|
||||
consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const imageNode: RFNode = { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} };
|
||||
const sourceCurvesNode: RFNode = {
|
||||
id: "node-curves-source",
|
||||
type: "curves",
|
||||
position: { x: 120, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-curves-deleted",
|
||||
type: "curves",
|
||||
position: { x: 240, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const targetCurvesNode: RFNode = {
|
||||
id: "node-curves-target",
|
||||
type: "curves",
|
||||
position: { x: 360, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const edges: RFEdge[] = [
|
||||
{ id: "edge-image-target", source: "node-image", target: "node-curves-target" },
|
||||
{ id: "edge-source-deleted", source: "node-curves-source", target: "node-curves-deleted" },
|
||||
{ id: "edge-deleted-target", source: "node-curves-deleted", target: "node-curves-target" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [imageNode, sourceCurvesNode, deletedNode, targetCurvesNode],
|
||||
edges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"[Canvas] skipped invalid bridge edge after delete",
|
||||
expect.objectContaining({
|
||||
canvasId: "canvas-1",
|
||||
deletedNodeIds: ["node-curves-deleted"],
|
||||
bridgeCreate: {
|
||||
sourceNodeId: "node-curves-source",
|
||||
targetNodeId: "node-curves-target",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
},
|
||||
validationError: "adjustment-incoming-limit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses live graph refs to avoid creating duplicate bridge edges", async () => {
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const sourceNode: RFNode = {
|
||||
id: "node-light-adjust",
|
||||
type: "light-adjust",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-middle",
|
||||
type: "color-adjust",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const renderNode: RFNode = {
|
||||
id: "node-render",
|
||||
type: "render",
|
||||
position: { x: 400, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const staleEdges: RFEdge[] = [
|
||||
{ id: "edge-source-middle", source: "node-light-adjust", target: "node-middle" },
|
||||
{ id: "edge-middle-render", source: "node-middle", target: "node-render" },
|
||||
];
|
||||
const liveEdges: RFEdge[] = [
|
||||
...staleEdges,
|
||||
{ id: "edge-source-render", source: "node-light-adjust", target: "node-render" },
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [sourceNode, deletedNode, renderNode],
|
||||
edges: staleEdges,
|
||||
liveNodes: [sourceNode, deletedNode, renderNode],
|
||||
liveEdges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips bridge edges when only an optimistic incoming edge already occupies the target", async () => {
|
||||
let resolveBatchRemove: (() => void) | null = null;
|
||||
const runBatchRemoveNodesMutation = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveBatchRemove = resolve;
|
||||
}),
|
||||
);
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
|
||||
const sourceNode: RFNode = {
|
||||
id: "node-image-source",
|
||||
type: "image",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const otherSourceNode: RFNode = {
|
||||
id: "node-image-other",
|
||||
type: "image",
|
||||
position: { x: 0, y: 120 },
|
||||
data: {},
|
||||
};
|
||||
const deletedNode: RFNode = {
|
||||
id: "node-middle-adjust",
|
||||
type: "curves",
|
||||
position: { x: 200, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
const targetNode: RFNode = {
|
||||
id: "node-target-adjust",
|
||||
type: "color-adjust",
|
||||
position: { x: 400, y: 0 },
|
||||
data: {},
|
||||
};
|
||||
|
||||
const liveEdges: RFEdge[] = [
|
||||
{ id: "edge-source-middle", source: "node-image-source", target: "node-middle-adjust" },
|
||||
{ id: "edge-middle-target", source: "node-middle-adjust", target: "node-target-adjust" },
|
||||
{
|
||||
id: "optimistic_edge_existing",
|
||||
source: "node-image-other",
|
||||
target: "node-target-adjust",
|
||||
},
|
||||
];
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
nodes: [sourceNode, otherSourceNode, deletedNode, targetNode],
|
||||
edges: liveEdges,
|
||||
liveNodes: [sourceNode, otherSourceNode, deletedNode, targetNode],
|
||||
liveEdges,
|
||||
runBatchRemoveNodesMutation,
|
||||
runCreateEdgeMutation,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onNodesDelete([deletedNode]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveBatchRemove?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user