Skip to content

Commit

Permalink
feat: provide ability to rearrange steps
Browse files Browse the repository at this point in the history
Co-authored-by: seunexplicit <[email protected]>
  • Loading branch information
gitstart and seunexplicit committed Nov 13, 2023
1 parent 947ab95 commit 31ad436
Show file tree
Hide file tree
Showing 8 changed files with 290 additions and 10 deletions.
140 changes: 138 additions & 2 deletions apps/web/src/components/workflow/FlowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import cloneDeep from 'lodash.clonedeep';
import { StepTypeEnum } from '@novu/shared';

import { colors } from '@novu/design-system';
import { getChannel } from '../../utils/channels';
import {
computeNodeActualPosition,
getChannel,
getOffsetPosition,
triggerFromReplaceHandle,
} from '../../utils/channels';
import { useEnvController } from '../../hooks';
import type { IEdge, IFlowStep } from './types';

Expand All @@ -40,6 +45,8 @@ const DEFAULT_WRAPPER_STYLES = {
minHeight: '600px',
};

const ClonedNodeId = 'temp_cloned_node';

interface IFlowEditorProps extends ReactFlowProps {
steps: IFlowStep[];
dragging?: boolean;
Expand All @@ -56,6 +63,7 @@ interface IFlowEditorProps extends ReactFlowProps {
onStepInit?: (step: IFlowStep) => Promise<void>;
onGetStepError?: (i: number, errors: any) => string;
addStep?: (channelType: StepTypeEnum, id: string, index?: number) => void;
moveStepPosition?: (id: string, stepIndex: number) => void;
}

export function FlowEditor({
Expand All @@ -79,12 +87,16 @@ export function FlowEditor({
onStepInit,
onGetStepError,
addStep,
moveStepPosition,
onDelete,
onNodeClick: onClick,
...restProps
}: IFlowEditorProps) {
const { colorScheme } = useMantineColorScheme();
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const nodesRef = useRef<Array<Node<any>>>([]);
const [onRepositioning, setOnRepositioning] = useState(false);
const [edges, setEdges, onEdgesChange] = useEdgesState<IEdge>([]);
const reactFlowInstance = useReactFlow();
const { readonly } = useEnvController();
Expand Down Expand Up @@ -151,6 +163,125 @@ export function FlowEditor({
[addNewNode, reactFlowInstance, nodes, edges]
);

const updateNodeConnections = useCallback(
(updatedNodes) => {
const edgeType = edgeTypes ? 'special' : 'default';

setEdges(
updatedNodes
.slice(1, nodes.length - 1)
.map(({ id, parentNode }) => buildNewEdge(parentNode ?? '', id, edgeType))
);
},
[setEdges]
);

const onNodeDragStop = useCallback(
(_, node: Node<any>) => {
if (onRepositioning) {
// Check the direction of the swapping, if it is with a node at the top or bottom.
const isUpward = node.position.y > (nodesRef.current?.find(({ id }) => id === node.id)?.position.y ?? 0);

/*
* Filter out the cloned node & also compute every node actual position relative to the flow
* work area. This is used to sort the nodes, which helps to get the new position of node being swapped.
*/
const clonedNodes = nodes
.filter(({ id }) => id !== ClonedNodeId)
.map((mappedNode) => computeNodeActualPosition(getOffsetPosition(node.id, mappedNode, isUpward), nodes));

const filteredNodes = cloneDeep(clonedNodes).filter(({ type }) => type === 'channelNode');

// Get the initial position before the swapping.
const sourcePosition = filteredNodes.findIndex(({ id }) => id === node.id);

// Get the current position after the swapping.
const targetPosition = filteredNodes
.sort(({ actualPosition: { y: aY } }, { actualPosition: { y: bY } }) => aY - bY)
.findIndex(({ id }) => node.id === id);

/*
* Updates the positions of the nodes to their initial positions. If no swapping has occurred,
* the node is snapped back to its original position. Additionally, it handles updating the node whose
* parent node ID has been changed to the cloned ID, resetting it to its initial parent node ID.
*/
const newNodes = clonedNodes.map(({ parentNode, ...others }, index) => ({
...others,
parentNode: parentNode === ClonedNodeId ? node.id : parentNode,
position: nodesRef.current[index].position,
}));

setNodes(newNodes);

// If no swapping occurred, update the edges & return.
if (sourcePosition === targetPosition) {
updateNodeConnections(newNodes);
setOnRepositioning(false);

return;
}

moveStepPosition?.(node.id, targetPosition);
}

setOnRepositioning(false);
},
[nodes, onRepositioning, setOnRepositioning, edges]
);

const onNodeDragStart = useCallback(
(event: ReactMouseEvent, node: Node<any>) => {
if (!triggerFromReplaceHandle(event)) return;
/**
* The nodes are cloned and a new node is created.
*/
nodesRef.current = nodes;
const clonedNodes = cloneDeep(nodes);
const clonedNode = cloneDeep(node);
const childNode = clonedNodes.find(({ parentNode }) => parentNode === node.id) as Node;
clonedNode.id = ClonedNodeId;

if (childNode) childNode.parentNode = ClonedNodeId;

setOnRepositioning(true);
/*
* When the user tries to reposition a node, the connecting edge is removed so that it doesn't
* cause confusion for the user, making them think they are repositioning the node and all its descendants.
*/

setEdges((previousEdges) =>
previousEdges.map((edge) => {
const source = node.id === edge.source ? ClonedNodeId : edge.source;
const target = node.id === edge.target ? ClonedNodeId : edge.target;

return { ...edge, source, target };
})
);

setNodes([...clonedNodes, clonedNode]);

const nodeDocument = document.querySelector(`.react-flow__node[data-id="${node.id}"]`);
if (nodeDocument) nodeDocument.classList.add('swap-drag-active');
},
[setEdges, nodes]
);

const onNodeClick = useCallback(
(event: ReactMouseEvent, node: Node<any>) => {
onClick?.(event, node);

const childNode = nodes.find(({ parentNode }) => parentNode === ClonedNodeId);
if (childNode) {
childNode.parentNode = node.id;
const newNodes = nodes.filter(({ id }) => id !== ClonedNodeId);
setNodes(newNodes);

updateNodeConnections(newNodes);
}
},
[setNodes, nodes]
);

async function initializeWorkflowTree() {
let parentId = '1';
const finalNodes = [cloneDeep(triggerNode)];
Expand Down Expand Up @@ -304,6 +435,9 @@ export function FlowEditor({
minZoom={minZoom}
maxZoom={maxZoom}
defaultZoom={defaultZoom}
onNodeDragStop={onNodeDragStop}
onNodeDragStart={onNodeDragStart}
onNodeClick={onNodeClick}
{...restProps}
>
{withControls && <Controls />}
Expand All @@ -330,7 +464,9 @@ const Wrapper = styled.div<{ dark: boolean }>`
width: 280px;
height: 80px;
cursor: pointer;
&.swap-drag-active {
z-index: 1001 !important;
}
[data-blue-gradient-svg] {
stop:first-of-type {
stop-color: #4c6dd4 !important;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ interface ITemplateEditorFormContext {
onSubmit: (data: IForm) => Promise<void>;
addStep: (channelType: StepTypeEnum, id: string, stepIndex?: number) => void;
deleteStep: (index: number) => void;
moveStepPosition: (id: string, stepIndex: number) => void;
}

const TemplateEditorFormContext = createContext<ITemplateEditorFormContext>({
Expand All @@ -104,6 +105,7 @@ const TemplateEditorFormContext = createContext<ITemplateEditorFormContext>({
onSubmit: (() => {}) as any,
addStep: () => {},
deleteStep: () => {},
moveStepPosition: () => {},
});

const defaultValues: IForm = {
Expand Down Expand Up @@ -220,6 +222,16 @@ const TemplateEditorFormProvider = ({ children }) => {
[steps]
);

const moveStepPosition = useCallback(
(id: string, stepIndex: number) => {
const index = steps.fields.findIndex(({ _id: stepId }) => id === stepId);
if (index !== -1) {
steps.swap(index, stepIndex);
}
},
[steps]
);

const deleteStep = useCallback(
(index: number) => {
steps.remove(index);
Expand All @@ -238,8 +250,21 @@ const TemplateEditorFormProvider = ({ children }) => {
onSubmit,
addStep,
deleteStep,
moveStepPosition,
}),
[template, isLoading, isCreating, isUpdating, isDeleting, trigger, onSubmit, addStep, deleteStep, loadingGroups]
[
template,
isLoading,
isCreating,
isUpdating,
isDeleting,
trigger,
onSubmit,
addStep,
deleteStep,
moveStepPosition,
loadingGroups,
]
);

return (
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/pages/templates/workflow/WorkflowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { When } from '../../../components/utils/When';
import { FlowEditor } from '../../../components/workflow';
import { Button, Settings } from '@novu/design-system';
import { useEnvController } from '../../../hooks';
import { channels } from '../../../utils/channels';
import { channels, triggerFromReplaceHandle } from '../../../utils/channels';
import { errorMessage } from '../../../utils/notifications';
import { DeleteConfirmModal } from '../components/DeleteConfirmModal';
import type { IForm } from '../components/formTypes';
Expand All @@ -34,7 +34,7 @@ const nodeTypes = {
const edgeTypes = { special: AddNodeEdge };

const WorkflowEditor = () => {
const { addStep, deleteStep } = useTemplateEditorForm();
const { addStep, deleteStep, moveStepPosition } = useTemplateEditorForm();
const { channel } = useParams<{
channel: StepTypeEnum | undefined;
}>();
Expand All @@ -58,6 +58,7 @@ const WorkflowEditor = () => {
const onNodeClick = useCallback(
(event, node) => {
event.preventDefault();
if (triggerFromReplaceHandle(event)) return;

if (node.type === 'channelNode') {
navigate(basePath + `/${node.data.channelType}/${node.data.uuid}`);
Expand Down Expand Up @@ -183,6 +184,7 @@ const WorkflowEditor = () => {
onStepInit={onStepInit}
onGetStepError={onGetStepError}
onNodeClick={onNodeClick}
moveStepPosition={moveStepPosition}
/>
</div>
</div>
Expand Down Expand Up @@ -248,6 +250,7 @@ const WorkflowEditor = () => {
onStepInit={onStepInit}
onGetStepError={onGetStepError}
onNodeClick={onNodeClick}
moveStepPosition={moveStepPosition}
/>
</div>
<Outlet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export default memo(({ selected }: { selected: boolean }) => {
</When>
<WorkflowNode
showDelete={false}
enableDrag={false}
Icon={BoltOutlinedGradient}
label="Workflow trigger"
active={selected}
Expand Down
Loading

0 comments on commit 31ad436

Please sign in to comment.