diff --git a/workspace/src/app/store/ducks/error/errorSlice.ts b/workspace/src/app/store/ducks/error/errorSlice.ts index a858365e6f..5dc7d5c73e 100644 --- a/workspace/src/app/store/ducks/error/errorSlice.ts +++ b/workspace/src/app/store/ducks/error/errorSlice.ts @@ -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; }; }; diff --git a/workspace/src/app/views/layout/Header/KeyboardShortcutsModal.tsx b/workspace/src/app/views/layout/Header/KeyboardShortcutsModal.tsx index 266becb74a..e698769339 100644 --- a/workspace/src/app/views/layout/Header/KeyboardShortcutsModal.tsx +++ b/workspace/src/app/views/layout/Header/KeyboardShortcutsModal.tsx @@ -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"] }, diff --git a/workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx b/workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx index 301f29b583..dd6c89ebfa 100644 --- a/workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx +++ b/workspace/src/app/views/shared/RuleEditor/RuleEditor.tsx @@ -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 = ( diff --git a/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorModelContext.ts b/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorModelContext.ts index ffdb5ec796..c0ddc1acf0 100644 --- a/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorModelContext.ts +++ b/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorModelContext.ts @@ -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. */ @@ -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 { @@ -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 */ @@ -129,6 +134,8 @@ export const RuleEditorModelContext = React.createContext<RuleEditorModelContext return false; }, unsavedChanges: false, + copiedNodesCount: 0, + updateSelectedElements: () => {}, executeModelEditOperation: { startChangeTransaction: NOP, addStickyNode: NOP, @@ -146,6 +153,7 @@ export const RuleEditorModelContext = React.createContext<RuleEditorModelContext deleteEdges: NOP, changeSize: NOP, fixNodeInputs: NOP, + copyNodes: NOP, changeStickyNodeProperties: NOP, }, undo: () => false, diff --git a/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorUiContext.tsx b/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorUiContext.tsx index 091332be6d..2070ba5597 100644 --- a/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorUiContext.tsx +++ b/workspace/src/app/views/shared/RuleEditor/contexts/RuleEditorUiContext.tsx @@ -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 { @@ -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>({ @@ -41,4 +43,6 @@ export const RuleEditorUiContext = React.createContext<RuleEditorUiContextProps> showRuleOnly: false, hideMinimap: false, zoomRange: [0.25, 1.5], + onSelection: () => {}, + selectionState: { elements: null }, }); diff --git a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx index be414020e9..a6ffd339d3 100644 --- a/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx +++ b/workspace/src/app/views/shared/RuleEditor/model/RuleEditorModel.tsx @@ -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"]; @@ -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) { @@ -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 && @@ -495,6 +529,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => { ruleUndoStack.push(ruleModelChanges); } else { ruleUndoStack.push(ruleModelChanges); + console.log("Rule undo stack ==>", ruleUndoStack); } }; @@ -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) => { @@ -1762,6 +1921,8 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => { redo, canRedo, canvasId, + updateSelectedElements, + copiedNodesCount, executeModelEditOperation: { startChangeTransaction, addNode, @@ -1780,6 +1941,7 @@ export const RuleEditorModel = ({ children }: RuleEditorModelProps) => { deleteEdges, moveNodes, fixNodeInputs, + copyNodes, }, unsavedChanges: canUndo, isValidEdge, diff --git a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorCanvas.tsx b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorCanvas.tsx index b97d182d20..aeac620e1f 100644 --- a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorCanvas.tsx +++ b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorCanvas.tsx @@ -439,6 +439,9 @@ export const RuleEditorCanvas = () => { cloneSelection={() => { cloneNodes(nodeIds); }} + copySelection={() => { + modelContext.executeModelEditOperation.copyNodes(nodeIds); + }} /> ); }; @@ -454,6 +457,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 diff --git a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorToolbar.tsx b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorToolbar.tsx index 0786e6466b..446878733a 100644 --- a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorToolbar.tsx +++ b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorToolbar.tsx @@ -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"; @@ -138,6 +138,8 @@ export const RuleEditorToolbar = () => { } }; + const numberOfCopiedNodes = modelContext.copiedNodesCount; + return ( <> {ruleEditorContext.editorTitle ? ( @@ -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> diff --git a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorView.tsx b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorView.tsx index 5fd509c3ec..ef097efd00 100644 --- a/workspace/src/app/views/shared/RuleEditor/view/RuleEditorView.tsx +++ b/workspace/src/app/views/shared/RuleEditor/view/RuleEditorView.tsx @@ -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. */ @@ -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 @@ -40,6 +46,8 @@ export const RuleEditorView = ({ showRuleOnly, hideMinimap, zoomRange, readOnlyM showRuleOnly, hideMinimap, zoomRange, + onSelection, + selectionState, }} > <Grid verticalStretchable={true} useAbsoluteSpace={true} style={{ backgroundColor: "white" }}> diff --git a/workspace/src/app/views/shared/RuleEditor/view/ruleNode/RuleNodeMenu.tsx b/workspace/src/app/views/shared/RuleEditor/view/ruleNode/RuleNodeMenu.tsx index be41e8a7e2..235c0ade37 100644 --- a/workspace/src/app/views/shared/RuleEditor/view/ruleNode/RuleNodeMenu.tsx +++ b/workspace/src/app/views/shared/RuleEditor/view/ruleNode/RuleNodeMenu.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { NodeTools, NodeToolsMenuFunctions } from "@eccenca/gui-elements/src/extensions/react-flow/nodes/NodeTools"; -import { Menu, MenuItem } from "@eccenca/gui-elements"; +import { Menu, MenuDivider, MenuItem } from "@eccenca/gui-elements"; import { RuleEditorUiContext } from "../../contexts/RuleEditorUiContext"; import { RuleEditorEvaluationContext } from "../../contexts/RuleEditorEvaluationContext"; import { RuleEditorModelContext } from "../../contexts/RuleEditorModelContext"; @@ -42,18 +42,6 @@ export const RuleNodeMenu = ({ return ( <NodeTools menuButtonDataTestId={"node-menu-btn"} menuFunctionsCallback={menuFunctionsCallback}> <Menu> - <MenuItem - data-test-id="rule-node-delete-btn" - key="delete" - icon={"item-remove"} - onClick={(e) => { - e.preventDefault(); - handleDeleteNode(nodeId); - }} - text={t("RuleEditor.node.menu.remove.label")} - htmlTitle={"Hotkey: <Backspace>"} - intent="danger" - /> <MenuItem data-test-id="rule-node-clone-btn" key="clone" @@ -103,6 +91,19 @@ export const RuleNodeMenu = ({ )} /> ) : null} + <MenuDivider /> + <MenuItem + data-test-id="rule-node-delete-btn" + key="delete" + icon={"item-remove"} + onClick={(e) => { + e.preventDefault(); + handleDeleteNode(nodeId); + }} + text={t("RuleEditor.node.menu.remove.label")} + htmlTitle={"Hotkey: <Backspace>"} + intent="danger" + /> </Menu> </NodeTools> ); diff --git a/workspace/src/app/views/shared/RuleEditor/view/ruleNode/SelectionMenu.tsx b/workspace/src/app/views/shared/RuleEditor/view/ruleNode/SelectionMenu.tsx index a96619af6a..96507a7a47 100644 --- a/workspace/src/app/views/shared/RuleEditor/view/ruleNode/SelectionMenu.tsx +++ b/workspace/src/app/views/shared/RuleEditor/view/ruleNode/SelectionMenu.tsx @@ -1,7 +1,6 @@ import React from "react"; -import { EdgeTools } from "@eccenca/gui-elements/src/extensions/react-flow"; import { XYPosition } from "react-flow-renderer/dist/types"; -import { Button } from "@eccenca/gui-elements"; +import { ContextMenu, MenuItem, MenuDivider } from "@eccenca/gui-elements"; import { useTranslation } from "react-i18next"; interface SelectionMenuProps { @@ -13,50 +12,68 @@ 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 - <EdgeTools posOffset={{ left: position.x, top: position.y }} onClose={onClose}> - <Button - minimal - icon="item-remove" - data-test-id={"selection-menu-remove-btn"} - tooltip={t("RuleEditor.selection.menu.delete.tooltip")} - tooltipProps={{ - autoFocus: false, - enforceFocus: false, - openOnTargetFocus: false, - }} - small - onClick={() => { - onClose(); - removeSelection(); - }} - > - {t("RuleEditor.selection.menu.delete.label")} - </Button> - <Button - minimal - icon="item-clone" - data-test-id={"selection-menu-clone-btn"} - tooltip={t("RuleEditor.selection.menu.clone.tooltip")} - tooltipProps={{ - autoFocus: false, - enforceFocus: false, - openOnTargetFocus: false, - }} - small - onClick={() => { - onClose(); - cloneSelection(); + <div + style={{ + position: "fixed", + left: position.x, + top: position.y, + }} + > + <ContextMenu + contextOverlayProps={{ + onClose, + defaultIsOpen: true, + autoFocus: true, + interactionKind: "hover", }} + togglerElement={<div />} > - {t("RuleEditor.selection.menu.clone.label")} - </Button> - </EdgeTools> + <MenuItem + text={t("RuleEditor.selection.menu.copy.label")} + icon="item-copy" + data-test-id={"selection-menu-copy-btn"} + htmlTitle={t("RuleEditor.selection.menu.copy.tooltip")} + onClick={() => { + onClose(); + copySelection(); + }} + /> + <MenuItem + text={t("RuleEditor.selection.menu.clone.label")} + icon="item-clone" + data-test-id={"selection-menu-clone-btn"} + htmlTitle={t("RuleEditor.selection.menu.clone.tooltip")} + onClick={() => { + onClose(); + cloneSelection(); + }} + /> + <MenuDivider /> + <MenuItem + icon="item-remove" + data-test-id={"selection-menu-remove-btn"} + htmlTitle={t("RuleEditor.selection.menu.delete.tooltip")} + onClick={() => { + onClose(); + removeSelection(); + }} + intent="danger" + text={t("RuleEditor.selection.menu.delete.label")} + /> + </ContextMenu> + </div> ); }; diff --git a/workspace/src/locales/manual/en.json b/workspace/src/locales/manual/en.json index b1cef18a3a..afe7d7ec06 100644 --- a/workspace/src/locales/manual/en.json +++ b/workspace/src/locales/manual/en.json @@ -262,14 +262,16 @@ } }, "rule-editors": { - "label": "Linking editor", + "label": "Linking editor / Value formula editor", "shortcuts": { "duplicate-nodes": "Duplicate selected nodes", "undo": "Undo change", "redo": "Redo change", "delete": "Remove selected elements", "multiselect": "Select multiple nodes", - "multiselectDesc": "Select nodes either by clicking on them or by dragging a selection area" + "multiselectDesc": "Select nodes either by clicking on them or by dragging a selection area", + "copySelectedNodes": "Copy selected nodes to clipboard", + "pasteNodes": "Paste copied nodes" } }, "workflow-editor": { @@ -646,6 +648,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 to clipboard", + "tooltip": "Copy all selected nodes and inter-connections. Can be also triggered with with following keyboard shortcut: ^Ctrl + c / ⌘Command + c. The copied elements can be inserted as copies with following keyboard shortcut: ^Ctrl + v / ⌘Command + v.The copied elements can also be inserted in editors in different tabs, windows or browsers." } } }, @@ -697,7 +703,8 @@ }, "startEvaluation": "Start evaluation", "showEvaluation": "Show evaluation examples", - "hideEvaluation": "Hide evaluation examples" + "hideEvaluation": "Hide evaluation examples", + "copiedNotificationText": "Copied {{numberOfNodes}} to clipboard" } }, "taskViews": {