diff --git a/GUI/src/components/Flow/Controls/CopyPasteControls.tsx b/GUI/src/components/Flow/Controls/CopyPasteControls.tsx index b1b4db153..a288c8e36 100644 --- a/GUI/src/components/Flow/Controls/CopyPasteControls.tsx +++ b/GUI/src/components/Flow/Controls/CopyPasteControls.tsx @@ -20,7 +20,7 @@ interface CopyPasteControlsProps { const CopyPasteControls: FC = ({ onNodesDelete }) => { const { getNodes, getEdges, setNodes, setEdges } = useReactFlow(); const { t } = useTranslation(); - const { setHasUnsavedChanges, saveToHistory } = useServiceStore(); + const { setHasUnsavedChanges, saveToHistory, setNodes: setStoreNodes, setEdges: setStoreEdges } = useServiceStore(); const [hasClipboardData, setHasClipboardData] = useState(false); const selectedNodes = useServiceStore((state) => state.flowSelectedNodes); const reactFlowInstance = useServiceStore.getState().reactFlowInstance; @@ -147,9 +147,11 @@ const CopyPasteControls: FC = ({ onNodesDelete }) => { const uniqueLabel = generateUniqueLabel(node.data.label as string, allExistingNodes); processedLabels.add(uniqueLabel); + const position = node.position ?? { x: 0, y: 0 }; return { ...node, id: newId, + position: { x: position.x, y: position.y }, selected: false, data: { ...node.data, @@ -293,15 +295,31 @@ const CopyPasteControls: FC = ({ onNodesDelete }) => { } }); - setNodes((prevNodes) => [...prevNodes, ...newNodes, ...ghostNodes]); - setEdges((prevEdges) => [...prevEdges, ...newEdges, ...ghostEdges]); + const currentEdges = getEdges(); + const finalNodes = [...currentNodes, ...newNodes, ...ghostNodes]; + const finalEdges = [...currentEdges, ...newEdges, ...ghostEdges]; + setStoreNodes(finalNodes); + setStoreEdges(finalEdges); + setNodes(finalNodes); + setEdges(finalEdges); setHasUnsavedChanges(true); - saveToHistory(); + saveToHistory({ nodes: finalNodes, edges: finalEdges }); useToastStore .getState() .success({ title: t('serviceFlow.nodesPasted', { count: newNodes.length, s: newNodes.length > 1 ? 's' : '' }) }); - }, [fallbackClipboardData, getNodes, setNodes, setEdges, setHasUnsavedChanges, t, saveToHistory]); + }, [ + fallbackClipboardData, + getEdges, + getNodes, + setNodes, + setEdges, + setStoreNodes, + setStoreEdges, + setHasUnsavedChanges, + t, + saveToHistory, + ]); const cutNodes = useCallback(async () => { if (selectedNodes.length === 0) { diff --git a/GUI/src/components/Flow/Controls/ImportExportControls.tsx b/GUI/src/components/Flow/Controls/ImportExportControls.tsx index 0d7e903d1..0fe2d5b95 100644 --- a/GUI/src/components/Flow/Controls/ImportExportControls.tsx +++ b/GUI/src/components/Flow/Controls/ImportExportControls.tsx @@ -11,9 +11,9 @@ import { FlowData } from 'types/service-flow'; import { removeTrailingUnderscores } from 'utils/string-util'; const ImportExportControls: FC = () => { - const { getNodes, getEdges, setNodes, setEdges } = useReactFlow(); + const { getNodes, getEdges } = useReactFlow(); const { t } = useTranslation(); - const { setHasUnsavedChanges } = useServiceStore(); + const { setHasUnsavedChanges, saveToHistory, setNodes: setStoreNodes, setEdges: setStoreEdges } = useServiceStore(); const fileInputRef = useRef(null); const serviceName = useServiceStore((state) => removeTrailingUnderscores(state.serviceNameDashed())); const [isConfirmImportModalVisible, setIsConfirmImportModalVisible] = useState(false); @@ -69,14 +69,16 @@ const ImportExportControls: FC = () => { }; return node; }); - setNodes(nodes); - setEdges(flowData.edges); + saveToHistory(); + setStoreNodes(nodes); + setStoreEdges(flowData.edges); + saveToHistory({ nodes, edges: flowData.edges }); setHasUnsavedChanges(true); } else { useToastStore.getState().error({ title: t('global.notificationError'), message: t('serviceFlow.parseError') }); } }, - [setNodes, setEdges, setHasUnsavedChanges, t], + [setStoreNodes, setStoreEdges, setHasUnsavedChanges, saveToHistory, t], ); const handleImport = useCallback( diff --git a/GUI/src/components/FlowBuilder/FlowBuilder.tsx b/GUI/src/components/FlowBuilder/FlowBuilder.tsx index 50cc408c1..a922006b3 100644 --- a/GUI/src/components/FlowBuilder/FlowBuilder.tsx +++ b/GUI/src/components/FlowBuilder/FlowBuilder.tsx @@ -72,25 +72,27 @@ const FlowBuilder: FC = ({ nodes, edges }) => { return targetNode?.type === 'ghost'; }); + let finalNodes = nodes; + let finalEdges = edges; + if (ghostEdges.length > 0) { const ghostNodeIds = new Set(ghostEdges.map((edge) => edge.target)); - const updatedEdges = edges.filter((edge) => !ghostEdges.includes(edge)); - const updatedNodes = nodes.filter((node) => !ghostNodeIds.has(node.id)); - setNodes(updatedNodes); - setEdges(updatedEdges); + finalEdges = edges.filter((edge) => !ghostEdges.includes(edge)); + finalNodes = nodes.filter((node) => !ghostNodeIds.has(node.id)); } - setEdges((eds) => [ - ...eds, - { - id: `${source}->${target}`, - source: source, - target: target, - type: 'step', - }, - ]); + const newEdge = { + id: `${source}->${target}`, + source: source, + target: target, + type: 'step', + }; + finalEdges = [...finalEdges, newEdge]; + + setNodes(finalNodes); + setEdges(finalEdges); setHasUnsavedChanges(true); - saveToHistory(); + saveToHistory({ nodes: finalNodes, edges: finalEdges }); }, [getEdges, getNodes, setEdges, setHasUnsavedChanges, setNodes, saveToHistory], ); @@ -176,13 +178,13 @@ const FlowBuilder: FC = ({ nodes, edges }) => { onEdgesDelete={(edges) => { onEdgesDelete(edges); setHasUnsavedChanges(true); - saveToHistory(); + setTimeout(() => saveToHistory(), 0); }} onBeforeDelete={onBeforeDelete} onNodesDelete={(nodes) => { onNodesDelete(nodes); setHasUnsavedChanges(true); - saveToHistory(); + setTimeout(() => saveToHistory(), 0); }} fitView fitViewOptions={{ padding: 5 }} diff --git a/GUI/src/hooks/flow/useEdgeAdd.ts b/GUI/src/hooks/flow/useEdgeAdd.ts index d7d7bd584..099597533 100644 --- a/GUI/src/hooks/flow/useEdgeAdd.ts +++ b/GUI/src/hooks/flow/useEdgeAdd.ts @@ -122,9 +122,16 @@ function useEdgeAdd(id: string) { return newNodes; }); - setTimeout(() => { - useServiceStore.getState().saveToHistory(); - }, 0); + const isFinishingStep = [ + StepType.DynamicChoices, + StepType.FinishingStepEnd, + StepType.FinishingStepRedirect, + ].includes(stepType); + if (!isFinishingStep) { + setTimeout(() => { + useServiceStore.getState().saveToHistory(); + }, 0); + } }; return handleEdgeClick; } diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts index 355c5b54b..3bc2aef37 100644 --- a/GUI/src/services/service-builder.ts +++ b/GUI/src/services/service-builder.ts @@ -113,7 +113,26 @@ const hasInvalidElements = (elements: any[]): boolean => { }); }; -const buildConditionString = (group: any): string => { +const getAssignedVariableNames = (nodes: Node[]): Set => { + const names = new Set(['chatId', 'authorId', 'input', 'buttons', 'res']); + for (const node of nodes) { + const data = node.data as NodeDataProps | undefined; + if (data?.stepType === StepType.Assign && Array.isArray(data.assignElements)) { + for (const e of data.assignElements) { + const key = e.key?.replaceAll('${', '').replaceAll('}', '').trim(); + if (key) names.add(key); + } + } + } + return names; +}; + +const buildConditionString = (group: any, assignedVariableNames: Set): string => { + const formatField = (rawField: string): string => { + if (assignedVariableNames.has(rawField)) return rawField; + return isNumericString(rawField) ? rawField : `"${rawField}"`; + }; + if ('children' in group) { const subgroup = group as Group; if (subgroup.children.length === 0) { @@ -122,12 +141,14 @@ const buildConditionString = (group: any): string => { const conditions = subgroup.children.map((child) => { if ('children' in child) { - return `(${buildConditionString(child)})`; + return `(${buildConditionString(child, assignedVariableNames)})`; } else { const rule = child; + const rawField = rule.field.replaceAll('${', '').replaceAll('}', ''); const absoluteValue = removeWrapperQuotes(rule.value.replaceAll('${', '').replaceAll('}', '')); const value = isNumericString(absoluteValue) ? absoluteValue : `"${absoluteValue}"`; - return `${rule.field.replaceAll('${', '').replaceAll('}', '')} ${rule.operator} ${value}`; + const field = formatField(rawField); + return `${field} ${rule.operator} ${value}`; } }); @@ -138,9 +159,11 @@ const buildConditionString = (group: any): string => { } } else { const rule = group as Rule; + const rawField = rule.field.replaceAll('${', '').replaceAll('}', ''); const absoluteValue = removeWrapperQuotes(rule.value.replaceAll('${', '').replaceAll('}', '')); const value = isNumericString(absoluteValue) ? absoluteValue : `"${absoluteValue}"`; - return `${rule.field.replaceAll('${', '').replaceAll('}', '')} ${rule.operator} ${value}`; + const field = formatField(rawField); + return `${field} ${rule.operator} ${value}`; } }; @@ -615,10 +638,11 @@ function handleConditionStep( throw new Error(i18next.t('toast.missing-condition-rules') ?? 'Error'); } + const assignedVariableNames = getAssignedVariableNames(nodes); finishedFlow.set(parentStepName, { switch: [ { - condition: `\${${buildConditionString(parentNode.data.rules)}}`, + condition: `\${${buildConditionString(parentNode.data.rules, assignedVariableNames)}}`, next: toSnakeCase(firstChild?.data?.label ?? '') ?? '', }, ], @@ -860,9 +884,6 @@ export const saveFlowClick = async (status: 'draft' | 'ready' = 'ready', showErr title: i18next.t('newService.toast.failed'), message: e.response?.status === 409 ? t('newService.toast.serviceNameAlreadyExists') : e?.message, }); - throw new Error( - e.response?.status === 409 ? t('newService.toast.serviceNameAlreadyExists').toString() : e?.message, - ); }, description, slot, diff --git a/GUI/src/store/new-services.store.ts b/GUI/src/store/new-services.store.ts index 431af448d..5dce2a258 100644 --- a/GUI/src/store/new-services.store.ts +++ b/GUI/src/store/new-services.store.ts @@ -143,7 +143,7 @@ export interface ServiceStoreState { handleProgrammaticNavigation: (to: string) => boolean; history: { nodes: Node[]; edges: Edge[] }[]; historyIndex: number; - saveToHistory: () => void; + saveToHistory: (state?: { nodes: Node[]; edges: Edge[] }) => void; undo: () => void; redo: () => void; canUndo: () => boolean; @@ -820,13 +820,18 @@ const useServiceStore = create((set, get) => ({ }); }); }, - saveToHistory: () => { + saveToHistory: (stateOverride?: { nodes: Node[]; edges: Edge[] }) => { const { nodes, edges, history, historyIndex } = get(); - const currentState = { - nodes: JSON.parse(JSON.stringify(nodes)), - edges: JSON.parse(JSON.stringify(edges)), - }; + const currentState = stateOverride + ? { + nodes: JSON.parse(JSON.stringify(stateOverride.nodes)), + edges: JSON.parse(JSON.stringify(stateOverride.edges)), + } + : { + nodes: JSON.parse(JSON.stringify(nodes)), + edges: JSON.parse(JSON.stringify(edges)), + }; const lastState = history[historyIndex]; @@ -839,11 +844,13 @@ const useServiceStore = create((set, get) => ({ return; } - history.push(currentState); + const truncatedHistory = historyIndex < history.length - 1 ? history.slice(0, historyIndex + 1) : history; + + truncatedHistory.push(currentState); set({ - history, - historyIndex: historyIndex + 1, + history: truncatedHistory, + historyIndex: truncatedHistory.length - 1, hasUnsavedChanges: true, }); }, @@ -855,8 +862,10 @@ const useServiceStore = create((set, get) => ({ nodes = nodes.map((node: any) => { if (node.type !== 'custom') return node; + const { stepType, ...restData } = node.data; node.data = { - ...node.data, + ...restData, + stepType, onDelete: get().onDelete, setClickedNode: get().setClickedNode, onEdit: get().handleNodeEdit, @@ -882,8 +891,10 @@ const useServiceStore = create((set, get) => ({ nodes = nodes.map((node: any) => { if (node.type !== 'custom') return node; + const { stepType, ...restData } = node.data; node.data = { - ...node.data, + ...restData, + stepType, onDelete: get().onDelete, setClickedNode: get().setClickedNode, onEdit: get().handleNodeEdit,