From 89911267c57d182a84ca56be68752cc806caa1ab Mon Sep 17 00:00:00 2001 From: priyanshu6238 Date: Mon, 5 Jan 2026 10:45:49 +0530 Subject: [PATCH 1/3] feat: add copy functionality for actions and nodes, including UI updates --- src/components/flow/actions/action/Action.tsx | 30 +++-- src/components/flow/node/Node.tsx | 21 ++-- src/components/titlebar/TitleBar.module.scss | 20 +++- src/components/titlebar/TitleBar.tsx | 24 ++++ src/store/thunks.ts | 103 ++++++++++++++++++ 5 files changed, 182 insertions(+), 16 deletions(-) diff --git a/src/components/flow/actions/action/Action.tsx b/src/components/flow/actions/action/Action.tsx index 1566b44e2..6d896e446 100644 --- a/src/components/flow/actions/action/Action.tsx +++ b/src/components/flow/actions/action/Action.tsx @@ -17,7 +17,9 @@ import { moveActionUp, OnOpenNodeEditor, onOpenNodeEditor, - removeAction + removeAction, + CopyNode, + copyNode } from 'store/thunks'; import { createClickHandler, getLocalization } from 'utils'; @@ -43,6 +45,7 @@ export interface ActionWrapperStoreProps { onOpenNodeEditor: OnOpenNodeEditor; removeAction: ActionAC; moveActionUp: ActionAC; + copyNode: CopyNode; scrollToAction: string; } @@ -98,6 +101,17 @@ export class ActionWrapper extends React.Component { this.props.moveActionUp(this.props.renderNode.node.uuid, this.props.action); } + public handleCopy(event: React.MouseEvent): void { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + // Only show copy on the first action to copy the entire node + if (this.props.first) { + this.props.copyNode(this.props.renderNode); + } + } + public getAction(): Action { // if we are translating, us our localized version if (this.props.translating) { @@ -196,6 +210,8 @@ export class ActionWrapper extends React.Component { showRemoval={showRemoval} showMove={showMove} onMoveUp={this.handleMoveUp} + showCopy={!this.props.translating && this.props.first} + onCopy={this.handleCopy} shouldCancelClick={() => this.props.selected} />
@@ -243,16 +259,14 @@ const mapDispatchToProps = (dispatch: DispatchWithState) => { onOpenNodeEditor, removeAction, - moveActionUp + moveActionUp, + copyNode }, dispatch ); -const ConnectedActionWrapper = connect( - mapStateToProps, - mapDispatchToProps, - null, - { forwardRef: true } -)(ActionWrapper); +const ConnectedActionWrapper = connect(mapStateToProps, mapDispatchToProps, null, { + forwardRef: true +})(ActionWrapper); export default ConnectedActionWrapper; diff --git a/src/components/flow/node/Node.tsx b/src/components/flow/node/Node.tsx index 03eb45ba6..840993bfb 100644 --- a/src/components/flow/node/Node.tsx +++ b/src/components/flow/node/Node.tsx @@ -33,7 +33,9 @@ import { OnOpenNodeEditor, onOpenNodeEditor, RemoveNode, - removeNode + removeNode, + CopyNode, + copyNode } from 'store/thunks'; import { ClickHandler, createClickHandler } from 'utils'; @@ -75,6 +77,7 @@ export interface NodeStoreProps { onAddToNode: OnAddToNode; onOpenNodeEditor: OnOpenNodeEditor; removeNode: RemoveNode; + copyNode: CopyNode; mergeEditorState: MergeEditorState; scrollToNode: string; scrollToAction: string; @@ -211,6 +214,12 @@ export class NodeComp extends React.PureComponent { this.props.removeNode(this.props.renderNode.node); } + private handleCopy(event: React.MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.props.copyNode(this.props.renderNode); + } + private getExits(): JSX.Element[] { if (this.props.renderNode.node.exits) { return this.props.renderNode.node.exits.map((exit: Exit, idx: number) => ( @@ -370,6 +379,8 @@ export class NodeComp extends React.PureComponent { nodeUUID={showLabel && this.props.nodeUUID} showRemoval={!this.props.translating} onRemoval={this.handleRemoval} + showCopy={!this.props.translating} + onCopy={this.handleCopy} shouldCancelClick={this.handleShouldCancelClick} title={title} /> @@ -542,14 +553,10 @@ const mapDispatchToProps = (dispatch: DispatchWithState) => onAddToNode, onOpenNodeEditor, removeNode, + copyNode, mergeEditorState }, dispatch ); -export default connect( - mapStateToProps, - mapDispatchToProps, - null, - { forwardRef: true } -)(NodeComp); +export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(NodeComp); diff --git a/src/components/titlebar/TitleBar.module.scss b/src/components/titlebar/TitleBar.module.scss index 7dd3673be..6f58a9501 100644 --- a/src/components/titlebar/TitleBar.module.scss +++ b/src/components/titlebar/TitleBar.module.scss @@ -37,12 +37,26 @@ } } + .copy_button { + padding-right: 5px; + width: 20px; + color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; + } + .remove_button { padding-right: 0px; visibility: hidden; width: 20px; - right: 0; color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; } .up_button { @@ -53,6 +67,10 @@ } &:hover { + .copy_button { + opacity: 1; + } + .remove_button { visibility: visible; } diff --git a/src/components/titlebar/TitleBar.tsx b/src/components/titlebar/TitleBar.tsx index e01baef3a..8cb8a5b61 100644 --- a/src/components/titlebar/TitleBar.tsx +++ b/src/components/titlebar/TitleBar.tsx @@ -13,6 +13,8 @@ export interface TitleBarProps { showRemoval?: boolean; showMove?: boolean; onMoveUp?(event: React.MouseEvent): any; + onCopy?(event: React.MouseEvent): any; + showCopy?: boolean; shouldCancelClick?: () => boolean; } @@ -106,6 +108,26 @@ export default class TitleBar extends React.Component + +
+ ); + } + + return
; + } + private getRemove(): JSX.Element { let remove: JSX.Element = (
@@ -159,6 +181,7 @@ export default class TitleBar extends React.Component @@ -167,6 +190,7 @@ export default class TitleBar extends React.Component {this.props.title} {this.props.nodeUUID && `: ${this.props.nodeUUID.slice(-4)}`} + {copy} {remove} {confirmation} diff --git a/src/store/thunks.ts b/src/store/thunks.ts index 1689fcc3d..40b04cf6f 100644 --- a/src/store/thunks.ts +++ b/src/store/thunks.ts @@ -10,6 +10,7 @@ import { Action, AnyAction, Category, + Case, Dimensions, Endpoints, Exit, @@ -19,6 +20,7 @@ import { SetContactField, SetRunResult, StickyNote, + SwitchRouter, FlowDetails } from 'flowTypes'; import mutate from 'immutability-helper'; @@ -96,6 +98,8 @@ export type AddAsset = (assetType: string, asset: Asset) => Thunk; export type RemoveNode = (nodeToRemove: FlowNode) => Thunk; +export type CopyNode = (nodeToCopy: RenderNode) => Thunk; + export type UpdateDimensions = (uuid: string, dimensions: Dimensions) => Thunk; export type FetchFlow = ( @@ -949,6 +953,105 @@ export const onRemoveNodes = (uuids: string[]) => ( return nodes; }; +/** + * Creates a deep copy of a node with new UUIDs and offset position + * @param nodeToCopy the RenderNode to copy + */ +export const copyNode = (nodeToCopy: RenderNode) => ( + dispatch: DispatchWithState, + getState: GetState +): RenderNodeMap => { + const { + flowContext: { nodes } + } = getState(); + + // Deep clone the node + const clonedNode: RenderNode = JSON.parse(JSON.stringify(nodeToCopy)); + + // Create UUID mapping for all UUIDs that need to be remapped + const uuidMap: { [oldUUID: string]: string } = {}; + + // Remap node UUID + const newNodeUUID = createUUID(); + uuidMap[clonedNode.node.uuid] = newNodeUUID; + clonedNode.node.uuid = newNodeUUID; + + // Remap action UUIDs + if (clonedNode.node.actions) { + clonedNode.node.actions.forEach((action: AnyAction) => { + const newActionUUID = createUUID(); + uuidMap[action.uuid] = newActionUUID; + action.uuid = newActionUUID; + }); + } + + // Remap exit UUIDs + if (clonedNode.node.exits) { + clonedNode.node.exits.forEach((exit: Exit) => { + const newExitUUID = createUUID(); + uuidMap[exit.uuid] = newExitUUID; + exit.uuid = newExitUUID; + // Clear destination since it's a copy + exit.destination_uuid = null; + }); + } + + // Remap router category and case UUIDs if router exists + if (clonedNode.node.router) { + const router = clonedNode.node.router as SwitchRouter; + + if (router.categories) { + router.categories.forEach((category: Category) => { + const newCategoryUUID = createUUID(); + uuidMap[category.uuid] = newCategoryUUID; + category.uuid = newCategoryUUID; + + // Remap exit_uuid reference + if (uuidMap[category.exit_uuid]) { + category.exit_uuid = uuidMap[category.exit_uuid]; + } + }); + } + + if (router.cases) { + router.cases.forEach((caseItem: Case) => { + const newCaseUUID = createUUID(); + uuidMap[caseItem.uuid] = newCaseUUID; + caseItem.uuid = newCaseUUID; + + // Remap category_uuid reference + if (uuidMap[caseItem.category_uuid]) { + caseItem.category_uuid = uuidMap[caseItem.category_uuid]; + } + }); + } + + // Remap default_category_uuid + if (router.default_category_uuid && uuidMap[router.default_category_uuid]) { + router.default_category_uuid = uuidMap[router.default_category_uuid]; + } + } + + // Offset position to avoid overlap (offset by NODE_SPACING) + clonedNode.ui.position = { + left: clonedNode.ui.position.left + NODE_SPACING * 2, + top: clonedNode.ui.position.top + NODE_SPACING * 2 + }; + + // Clear inbound connections since it's a copy + clonedNode.inboundConnections = {}; + + // Remove ghost flag if present + delete clonedNode.ghost; + + // Add the copied node to the flow + const updatedNodes = mutators.mergeNode(nodes, clonedNode); + dispatch(updateNodes(updatedNodes)); + markDirty(); + + return updatedNodes; +}; + export const onUpdateCanvasPositions = (positions: CanvasPositions) => ( dispatch: DispatchWithState, getState: GetState From a0ee93e8e8aaeee800ca13fe0a3ea9f0b051f1a8 Mon Sep 17 00:00:00 2001 From: priyanshu6238 Date: Thu, 8 Jan 2026 15:28:27 +0530 Subject: [PATCH 2/3] refactor: copy node file --- src/store/thunks.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/store/thunks.ts b/src/store/thunks.ts index 40b04cf6f..b553d1587 100644 --- a/src/store/thunks.ts +++ b/src/store/thunks.ts @@ -965,18 +965,14 @@ export const copyNode = (nodeToCopy: RenderNode) => ( flowContext: { nodes } } = getState(); - // Deep clone the node const clonedNode: RenderNode = JSON.parse(JSON.stringify(nodeToCopy)); - // Create UUID mapping for all UUIDs that need to be remapped const uuidMap: { [oldUUID: string]: string } = {}; - // Remap node UUID const newNodeUUID = createUUID(); uuidMap[clonedNode.node.uuid] = newNodeUUID; clonedNode.node.uuid = newNodeUUID; - // Remap action UUIDs if (clonedNode.node.actions) { clonedNode.node.actions.forEach((action: AnyAction) => { const newActionUUID = createUUID(); @@ -985,18 +981,16 @@ export const copyNode = (nodeToCopy: RenderNode) => ( }); } - // Remap exit UUIDs if (clonedNode.node.exits) { clonedNode.node.exits.forEach((exit: Exit) => { const newExitUUID = createUUID(); uuidMap[exit.uuid] = newExitUUID; exit.uuid = newExitUUID; - // Clear destination since it's a copy + exit.destination_uuid = null; }); } - // Remap router category and case UUIDs if router exists if (clonedNode.node.router) { const router = clonedNode.node.router as SwitchRouter; @@ -1006,7 +1000,6 @@ export const copyNode = (nodeToCopy: RenderNode) => ( uuidMap[category.uuid] = newCategoryUUID; category.uuid = newCategoryUUID; - // Remap exit_uuid reference if (uuidMap[category.exit_uuid]) { category.exit_uuid = uuidMap[category.exit_uuid]; } @@ -1019,32 +1012,25 @@ export const copyNode = (nodeToCopy: RenderNode) => ( uuidMap[caseItem.uuid] = newCaseUUID; caseItem.uuid = newCaseUUID; - // Remap category_uuid reference if (uuidMap[caseItem.category_uuid]) { caseItem.category_uuid = uuidMap[caseItem.category_uuid]; } }); } - // Remap default_category_uuid if (router.default_category_uuid && uuidMap[router.default_category_uuid]) { router.default_category_uuid = uuidMap[router.default_category_uuid]; } } - // Offset position to avoid overlap (offset by NODE_SPACING) clonedNode.ui.position = { left: clonedNode.ui.position.left + NODE_SPACING * 2, top: clonedNode.ui.position.top + NODE_SPACING * 2 }; - // Clear inbound connections since it's a copy clonedNode.inboundConnections = {}; - - // Remove ghost flag if present delete clonedNode.ghost; - // Add the copied node to the flow const updatedNodes = mutators.mergeNode(nodes, clonedNode); dispatch(updateNodes(updatedNodes)); markDirty(); From 6be058ec760073bd1d1e92d1c62f8761d4efb958 Mon Sep 17 00:00:00 2001 From: priyanshu6238 Date: Thu, 8 Jan 2026 16:23:42 +0530 Subject: [PATCH 3/3] fix: test case --- .../action/__snapshots__/Action.test.ts.snap | 14 +++++++++++ .../action/__snapshots__/Action.test.tsx.snap | 13 ++++++++++ .../node/__snapshots__/Node.test.tsx.snap | 9 +++++++ .../__snapshots__/TitleBar.test.tsx.snap | 24 +++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/src/components/flow/actions/action/__snapshots__/Action.test.ts.snap b/src/components/flow/actions/action/__snapshots__/Action.test.ts.snap index 936a2df26..585ecc52a 100644 --- a/src/components/flow/actions/action/__snapshots__/Action.test.ts.snap +++ b/src/components/flow/actions/action/__snapshots__/Action.test.ts.snap @@ -17,9 +17,11 @@ exports[`ActionWrapper render should display hybrid style 1`] = ` > +
+
+ +
+
+ +
+
+
+
+
+
+