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 17 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
2 changes: 1 addition & 1 deletion workspace/src/app/store/ducks/error/errorSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type RegisterErrorActionType = {
/** The error that should be displayed. */
newError: Pick<DIErrorFormat, "id" | "message" | "cause" | "alternativeIntent">;
/** An optional error notification instance ID when this error should only be shown in a specific error notification widget. */
errorNotificationInstanceId?: string
errorNotificationInstanceId?: string;
};
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const shortcuts: Record<typeof sectionKeys[number], Array<{ key: string; command
},
{ key: "delete", commands: ["backspace"] },
{ key: "multiselect", commands: ["shift+mouse select"] },
{ key: "copySelectedNodes", commands: ["ctrl+c", "cmd+c"] },
{ key: "pasteNodes", commands: ["ctrl+v", "cmd+v"] },
],
"workflow-editor": [
{ key: "delete", commands: ["backspace"] },
Expand Down
4 changes: 2 additions & 2 deletions workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import utils from "./RuleEditor.utils";
import { IStickyNote } from "views/taskViews/shared/task.typings";
import { DatasetCharacteristics } from "../typings";
import { ReactFlowHotkeyContext } from "@eccenca/gui-elements/src/cmem/react-flow/extensions/ReactFlowHotkeyContext";
import {Notification} from "@eccenca/gui-elements"
import {diErrorMessage} from "@ducks/error/typings";
import { Notification } from "@eccenca/gui-elements";
import { diErrorMessage } from "@ducks/error/typings";

/** Function to fetch the rule operator spec. */
export type RuleOperatorFetchFnType = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface RuleEditorModelContextProps {
saveRule: () => Promise<boolean> | boolean;
/** If there are unsaved changes. */
unsavedChanges: boolean;
/** Number of selected nodes copied */
copiedNodesCount: number;
/** Executes an operation that will change the model. */
executeModelEditOperation: IModelActions;
/** Undo last changes. Return true if changes have been undone. */
Expand All @@ -47,6 +49,7 @@ export interface RuleEditorModelContextProps {
ruleOperatorNodes: () => IRuleOperatorNode[];
/** The ID of the rule editor canvas element. */
canvasId: string;
updateSelectedElements: (elements: Elements | null) => void;
}

export interface IModelActions {
Expand Down Expand Up @@ -86,6 +89,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 @@ -129,6 +134,8 @@ export const RuleEditorModelContext = React.createContext<RuleEditorModelContext
return false;
},
unsavedChanges: false,
copiedNodesCount: 0,
updateSelectedElements: () => {},
executeModelEditOperation: {
startChangeTransaction: NOP,
addStickyNode: NOP,
Expand All @@ -146,6 +153,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 },
});
162 changes: 162 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 { copyToClipboard } from "../../../../utils/copyToClipboard";

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

Expand Down Expand Up @@ -122,10 +124,41 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
const [utils] = React.useState(ruleEditorModelUtilsFactory());
/** ID of the rule editor canvas. This is needed for the auto-layout operation. */
const canvasId = `ruleEditor-react-flow-canvas-${ruleEditorContext.instanceId}`;
/** when a node is clicked the selected nodes appears here */
const [selectedElements, updateSelectedElements] = React.useState<Elements | null>(null);
const [copiedNodesCount, setCopiedNodesCount] = React.useState<number>(0);

/** react-flow related functions */
const { setCenter } = useZoomPanHelper();

React.useEffect(() => {
if (copiedNodesCount) {
setTimeout(() => {
setCopiedNodesCount(0);
}, 3000);
}
}, [copiedNodesCount]);

React.useEffect(() => {
const handlePaste = async (e) => await pasteNodes(e);
const handleCopy = async (e) => {
if (selectedElements) {
await copyNodes(
selectedElements.map((n) => n.id),
e
);
e.preventDefault();
}
};
window.addEventListener("paste", handlePaste);
window.addEventListener("copy", handleCopy);

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

const edgeType = (ruleOperatorNode?: IRuleOperatorNode) => {
if (ruleOperatorNode) {
switch (ruleOperatorNode.pluginType) {
Expand Down Expand Up @@ -485,6 +518,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
const addOrMergeRuleModelChange = (ruleModelChanges: RuleModelChanges) => {
const lastChange = asChangeNodeParameter(ruleUndoStack[ruleUndoStack.length - 1]);
const parameterChange = asChangeNodeParameter(ruleModelChanges);

if (
parameterChange &&
lastChange &&
Expand All @@ -495,6 +529,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
ruleUndoStack.push(ruleModelChanges);
} else {
ruleUndoStack.push(ruleModelChanges);
console.log("Rule undo stack ==>", ruleUndoStack);
}
};

Expand Down Expand Up @@ -1219,6 +1254,130 @@ 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
if (pasteInfo.task) {
changeElementsInternal((els) => {
const nodes = pasteInfo.task.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) throw new Error(`Missing plugins for operator plugin ${node.pluginId}`);
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.task.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);
}
});
startChangeTransaction();
const withNodes = addAndExecuteRuleModelChangeInternal(
RuleModelChangesFactory.addNodes(newNodes),
els
);
resetSelectedElements();
setTimeout(() => {
unsetUserSelection();
setSelectedElements([...newNodes, ...newEdges]);
}, 100);
return addAndExecuteRuleModelChangeInternal(RuleModelChangesFactory.addEdges(newEdges), withNodes);
});
}
} catch (err) {
//todo handle errors
const unExpectedTokenError = /Unexpected token/.exec(err?.message ?? "");
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);
} else {
registerError("RuleEditorModel.pasteCopiedNodes", err?.message, err);
}
}
};

const copyNodes = async (nodeIds: string[], event?: any) => {
//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 { projectId, editedItemId, editedItem } = ruleEditorContext;
const taskType = (editedItem as { type: string })?.type === "linking" ? "linking" : "transform";
const data = JSON.stringify({
task: {
data: {
nodes,
edges,
},
metaData: {
domain: PUBLIC_URL,
project: projectId,
task: editedItemId,
},
},
});
if (event) {
event.clipboardData.setData("text/plain", data);
event.preventDefault();
} else {
copyToClipboard(data);
}
setCopiedNodesCount(nodes.length);
};

/** Copy and paste nodes with a given offset. */
const copyAndPasteNodes = (nodeIds: string[], offset: XYPosition = { x: 100, y: 100 }) => {
changeElementsInternal((els) => {
Expand Down Expand Up @@ -1762,6 +1921,8 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => {
redo,
canRedo,
canvasId,
updateSelectedElements,
copiedNodesCount,
executeModelEditOperation: {
startChangeTransaction,
addNode,
Expand All @@ -1780,6 +1941,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,8 @@ export const RuleEditorCanvas = () => {
// Track current selection
const onSelectionChange = (elements: Elements | null) => {
selectionState.elements = elements;
ruleEditorUiContext.onSelection(elements);
modelContext.updateSelectedElements(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 @@ -138,6 +138,8 @@ export const RuleEditorToolbar = () => {
}
};

const numberOfCopiedNodes = modelContext.copiedNodesCount;

return (
<>
{ruleEditorContext.editorTitle ? (
Expand Down Expand Up @@ -218,6 +220,21 @@ export const RuleEditorToolbar = () => {
<ToolbarSection canGrow>
<Spacing vertical size={"small"} />
</ToolbarSection>
{numberOfCopiedNodes ? (
<>
<Icon
name="item-copy"
tooltipText={t("RuleEditor.toolbar.copiedNotificationText", {
numberOfNodes: `${numberOfCopiedNodes} node${numberOfCopiedNodes > 1 ? "s" : ""}`,
})}
tooltipProps={{
isOpen: true,
placement: "left",
}}
/>
<Spacing vertical size={"small"} />
</>
) : null}
{ruleEditorContext.additionalToolBarComponents ? ruleEditorContext.additionalToolBarComponents() : null}
{ruleEvaluationContext.evaluationResultsShown || ruleEvaluationContext.supportsEvaluation ? (
<ToolbarSection>
Expand Down
Loading