Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/provide copy paste between linking and mapping editors cmem 5055 #857

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export interface IModelActions {
deleteEdges: (edgeIds: string[]) => void;
/** Copy and paste a selection of nodes. Move pasted selection by the defined offset. */
copyAndPasteNodes: (nodeIds: string[], offset?: XYPosition) => void;
/** Just copy a selection of nodes. */
copyNodes: (nodeIds: string[], offset?: XYPosition) => void;
/** Move a single node to a new position. */
moveNode: (nodeId: string, newPosition: XYPosition) => void;
/** changes the size of a node to the given new dimensions */
Expand Down Expand Up @@ -146,6 +148,7 @@ export const RuleEditorModelContext = React.createContext<RuleEditorModelContext
deleteEdges: NOP,
changeSize: NOP,
fixNodeInputs: NOP,
copyNodes: NOP,
changeStickyNodeProperties: NOP,
},
undo: () => false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { OnLoadParams } from "react-flow-renderer";
import { Elements, OnLoadParams } from "react-flow-renderer";

/** Context for all UI related properties. */
export interface RuleEditorUiContextProps {
Expand All @@ -26,6 +26,8 @@ export interface RuleEditorUiContextProps {
hideMinimap?: boolean;
/** Defines minimun and maximum of the available zoom levels */
zoomRange?: [number, number];
onSelection: (elements: Elements | null) => void;
selectionState: { elements: Elements | null };
}

export const RuleEditorUiContext = React.createContext<RuleEditorUiContextProps>({
Expand All @@ -41,4 +43,6 @@ export const RuleEditorUiContext = React.createContext<RuleEditorUiContextProps>
showRuleOnly: false,
hideMinimap: false,
zoomRange: [0.25, 1.5],
onSelection: () => {},
selectionState: { elements: null },
});
144 changes: 144 additions & 0 deletions workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import StickyMenuButton from "../view/components/StickyMenuButton";
import { LanguageFilterProps } from "../view/ruleNode/PathInputOperator";
import { requestRuleOperatorPluginDetails } from "@ducks/common/requests";
import useErrorHandler from "../../../../hooks/useErrorHandler";
import { PUBLIC_URL } from "../../../../constants/path";
import useHotKey from "../../../../views/shared/HotKeyHandler/HotKeyHandler";

type NodeDimensions = NodeContentProps<any>["nodeDimensions"];

Expand Down Expand Up @@ -126,6 +128,15 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
/** react-flow related functions */
const { setCenter } = useZoomPanHelper();

React.useEffect(() => {
const handlePaste = async (e) => await pasteNodes(e);
window.addEventListener("paste", handlePaste);

return () => {
window.removeEventListener("paste", handlePaste);
};
}, [nodeParameters, ruleEditorContext.operatorList]);

const edgeType = (ruleOperatorNode?: IRuleOperatorNode) => {
if (ruleOperatorNode) {
switch (ruleOperatorNode.pluginType) {
Expand Down Expand Up @@ -1219,6 +1230,138 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
}, true);
};

const pasteNodes = async (e: any) => {
try {
const clipboardData = e.clipboardData?.getData("Text");
const pasteInfo = JSON.parse(clipboardData); // Parse JSON
const context = window.location.pathname.split("/").find((path) => path === "linking")
? "linking"
: "transform";
if (pasteInfo[context]) {
changeElementsInternal((els) => {
const nodes = pasteInfo[context].data.nodes ?? [];
const nodeIdMap = new Map<string, string>();
const newNodes: RuleEditorNode[] = [];
nodes.forEach((node) => {
const position = { x: node.position.x + 100, y: node.position.y + 100 };
const op = fetchRuleOperatorByPluginId(node.pluginId, node.pluginType);
if (op) {
const newNode = createNodeInternal(
op,
position,
Object.fromEntries(nodeParameters.get(node.id) ?? new Map())
);
if (newNode) {
nodeIdMap.set(node.id, newNode.id);
newNodes.push({
...newNode,
data: {
...newNode.data,
introductionTime: {
run: 1800,
delay: 300,
},
},
});
}
}
});
const newEdges: Edge[] = [];
pasteInfo[context].data.edges.forEach((edge) => {
if (nodeIdMap.has(edge.source) && nodeIdMap.has(edge.target)) {
const newEdge = utils.createEdge(
nodeIdMap.get(edge.source)!!,
nodeIdMap.get(edge.target)!!,
edge.targetHandle!!,
edge.type ?? "step"
);
newEdges.push(newEdge);
}
});

const withNodes = addAndExecuteRuleModelChangeInternal(
RuleModelChangesFactory.addNodes(newNodes),
els
);
console.log({ newNodes, newEdges });
resetSelectedElements();
setTimeout(() => {
unsetUserSelection();
setSelectedElements([...newNodes, ...newEdges]);
}, 100);
return addAndExecuteRuleModelChangeInternal(RuleModelChangesFactory.addEdges(newEdges), withNodes);
});
}
} catch (err) {
//todo handle errors
console.log("Error ==>", err);
const unExpectedTokenError = /Unexpected token/.exec(err);
if (unExpectedTokenError) {
//that is, not the expected json format that contains nodes
registerError(
"RuleEditorModel.pasteCopiedNodes",
"No operator has been found in the pasted data",
err,
RULE_EDITOR_NOTIFICATION_INSTANCE
);
}
}
};

const copyNodes = async (nodeIds: string[]) => {
//Get nodes and related edges
const nodeIdMap = new Map<string, string>(nodeIds.map((id) => [id, id]));
const edges: Partial<Edge>[] = [];

const originalNodes = utils.nodesById(elements, nodeIds);
const nodes = originalNodes.map((node) => {
const ruleOperatorNode = node.data.businessData.originalRuleOperatorNode;
return {
id: node.id,
pluginId: ruleOperatorNode.pluginId,
pluginType: ruleOperatorNode.pluginType,
position: node.position,
};
});

elements.forEach((elem) => {
if (utils.isEdge(elem)) {
const edge = utils.asEdge(elem)!!;
if (nodeIdMap.has(edge.source) && nodeIdMap.has(edge.target)) {
//edges worthy of copying
edges.push({
source: edge.source,
target: edge.target,
targetHandle: edge.targetHandle,
type: edge.type ?? "step",
});
}
}
});
//paste to clipboard.
const [, , , project, taskType, task] = window.location.pathname.split("/");
navigator.clipboard
.writeText(
JSON.stringify({
[taskType]: {
data: {
nodes,
edges,
},
metaData: {
domain: PUBLIC_URL,
project,
task,
},
},
})
)
.catch((err) => {
//todo handle errors
console.error("ERROR ==>", err);
});
};

/** Copy and paste nodes with a given offset. */
const copyAndPasteNodes = (nodeIds: string[], offset: XYPosition = { x: 100, y: 100 }) => {
changeElementsInternal((els) => {
Expand Down Expand Up @@ -1780,6 +1923,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
deleteEdges,
moveNodes,
fixNodeInputs,
copyNodes,
},
unsavedChanges: canUndo,
isValidEdge,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export const RuleEditorCanvas = () => {
enabled: !ruleEditorUiContext.modalShown && !hotKeysDisabled,
});

useHotKey({
hotkey: "mod+c",
handler: (e) => {
const nodeIds = selectedNodeIds();
if (nodeIds.length > 0) {
modelContext.executeModelEditOperation.copyNodes(nodeIds);
}
},
enabled: !hotKeysDisabled,
});

/** Selection helper methods. */
const selectedNodeIds = (): string[] => {
const selectedNodes = modelUtils.elementNodes(selectionState.elements ?? []);
Expand Down Expand Up @@ -439,6 +450,9 @@ export const RuleEditorCanvas = () => {
cloneSelection={() => {
cloneNodes(nodeIds);
}}
copySelection={() => {
modelContext.executeModelEditOperation.copyNodes(nodeIds);
}}
/>
);
};
Expand All @@ -454,6 +468,7 @@ export const RuleEditorCanvas = () => {
// Track current selection
const onSelectionChange = (elements: Elements | null) => {
selectionState.elements = elements;
ruleEditorUiContext.onSelection(elements);
};

// Triggered after the react-flow instance has been loaded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { RuleEditorEvaluationContext, RuleEditorEvaluationContextProps } from ".
import { EvaluationActivityControl } from "./evaluation/EvaluationActivityControl";
import { Prompt } from "react-router";
import { RuleValidationError } from "../RuleEditor.typings";
import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../model/RuleEditorModel.utils";
import utils, { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../model/RuleEditorModel.utils";
import { RuleEditorBaseModal } from "./components/RuleEditorBaseModal";
import { ReactFlowHotkeyContext } from "@eccenca/gui-elements/src/cmem/react-flow/extensions/ReactFlowHotkeyContext";

Expand Down Expand Up @@ -204,6 +204,17 @@ export const RuleEditorToolbar = () => {
onClick={() => setShowCreateStickyModal(true)}
/>
<Spacing vertical size={"small"} />
<IconButton
data-test-id="rule-editor-header-sticky-btn"
name="item-copy"
text={t("RuleEditor.selection.menu.copy.label")}
onClick={() =>
modelContext.executeModelEditOperation.copyNodes(
utils.elementNodes(ruleEditorUiContext.selectionState.elements ?? []).map((n) => n.id)
)
}
/>
<Spacing vertical size={"small"} />
<Switch
data-test-id={"rule-editor-advanced-toggle"}
label={t("RuleEditor.toolbar.advancedParameterMode")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { RuleEditorOperatorSidebar } from "./sidebar/RuleEditorOperatorSidebar";
import React from "react";
import { RuleEditorCanvas } from "./RuleEditorCanvas";
import { RuleEditorUiContext } from "../contexts/RuleEditorUiContext";
import { OnLoadParams } from "react-flow-renderer";
import { Elements, OnLoadParams } from "react-flow-renderer";

interface RuleEditorViewProps {
/** When enabled only the rule is shown without side- and toolbar and any other means to edit the rule. */
Expand All @@ -24,6 +24,12 @@ export const RuleEditorView = ({ showRuleOnly, hideMinimap, zoomRange, readOnlyM
const [currentRuleNodeDescription, setCurrentRuleNodeDescription] = React.useState<string | undefined>("");
const reactFlowWrapper = React.useRef<any>(null);
const [reactFlowInstance, setReactFlowInstance] = React.useState<OnLoadParams | undefined>(undefined);
// At the moment react-flow's selection logic is buggy in some places, e.g. https://github.com/wbkd/react-flow/issues/1314
// Until fixed, we will track selections ourselves and use them where bugs exist.
const [selectionState] = React.useState<{ elements: Elements | null }>({ elements: null });
const onSelection = React.useCallback((elements: Elements | null) => {
selectionState.elements = elements;
}, []);

return (
<RuleEditorUiContext.Provider
Expand All @@ -40,6 +46,8 @@ export const RuleEditorView = ({ showRuleOnly, hideMinimap, zoomRange, readOnlyM
showRuleOnly,
hideMinimap,
zoomRange,
onSelection,
selectionState,
}}
>
<Grid verticalStretchable={true} useAbsoluteSpace={true} style={{ backgroundColor: "white" }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ interface SelectionMenuProps {
removeSelection: () => any;
/** Clone selection. */
cloneSelection: () => any;
/** Clone selection. */
copySelection: () => any;
}

/** Rule edge menu. */
export const SelectionMenu = ({ position, onClose, removeSelection, cloneSelection }: SelectionMenuProps) => {
export const SelectionMenu = ({
position,
onClose,
removeSelection,
cloneSelection,
copySelection,
}: SelectionMenuProps) => {
const [t] = useTranslation();
return (
// FIXME: CMEM-3742: Use a generic "tools" component or rename EdgeTools
Expand Down Expand Up @@ -57,6 +65,24 @@ export const SelectionMenu = ({ position, onClose, removeSelection, cloneSelecti
>
{t("RuleEditor.selection.menu.clone.label")}
</Button>
<Button
minimal
icon="item-copy"
data-test-id={"selection-menu-copy-btn"}
tooltip={t("RuleEditor.selection.menu.copy.tooltip")}
tooltipProps={{
autoFocus: false,
enforceFocus: false,
openOnTargetFocus: false,
}}
small
onClick={() => {
onClose();
copySelection();
}}
>
{t("RuleEditor.selection.menu.copy.label")}
</Button>
</EdgeTools>
);
};
4 changes: 4 additions & 0 deletions workspace/src/locales/manual/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,10 @@
"clone": {
"label": "Clone selected nodes",
"tooltip": "Clones all selected nodes and inter-connections and inserts them as new selection. Following key combination also triggers this action: CTRL/CMD + d"
},
"copy": {
"label": "Copy selected nodes",
"tooltip": "Copy all selected nodes and inter-connections and inserts them as new selection. Following key combination also triggers this action: CTRL/CMD + d"
}
}
},
Expand Down