Skip to content
Merged
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
5 changes: 3 additions & 2 deletions GUI/src/components/Flow/McqBranchSelectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ type McqBranchSelectModalProps = {
readonly emptyBranches: McqEmptyBranch[];
readonly onSelect: (branch: McqEmptyBranch) => void;
readonly onClose: () => void;
readonly description?: string;
};

const McqBranchSelectModal: FC<McqBranchSelectModalProps> = ({ emptyBranches, onSelect, onClose }) => {
const McqBranchSelectModal: FC<McqBranchSelectModalProps> = ({ emptyBranches, onSelect, onClose, description }) => {
const { t } = useTranslation();

return (
<Modal title={t('serviceFlow.mcq.selectBranchTitle')} onClose={onClose}>
<p>{t('serviceFlow.mcq.emptyBranchesMessage', { count: emptyBranches.length })}</p>
<p>{description ?? t('serviceFlow.mcq.emptyBranchesMessage', { count: emptyBranches.length })}</p>
<Track direction="vertical" gap={8} align="left" style={{ marginTop: 16, width: '100%' }}>
{emptyBranches.map((branch) => (
<Button key={branch.edgeId} className="mcq-branch-select-modal__button" onClick={() => onSelect(branch)}>
Expand Down
33 changes: 30 additions & 3 deletions GUI/src/components/Flow/NodeTypes/CustomNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MdDeleteOutline, MdOutlineEdit, MdOutlineRemoveRedEye } from 'react-ico
import useServiceStore from 'store/services.store';
import { StepType } from 'types';
import { NodeDataProps } from 'types/service-flow';
import { CONDITION_SOURCE_HANDLE_ID, conditionHasEmptyBranches } from 'utils/conditional-flow-utils';
import { MCQ_SOURCE_HANDLE_ID, mcqHasEmptyBranches } from 'utils/mcq-flow-utils';

import StepNode from './StepNode';
Expand All @@ -20,20 +21,34 @@ const CustomNode: FC<NodeProps & CustomNodeProps> = (props) => {
const { data, isConnectable, id } = props;
const orientation = useServiceStore((state) => state.orientation);
const isMcq = data.stepType === StepType.MultiChoiceQuestion;
const shouldOffsetHandles = !isMcq && data.childrenCount > 1;
const isCondition = data.stepType === StepType.Condition;
const shouldOffsetHandles = !isMcq && !isCondition && data.childrenCount > 1;

const edges = useStore((state) => state.edges);
const nodes = useStore((state) => state.nodes);

const mcqCanConnect = useMemo(() => !isMcq || mcqHasEmptyBranches(id, nodes, edges), [edges, id, isMcq, nodes]);
const conditionCanConnect = useMemo(
() => !isCondition || conditionHasEmptyBranches(id, nodes, edges),
[edges, id, isCondition, nodes],
);

const canConnect = isConnectable && mcqCanConnect;
const canConnect = isConnectable && mcqCanConnect && conditionCanConnect;

const updateNodeInternals = useUpdateNodeInternals();

useEffect(() => {
updateNodeInternals(id);
}, [data.childrenCount, id, isMcq, mcqCanConnect, updateNodeInternals, orientation]);
}, [
data.childrenCount,
id,
isMcq,
isCondition,
mcqCanConnect,
conditionCanConnect,
updateNodeInternals,
orientation,
]);

const isFinishingStep = () => {
return data.type === 'finishing-step';
Expand All @@ -60,6 +75,18 @@ const CustomNode: FC<NodeProps & CustomNodeProps> = (props) => {
);
}

if (isCondition) {
return (
<Handle
id={CONDITION_SOURCE_HANDLE_ID}
type="source"
position={getSourcePosition()}
isConnectable={canConnect}
hidden={isFinishingStep()}
/>
);
}

return (
<>
{new Array(data.childrenCount).fill(0).map((_, i) => (
Expand Down
9 changes: 7 additions & 2 deletions GUI/src/components/FlowBuilder/FlowBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ const FlowBuilder: FC<FlowBuilderProps> = ({ nodes, edges }) => {
setDeletedNodes(null);
try {
if (edgesToDelete.length > 0 && nodesToDelete.length === 0) {
const shouldPreventDelete = getNode(edgesToDelete[0].source)?.data.stepType === StepType.MultiChoiceQuestion;
if (shouldPreventDelete) {
const sourceStepType = getNode(edgesToDelete[0].source)?.data.stepType;
if (sourceStepType === StepType.MultiChoiceQuestion) {
return Promise.resolve(false);
}
}
Expand Down Expand Up @@ -250,6 +250,11 @@ const FlowBuilder: FC<FlowBuilderProps> = ({ nodes, edges }) => {
emptyBranches={pendingConnection.emptyBranches}
onSelect={confirmBranch}
onClose={cancelBranchSelection}
description={
pendingConnection.nodeType === StepType.Condition
? t('serviceFlow.condition.emptyPathsMessage', { count: pendingConnection.emptyBranches.length })
: undefined
}
/>
)}
</>
Expand Down
111 changes: 90 additions & 21 deletions GUI/src/hooks/flow/useMcqConnect.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Connection, useReactFlow } from '@xyflow/react';
import { useCallback, useState } from 'react';
import useServiceStore from 'store/new-services.store';
import { StepType } from 'types';
import {
applyConditionBranchConnection,
conditionHasEmptyBranches,
getConditionNodeIdFromConnection,
getEmptyConditionBranches,
} from 'utils/conditional-flow-utils';
import {
applyMcqBranchConnection,
applySimpleConnection,
Expand All @@ -12,6 +19,7 @@ import {
export type PendingMcqConnection = {
readonly connection: Connection;
readonly emptyBranches: McqEmptyBranch[];
readonly nodeType: StepType.MultiChoiceQuestion | StepType.Condition;
};

function useMcqConnect() {
Expand Down Expand Up @@ -49,7 +57,26 @@ function useMcqConnect() {
[commitConnection, getEdges, getNodes],
);

const applyMcqIncomingConnection = useCallback(
const applyConditionOutgoingConnection = useCallback(
(connection: Connection, branch: McqEmptyBranch) => {
const { source, target } = connection;
if (!source || !target) return;

const nodes = getNodes();
const edges = getEdges();
const result = applyConditionBranchConnection({
nodes,
edges,
conditionId: source,
targetId: target,
branch,
});
commitConnection(result.nodes, result.edges);
},
[commitConnection, getEdges, getNodes],
);

const applyIncomingConnection = useCallback(
(connection: Connection) => {
const nodes = getNodes();
const edges = getEdges();
Expand All @@ -62,35 +89,68 @@ function useMcqConnect() {
const handleConnect = useCallback(
(connection: Connection) => {
const mcqId = getMcqNodeIdFromConnection(connection, getNode);
if (!mcqId) return false;

if (connection.target === mcqId) {
applyMcqIncomingConnection(connection);
if (mcqId) {
if (connection.target === mcqId) {
applyIncomingConnection(connection);
return true;
}

const nodes = getNodes();
const edges = getEdges();
const emptyBranches = getEmptyMcqBranches(mcqId, nodes, edges);
if (emptyBranches.length === 0) return true;

if (emptyBranches.length === 1) {
applyMcqOutgoingConnection(connection, emptyBranches[0]);
return true;
}
setPendingConnection({ connection, emptyBranches, nodeType: StepType.MultiChoiceQuestion });
return true;
}

const nodes = getNodes();
const edges = getEdges();
const emptyBranches = getEmptyMcqBranches(mcqId, nodes, edges);
if (emptyBranches.length === 0) return true;

if (emptyBranches.length === 1) {
applyMcqOutgoingConnection(connection, emptyBranches[0]);
} else {
setPendingConnection({ connection, emptyBranches });
const conditionId = getConditionNodeIdFromConnection(connection, getNode);
if (conditionId) {
if (connection.target === conditionId) {
applyIncomingConnection(connection);
return true;
}

const nodes = getNodes();
const edges = getEdges();
const emptyBranches = getEmptyConditionBranches(conditionId, nodes, edges);
if (emptyBranches.length === 0) return true;

if (emptyBranches.length === 1) {
applyConditionOutgoingConnection(connection, emptyBranches[0]);
return true;
}
setPendingConnection({ connection, emptyBranches, nodeType: StepType.Condition });
return true;
}
return true;

return false;
},
[applyMcqIncomingConnection, applyMcqOutgoingConnection, getEdges, getNode, getNodes],
[
applyIncomingConnection,
applyMcqOutgoingConnection,
applyConditionOutgoingConnection,
getEdges,
getNode,
getNodes,
],
);

const confirmBranch = useCallback(
(branch: McqEmptyBranch) => {
if (!pendingConnection) return;
applyMcqOutgoingConnection(pendingConnection.connection, branch);
if (pendingConnection.nodeType === StepType.MultiChoiceQuestion) {
applyMcqOutgoingConnection(pendingConnection.connection, branch);
} else {
applyConditionOutgoingConnection(pendingConnection.connection, branch);
}
setPendingConnection(null);
},
[applyMcqOutgoingConnection, pendingConnection],
[applyMcqOutgoingConnection, applyConditionOutgoingConnection, pendingConnection],
);

const cancelBranchSelection = useCallback(() => {
Expand All @@ -102,10 +162,19 @@ function useMcqConnect() {
if (connection.source === connection.target) return false;

const mcqId = getMcqNodeIdFromConnection(connection, getNode);
if (!mcqId) return true;
if (mcqId) {
if (connection.source === mcqId) {
return getEmptyMcqBranches(mcqId, getNodes(), getEdges()).length > 0;
}
return true;
}

if (connection.source === mcqId) {
return getEmptyMcqBranches(mcqId, getNodes(), getEdges()).length > 0;
const conditionId = getConditionNodeIdFromConnection(connection, getNode);
if (conditionId) {
if (connection.source === conditionId) {
return conditionHasEmptyBranches(conditionId, getNodes(), getEdges());
}
return true;
}

return true;
Expand Down
3 changes: 3 additions & 0 deletions GUI/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,9 @@
"selectBranchTitle": "Select branch",
"emptyBranchesMessage": "This multi-choice question has {{count}} empty branches. Which branch do you want to connect?"
},
"condition": {
"emptyPathsMessage": "This conditional node has {{count}} empty paths. Which path do you want to connect?"
},
"previousVariables": {
"assignElements": "Assigned Variables",
"dateAndTime": {
Expand Down
3 changes: 3 additions & 0 deletions GUI/src/i18n/et/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,9 @@
"selectBranchTitle": "Vali haru",
"emptyBranchesMessage": "Sellel valikvastuse küsimusel on {{count}} tühja haru. Millisele harule soovid ühendada?"
},
"condition": {
"emptyPathsMessage": "Sellel tingimusliku sõlmel on {{count}} tühja teed. Millisele teele soovid ühendada?"
},
"previousVariables": {
"assignElements": "Määratud Väärtused",
"noName": "<Nimeta>",
Expand Down
77 changes: 77 additions & 0 deletions GUI/src/utils/conditional-flow-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Connection, Edge, getIncomers, getOutgoers, Node } from '@xyflow/react';
import { StepType } from 'types';
import { McqEmptyBranch } from 'utils/mcq-flow-utils';

export const CONDITION_SOURCE_HANDLE_ID = 'condition-out';
export const CONDITION_LABELS = ['Success', 'Failure'] as const;

export const getConditionNodeIdFromConnection = (
connection: Connection,
getNode: (id: string) => Node | undefined,
): string | null => {
const sourceNode = connection.source ? getNode(connection.source) : undefined;
const targetNode = connection.target ? getNode(connection.target) : undefined;

if (sourceNode?.data?.stepType === StepType.Condition) return sourceNode.id;
if (targetNode?.data?.stepType === StepType.Condition) return targetNode.id;
return null;
};

export const getEmptyConditionBranches = (conditionNodeId: string, nodes: Node[], edges: Edge[]): McqEmptyBranch[] => {
const conditionNode = nodes.find((n) => n.id === conditionNodeId);
if (!conditionNode || conditionNode.data?.stepType !== StepType.Condition) return [];

const outgoing = edges.filter((e) => e.source === conditionNodeId);

return CONDITION_LABELS.flatMap((label, handleIndex) => {
const edge = outgoing.find((e) => e.label === label);
if (!edge) return [];

const target = nodes.find((n) => n.id === edge.target);
if (target?.type !== 'ghost') return [];

return [{ edgeId: edge.id, label, ghostNodeId: edge.target, handleIndex }];
});
};

export const conditionHasEmptyBranches = (conditionNodeId: string, nodes: Node[], edges: Edge[]): boolean =>
getEmptyConditionBranches(conditionNodeId, nodes, edges).length > 0;

export const applyConditionBranchConnection = ({
nodes,
edges,
conditionId,
targetId,
branch,
}: {
nodes: Node[];
edges: Edge[];
conditionId: string;
targetId: string;
branch: McqEmptyBranch;
}): { nodes: Node[]; edges: Edge[] } => {
let finalNodes = nodes.filter((n) => n.id !== branch.ghostNodeId);
let finalEdges = edges.filter((e) => e.id !== branch.edgeId);

finalEdges = [
...finalEdges,
{
id: `${conditionId}->${targetId}-${branch.label}`,
source: conditionId,
target: targetId,
type: 'step',
label: branch.label,
animated: false,
deletable: true,
},
];

finalNodes = finalNodes.filter(
(node) =>
node.type !== 'ghost' ||
getIncomers(node, finalNodes, finalEdges).length > 0 ||
getOutgoers(node, finalNodes, finalEdges).length > 0,
);

return { nodes: finalNodes, edges: finalEdges };
};
Loading