diff --git a/GUI/src/components/Collapsible/Collapsible.scss b/GUI/src/components/Collapsible/Collapsible.scss index cbff36533..2e47cec93 100644 --- a/GUI/src/components/Collapsible/Collapsible.scss +++ b/GUI/src/components/Collapsible/Collapsible.scss @@ -39,6 +39,10 @@ background-color: get-color(white); border-radius: 0 0 4px 4px; overflow: hidden; + + &[data-state='open'] { + overflow: visible; + } } } diff --git a/GUI/src/components/DynamicList/index.tsx b/GUI/src/components/DynamicList/index.tsx index 1813ef57e..f10a16150 100644 --- a/GUI/src/components/DynamicList/index.tsx +++ b/GUI/src/components/DynamicList/index.tsx @@ -15,7 +15,7 @@ type DynamicListProps = { tooltipText?: string; }; -const DynamicList: FC = ({ label, labelWidth = 120, value, onChange, placeholder, tooltipText }) => { +const DynamicList: FC = ({ label, labelWidth = 170, value, onChange, placeholder, tooltipText }) => { const { t } = useTranslation(); const items = value?.length > 0 ? value : ['']; diff --git a/GUI/src/components/ExportServicesModal/index.tsx b/GUI/src/components/ExportServicesModal/index.tsx index 5488bb840..1fbf231a8 100644 --- a/GUI/src/components/ExportServicesModal/index.tsx +++ b/GUI/src/components/ExportServicesModal/index.tsx @@ -98,8 +98,8 @@ const ExportServicesModal: FC = ({ isVisible, onClose try { const servicesWithStructure = await Promise.all( selectedServices.map(async (service) => { - const response = await api.post(getServiceById(), { id: service.serviceId, search: '' }); - return { ...service, structure: response.data.structure }; + const response = await api.post(getServiceById(), { id: service.serviceId, search: '' }); + return response.data; }), ); const success = await exportServices(servicesWithStructure); diff --git a/GUI/src/components/Flow/Controls/ImportExportControls.tsx b/GUI/src/components/Flow/Controls/ImportExportControls.tsx index 0fe2d5b95..fe64d248d 100644 --- a/GUI/src/components/Flow/Controls/ImportExportControls.tsx +++ b/GUI/src/components/Flow/Controls/ImportExportControls.tsx @@ -8,10 +8,18 @@ import { updateFlowInputRules } from 'services/flow-builder'; import useServiceStore from 'store/new-services.store'; import useToastStore from 'store/toasts.store'; import { FlowData } from 'types/service-flow'; +import { appendFlowNodes } from 'utils/append-flow-nodes'; +import { + applyServiceSettings, + buildServiceSettingsFromStore, + isValidFlowData, + parseFlowArtifact, + serializeFlowArtifact, +} from 'utils/service-flow-artifact'; import { removeTrailingUnderscores } from 'utils/string-util'; const ImportExportControls: FC = () => { - const { getNodes, getEdges } = useReactFlow(); + const { getNodes, getEdges, setNodes, setEdges } = useReactFlow(); const { t } = useTranslation(); const { setHasUnsavedChanges, saveToHistory, setNodes: setStoreNodes, setEdges: setStoreEdges } = useServiceStore(); const fileInputRef = useRef(null); @@ -21,7 +29,7 @@ const ImportExportControls: FC = () => { const handleExport = useCallback(async () => { try { - const dataString = JSON.stringify({ nodes: getNodes(), edges: getEdges() }); + const dataString = serializeFlowArtifact(getNodes(), getEdges(), buildServiceSettingsFromStore()); const fileName = `${serviceName != undefined && serviceName != '' ? serviceName : 'flow'}_${format(new Date(), 'yyyy_MM_dd_HH_mm_ss')}.json`; if ('showSaveFilePicker' in window) { @@ -56,8 +64,9 @@ const ImportExportControls: FC = () => { }, [getNodes, getEdges, serviceName, t]); const applyImportedFlow = useCallback( - (flowData: FlowData) => { + (flowData: FlowData, settings?: FlowData['settings']) => { if (isValidFlowData(flowData)) { + applyServiceSettings(settings); const nodes = flowData.nodes.map((node: any) => { if (node.type !== 'custom') return node; node.data = { @@ -90,13 +99,14 @@ const ImportExportControls: FC = () => { reader.onload = (e) => { try { const content = e.target?.result as string; - const flowData = JSON.parse(content) as FlowData; + const { nodes, edges, settings } = parseFlowArtifact(content); + const flowData = { nodes, edges } as FlowData; const currentNodes = getNodes().filter((node) => node.type !== 'ghost'); if (currentNodes.length === 1 && currentNodes[0].type === 'start') { - applyImportedFlow(flowData); + applyImportedFlow(flowData, settings); } else { - setImportedFlowData(flowData); + setImportedFlowData({ ...flowData, settings }); setIsConfirmImportModalVisible(true); } } catch (error) { @@ -115,28 +125,46 @@ const ImportExportControls: FC = () => { [getNodes, applyImportedFlow, t], ); + const closeImportModal = useCallback(() => { + setIsConfirmImportModalVisible(false); + setImportedFlowData(null); + }, []); + const handleConfirmImport = useCallback(() => { if (importedFlowData) { - applyImportedFlow(importedFlowData); + const { settings, ...flowData } = importedFlowData; + applyImportedFlow(flowData, settings); } - setIsConfirmImportModalVisible(false); - setImportedFlowData(null); - }, [importedFlowData, applyImportedFlow]); + closeImportModal(); + }, [importedFlowData, applyImportedFlow, closeImportModal]); - const handleCancelImport = useCallback(() => { - setIsConfirmImportModalVisible(false); - setImportedFlowData(null); - }, []); + const handleImportFlowOnly = useCallback(() => { + if (!importedFlowData) { + closeImportModal(); + return; + } - const isValidFlowData = (data: any): data is FlowData => { - return ( - data && - Array.isArray(data.nodes) && - Array.isArray(data.edges) && - data.nodes.every((node: any) => node.id && node.type) && - data.edges.every((edge: any) => edge.id && edge.source && edge.target) - ); - }; + const { nodes, edges } = appendFlowNodes(getNodes(), getEdges(), importedFlowData.nodes, importedFlowData.edges); + saveToHistory(); + setStoreNodes(nodes); + setStoreEdges(edges); + setNodes(nodes); + setEdges(edges); + saveToHistory({ nodes, edges }); + setHasUnsavedChanges(true); + closeImportModal(); + }, [ + importedFlowData, + getNodes, + getEdges, + setNodes, + setEdges, + setStoreNodes, + setStoreEdges, + setHasUnsavedChanges, + saveToHistory, + closeImportModal, + ]); const triggerFileInput = useCallback(() => { if (fileInputRef.current) { @@ -158,12 +186,13 @@ const ImportExportControls: FC = () => { {isConfirmImportModalVisible && ( - + - + diff --git a/GUI/src/components/Flow/McqBranchSelectModal.scss b/GUI/src/components/Flow/McqBranchSelectModal.scss new file mode 100644 index 000000000..4689633a2 --- /dev/null +++ b/GUI/src/components/Flow/McqBranchSelectModal.scss @@ -0,0 +1,10 @@ +.mcq-branch-select-modal__button { + display: inline-flex; + width: fit-content; + max-width: 100%; + overflow: hidden; + + .multiple-choice-question-button__text { + max-width: 100%; + } +} diff --git a/GUI/src/components/Flow/McqBranchSelectModal.tsx b/GUI/src/components/Flow/McqBranchSelectModal.tsx new file mode 100644 index 000000000..58f17b33a --- /dev/null +++ b/GUI/src/components/Flow/McqBranchSelectModal.tsx @@ -0,0 +1,32 @@ +import { Button, Modal, Track } from 'components'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { McqEmptyBranch } from 'utils/mcq-flow-utils'; + +import '../FlowElementsPopup/styles.scss'; +import './McqBranchSelectModal.scss'; + +type McqBranchSelectModalProps = { + readonly emptyBranches: McqEmptyBranch[]; + readonly onSelect: (branch: McqEmptyBranch) => void; + readonly onClose: () => void; +}; + +const McqBranchSelectModal: FC = ({ emptyBranches, onSelect, onClose }) => { + const { t } = useTranslation(); + + return ( + +

{t('serviceFlow.mcq.emptyBranchesMessage', { count: emptyBranches.length })}

+ + {emptyBranches.map((branch) => ( + + ))} + +
+ ); +}; + +export default McqBranchSelectModal; diff --git a/GUI/src/components/Flow/NodeTypes/CustomNode.tsx b/GUI/src/components/Flow/NodeTypes/CustomNode.tsx index d75ffb5f0..47b3198dc 100644 --- a/GUI/src/components/Flow/NodeTypes/CustomNode.tsx +++ b/GUI/src/components/Flow/NodeTypes/CustomNode.tsx @@ -1,12 +1,14 @@ -import { Handle, NodeProps, Position, useUpdateNodeInternals } from '@xyflow/react'; +import { Handle, NodeProps, Position, useStore, useUpdateNodeInternals } from '@xyflow/react'; import './Node.scss'; import Button from 'components/Button'; import Icon from 'components/Icon'; import Track from 'components/Track'; -import React, { FC, useEffect } from 'react'; +import React, { FC, useEffect, useMemo } from 'react'; import { MdDeleteOutline, MdOutlineEdit, MdOutlineRemoveRedEye } from 'react-icons/md'; import useServiceStore from 'store/services.store'; +import { StepType } from 'types'; import { NodeDataProps } from 'types/service-flow'; +import { MCQ_SOURCE_HANDLE_ID, mcqHasEmptyBranches } from 'utils/mcq-flow-utils'; import StepNode from './StepNode'; @@ -17,13 +19,21 @@ type CustomNodeProps = { const CustomNode: FC = (props) => { const { data, isConnectable, id } = props; const orientation = useServiceStore((state) => state.orientation); - const shouldOffsetHandles = data.childrenCount > 1; + const isMcq = data.stepType === StepType.MultiChoiceQuestion; + const shouldOffsetHandles = !isMcq && 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 canConnect = isConnectable && mcqCanConnect; const updateNodeInternals = useUpdateNodeInternals(); useEffect(() => { updateNodeInternals(id); - }, [data.childrenCount, id, updateNodeInternals, orientation]); + }, [data.childrenCount, id, isMcq, mcqCanConnect, updateNodeInternals, orientation]); const isFinishingStep = () => { return data.type === 'finishing-step'; @@ -38,6 +48,18 @@ const CustomNode: FC = (props) => { }; const bottomHandles = (): React.JSX.Element => { + if (isMcq) { + return ( +
)} + {pendingConnection && ( + + )} ); }; diff --git a/GUI/src/components/FlowElementsPopup/index.tsx b/GUI/src/components/FlowElementsPopup/index.tsx index 2c98dbcbe..ddaaa5d7d 100644 --- a/GUI/src/components/FlowElementsPopup/index.tsx +++ b/GUI/src/components/FlowElementsPopup/index.tsx @@ -206,6 +206,7 @@ const FlowElementsPopup: React.FC = () => { dynamicChoices: node.data.stepType === StepType.DynamicChoices ? dynamicChoices : undefined, endpoint: nodeEndpoint ?? node.data?.endpoint, testingPassed: undefined, + childrenCount: node.data.stepType === StepType.MultiChoiceQuestion ? 1 : node.data.childrenCount, }, }; @@ -394,6 +395,8 @@ const FlowElementsPopup: React.FC = () => { draggable: false, })); + updatedNode.data.childrenCount = 1; + let finalNodes = [...nodes.filter((n) => n.id !== updatedNode.id), updatedNode, ...newGhostNodes]; let finalEdges = [...filteredEdges, ...newEdges]; @@ -450,11 +453,6 @@ const FlowElementsPopup: React.FC = () => { {t('serviceFlow.tabs.setup')} - {!isReadonly && ( - - {t('serviceFlow.tabs.test')} - - )} {stepType === StepType.Textfield && ( diff --git a/GUI/src/components/ServicesTable/columns.tsx b/GUI/src/components/ServicesTable/columns.tsx index d9bf92bc5..b8adf0b6c 100644 --- a/GUI/src/components/ServicesTable/columns.tsx +++ b/GUI/src/components/ServicesTable/columns.tsx @@ -185,8 +185,11 @@ export const getColumns = ({ isCommon, navigate, hideDeletePopup, showReadyPopup