Browse Source

feat(workflow): 实现选中分组时创建新组替换原有分组的功能

1. 新增工作流集成测试验证分组替换逻辑
2. 重构createGroup方法,支持选中分组节点时自动替换为子节点创建新组
3. 修复多选工具栏的定位逻辑,使用react-flow内置方法计算边界
4. 更新多选工具栏显示逻辑,选中分组节点时显示创建组按钮而非取消分组
5. 新增工具栏测试用例覆盖分组创建和定位场景
feature/group
huangmin 1 month ago
parent
commit
6cc4f11f96
  1. 34
      src/components/MultiSelectToolbar.tsx
  2. 105
      src/components/__tests__/MultiSelectToolbar.test.tsx
  3. 34
      src/store/__tests__/workflowStore.integration.test.ts
  4. 31
      src/store/workflowStore.ts

34
src/components/MultiSelectToolbar.tsx

@ -17,13 +17,18 @@ const STACK_GAP = 20;
export function MultiSelectToolbar() { export function MultiSelectToolbar() {
const { t } = useI18n(); const { t } = useI18n();
const { nodes, onNodesChange, createGroup, removeNodesFromGroup } = useWorkflowStore(); const { nodes, onNodesChange, createGroup, removeNodesFromGroup } = useWorkflowStore();
const { getViewport } = useReactFlow(); const { flowToScreenPosition, getNodesBounds } = useReactFlow();
const selectedNodes = useMemo( const selectedNodes = useMemo(
() => nodes.filter((node) => node.selected), () => nodes.filter((node) => node.selected),
[nodes] [nodes]
); );
const hasSelectedGroupNode = useMemo(
() => selectedNodes.some((node) => node.type === "group"),
[selectedNodes]
);
// Check if any selected nodes are in a group // Check if any selected nodes are in a group
const selectedNodeGroups = useMemo(() => { const selectedNodeGroups = useMemo(() => {
const groupIds = new Set(selectedNodes.map((n) => n.groupId).filter(Boolean)); const groupIds = new Set(selectedNodes.map((n) => n.groupId).filter(Boolean));
@ -36,27 +41,14 @@ export function MultiSelectToolbar() {
const toolbarPosition = useMemo(() => { const toolbarPosition = useMemo(() => {
if (selectedNodes.length < 2) return null; if (selectedNodes.length < 2) return null;
const viewport = getViewport(); const bounds = getNodesBounds(selectedNodes.map((node) => node.id));
const topCenter = flowToScreenPosition({
// Find bounding box of selected nodes x: bounds.x + bounds.width / 2,
let minX = Infinity; y: bounds.y,
let minY = Infinity;
let maxX = -Infinity;
selectedNodes.forEach((node) => {
const nodeWidth = (node.style?.width as number) || node.measured?.width || 220;
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + nodeWidth);
}); });
// Convert flow coordinates to screen coordinates return { x: topCenter.x, y: topCenter.y - 50 };
const centerX = (minX + maxX) / 2; }, [flowToScreenPosition, getNodesBounds, selectedNodes]);
const screenX = centerX * viewport.zoom + viewport.x;
const screenY = minY * viewport.zoom + viewport.y - 50; // 50px above the top
return { x: screenX, y: screenY };
}, [selectedNodes, getViewport]);
const handleStackHorizontally = () => { const handleStackHorizontally = () => {
if (selectedNodes.length < 2) return; if (selectedNodes.length < 2) return;
@ -260,7 +252,7 @@ export function MultiSelectToolbar() {
<div className="w-px h-4 bg-neutral-600 mx-0.5" /> <div className="w-px h-4 bg-neutral-600 mx-0.5" />
{/* Group/Ungroup buttons */} {/* Group/Ungroup buttons */}
{someInGroup ? ( {someInGroup && !hasSelectedGroupNode ? (
<button <button
onClick={handleUngroup} onClick={handleUngroup}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors" className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"

105
src/components/__tests__/MultiSelectToolbar.test.tsx

@ -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 });
}); });
}); });
}); });

34
src/store/__tests__/workflowStore.integration.test.ts

@ -2801,6 +2801,40 @@ describe("workflowStore integration tests", () => {
expect(group.size.width).toBeGreaterThanOrEqual(300); expect(group.size.width).toBeGreaterThanOrEqual(300);
expect(group.size.height).toBeGreaterThanOrEqual(200); expect(group.size.height).toBeGreaterThanOrEqual(200);
}); });
it("should replace selected existing groups when creating a new group", () => {
useWorkflowStore.setState({
nodes: [
createTestNode("prompt-1", "prompt", { prompt: "one" }, { x: 0, y: 0 }),
createTestNode("prompt-2", "prompt", { prompt: "two" }, { x: 300, y: 0 }),
createTestNode("prompt-3", "prompt", { prompt: "three" }, { x: 700, y: 0 }),
],
edges: [],
groups: {},
});
let store = useWorkflowStore.getState();
const firstGroupId = store.createGroup(["prompt-1", "prompt-2"]);
store = useWorkflowStore.getState();
const firstGroupNodeId = `group-node-${firstGroupId}`;
const secondGroupId = store.createGroup([firstGroupNodeId, "prompt-3"]);
store = useWorkflowStore.getState();
expect(secondGroupId).toBeTruthy();
expect(store.groups[firstGroupId]).toBeUndefined();
expect(store.groups[secondGroupId]).toBeDefined();
expect(store.nodes.some((node) => node.id === firstGroupNodeId)).toBe(false);
const regroupedNodes = store.nodes.filter((node) =>
["prompt-1", "prompt-2", "prompt-3"].includes(node.id)
);
expect(regroupedNodes).toHaveLength(3);
regroupedNodes.forEach((node) => {
expect(node.groupId).toBe(secondGroupId);
expect(node.parentId).toBe(`group-node-${secondGroupId}`);
});
});
}); });
describe("addNodesToGroup with non-standard node types", () => { describe("addNodesToGroup with non-standard node types", () => {

31
src/store/workflowStore.ts

@ -1653,18 +1653,33 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
if (nodeIds.length === 0) return ""; if (nodeIds.length === 0) return "";
// Get the nodes to group const selectedIdSet = new Set(nodeIds);
const nodesToGroup = nodes.filter((n) => nodeIds.includes(n.id) && n.type !== "group"); const selectedGroupIds = new Set(
nodes
.filter((node) => node.type === "group" && selectedIdSet.has(node.id))
.map((node) => (node.data as { groupId?: string }).groupId)
.filter((groupId): groupId is string => Boolean(groupId && groups[groupId]))
);
// Get the nodes to group. If a group node is selected, replace that group with
// its child nodes so the new group becomes the only wrapper.
const nodesToGroup = nodes.filter(
(node) =>
node.type !== "group" &&
(selectedIdSet.has(node.id) || Boolean(node.groupId && selectedGroupIds.has(node.groupId)))
);
if (nodesToGroup.length === 0) return ""; if (nodesToGroup.length === 0) return "";
pushUndoCheckpoint(get, set); pushUndoCheckpoint(get, set);
// Calculate bounding box of selected nodes // Calculate bounding box of selected nodes
const nodeById = new Map(nodes.map((node) => [node.id, node])); const nodeById = new Map(nodes.map((node) => [node.id, node]));
const absolutePositionByNodeId = new Map<string, XYPosition>();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodesToGroup.forEach((node) => { nodesToGroup.forEach((node) => {
// Use measured dimensions (actual rendered size) first, then style, then type-specific defaults // Use measured dimensions (actual rendered size) first, then style, then type-specific defaults
const { width, height } = getNodeSize(node); const { width, height } = getNodeSize(node);
const absolutePosition = getAbsoluteNodePosition(node, nodeById); const absolutePosition = getAbsoluteNodePosition(node, nodeById);
absolutePositionByNodeId.set(node.id, absolutePosition);
minX = Math.min(minX, absolutePosition.x); minX = Math.min(minX, absolutePosition.x);
minY = Math.min(minY, absolutePosition.y); minY = Math.min(minY, absolutePosition.y);
@ -1706,13 +1721,14 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const groupNode = createGroupNode(newGroup); const groupNode = createGroupNode(newGroup);
const selectedIds = new Set(nodesToGroup.map((node) => node.id)); const selectedIds = new Set(nodesToGroup.map((node) => node.id));
const selectedGroupNodeIds = new Set([...selectedGroupIds].map((groupId) => getGroupNodeId(groupId)));
set((state) => ({ set((state) => ({
nodes: [ nodes: [
groupNode, groupNode,
...state.nodes.map((node) => { ...state.nodes.filter((node) => !selectedGroupNodeIds.has(node.id)).map((node) => {
if (!selectedIds.has(node.id)) return node; if (!selectedIds.has(node.id)) return node;
const absolutePosition = getAbsoluteNodePosition(node, nodeById); const absolutePosition = absolutePositionByNodeId.get(node.id) ?? getAbsoluteNodePosition(node, nodeById);
return { return {
...node, ...node,
parentId: groupNode.id, parentId: groupNode.id,
@ -1727,7 +1743,12 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
} as WorkflowNode; } as WorkflowNode;
}), }),
], ],
groups: { ...state.groups, [id]: newGroup }, groups: {
...Object.fromEntries(
Object.entries(state.groups).filter(([groupId]) => !selectedGroupIds.has(groupId))
),
[id]: newGroup,
},
hasUnsavedChanges: true, hasUnsavedChanges: true,
})); }));

Loading…
Cancel
Save