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/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`] = ` > +
+
+ +
{ 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/flow/node/__snapshots__/Node.test.tsx.snap b/src/components/flow/node/__snapshots__/Node.test.tsx.snap index 6d1927fb3..6b230970f 100644 --- a/src/components/flow/node/__snapshots__/Node.test.tsx.snap +++ b/src/components/flow/node/__snapshots__/Node.test.tsx.snap @@ -137,6 +137,15 @@ exports[`NodeComp renders a named random split 1`] = ` Split Randomly
+
+ +
): 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/components/titlebar/__snapshots__/TitleBar.test.tsx.snap b/src/components/titlebar/__snapshots__/TitleBar.test.tsx.snap index 6583af11b..e074805c0 100644 --- a/src/components/titlebar/__snapshots__/TitleBar.test.tsx.snap +++ b/src/components/titlebar/__snapshots__/TitleBar.test.tsx.snap @@ -31,6 +31,10 @@ exports[`TitleBar render confirmation should render confirmation markup 1`] = ` Send Message
+
+
+
+
+
+
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,91 @@ 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(); + + const clonedNode: RenderNode = JSON.parse(JSON.stringify(nodeToCopy)); + + const uuidMap: { [oldUUID: string]: string } = {}; + + const newNodeUUID = createUUID(); + uuidMap[clonedNode.node.uuid] = newNodeUUID; + clonedNode.node.uuid = newNodeUUID; + + if (clonedNode.node.actions) { + clonedNode.node.actions.forEach((action: AnyAction) => { + const newActionUUID = createUUID(); + uuidMap[action.uuid] = newActionUUID; + action.uuid = newActionUUID; + }); + } + + if (clonedNode.node.exits) { + clonedNode.node.exits.forEach((exit: Exit) => { + const newExitUUID = createUUID(); + uuidMap[exit.uuid] = newExitUUID; + exit.uuid = newExitUUID; + + exit.destination_uuid = null; + }); + } + + 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; + + 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; + + if (uuidMap[caseItem.category_uuid]) { + caseItem.category_uuid = uuidMap[caseItem.category_uuid]; + } + }); + } + + if (router.default_category_uuid && uuidMap[router.default_category_uuid]) { + router.default_category_uuid = uuidMap[router.default_category_uuid]; + } + } + + clonedNode.ui.position = { + left: clonedNode.ui.position.left + NODE_SPACING * 2, + top: clonedNode.ui.position.top + NODE_SPACING * 2 + }; + + clonedNode.inboundConnections = {}; + delete clonedNode.ghost; + + const updatedNodes = mutators.mergeNode(nodes, clonedNode); + dispatch(updateNodes(updatedNodes)); + markDirty(); + + return updatedNodes; +}; + export const onUpdateCanvasPositions = (positions: CanvasPositions) => ( dispatch: DispatchWithState, getState: GetState