diff --git a/src/ui/src/builder/builderManager.ts b/src/ui/src/builder/builderManager.ts index 423d1af9..550f2394 100644 --- a/src/ui/src/builder/builderManager.ts +++ b/src/ui/src/builder/builderManager.ts @@ -152,6 +152,14 @@ export function generateBuilderManager() { return state.value.selection.some((s) => s.componentId === componentId); }; + const removeSelectedComponentId = (componentId: string) => { + const newSelection = state.value.selection.filter( + (c) => c.componentId === componentId, + ); + if (newSelection.length === state.value.selection.length) return; + state.value.selection = newSelection; + }; + /** @deprecated use `selectionStatus` instead */ const isSelectionActive = () => { return state.value.selection.length > 0; @@ -358,6 +366,7 @@ export function generateBuilderManager() { firstSelectedId, firstSelectedItem, isSelectionActive, + removeSelectedComponentId, setSelection, appendSelection, getSelection, diff --git a/src/ui/src/builder/settings/BuilderSettingsActions.vue b/src/ui/src/builder/settings/BuilderSettingsActions.vue index c8e0c30e..9b4a423f 100644 --- a/src/ui/src/builder/settings/BuilderSettingsActions.vue +++ b/src/ui/src/builder/settings/BuilderSettingsActions.vue @@ -193,7 +193,7 @@ const { isPasteAllowed, isDeleteAllowed, getEnabledMoves, - removeComponentSubtree, + removeComponentsSubtree, goToParent, } = useComponentActions(wf, ssbm); @@ -201,10 +201,9 @@ function deleteSelectedComponents() { if (!shortcutsInfo.value.isDeleteEnabled) return; // TODO: do one transaction - for (const { componentId } of ssbm.getSelection()) { - removeComponentSubtree(componentId); - } - ssbm.setSelection(null); + const componentIds = ssbm.getSelection().map((c) => c.componentId); + if (componentIds.length === 0) return; + removeComponentsSubtree(...componentIds); } const selectedId = ssbm.firstSelectedId; @@ -239,10 +238,6 @@ const validChildrenTypes = computed(() => { return result; }); -function clearSelection() { - ssbm.setSelection(null); -} - function addComponent(event: Event) { const definitionName = (event.target as HTMLInputElement).value; const matchingTypes = Object.entries(validChildrenTypes.value).filter( diff --git a/src/ui/src/builder/useComponentAction.spec.ts b/src/ui/src/builder/useComponentAction.spec.ts new file mode 100644 index 00000000..4fed9998 --- /dev/null +++ b/src/ui/src/builder/useComponentAction.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useComponentActions } from "./useComponentActions"; +import { Core } from "@/writerTypes.js"; +import { generateBuilderManager } from "./builderManager"; + +describe(useComponentActions.name, () => { + let core: Core; + let ssbm: ReturnType; + + beforeEach(() => { + ssbm = generateBuilderManager(); + + // @ts-expect-error we mock only necessary functions + core = { + getComponentById: vi + .fn() + .mockImplementation((id: string) => ({ id })), + getComponents: vi.fn().mockReturnValue([]), + deleteComponent: vi.fn(), + sendComponentUpdate: vi.fn(), + }; + }); + + describe("removeComponentSubtree", () => { + it("should delete the component in a transaction", () => { + const { removeComponentSubtree } = useComponentActions(core, ssbm); + + const openMutationTransaction = vi.spyOn( + ssbm, + "openMutationTransaction", + ); + + removeComponentSubtree("1"); + + expect(core.sendComponentUpdate).toHaveBeenCalledOnce(); + expect(openMutationTransaction).toHaveBeenNthCalledWith( + 1, + "delete-1", + "Delete", + ); + }); + }); + + describe("removeComponentsSubtree", () => { + it("should delete the component in a transaction", () => { + const { removeComponentsSubtree } = useComponentActions(core, ssbm); + + const openMutationTransaction = vi.spyOn( + ssbm, + "openMutationTransaction", + ); + + removeComponentsSubtree("1", "2", "3"); + + expect(core.sendComponentUpdate).toHaveBeenCalledOnce(); + expect(openMutationTransaction).toHaveBeenNthCalledWith( + 1, + "delete-1,2,3", + "Delete", + ); + }); + }); +}); diff --git a/src/ui/src/builder/useComponentActions.ts b/src/ui/src/builder/useComponentActions.ts index c3c863d6..68cc105c 100644 --- a/src/ui/src/builder/useComponentActions.ts +++ b/src/ui/src/builder/useComponentActions.ts @@ -251,34 +251,51 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) { } /** - * Removes a component and all its descendents + * Removes multiples components at the same time (including their descendents) inside an unique transaction. * * @param componentId Id of the target component */ - function removeComponentSubtree(componentId: Component["id"]): void { - const component = wf.getComponentById(componentId); - if (!component) return; - const parentId = wf.getComponentById(componentId).parentId; + function removeComponentsSubtree(...componentIds: Component["id"][]): void { + const components = componentIds + .map((i) => wf.getComponentById(i)) + .filter(Boolean); + if (components.length === 0) return; - const transactionId = `delete-${componentId}`; + const transactionId = `delete-${components.map((c) => c.id).join(",")}`; ssbm.openMutationTransaction(transactionId, `Delete`); - if (parentId) { - repositionHigherSiblings(component.id, -1); + + for (const component of components) { + if (wf.getComponentById(component.id).parentId) { + repositionHigherSiblings(component.id, -1); + } + const dependencies = getNodeDependencies(component.id); + for (const c of dependencies) { + ssbm.registerPreMutation(c); + c.outs = [ + ...c.outs.filter((out) => out.toNodeId !== component.id), + ]; + } + const subtree = getFlatComponentSubtree(component.id); + for (const c of subtree) { + ssbm.registerPreMutation(c); + wf.deleteComponent(c.id); + ssbm.removeSelectedComponentId(c.id); + } } - const dependencies = getNodeDependencies(componentId); - dependencies.map((c) => { - ssbm.registerPreMutation(c); - c.outs = [...c.outs.filter((out) => out.toNodeId !== componentId)]; - }); - const subtree = getFlatComponentSubtree(componentId); - subtree.map((c) => { - ssbm.registerPreMutation(c); - wf.deleteComponent(c.id); - }); + ssbm.closeMutationTransaction(transactionId); wf.sendComponentUpdate(); } + /** + * Removes a component and all its descendents + * + * @param componentId Id of the target component + */ + function removeComponentSubtree(componentId: Component["id"]): void { + return removeComponentsSubtree(componentId); + } + /** * Whether a target component is the root */ @@ -941,6 +958,7 @@ export function useComponentActions(wf: Core, ssbm: BuilderManager) { pasteComponent, createAndInsertComponent, removeComponentSubtree, + removeComponentsSubtree, isPasteAllowed, undo, redo,