diff --git a/DSL/Liquibase/changelog/20250516152344-add-dynamic-choices-to-step-type-enum.xml b/DSL/Liquibase/changelog/20250516152344-add-dynamic-choices-to-step-type-enum.xml new file mode 100644 index 000000000..667683890 --- /dev/null +++ b/DSL/Liquibase/changelog/20250516152344-add-dynamic-choices-to-step-type-enum.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/DSL/Liquibase/changelog/migrations/20250516152344-add-dynamic-choices-to-step-type-enum.sql b/DSL/Liquibase/changelog/migrations/20250516152344-add-dynamic-choices-to-step-type-enum.sql new file mode 100644 index 000000000..aa69624d9 --- /dev/null +++ b/DSL/Liquibase/changelog/migrations/20250516152344-add-dynamic-choices-to-step-type-enum.sql @@ -0,0 +1,4 @@ +-- liquibase formatted sql +-- changeset 1AhmedYasser:20250516152344 + +ALTER TYPE step_type ADD VALUE 'dynamic-choices'; diff --git a/DSL/Liquibase/changelog/migrations/rollback/20250516152344_rollback.sql b/DSL/Liquibase/changelog/migrations/rollback/20250516152344_rollback.sql new file mode 100644 index 000000000..4e0f2f10a --- /dev/null +++ b/DSL/Liquibase/changelog/migrations/rollback/20250516152344_rollback.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- rollback + +-- Remove the 'dynamic-choices' value from the step_type enum +ALTER TYPE step_type DROP VALUE 'dynamic-choices'; diff --git a/DSL/Resql/services/POST/seed-user-step-preferences.sql b/DSL/Resql/services/POST/seed-user-step-preferences.sql index 0b22f8ecc..3b84b6529 100644 --- a/DSL/Resql/services/POST/seed-user-step-preferences.sql +++ b/DSL/Resql/services/POST/seed-user-step-preferences.sql @@ -1,2 +1,2 @@ INSERT INTO user_step_preference (user_id_code, steps) -VALUES (:user_id_code, '{assign,textfield,condition,multi-choice-question,finishing-step-end,input,auth,open-webpage,file-generate,file-sign,finising-step-redirect,rasa-rules,siga}') +VALUES (:user_id_code, '{assign,textfield,condition,multi-choice-question,dynamic-choices,finishing-step-end,input,auth,open-webpage,file-generate,file-sign,finising-step-redirect,rasa-rules,siga}') diff --git a/DSL/Ruuter/services/POST/rasa/rules/add.yml b/DSL/Ruuter/services/POST/rasa/rules/add.yml index 2baa3ad62..c41581abf 100644 --- a/DSL/Ruuter/services/POST/rasa/rules/add.yml +++ b/DSL/Ruuter/services/POST/rasa/rules/add.yml @@ -8,12 +8,9 @@ declaration: namespace: service allowlist: body: - - field: rule + - field: data type: object - description: "Body field 'rule'" - - field: story - type: object - description: "Body field 'story'" + description: "Body field 'data'" headers: - field: cookie type: string @@ -42,6 +39,8 @@ getRuleNames: call: http.get args: url: "[#SERVICE_RUUTER]/rasa/rule-names" + headers: + cookie: ${incoming.headers.cookie} result: ruleResult validateRuleName: @@ -56,6 +55,8 @@ getFileLocations: call: http.get args: url: "[#SERVICE_RUUTER]/internal/return-file-locations" + headers: + cookie: ${incoming.headers.cookie} result: fileLocations getRulesFile: diff --git a/DSL/Ruuter/services/POST/services/domain-intent-service-link.yml b/DSL/Ruuter/services/POST/services/domain-intent-service-link.yml index 0b841a555..b38fcd430 100644 --- a/DSL/Ruuter/services/POST/services/domain-intent-service-link.yml +++ b/DSL/Ruuter/services/POST/services/domain-intent-service-link.yml @@ -104,6 +104,8 @@ add_rule: call: http.post args: url: "[#SERVICE_RUUTER]/rasa/rules/add" + headers: + cookie: ${incoming.headers.cookie} body: data: ${data} result: add_rule_res diff --git a/GUI/src/components/ApiEndpointCard/index.tsx b/GUI/src/components/ApiEndpointCard/index.tsx index 92548ad80..a1a2c1a22 100644 --- a/GUI/src/components/ApiEndpointCard/index.tsx +++ b/GUI/src/components/ApiEndpointCard/index.tsx @@ -8,6 +8,7 @@ import { RequestTab } from "../../types"; import { EndpointData, EndpointEnv, EndpointTab } from "../../types/endpoint"; import useServiceStore from "store/new-services.store"; import { EndpointType } from "types/endpoint/endpoint-type"; +import { removeTrailingUnderscores } from "utils/string-util"; type EndpointCardProps = { endpoint: EndpointData; @@ -88,10 +89,16 @@ const ApiEndpointCard: FC = ({ name="endpointName" label="" placeholder={t("newService.endpoint.insertName").toString()} + maxLength={30} value={endpointName} disabled={isNameDisabled || selectedTab === EndpointEnv.Test} onChange={(e) => { - setEndpointName(e.target.value); + const sanitizedValue = e.target.value + .replace(/[^a-zA-Z0-9_\s]/g, "") + .replace(/\s+/g, "_") + .replace(/_+/g, "_"); + + setEndpointName(sanitizedValue); const endpointsNames = useServiceStore .getState() .endpoints.map((ep) => ep.name) @@ -99,7 +106,7 @@ const ApiEndpointCard: FC = ({ const isNameExist = endpointsNames.includes(e.target.value); setNameExists(isNameExist); onNameExists?.(isNameExist); - onNameChange?.(e.target.value); + onNameChange?.(removeTrailingUnderscores(sanitizedValue)); }} /> {nameExists && ( diff --git a/GUI/src/components/Flow/EdgeTypes/CustomEdge.tsx b/GUI/src/components/Flow/EdgeTypes/CustomEdge.tsx index 91808b04b..a6a3db7fa 100644 --- a/GUI/src/components/Flow/EdgeTypes/CustomEdge.tsx +++ b/GUI/src/components/Flow/EdgeTypes/CustomEdge.tsx @@ -87,7 +87,7 @@ function CustomEdge({ StepType.Assign, StepType.Textfield, StepType.MultiChoiceQuestion, - StepType.FinishingStepRedirect, + StepType.DynamicChoices, StepType.FinishingStepEnd, ]; diff --git a/GUI/src/components/Flow/NodeTypes/CustomNode.tsx b/GUI/src/components/Flow/NodeTypes/CustomNode.tsx index 006738d1a..c5a413bdf 100644 --- a/GUI/src/components/Flow/NodeTypes/CustomNode.tsx +++ b/GUI/src/components/Flow/NodeTypes/CustomNode.tsx @@ -21,12 +21,6 @@ type NodeDataProps = { }; }; -const boxTypeColors: { [key: string]: any } = { - step: "blue", - "finishing-step": "red", - rule: "gray", -}; - const CustomNode: FC = (props) => { const { data, isConnectable, id } = props; const shouldOffsetHandles = data.childrenCount > 1; diff --git a/GUI/src/components/Flow/NodeTypes/StepNode.tsx b/GUI/src/components/Flow/NodeTypes/StepNode.tsx index d64fa766c..6f89cf759 100644 --- a/GUI/src/components/Flow/NodeTypes/StepNode.tsx +++ b/GUI/src/components/Flow/NodeTypes/StepNode.tsx @@ -9,14 +9,12 @@ import CheckBadge from "components/CheckBadge"; import ExclamationBadge from "components/ExclamationBadge"; import Track from "components/Track"; import { StepType } from "types"; +import { DynamicChoices } from "types/dynamic-choices"; type NodeDataProps = { data: { childrenCount: number; clientInputId: number; - conditionId: number; - multiChoiceQuestionId: number; - assignId: number; label: string; onDelete: (id: string) => void; onEdit: (id: string) => void; @@ -36,6 +34,7 @@ type NodeDataProps = { rules?: Group; assignElements?: Assign[]; multiChoiceQuestion?: MultiChoiceQuestion; + dynamicChoices?: DynamicChoices; }; }; @@ -74,6 +73,11 @@ const StepNode: FC = ({ data }) => { if (data.stepType === StepType.MultiChoiceQuestion) { return !data?.multiChoiceQuestion?.question || data.multiChoiceQuestion?.buttons?.find((e) => e.title === "") != undefined; } + + if (data.stepType === StepType.DynamicChoices) { + return !data?.dynamicChoices?.list || !data?.dynamicChoices?.serviceName || !data?.dynamicChoices?.key; + } + if (data.stepType === StepType.UserDefined) return; if (data.stepType === StepType.OpenWebpage) return !data.link || !data.linkText; if (data.stepType === StepType.FileGenerate) return !data.fileName || !data.fileContent; diff --git a/GUI/src/components/FlowBuilder/FlowBuilder.tsx b/GUI/src/components/FlowBuilder/FlowBuilder.tsx index cd1118b0b..a316c1b12 100644 --- a/GUI/src/components/FlowBuilder/FlowBuilder.tsx +++ b/GUI/src/components/FlowBuilder/FlowBuilder.tsx @@ -17,11 +17,20 @@ type FlowBuilderProps = { const FlowBuilder: FC = ({ nodes, edges }) => { useLayout(); - const { getNodes, getEdges, setNodes, setEdges } = useReactFlow(); + const { getNodes, getEdges, setNodes, setEdges, getNode } = useReactFlow(); const setReactFlowInstance = useServiceStore((state) => state.setReactFlowInstance); const { t } = useTranslation(); - const { onNodesDelete, onEdgesDelete, isDeleteConnectionsModalVisible, setIsDeleteConnectionsModalVisible, onDeleteConfirmed, onKeepItConfirmed, hasConnectedNodes, setDeletedNodes } = - useOnNodesDelete(); + const { + onNodesDelete, + onEdgesDelete, + isDeleteConnectionsModalVisible, + setIsDeleteConnectionsModalVisible, + onDeleteConfirmed, + onKeepItConfirmed, + hasConnectedNodes, + setDeletedNodes, + setNodeToDelete, + } = useOnNodesDelete(); const { setHasUnsavedChanges } = useServiceStore(); const onConnect = useCallback(({ source, target }: any) => { @@ -60,9 +69,16 @@ const FlowBuilder: FC = ({ nodes, edges }) => { }, []); const onBeforeDelete = useCallback( - async ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => { + async ({ nodes: nodesToDelete, edges: edgesToDelete }: { nodes: Node[]; edges: Edge[] }) => { setDeletedNodes(null); try { + if (edgesToDelete.length > 0 && nodesToDelete.length === 0) { + const shouldPreventDelete = getNode(edgesToDelete[0].source)?.data.stepType === StepType.MultiChoiceQuestion; + if (shouldPreventDelete) { + return false; + } + } + if (nodesToDelete.length === 0 || ![StepType.MultiChoiceQuestion, StepType.Condition, StepType.Input] .includes(nodesToDelete[0]?.data.stepType as StepType)) return true; @@ -118,7 +134,10 @@ const FlowBuilder: FC = ({ nodes, edges }) => { {isDeleteConnectionsModalVisible && ( - + { + setNodeToDelete(null); + setIsDeleteConnectionsModalVisible(false); + }}> + {onRemove && ( + + )} ); diff --git a/GUI/src/components/FlowElementsPopup/DynamicChoicesContent.tsx b/GUI/src/components/FlowElementsPopup/DynamicChoicesContent.tsx new file mode 100644 index 000000000..2a0f5e389 --- /dev/null +++ b/GUI/src/components/FlowElementsPopup/DynamicChoicesContent.tsx @@ -0,0 +1,94 @@ +import { FC } from "react"; +import Track from "../Track"; +import PreviousVariables from "./PreviousVariables"; +import { DynamicChoices } from "types/dynamic-choices"; +import AssignElement from "./AssignBuilder/assignElement"; +import { MdOutlineInfo } from "react-icons/md"; +import Tooltip from "components/Tooltip"; +import { t } from "i18next"; + +type DynamicChoicesContentProps = { + readonly nodeId: string; + readonly dynamicChoices?: DynamicChoices; + readonly onDynamicChoicesChange?: (dynamicChoices: DynamicChoices) => void; +}; + +const fields: Array<{ + key: keyof DynamicChoices; + label: string; + tooltip: string; +}> = [ + { + key: "list", + label: t("serviceFlow.element.dynamicChoices.list"), + tooltip: t("serviceFlow.element.dynamicChoices.listTooltip"), + }, + { + key: "serviceName", + label: t("serviceFlow.element.dynamicChoices.serviceName"), + tooltip: t("serviceFlow.element.dynamicChoices.serviceNameTooltip"), + }, + { + key: "key", + label: t("serviceFlow.element.dynamicChoices.key"), + tooltip: t("serviceFlow.element.dynamicChoices.keyTooltip"), + }, + { + key: "payloadKeys", + label: t("serviceFlow.element.dynamicChoices.payloadKeys"), + tooltip: t("serviceFlow.element.dynamicChoices.payloadKeysTooltip"), + }, +]; + +const DynamicChoicesContent: FC = ({ + nodeId, + dynamicChoices = { + list: "", + serviceName: "", + key: "", + payloadKeys: "", + }, + onDynamicChoicesChange, +}) => { + const handleChange = (field: keyof DynamicChoices, value: string) => { + onDynamicChoicesChange?.({ + ...dynamicChoices, + [field]: value, + }); + }; + + return ( + + + {fields.map((field) => ( + + handleChange(field.key, element.value)} + /> + + + + + ))} + + + + ); +}; + +export default DynamicChoicesContent; diff --git a/GUI/src/components/FlowElementsPopup/MultiChoiceQuestionContent.tsx b/GUI/src/components/FlowElementsPopup/MultiChoiceQuestionContent.tsx index 6b9ae5e3b..2e79dd83a 100644 --- a/GUI/src/components/FlowElementsPopup/MultiChoiceQuestionContent.tsx +++ b/GUI/src/components/FlowElementsPopup/MultiChoiceQuestionContent.tsx @@ -53,6 +53,7 @@ const MultiChoiceQuestionContent: FC = ({ setButtons(newButtons); setEditIndex(null); setEditValue(""); + setIsSaveEnabled(newButtons.length > 1 && newButtons.every((btn) => btn.title.length > 0)); }; const handleDelete = (idx: number) => { @@ -62,7 +63,7 @@ const MultiChoiceQuestionContent: FC = ({ setEditIndex(null); setEditValue(""); } - setIsSaveEnabled(newButtons.length > 1); + setIsSaveEnabled(newButtons.length > 1 && newButtons.every((btn) => btn.title.length > 0)); }; const handleAdd = () => { @@ -77,7 +78,7 @@ const MultiChoiceQuestionContent: FC = ({ }, ]; setButtons(newButtons); - setIsSaveEnabled(newButtons.length > 1); + setIsSaveEnabled(false); }; return ( diff --git a/GUI/src/components/FlowElementsPopup/index.tsx b/GUI/src/components/FlowElementsPopup/index.tsx index 12d6aee9f..7220af0ba 100644 --- a/GUI/src/components/FlowElementsPopup/index.tsx +++ b/GUI/src/components/FlowElementsPopup/index.tsx @@ -33,6 +33,8 @@ import useServiceListStore from "store/services.store"; import api from "../../services/api-dev"; import { EndpointData } from "types/endpoint"; import useToastStore from "store/toasts.store"; +import { DynamicChoices } from "types/dynamic-choices"; +import DynamicChoicesContent from "./DynamicChoicesContent"; const FlowElementsPopup: React.FC = () => { const { t } = useTranslation(); @@ -54,20 +56,27 @@ const FlowElementsPopup: React.FC = () => { const defaultMultiChoiceQuestionButtons = [ { id: "1", - title: "Yes", + title: "Jah", payload: `#service, /${selectedService?.type ?? "POST"}/services/active/${serviceName}_mcq_${ node?.data.label[node?.data.label.length - 1] }_0`, }, { id: "2", - title: "No", + title: "Ei", payload: `#service, /${selectedService?.type ?? "POST"}/services/active/${serviceName}_mcq_${ node?.data.label[node?.data.label.length - 1] }_1`, }, ]; + const defaultDynamicChoices: DynamicChoices = { + list: "", + serviceName: "", + key: "", + payloadKeys: "", + }; + useEffect(() => { if (node) node.data.rules = rules; }, [rules]); @@ -94,34 +103,51 @@ const FlowElementsPopup: React.FC = () => { const [multiChoiceQuestionButtons, setMultiChoiceQuestionButtons] = useState( node?.data.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons ); + const [dynamicChoices, setDynamicChoices] = useState( + node?.data.dynamicChoices ?? defaultDynamicChoices + ); const [nodeEndpoint, setNodeEndpoint] = useState(node?.data.endpoint); + const [title, setTitle] = useState(node?.data.label ?? ""); + const [titleError, setTitleError] = useState(undefined); const stepType = node?.data.stepType; useEffect(() => { - if (stepType !== StepType.Input && stepType !== StepType.Condition) return; - if (!node?.data?.rules) return; + if (!node) return; - useServiceStore.getState().changeRulesNode(node.data.rules); - }, [stepType === StepType.Input, stepType === StepType.Condition]); + setTitle(node.data.label ?? ""); - useEffect(() => { - if (stepType !== StepType.Assign) return; - if (!node?.data?.assignElements) return; + switch (stepType) { + case StepType.Input: + case StepType.Condition: + if (node.data?.rules) { + useServiceStore.getState().changeRulesNode(node.data.rules); + } + break; - useServiceStore.getState().changeAssignNode(node.data.assignElements); - }, [stepType === StepType.Assign]); + case StepType.Assign: + if (node.data?.assignElements) { + useServiceStore.getState().changeAssignNode(node.data.assignElements); + } + break; - useEffect(() => { - if (!node) return; - setMultiChoiceQuestionQuestion(node?.data?.multiChoiceQuestion?.question ?? ""); - setMultiChoiceQuestionButtons(node?.data?.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons); - }, [stepType === StepType.MultiChoiceQuestion]); + case StepType.MultiChoiceQuestion: + setMultiChoiceQuestionQuestion(node.data?.multiChoiceQuestion?.question ?? ""); + setMultiChoiceQuestionButtons(node.data?.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons); + break; + + case StepType.DynamicChoices: + setDynamicChoices(node.data?.dynamicChoices ?? defaultDynamicChoices); + break; + + default: + break; + } + }, [stepType]); if (!node) return <>; - const title = node.data.label; const isReadonly = node.data.readonly; const onClose = () => { @@ -136,6 +162,8 @@ const FlowElementsPopup: React.FC = () => { setTextfieldMessagePlaceholders({}); setMultiChoiceQuestionQuestion(""); setMultiChoiceQuestionButtons(defaultMultiChoiceQuestionButtons); + setIsSaveEnabled(true); + setDynamicChoices(defaultDynamicChoices); useServiceStore.getState().resetSelectedNode(); useServiceStore.getState().resetRules(); useServiceStore.getState().resetAssign(); @@ -146,6 +174,7 @@ const FlowElementsPopup: React.FC = () => { ...node, data: { ...node.data, + label: title, message: textfieldMessage ?? node.data?.message, link: webpageUrl ?? node.data?.link, linkText: webpageName ?? node.data?.linkText, @@ -156,6 +185,7 @@ const FlowElementsPopup: React.FC = () => { question: multiChoiceQuestionQuestion, buttons: multiChoiceQuestionButtons, }, + dynamicChoices: dynamicChoices, endpoint: nodeEndpoint ?? node.data?.endpoint, }, }; @@ -387,6 +417,17 @@ const FlowElementsPopup: React.FC = () => { } + titleEditable + onTitleChange={(newTitle) => { + const nodeWithSameLabel = instance?.getNodes().find((n) => n.data.label === newTitle && n.id !== node.id); + setTitleError(nodeWithSameLabel ? t("newService.toast.elementNameAlreadyExists").toString() : undefined); + }} + onTitleSave={setTitle} + onTitleEditCancel={() => { + setTitleError(undefined); + setTitle(node.data.label ?? ""); + }} + titleError={titleError} > { {stepType === StepType.RasaRules && } {stepType === StepType.Assign && } {stepType === StepType.Condition && } + {stepType === StepType.DynamicChoices && ( + + )} {stepType === StepType.UserDefined && ( = ({ event.dataTransfer.setData( ASSIGN_DRAG_TYPE, // Need to check for StepType.Assign here since ReactQuill does not support custom onDrop events - node?.data.stepType === StepType.Assign ? JSON.stringify(dragData) : dragData.value + node?.data.stepType === StepType.Assign || node?.data.stepType === StepType.DynamicChoices + ? JSON.stringify(dragData) + : dragData.value ); }; diff --git a/GUI/src/components/Popup/index.tsx b/GUI/src/components/Popup/index.tsx index 3d50c303b..3e5dc1ac3 100644 --- a/GUI/src/components/Popup/index.tsx +++ b/GUI/src/components/Popup/index.tsx @@ -1,9 +1,9 @@ -import React, { FC, HTMLAttributes, PropsWithChildren, ReactNode } from 'react'; -import * as RadixDialog from '@radix-ui/react-dialog'; -import { MdOutlineClose } from 'react-icons/md'; +import React, { FC, HTMLAttributes, PropsWithChildren, ReactNode, useEffect, useRef, useState } from "react"; +import * as RadixDialog from "@radix-ui/react-dialog"; +import { MdOutlineClose, MdOutlineEdit, MdCheck, MdClose } from "react-icons/md"; -import { Icon, Track } from '..'; -import './Popup.scss'; +import { Button, FormInput, Icon, Track } from ".."; +import "./Popup.scss"; type DialogProps = HTMLAttributes & { title: string; @@ -11,27 +11,150 @@ type DialogProps = HTMLAttributes & { footer?: ReactNode; hasDefaultBody?: boolean; onClose: () => void; + onTitleChange?: (newTitle: string) => void; + onTitleSave?: (newTitle: string) => void; + onTitleEditCancel?: () => void; + titleEditable?: boolean; + titleError?: string; }; -const Popup: FC> = ({ title, description, footer, onClose, hasDefaultBody = true, children, ...rest }) => { +const Popup: FC> = ({ + title: initialTitle, + description, + footer, + onClose, + hasDefaultBody = true, + titleEditable = false, + onTitleChange, + onTitleSave, + onTitleEditCancel, + titleError, + children, + ...rest +}) => { + const [title, setTitle] = useState(initialTitle); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [tempTitle, setTempTitle] = useState(initialTitle); + + useEffect(() => { + setTitle(initialTitle); + setTempTitle(initialTitle); + }, [initialTitle]); + + const handleTitleEditStart = () => { + setTempTitle(title); + setIsEditingTitle(true); + }; + + const handleTitleChange = (e: React.ChangeEvent) => { + const sanitizedValue = e.target.value.replace(/[^a-zA-Z0-9-\s]/g, ""); + setTempTitle(sanitizedValue); + onTitleChange?.(sanitizedValue); + }; + + const handleTitleSave = () => { + if (!titleError && tempTitle.trim() !== "") { + setIsEditingTitle(false); + setTitle(tempTitle.trim()); + if (onTitleSave && tempTitle !== title) { + onTitleSave?.(tempTitle.trim()); + } + } + }; + + const handleTitleCancel = () => { + setIsEditingTitle(false); + setTempTitle(title); + onTitleEditCancel?.(); + }; + + const handleTitleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleTitleSave(); + } + }; + + const titleRef = useRef(null); + return ( - - -
- {title} - - - -
-
- {children} + + +
+ + + {isEditingTitle ? ( + +
+ +
+ + + {titleEditable && !isEditingTitle && ( + + )} + {titleError && } + + ) : ( + {title} + )} + {titleEditable && !isEditingTitle && ( + + )} + + + + +
+
{children}
{footer && ( - {footer} + + {footer} + )}
diff --git a/GUI/src/hooks/flow/useEdgeAdd.ts b/GUI/src/hooks/flow/useEdgeAdd.ts index bda5f544c..97402c756 100644 --- a/GUI/src/hooks/flow/useEdgeAdd.ts +++ b/GUI/src/hooks/flow/useEdgeAdd.ts @@ -25,7 +25,7 @@ function useEdgeAdd(id: string) { label: nodeLabel, onDelete: useServiceStore.getState().onDelete, onEdit: useServiceStore.getState().handleNodeEdit, - type: [StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(stepType) + type: [StepType.DynamicChoices, StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(stepType) ? "finishing-step" : "step", stepType: stepType, @@ -35,6 +35,8 @@ function useEdgeAdd(id: string) { }, className: [StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(stepType) ? "finishing-step" + : [StepType.DynamicChoices].includes(stepType) + ? "dynamic-choices" : "step", type: "custom", }; @@ -49,7 +51,11 @@ function useEdgeAdd(id: string) { let targetEdge: Edge | null = null; - if (stepType != StepType.FinishingStepEnd && stepType != StepType.FinishingStepRedirect) { + if ( + stepType != StepType.DynamicChoices && + stepType != StepType.FinishingStepEnd && + stepType != StepType.FinishingStepRedirect + ) { targetEdge = { id: `${newNodeId}->${edge.target}`, source: newNodeId, @@ -64,7 +70,7 @@ function useEdgeAdd(id: string) { let ghostEdges: Edge[] = []; if (stepType === StepType.MultiChoiceQuestion || stepType === StepType.Condition || stepType === StepType.Input) { - const labels = stepType === StepType.MultiChoiceQuestion ? ["Yes", "No"] : ["Success", "Failure"]; + const labels = stepType === StepType.MultiChoiceQuestion ? ["Jah", "Ei"] : ["Success", "Failure"]; ghostNodes = labels.slice(1).map((_, i) => ({ id: crypto.randomUUID(), type: "ghost", diff --git a/GUI/src/hooks/flow/useOnNodeDelete.ts b/GUI/src/hooks/flow/useOnNodeDelete.ts index 3dc16de1f..692476607 100644 --- a/GUI/src/hooks/flow/useOnNodeDelete.ts +++ b/GUI/src/hooks/flow/useOnNodeDelete.ts @@ -202,5 +202,6 @@ const processDeletedNodes = ( onKeepItConfirmed, hasConnectedNodes, setDeletedNodes, + setNodeToDelete, }; }; diff --git a/GUI/src/i18n/en/common.json b/GUI/src/i18n/en/common.json index 8e555764b..652226fd1 100644 --- a/GUI/src/i18n/en/common.json +++ b/GUI/src/i18n/en/common.json @@ -285,7 +285,18 @@ "redirectConversationToSupport": "Direct to Customer Support", "rules": "Rules", "rasaRules": "Rasa Rules", - "siga": "SiGa" + "siga": "SiGa", + "dynamicChoices": { + "title": "Dynamic Choices", + "list": "List", + "serviceName": "Service Name", + "key": "Key", + "payloadKeys": "Payload Keys", + "listTooltip": "The list needed to generate choices\nExample: [{\"name\":\"choice1\",\"price\":12},{\"name\":\"choice2\",\"price\":15}]", + "serviceNameTooltip": "The name of the service that will be executed when a choice is made\nExample: test_service", + "keyTooltip": "The key will be used as the title for each choice\nExample: name for the above list", + "payloadKeysTooltip": "Comma-separated keys to be the data sent to the service when a choice is made\nExample: name,price" + } }, "popup": { "messageLabel": "Message", diff --git a/GUI/src/i18n/et/common.json b/GUI/src/i18n/et/common.json index 5eb608e44..0c93c9278 100644 --- a/GUI/src/i18n/et/common.json +++ b/GUI/src/i18n/et/common.json @@ -285,7 +285,18 @@ "redirectConversationToSupport": "Klienditeenindusse suunamine", "rules": "Reeglid", "rasaRules": "Rasa reeglid", - "siga": "Allkirjastamine" + "siga": "Allkirjastamine", + "dynamicChoices": { + "title": "Dünaamilised valikud", + "list": "Nimekiri", + "serviceName": "Teenuse nimi", + "key": "Võti", + "payloadKeys": "Andmete võtmed", + "listTooltip": "Valikute genereerimiseks vajalik nimekiri\nNäide: [{\"name\":\"choice1\",\"price\":12},{\"name\":\"choice2\",\"price\":15}]", + "serviceNameTooltip": "Teenuse nimi, mis valiku tegemisel käivitatakse\nNäide: test_service", + "keyTooltip": "Võtit kasutatakse iga valiku pealkirjana\nNäide: name for the above list", + "payloadKeysTooltip": "Komadega eraldatud võtmed peavad olema andmed, mis saadetakse teenusele valiku tegemisel\nNäide: name,price" + } }, "popup": { "messageLabel": "Sõnum", diff --git a/GUI/src/pages/ServiceFlowPage.scss b/GUI/src/pages/ServiceFlowPage.scss index 82d3148ad..afd8d33fc 100644 --- a/GUI/src/pages/ServiceFlowPage.scss +++ b/GUI/src/pages/ServiceFlowPage.scss @@ -123,6 +123,11 @@ border: 1px solid get-color(jasper-3); } + &.dynamic-choices { + background-color: get-color(purple-1); + border: 1px solid get-color(purple-3); + } + &.selected { border: dashed 1px get-color(black-coral-1); } diff --git a/GUI/src/services/flow-builder.ts b/GUI/src/services/flow-builder.ts index 62ce323e0..4c2701da2 100644 --- a/GUI/src/services/flow-builder.ts +++ b/GUI/src/services/flow-builder.ts @@ -280,252 +280,6 @@ const buildRuleEdges = ({ ]; }; -export const onNodeDrag = (_event: React.MouseEvent, draggedNode: Node) => { - // Move the placeholder together with the node being moved - const edges = useServiceStore.getState().edges; - const nodes = useServiceStore.getState().nodes; - - const draggedEdges = edges.filter((edge) => edge.source === draggedNode.id); - if (draggedEdges.length === 0) return; - const placeholders = nodes.filter( - (node) => draggedEdges.map((edge) => edge.target).includes(node.id) && node.type === "placeholder" - ); - // only drag placeholders following the node - if (placeholders.length === 0) return; - - useServiceStore.getState().setNodes((prevNodes) => - prevNodes.map((prevNode) => { - const placeholderIndex = placeholders.findIndex((p) => p.id === prevNode.id); - if (placeholderIndex >= 0) { - const totalPlaceholders = placeholders.length; - const baseY = draggedNode.position.y + EDGE_LENGTH * 1.5; - const baseX = draggedNode.position.x; - const widthOffset = (draggedNode.width ?? 0) * 0.75; - const spacing = widthOffset * 1.7; - - if (totalPlaceholders === 1) { - prevNode.position.x = baseX; - prevNode.position.y = baseY + (draggedNode.height ?? 0); - } else { - const middleIndex = Math.floor(totalPlaceholders / 2); - const offset = (placeholderIndex - middleIndex + (totalPlaceholders % 2 === 0 ? 0.5 : 0)) * spacing; - - prevNode.position.x = baseX + offset; - prevNode.position.y = baseY; - } - } - return prevNode; - }) - ); -}; - -export const onDrop = ( - event: React.DragEvent, - reactFlowWrapper: React.RefObject, - setDefaultMessages: (stepType: StepType) => any -) => { - // Dragging and dropping the element from the list on the left - // onto the placeholder node adds it to the flow - - const reactFlowInstance = useServiceStore.getState().reactFlowInstance; - - event.preventDefault(); - // Find matching placeholder - if (!reactFlowInstance || !reactFlowWrapper.current) return; - - const [label, type, originalDefinedNodeId] = [ - event.dataTransfer.getData("application/reactflow-label"), - event.dataTransfer.getData("application/reactflow-type") as StepType, - event.dataTransfer.getData("application/reactflow-originalDefinedNodeId"), - ]; - - const position = reactFlowInstance.screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - - const matchingPlaceholder = reactFlowInstance.getNodes().find((node) => { - if (node.type !== "placeholder") return false; - return ( - node.position.x <= position.x && - position.x <= node.position.x + node.width! && - node.position.y <= position.y && - position.y <= node.position.y + node.height! - ); - }); - if (!matchingPlaceholder) return; - const connectedNodeEdge = reactFlowInstance.getEdges().find((edge) => edge.target === matchingPlaceholder.id); - if (!connectedNodeEdge) return; - - useServiceStore.getState().setNodes((prevNodes) => { - const newNodeId = - prevNodes.length > 2 - ? `${Math.max(...useServiceStore.getState().nodes.map((node) => +node.id)) + 1}` - : matchingPlaceholder.id; - const newPlaceholderId = Math.max(...useServiceStore.getState().nodes.map((node) => +node.id)) + 2; - - const baseY = matchingPlaceholder.position.y + EDGE_LENGTH * 1.5; - const baseX = matchingPlaceholder.position.x; - const widthOffset = (matchingPlaceholder.width ?? 0) * 0.75; - - useServiceStore.getState().setEdges((prevEdges) => { - // Point edge from previous node to new node - const newEdges = [...prevEdges]; - let matchingPlaceholderNextNodeId = undefined; - - // Point edge from matching placeholder to new node - if (prevNodes.length > 2) { - newEdges.push( - buildEdge({ - id: `edge-${matchingPlaceholder.id}-${newNodeId + 1}`, - source: matchingPlaceholder.id, - sourceHandle: `handle-${matchingPlaceholder.id}-${newNodeId}`, - target: newNodeId, - }) - ); - - // Check if the new node is added in between two nodes - const previousEdgesOfMatchingPlaceholder = newEdges.filter( - (edge) => edge.source === matchingPlaceholder.id - ).length; - if (previousEdgesOfMatchingPlaceholder > 1) { - matchingPlaceholderNextNodeId = newEdges.find((edge) => edge.source === matchingPlaceholder.id)?.target; - newEdges.splice( - newEdges.findIndex((edge) => edge.source === matchingPlaceholder.id), - 1 - ); - } - } - // Point edge from new node to new placeholder - newEdges.push( - buildEdge({ - id: `edge-${newNodeId}-${newPlaceholderId + 1}`, - source: newNodeId, - sourceHandle: `handle-${newNodeId}-0`, - target: `${newPlaceholderId + 1}`, - }) - ); - - // In-case there is a node after the matching placeholder, point edge from new placeholder to that node - if (matchingPlaceholderNextNodeId) { - newEdges.push( - buildEdge({ - id: `edge-${newPlaceholderId + 1}-${matchingPlaceholderNextNodeId}`, - source: `${newPlaceholderId + 1}`, - sourceHandle: `handle-${newNodeId}-0`, - target: matchingPlaceholderNextNodeId, - }) - ); - } - - if (type === StepType.Input || type === StepType.Condition || type === StepType.MultiChoiceQuestion) { - newEdges.push( - buildEdge({ - id: `edge-${newNodeId}-${newPlaceholderId + 2}`, - source: newNodeId, - sourceHandle: `handle-${newNodeId}-1`, - target: `${newPlaceholderId + 2}`, - }) - ); - } - - return newEdges; - }); - - const nodeLabel = getNodeLabel(type, prevNodes, label); - - const matchingPlaceholderIndex = prevNodes.findIndex((node) => node.id === matchingPlaceholder.id); - - // Add new node in place of old placeholder - const previousNodes = [...prevNodes.slice(0, matchingPlaceholderIndex + 1)]; - const nextNodes = [...prevNodes.slice(matchingPlaceholderIndex + 1)]; - nextNodes.forEach((node) => { - node.position.y += EDGE_LENGTH * 1.5; - }); - const id = parseInt(nodeLabel.split("-").pop()?.trim() ?? '1'); - const newNodes = [ - ...previousNodes, - { - id: `${newNodeId}`, - position: - prevNodes.length > 2 - ? { - y: matchingPlaceholder.position.y + EDGE_LENGTH, - x: matchingPlaceholder.position.x, - } - : matchingPlaceholder.position, - type: "custom", - data: { - label: nodeLabel, - onDelete: useServiceStore.getState().onDelete, - onEdit: useServiceStore.getState().handleNodeEdit, - type: [StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(type) ? "finishing-step" : "step", - stepType: type, - clientInputId: type === StepType.Input ? id : undefined, - conditionId: type === StepType.Condition ? id : undefined, - multiChoiceQuestionId: - type === StepType.MultiChoiceQuestion ? id : undefined, - assignId: type === StepType.Assign ? id : undefined, - readonly: [ - StepType.Auth, - StepType.FinishingStepEnd, - StepType.FinishingStepRedirect, - ].includes(type), - childrenCount: type === StepType.Input || type === StepType.Condition || type === StepType.MultiChoiceQuestion ? 2 : 1, - setClickedNode: useServiceStore.getState().setClickedNode, - message: setDefaultMessages(type), - originalDefinedNodeId: type === StepType.UserDefined ? originalDefinedNodeId : undefined, - }, - className: [StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(type) - ? "finishing-step" - : "step", - }, - ...nextNodes, - ]; - - if ( - ![StepType.Input, StepType.Condition, StepType.MultiChoiceQuestion, StepType.FinishingStepEnd, StepType.FinishingStepRedirect].includes(type) - ) { - // Add placeholder right below new node - newNodes.push( - buildPlaceholder({ - id: `${newPlaceholderId + 1}`, - matchingPlaceholder, - position: { - y: matchingPlaceholder.position.y + EDGE_LENGTH * 1.5, - x: matchingPlaceholder.position.x, - }, - }) - ); - } - - if ([StepType.MultiChoiceQuestion, StepType.Input, StepType.Condition].includes(type)) { - const labels = - type === StepType.MultiChoiceQuestion - ? ["Yes", "No"] - : ["serviceFlow.placeholderNodeSuccess", "serviceFlow.placeholderNodeFailure"]; - - const middleIndex = Math.floor(labels.length / 2); - const spacing = widthOffset * 1.7; - - labels.forEach((label, index) => { - const offset = (index - middleIndex + (labels.length % 2 === 0 ? 0.5 : 0)) * spacing; - newNodes.push( - buildPlaceholder({ - id: `${newPlaceholderId + (index + 1)}`, - label: label, - position: { y: baseY, x: baseX + offset }, - }) - ); - }); - } - - return newNodes; - }); - - useServiceStore.getState().disableTestButton(); -}; - export const onFlowNodeDragStop = ( event: any, draggedNode: Node, @@ -598,19 +352,3 @@ export const onFlowNodeDragStop = ( }); startDragNode.current = undefined; }; -function getNodeLabel(type: StepType, nodes: Node[], label: string) { - const prevNodes = nodes.filter((node) => node.data.stepType === type); - const lastNode = prevNodes[prevNodes.length - 1]?.data; - switch (type) { - case StepType.Input: - return `${label} - ${(lastNode?.clientInputId ?? 0) + 1}`; - case StepType.Condition: - return `${label} - ${(lastNode?.conditionId ?? 0) + 1}`; - case StepType.MultiChoiceQuestion: - return `${label} - ${(lastNode?.multiChoiceQuestionId ?? 0) + 1}`; - case StepType.Assign: - return `${label} - ${(lastNode?.assignId ?? 0) + 1}`; - default: - return label; - } -} diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts index dd4588dd0..5780fa6d3 100644 --- a/GUI/src/services/service-builder.ts +++ b/GUI/src/services/service-builder.ts @@ -10,7 +10,7 @@ import { StepType } from "types"; import { EndpointData, EndpointVariableData } from "types/endpoint"; import api from "../services/api-dev"; import { NodeDataProps } from "types/service-flow"; -import { getLastDigits, removeTrailingUnderscores, toSnakeCase } from "utils/string-util"; +import { getLastDigits, removeTrailingUnderscores, stringToArray, toSnakeCase } from "utils/string-util"; import { format } from "date-fns"; import { AxiosError } from "axios"; @@ -353,6 +353,10 @@ function getYamlContent(nodes: Node[], edges: Edge[], name: string, description: return handleMultiChoiceQuestion(finishedFlow, parentStepName, parentNode, childNode); } + if (parentNode.data.stepType === StepType.DynamicChoices) { + return handleDynamicChoices(finishedFlow, parentStepName, parentNode, childNode); + } + if (parentNode.data.stepType === StepType.UserDefined) { return handleEndpointStep(parentNode, finishedFlow, parentStepName, childNode); } @@ -529,7 +533,7 @@ function handleEndpointStep( const methodType = endpointDefinition?.methodType?.toLowerCase(); const stepConfig: any = { - call: `http.${methodType}`, + call: `http.${methodType ?? 'post'}`, args: { url: endpointDefinition?.url?.split("?")[0] ?? "", }, @@ -578,6 +582,37 @@ function handleMultiChoiceQuestion( }); } +function handleDynamicChoices( + finishedFlow: Map, + parentStepName: string, + parentNode: Node, + childNode: Node | undefined +) { + const list = parentNode.data.dynamicChoices?.list ?? ""; + finishedFlow.set(parentStepName, { + call: "http.post", + args: { + url: "[#SERVICE_DMAPPER]/generate/buttons-list", + body: { + list: stringToArray(list, list), + service_name: parentNode.data.dynamicChoices?.serviceName ?? "", + key: parentNode.data.dynamicChoices?.key ?? "", + payload_prefix: "#service, /POST/", + payload_keys: parentNode.data.dynamicChoices?.payloadKeys.split(",") ?? [], + }, + }, + result: "dynamic_choices_res", + next: "assign_dynamic_choices_buttons", + }); + + return finishedFlow.set("assign_dynamic_choices_buttons", { + assign: { + buttons: "${dynamic_choices_res.response.body.response ?? []}", + }, + next: childNode ? toSnakeCase(childNode.data.label ?? "format_messages") : "format_messages", + }); +} + const getMapEntry = (value: string) => { const secrets = useServiceStore.getState().secrets; diff --git a/GUI/src/store/new-services.store.ts b/GUI/src/store/new-services.store.ts index bcebb015d..e1e7af5ee 100644 --- a/GUI/src/store/new-services.store.ts +++ b/GUI/src/store/new-services.store.ts @@ -716,7 +716,8 @@ const useServiceStore = create((set, get, store) => ({ prevNode.data.fileName != updatedNode.data.fileName || prevNode.data.fileContent != updatedNode.data.fileContent || prevNode.data.signOption != updatedNode.data.signOption || - prevNode.data.multiChoiceQuestion != updatedNode.data.multiChoiceQuestion + prevNode.data.multiChoiceQuestion != updatedNode.data.multiChoiceQuestion || + prevNode.data.dynamicChoices != updatedNode.data.dynamicChoices ) { useServiceStore.getState().disableTestButton(); } @@ -731,6 +732,7 @@ const useServiceStore = create((set, get, store) => ({ fileContent: updatedNode.data.fileContent, signOption: updatedNode.data.signOption, multiChoiceQuestion: updatedNode.data.multiChoiceQuestion, + dynamicChoices: updatedNode.data.dynamicChoices, endpoint: updatedNode.data.endpoint, label: updatedNode.data.label, }, diff --git a/GUI/src/styles/settings/variables/_colors.scss b/GUI/src/styles/settings/variables/_colors.scss index 4380fbc01..e8bcdb91c 100644 --- a/GUI/src/styles/settings/variables/_colors.scss +++ b/GUI/src/styles/settings/variables/_colors.scss @@ -115,6 +115,29 @@ $veera-colors: ( sapphire-blue-19: #00111e, sapphire-blue-20: #00090f, + // Purple + purple-0: #f2e7f9, + purple-1: #e5d0f3, + purple-2: #d8b9ed, + purple-3: #cba2e7, + purple-4: #be8be1, + purple-5: #b374db, + purple-6: #a65dd5, + purple-7: #9946cf, + purple-8: #8c2fd0, + purple-9: #7f18ca, + purple-10: #7201c4, + purple-11: #6a00b8, + purple-12: #6200a9, + purple-13: #5a009b, + purple-14: #52008d, + purple-15: #4a007f, + purple-16: #420071, + purple-17: #3a0063, + purple-18: #320055, + purple-19: #2a0047, + purple-20: #220039, + // Sea green sea-green-0: #ecf4ef, sea-green-1: #d9e9df, diff --git a/GUI/src/types/dynamic-choices.ts b/GUI/src/types/dynamic-choices.ts new file mode 100644 index 000000000..dbae53b25 --- /dev/null +++ b/GUI/src/types/dynamic-choices.ts @@ -0,0 +1,6 @@ +export type DynamicChoices = { + list: string; + serviceName: string; + key: string; + payloadKeys: string; +}; diff --git a/GUI/src/types/service-flow.ts b/GUI/src/types/service-flow.ts index 3fbe275e5..8f34adf72 100644 --- a/GUI/src/types/service-flow.ts +++ b/GUI/src/types/service-flow.ts @@ -1,3 +1,4 @@ +import { DynamicChoices } from "./dynamic-choices"; import { EndpointData } from "./endpoint"; import { MultiChoiceQuestion } from "./multi-choice-question"; import { StepType } from "./step-type.enum"; @@ -24,6 +25,7 @@ export type NodeDataProps = { rules?: any; assignElements?: any; multiChoiceQuestion?: MultiChoiceQuestion; + dynamicChoices?: DynamicChoices; childrenCount?: number; endpoint?: EndpointData }; diff --git a/GUI/src/types/step-type.enum.ts b/GUI/src/types/step-type.enum.ts index 710918a1a..356af9703 100644 --- a/GUI/src/types/step-type.enum.ts +++ b/GUI/src/types/step-type.enum.ts @@ -16,4 +16,5 @@ export enum StepType { UserDefined = "user-defined", RasaRules = "rasa-rules", SiGa = "siga", + DynamicChoices = "dynamic-choices", } diff --git a/GUI/src/types/step.ts b/GUI/src/types/step.ts index e7c5504bc..9f2248eb8 100644 --- a/GUI/src/types/step.ts +++ b/GUI/src/types/step.ts @@ -27,4 +27,5 @@ export const stepsLabels: Record = { [StepType.UserDefined]: "serviceFlow.element.userDefined", [StepType.RasaRules]: "serviceFlow.element.rasaRules", [StepType.SiGa]: "serviceFlow.element.siga", + [StepType.DynamicChoices]: "serviceFlow.element.dynamicChoices.title", }; diff --git a/GUI/src/utils/string-util.ts b/GUI/src/utils/string-util.ts index 23e3a1098..184735709 100644 --- a/GUI/src/utils/string-util.ts +++ b/GUI/src/utils/string-util.ts @@ -37,3 +37,15 @@ export const removeTrailingUnderscores = (value: string) => { while (end > 0 && value[end - 1] === "_") end--; return value.slice(0, end); }; + +export function stringToArray(str: string, fallback: any = []) { + try { + if (!str || typeof str !== "string" || str.trim() === "") { + return fallback; + } + const parsed = JSON.parse(str); + return Array.isArray(parsed) ? parsed : fallback; + } catch (e) { + return fallback; + } +}