|
|
@ -27,14 +27,44 @@ vi.mock("@/store/workflowStore", () => ({ |
|
|
})); |
|
|
})); |
|
|
|
|
|
|
|
|
// Mock useReactFlow
|
|
|
// Mock useReactFlow
|
|
|
const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })); |
|
|
const mockFlowToScreenPosition = vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y })); |
|
|
|
|
|
const mockGetNodesBounds = vi.fn((nodeIds: string[]) => { |
|
|
|
|
|
const state = mockUseWorkflowStore((s: any) => s) as { nodes: ReturnType<typeof createMockNode>[] }; |
|
|
|
|
|
const selectedNodes = state.nodes.filter((node) => nodeIds.includes(node.id)); |
|
|
|
|
|
const nodeById = new Map(state.nodes.map((node) => [node.id, node])); |
|
|
|
|
|
const getAbsolutePosition = (node: ReturnType<typeof createMockNode>): { x: number; y: number } => { |
|
|
|
|
|
if (!node.parentId) return node.position; |
|
|
|
|
|
const parent = nodeById.get(node.parentId); |
|
|
|
|
|
const parentPosition = parent ? getAbsolutePosition(parent) : { x: 0, y: 0 }; |
|
|
|
|
|
return { |
|
|
|
|
|
x: parentPosition.x + node.position.x, |
|
|
|
|
|
y: parentPosition.y + node.position.y, |
|
|
|
|
|
}; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
const boxes = selectedNodes.map((node) => { |
|
|
|
|
|
const position = getAbsolutePosition(node); |
|
|
|
|
|
return { |
|
|
|
|
|
x: position.x, |
|
|
|
|
|
y: position.y, |
|
|
|
|
|
width: (node.style?.width as number) || node.measured?.width || 220, |
|
|
|
|
|
height: (node.style?.height as number) || node.measured?.height || 200, |
|
|
|
|
|
}; |
|
|
|
|
|
}); |
|
|
|
|
|
const minX = Math.min(...boxes.map((box) => box.x)); |
|
|
|
|
|
const minY = Math.min(...boxes.map((box) => box.y)); |
|
|
|
|
|
const maxX = Math.max(...boxes.map((box) => box.x + box.width)); |
|
|
|
|
|
const maxY = Math.max(...boxes.map((box) => box.y + box.height)); |
|
|
|
|
|
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
vi.mock("@xyflow/react", async () => { |
|
|
vi.mock("@xyflow/react", async () => { |
|
|
const actual = await vi.importActual("@xyflow/react"); |
|
|
const actual = await vi.importActual("@xyflow/react"); |
|
|
return { |
|
|
return { |
|
|
...actual, |
|
|
...actual, |
|
|
useReactFlow: () => ({ |
|
|
useReactFlow: () => ({ |
|
|
getViewport: mockGetViewport, |
|
|
flowToScreenPosition: mockFlowToScreenPosition, |
|
|
|
|
|
getNodesBounds: mockGetNodesBounds, |
|
|
}), |
|
|
}), |
|
|
}; |
|
|
}; |
|
|
}); |
|
|
}); |
|
|
@ -67,6 +97,7 @@ const createDefaultState = (overrides = {}) => ({ |
|
|
describe("MultiSelectToolbar", () => { |
|
|
describe("MultiSelectToolbar", () => { |
|
|
beforeEach(() => { |
|
|
beforeEach(() => { |
|
|
vi.clearAllMocks(); |
|
|
vi.clearAllMocks(); |
|
|
|
|
|
mockFlowToScreenPosition.mockImplementation(({ x, y }: { x: number; y: number }) => ({ x, y })); |
|
|
// Default mock implementation - no nodes selected
|
|
|
// Default mock implementation - no nodes selected
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
return selector(createDefaultState()); |
|
|
return selector(createDefaultState()); |
|
|
@ -380,6 +411,34 @@ describe("MultiSelectToolbar", () => { |
|
|
expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]); |
|
|
expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
it("should show create group button when a selected group is included", () => { |
|
|
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
|
|
|
return selector(createDefaultState({ |
|
|
|
|
|
nodes: [ |
|
|
|
|
|
createMockNode("group-node-group-1", { |
|
|
|
|
|
type: "group", |
|
|
|
|
|
position: { x: 0, y: 0 }, |
|
|
|
|
|
data: { groupId: "group-1" }, |
|
|
|
|
|
measured: { width: 520, height: 240 }, |
|
|
|
|
|
}), |
|
|
|
|
|
createMockNode("node-3", { position: { x: 700, y: 0 } }), |
|
|
|
|
|
], |
|
|
|
|
|
})); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
render( |
|
|
|
|
|
<TestWrapper> |
|
|
|
|
|
<MultiSelectToolbar /> |
|
|
|
|
|
</TestWrapper> |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const createGroupButton = screen.getByTitle("Create group"); |
|
|
|
|
|
fireEvent.click(createGroupButton); |
|
|
|
|
|
|
|
|
|
|
|
expect(screen.queryByTitle("Remove from group")).not.toBeInTheDocument(); |
|
|
|
|
|
expect(mockCreateGroup).toHaveBeenCalledWith(["group-node-group-1", "node-3"]); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
it("should show ungroup button when selected nodes are in a group", () => { |
|
|
it("should show ungroup button when selected nodes are in a group", () => { |
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
return selector(createDefaultState({ |
|
|
return selector(createDefaultState({ |
|
|
@ -513,9 +572,8 @@ describe("MultiSelectToolbar", () => { |
|
|
expect(toolbar).toHaveStyle({ transform: "translateX(-50%)" }); |
|
|
expect(toolbar).toHaveStyle({ transform: "translateX(-50%)" }); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
it("should account for viewport zoom in positioning", () => { |
|
|
it("should position toolbar from React Flow screen coordinates", () => { |
|
|
mockGetViewport.mockReturnValue({ x: 100, y: 50, zoom: 2 }); |
|
|
mockFlowToScreenPosition.mockReturnValue({ x: 520, y: 180 }); |
|
|
|
|
|
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
return selector(createDefaultState({ |
|
|
return selector(createDefaultState({ |
|
|
nodes: [ |
|
|
nodes: [ |
|
|
@ -532,7 +590,42 @@ describe("MultiSelectToolbar", () => { |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const toolbar = container.firstChild as HTMLElement; |
|
|
const toolbar = container.firstChild as HTMLElement; |
|
|
expect(toolbar).toBeInTheDocument(); |
|
|
expect(mockFlowToScreenPosition).toHaveBeenCalledWith({ x: 210, y: 0 }); |
|
|
|
|
|
expect(toolbar).toHaveStyle({ left: "520px", top: "130px" }); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
it("should use absolute bounds for nodes inside groups", () => { |
|
|
|
|
|
mockUseWorkflowStore.mockImplementation((selector) => { |
|
|
|
|
|
return selector(createDefaultState({ |
|
|
|
|
|
nodes: [ |
|
|
|
|
|
createMockNode("group-node-group-1", { |
|
|
|
|
|
type: "group", |
|
|
|
|
|
position: { x: 500, y: 300 }, |
|
|
|
|
|
data: { groupId: "group-1" }, |
|
|
|
|
|
selected: false, |
|
|
|
|
|
measured: { width: 520, height: 260 }, |
|
|
|
|
|
}), |
|
|
|
|
|
createMockNode("node-1", { |
|
|
|
|
|
position: { x: 20, y: 30 }, |
|
|
|
|
|
parentId: "group-node-group-1", |
|
|
|
|
|
groupId: "group-1", |
|
|
|
|
|
}), |
|
|
|
|
|
createMockNode("node-2", { |
|
|
|
|
|
position: { x: 260, y: 40 }, |
|
|
|
|
|
parentId: "group-node-group-1", |
|
|
|
|
|
groupId: "group-1", |
|
|
|
|
|
}), |
|
|
|
|
|
], |
|
|
|
|
|
})); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
render( |
|
|
|
|
|
<TestWrapper> |
|
|
|
|
|
<MultiSelectToolbar /> |
|
|
|
|
|
</TestWrapper> |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
expect(mockFlowToScreenPosition).toHaveBeenCalledWith({ x: 750, y: 330 }); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|