diff --git a/DSL/DMapper/services/hbs/bot_responses_to_messages.handlebars b/DSL/DMapper/services/hbs/bot_responses_to_messages.handlebars index aa0230198..d0aacf50e 100644 --- a/DSL/DMapper/services/hbs/bot_responses_to_messages.handlebars +++ b/DSL/DMapper/services/hbs/bot_responses_to_messages.handlebars @@ -2,7 +2,7 @@ {{#each data.botMessages}} { "chatId": "{{../data.chatId}}", - "content": "{{filterControlCharacters result}}", + "content": "{{{filterControlCharacters (escapeQuotes result)}}}", "buttons": "[{{#each ../data.buttons}}{\"title\": \"{{#if (eq title true)}}Yes{{else if (eq title false)}}No{{else}}{{{title}}}{{/if}}\",\"payload\": \"{{{payload}}}\"}{{#unless @last}},{{/unless}}{{/each}}]", "authorTimestamp": "{{../data.authorTimestamp}}", "authorId": "{{../data.authorId}}", diff --git a/DSL/Ruuter/services/POST/services/edit.yml b/DSL/Ruuter/services/POST/services/edit.yml index 923466631..a2d661d4b 100644 --- a/DSL/Ruuter/services/POST/services/edit.yml +++ b/DSL/Ruuter/services/POST/services/edit.yml @@ -95,7 +95,17 @@ check_name_exists_result: switch: - condition: ${name_exists_res.response.body[0].nameExists} next: return_name_already_exists - next: delete_all_mcq_files + next: delete_old_mcq_files + +delete_old_mcq_files: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" + body: + path: "[#RUUTER_SERVICES_PATH]/${type}/services/draft" + keyword: "${get_service_result.response.body[0].name}_" + result: deleteOldRes + next: delete_all_mcq_files delete_all_mcq_files: call: http.post @@ -196,13 +206,28 @@ check_new_structure: use_new_structure: assign: new_structure: ${structure} - next: rename_dsl + next: check_rename_or_delete use_old_structure: assign: new_structure: ${old_structure.value} + next: check_rename_or_delete + +check_rename_or_delete: + switch: + - condition: ${content !== null && old_name !== name} + next: delete_old_main_file next: rename_dsl +delete_old_main_file: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${type}/[#RUUTER_SERVICES_DIR_PATH]/${old_state}/${old_name}.tmp" + result: delete_old_main_result + next: service_edit + rename_dsl: call: http.post args: diff --git a/GUI/src/components/Flow/NodeTypes/StepNode.tsx b/GUI/src/components/Flow/NodeTypes/StepNode.tsx index e33b8407f..27eb39a77 100644 --- a/GUI/src/components/Flow/NodeTypes/StepNode.tsx +++ b/GUI/src/components/Flow/NodeTypes/StepNode.tsx @@ -3,10 +3,12 @@ import ExclamationBadge from 'components/ExclamationBadge'; import Track from 'components/Track'; import { FC, memo, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import sanitizeHtml from 'sanitize-html'; import useServiceStore from 'store/new-services.store'; import { StepType } from 'types'; import { NodeDataProps } from 'types/service-flow'; import { validateStep } from 'utils/flow-utils'; +import { decodeHtmlEntities } from 'utils/string-util'; type StepNodeProps = { data: NodeDataProps; @@ -21,8 +23,17 @@ const StepNode: FC = ({ data }) => { fontWeight: 500, }; const createMarkup = (text: string) => { + const decodedText = decodeHtmlEntities(text); + const sanitizedText = sanitizeHtml(decodedText, { + allowedTags: ['a', 'b', 'strong', 'i', 'em', 'u', 'p', 'br', 'ul', 'ol', 'li', 'code'], + allowedAttributes: { + a: ['href', 'target', 'rel'], + }, + disallowedTagsMode: 'discard', + }); + return { - __html: text, + __html: sanitizedText, }; }; @@ -52,7 +63,9 @@ const StepNode: FC = ({ data }) => { }, [data, endpoints]); useEffect(() => { - void updateIsTestedAndPassed(); + updateIsTestedAndPassed().catch(() => { + setIsTestedAndPassed(false); + }); }, [updateIsTestedAndPassed]); return ( diff --git a/GUI/src/components/FlowElementsPopup/index.tsx b/GUI/src/components/FlowElementsPopup/index.tsx index 9b76c784a..e9f34538e 100644 --- a/GUI/src/components/FlowElementsPopup/index.tsx +++ b/GUI/src/components/FlowElementsPopup/index.tsx @@ -85,6 +85,12 @@ const FlowElementsPopup: React.FC = () => { [], ); + // Copy buttons to avoid shared object references between popup state and node data. + const copyMcqButtons = (buttons: MultiChoiceQuestionButton[]) => + buttons.map((button) => ({ + ...button, + })); + // StepType.Textfield const [textfieldMessage, setTextfieldMessage] = useState(null); const [textfieldMessagePlaceholders, setTextfieldMessagePlaceholders] = useState<{ [key: string]: string }>({}); @@ -101,7 +107,7 @@ const FlowElementsPopup: React.FC = () => { node?.data.multiChoiceQuestion?.question ?? '', ); const [multiChoiceQuestionButtons, setMultiChoiceQuestionButtons] = useState( - node?.data.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons, + copyMcqButtons(node?.data.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons), ); const [dynamicChoices, setDynamicChoices] = useState( node?.data.dynamicChoices ?? defaultDynamicChoices, @@ -136,7 +142,9 @@ const FlowElementsPopup: React.FC = () => { case StepType.MultiChoiceQuestion: setMultiChoiceQuestionQuestion(node.data?.multiChoiceQuestion?.question ?? ''); - setMultiChoiceQuestionButtons(node.data?.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons); + setMultiChoiceQuestionButtons( + copyMcqButtons(node.data?.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons), + ); break; case StepType.DynamicChoices: @@ -163,7 +171,7 @@ const FlowElementsPopup: React.FC = () => { setFileContent(null); setTextfieldMessagePlaceholders({}); setMultiChoiceQuestionQuestion(''); - setMultiChoiceQuestionButtons(defaultMultiChoiceQuestionButtons); + setMultiChoiceQuestionButtons(copyMcqButtons(defaultMultiChoiceQuestionButtons)); setIsSaveEnabled(true); setDynamicChoices(defaultDynamicChoices); useServiceStore.getState().resetSelectedNode(); @@ -187,7 +195,7 @@ const FlowElementsPopup: React.FC = () => { node.data.stepType === StepType.MultiChoiceQuestion ? { question: multiChoiceQuestionQuestion, - buttons: multiChoiceQuestionButtons, + buttons: copyMcqButtons(multiChoiceQuestionButtons), } : undefined, dynamicChoices: node.data.stepType === StepType.DynamicChoices ? dynamicChoices : undefined, @@ -217,7 +225,7 @@ const FlowElementsPopup: React.FC = () => { }; const prepareAssignForSaving = (updatedNode: Node) => { - const flatEndpointVariables = endpointsVariables.map((endpoint) => endpoint.chips).flat(); + const flatEndpointVariables = endpointsVariables.flatMap((endpoint) => endpoint.chips); assignElements.forEach((element) => { const key = removeTrailingUnderscores(element.key); element.key = key; @@ -339,8 +347,10 @@ const FlowElementsPopup: React.FC = () => { const saveMultiChoicePopup = (originalNode: Node, updatedNode: Node) => { if (!instance) return; - const currentButtons = originalNode.data.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons; - const newButtons = updatedNode.data.multiChoiceQuestion?.buttons ?? []; + const currentButtons = copyMcqButtons( + originalNode.data.multiChoiceQuestion?.buttons ?? defaultMultiChoiceQuestionButtons, + ); + const newButtons = copyMcqButtons(updatedNode.data.multiChoiceQuestion?.buttons ?? []); const edges = instance.getEdges(); const nodes = instance.getNodes(); @@ -362,7 +372,12 @@ const FlowElementsPopup: React.FC = () => { })); const updatedEdges = edges.map((edge) => { - if (!edge.label || !connectedEdges.some((ce) => ce.id === edge.id)) return edge; + if ( + !edge.label || + edge.source !== originalNode.id || + !connectedEdges.some((ce) => ce.id === edge.id) + ) + return edge; const rename = renamedButtons.find((r) => r.oldTitle === edge.label); if (rename) { return { ...edge, label: rename.newTitle }; @@ -387,10 +402,11 @@ const FlowElementsPopup: React.FC = () => { const buttonsNeedingEdges = addedButtons.filter((btn) => !existingButtonTitles.has(btn.title)); const newEdges = buttonsNeedingEdges.map((button) => { + const ghostNodeId = `${originalNode.id}-ghost-${button.id}`; const newEdge: Edge = { id: `${originalNode.id}->${button.id}`, source: originalNode.id, - target: `ghost-${button.id}`, + target: ghostNodeId, type: 'step', animated: true, deletable: false, diff --git a/GUI/src/components/Markdowify/index.tsx b/GUI/src/components/Markdowify/index.tsx index e4b192529..55b72aba8 100644 --- a/GUI/src/components/Markdowify/index.tsx +++ b/GUI/src/components/Markdowify/index.tsx @@ -72,6 +72,22 @@ const LinkPreview: React.FC<{ const hasSpecialFormat = (m: string) => m.includes('\n\n') && m.indexOf('.') > 0 && m.indexOf(':') > m.indexOf('.'); +const htmlLinkToMarkdown = (value: string): string => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = value; + const links = tempDiv.querySelectorAll('a'); + + let result = value; + links.forEach((link) => { + const href = link.getAttribute('href') || ''; + const text = link.textContent || href; + const markdown = href ? `[${text}](${href})` : text; + result = result.replace(link.outerHTML, markdown); + }); + + return result; +}; + function formatMessage(message?: string): string { const sanitizedMessage = sanitizeHtml(message ?? ''); @@ -87,9 +103,10 @@ function formatMessage(message?: string): string { dataImagePattern, (_, prefix, dataUrl) => `${prefix}[image](${dataUrl})`, ); + const markdownLinksMessage = htmlLinkToMarkdown(finalMessage); - return finalMessage - .replaceAll(/&#x([0-9A-F]+);/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))) + return markdownLinksMessage + .replaceAll(/&#x([0-9A-F]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) .replaceAll('&', '&') .replaceAll('>', '>') .replaceAll('<', '<') @@ -105,7 +122,7 @@ function formatMessage(message?: string): string { return `${prefix}${year}. `; } } - return `${prefix}${year}\\. `; + return String.raw`${prefix}${year}\. `; }) .replaceAll(/(?<=\n)\d+\.\s/g, hasSpecialFormat(finalMessage) ? '\n\n$&' : '$&') .replaceAll(/^(\s+)/g, (match) => match.replaceAll(' ', ' ')); diff --git a/GUI/src/components/chat/bot-message.tsx b/GUI/src/components/chat/bot-message.tsx index 068d6fdab..db3441a23 100644 --- a/GUI/src/components/chat/bot-message.tsx +++ b/GUI/src/components/chat/bot-message.tsx @@ -22,7 +22,6 @@ interface ChatMessageProps { const BotMessage = ({ message }: ChatMessageProps) => { const renderContent = useCallback(() => { - if (message.message.startsWith('

')) return message.message.replace('

', '').replace('

', ''); return ; }, [message.message]); diff --git a/GUI/src/components/chat/chat.module.scss b/GUI/src/components/chat/chat.module.scss index 6de0ece85..b7fc359ce 100644 --- a/GUI/src/components/chat/chat.module.scss +++ b/GUI/src/components/chat/chat.module.scss @@ -141,6 +141,16 @@ .content { border-radius: 6px 48px 48px 29px; background-color: blue; + color: white; + + a { + color: #a8d4ff; + text-decoration: underline; + + &:visited { + color: #c8a8ff; + } + } } .icon { @@ -223,7 +233,7 @@ pointer-events: none; user-select: none; background-color: #f5f5f5; - color: #999; + color: #696969; } } @@ -268,18 +278,18 @@ font-weight: bold; &.success { - color: rgb(0, 138, 0); + color: rgb(0, 100, 0); background: #0f02; } &.error { - color: rgb(170, 0, 0); - background: #f002; + color: rgb(80, 0, 0); + background: #ffe3e3; } &.info { - color: rgb(0, 0, 203); - background: #00f2; + color: rgb(0, 0, 80); + background: #e3e8ff; } &.normal { @@ -378,6 +388,16 @@ .main { .content { background-color: get-color(sapphire-blue-12); + color: var(--dark-bg-extra-light); + + a { + color: #a8d4ff; + text-decoration: underline; + + &:visited { + color: #c8a8ff; + } + } } } } diff --git a/GUI/src/pages/ServiceFlowPage.tsx b/GUI/src/pages/ServiceFlowPage.tsx index 33bd1f77b..3e354610c 100644 --- a/GUI/src/pages/ServiceFlowPage.tsx +++ b/GUI/src/pages/ServiceFlowPage.tsx @@ -46,6 +46,11 @@ const ServiceFlowPage: FC = () => { }; void loadData(); + + // Cleanup function to reset state when component unmounts (including browser back button) + return () => { + useServiceStore.getState().resetState(); + }; }, [id]); const edges = useServiceStore((state) => state.edges); @@ -69,15 +74,15 @@ const ServiceFlowPage: FC = () => { }} saveOnClick={async () => { setHasUnsavedChanges(false); - if (!id) { + if (id) { + await useServiceStore.getState().loadService(id); + } else { const serviceId = useServiceStore.getState().serviceId; const serviceResponse = await useServiceStore.getState().loadService(serviceId); if (serviceResponse) { useServiceListStore.getState().setSelectedService(serviceResponse?.data); navigate(ROUTES.replaceWithId(ROUTES.EDITSERVICE_ROUTE, serviceId)); } - } else { - await useServiceStore.getState().loadService(id); } }} /> diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts index 27e706935..33067b203 100644 --- a/GUI/src/services/service-builder.ts +++ b/GUI/src/services/service-builder.ts @@ -12,6 +12,7 @@ import { Assign } from 'types/assign'; import { EndpointData } from 'types/endpoint'; import { NodeDataProps } from 'types/service-flow'; import { + decodeHtmlEntities, getLastDigits, isNumericString, removeTrailingUnderscores, @@ -188,7 +189,7 @@ export const saveFlow = async ({ const mcqNodes = nodes.filter( (node) => node.data?.stepType === StepType.MultiChoiceQuestion, - ) as Node[]; + ); if (mcqNodes.length > 0) { const nodesUpToFirstMcq = nodes.slice( @@ -420,7 +421,7 @@ export function getYamlContent( try { allRelations.forEach((r) => { const [parentNodeId, childNodeId] = r.split(','); - const parentNode = nodes.findLast((node) => node.id === parentNodeId) as Node | undefined; + const parentNode = nodes.findLast((node) => node.id === parentNodeId); if ( !parentNode?.type || parentNode.type !== 'custom' || @@ -429,7 +430,7 @@ export function getYamlContent( return; } - const childNode = nodes.find((node) => node.id === childNodeId) as Node | undefined; + const childNode = nodes.find((node) => node.id === childNodeId); const parentStepName = toSnakeCase(parentNode.data.label); if (parentNode.data.stepType === StepType.Textfield) { @@ -602,11 +603,10 @@ function handleTextField( }); const spacePlaceholder = '___SPACE___'; + const rawMessage = typeof parentNode.data.message === 'string' ? decodeHtmlEntities(parentNode.data.message) : ''; const markdownMessage = htmlToMarkdown .translate( - typeof parentNode.data.message === 'string' - ? parentNode.data.message.replace('{{', '${').replace('}}', '}').replaceAll(' ', spacePlaceholder) - : '', + rawMessage.replace('{{', '${').replace('}}', '}').replaceAll(' ', spacePlaceholder), ) .replaceAll(spacePlaceholder, ' ') .replaceAll(/\\([-~>[\]_*#().!`=<\\])/g, String.raw`\\$1`); @@ -631,7 +631,7 @@ function handleTextField( function handleConditionStep( allRelations: any[], parentNodeId: any, - nodes: Node[], + nodes: Node[], parentNode: Node, finishedFlow: Map, parentStepName: string, @@ -640,8 +640,8 @@ function handleConditionStep( const firstChildNode = conditionRelations[0].split(',')[1]; const secondChildNode = conditionRelations[1].split(',')[1]; - const firstChild = nodes.find((node) => node.id === firstChildNode) as Node | undefined; - const secondChild = nodes.find((node) => node.id === secondChildNode) as Node | undefined; + const firstChild = nodes.find((node) => node.id === firstChildNode); + const secondChild = nodes.find((node) => node.id === secondChildNode); const rulesChildren = Array.isArray(parentNode.data.rules?.children) ? parentNode.data.rules.children : []; const invalidRulesExist = hasInvalidRules(rulesChildren); @@ -750,9 +750,12 @@ function handleMultiChoiceQuestion( childNode: Node | undefined, serviceName: string, ) { - parentNode.data.multiChoiceQuestion?.buttons.forEach( - (b) => (b.payload = b.payload.replaceAll('/_mcq_', `/${serviceName}_mcq_`)), - ); + const rootServiceName = serviceName.replace(/_mcq_\d+_\d+$/, ''); + parentNode.data.multiChoiceQuestion?.buttons.forEach((b) => { + + b.payload = b.payload.replace(/\/[^/]*_mcq_/, `/${rootServiceName}_mcq_`); + + }); return finishedFlow.set(parentStepName, { assign: { @@ -880,9 +883,7 @@ export const saveFlowClick = async (status: 'draft' | 'ready' = 'ready', showErr const nodes = useServiceStore.getState().nodes as Node[]; await saveFlow({ - name: !name - ? `${t('newService.defaultServiceName').toString()}_${format(new Date(), 'dd_MM_yyyy_HH_mm_ss')}` - : name, + name: name || `${t('newService.defaultServiceName').toString()}_${format(new Date(), 'dd_MM_yyyy_HH_mm_ss')}`, edges, nodes, onSuccess: () => { diff --git a/GUI/src/store/new-services.store.ts b/GUI/src/store/new-services.store.ts index ae4e16cc3..db190b811 100644 --- a/GUI/src/store/new-services.store.ts +++ b/GUI/src/store/new-services.store.ts @@ -229,7 +229,7 @@ const useServiceStore = create((set, get) => ({ unmarkAsNewService: () => set({ isNewService: false }), setServiceId: (id) => set({ serviceId: id }), setNodes: (nodes) => { - if (nodes instanceof Function) { + if (typeof nodes === 'function') { set((state) => { return { nodes: nodes(state.nodes), @@ -240,7 +240,7 @@ const useServiceStore = create((set, get) => ({ } }, setEdges: (edges) => { - if (edges instanceof Function) { + if (typeof edges === 'function') { set((state) => { return { edges: edges(state.edges), @@ -294,17 +294,18 @@ const useServiceStore = create((set, get) => ({ }); } - chips.push({ - name: 'Base Response', - value: `${endpoint?.name.replaceAll(' ', '_')}_res.response.body`, - data: `${endpoint?.name.replaceAll(' ', '_')}_res.response.body`, - }); - - chips.push({ - name: 'Status Code', - value: `${endpoint?.name.replaceAll(' ', '_')}_res.response.statusCodeValue`, - data: `${endpoint?.name.replaceAll(' ', '_')}_res.response.statusCodeValue`, - }); + chips.push( + { + name: 'Base Response', + value: `${endpoint?.name.replaceAll(' ', '_')}_res.response.body`, + data: `${endpoint?.name.replaceAll(' ', '_')}_res.response.body`, + }, + { + name: 'Status Code', + value: `${endpoint?.name.replaceAll(' ', '_')}_res.response.statusCodeValue`, + data: `${endpoint?.name.replaceAll(' ', '_')}_res.response.statusCodeValue`, + } + ); const variable: EndpointResponseVariable = { name: endpoint?.name ?? '', @@ -391,6 +392,8 @@ const useServiceStore = create((set, get) => ({ serviceId: uuid(), description: '', slot: '', + examples: [], + entities: [], secrets: { prod: [], test: [] }, availableVariables: { prod: [], test: [] }, isCommon: false, @@ -438,7 +441,7 @@ const useServiceStore = create((set, get) => ({ if (!nodes || nodes.length === 0) nodes = initialNodes; - if (!endpoints || !(endpoints instanceof Array)) endpoints = []; + if (!endpoints || !Array.isArray(endpoints)) endpoints = []; nodes = nodes.map((node: any) => { if (node.type !== 'custom') return node; @@ -677,7 +680,7 @@ const useServiceStore = create((set, get) => ({ title: i18next.t('newService.toast.missingFields'), message: i18next.t('newService.toast.serviceMissingFields'), }); - return Promise.reject(new Error(i18next.t('newService.toast.missingFields') ?? 'Error')); + throw new Error(i18next.t('newService.toast.missingFields') ?? 'Error'); } const { isNewService, onServiceSave } = get(); @@ -685,7 +688,7 @@ const useServiceStore = create((set, get) => ({ try { await onServiceSave(ServiceState.Ready); } catch (e: any) { - return Promise.reject(new Error(i18next.t('toast.cannot-save-flow') ?? (e?.message as string) ?? 'Error')); + throw new Error(i18next.t('toast.cannot-save-flow') ?? (e?.message as string) ?? 'Error'); } if (isNewService) { diff --git a/GUI/src/utils/string-util.ts b/GUI/src/utils/string-util.ts index 291061fc1..92093dc7f 100644 --- a/GUI/src/utils/string-util.ts +++ b/GUI/src/utils/string-util.ts @@ -24,7 +24,7 @@ export const templateToString = (value: string | number) => { }; export const toSnakeCase = (value: string) => { - return value.toLowerCase().trim().replace(/\s+/g, '_').replace(/-+/g, '_').replace(/_+/g, '_'); + return value.toLowerCase().trim().replaceAll(/\s+/g, '_').replaceAll(/-+/g, '_').replaceAll(/_+/g, '_'); }; export const fromSnakeCase = (value: string) => { @@ -96,7 +96,7 @@ export function removeNestedTemplates(str: string): string { changed = false; iterationCount++; - str = str.replace( + str = str.replaceAll( /\$\{([^${}]*)\$\{([^}]*)\}([^}]*)\}/g, (match: string, p1: string, p2: string, p3: string): string => { changed = true; @@ -116,7 +116,7 @@ export function removeNestedTemplates(str: string): string { changed = false; iterationCount++; - str = str.replace( + str = str.replaceAll( /\$\{([^{}]*)\{([^}]*)\}([^}]*)\}/g, (match: string, p1: string, p2: string, p3: string): string => { changed = true; @@ -151,3 +151,23 @@ export function removeWrapperQuotes(str: string): string { return str.substring(start, end + 1); } + +export function decodeHtmlEntities(value: string): string { + if (typeof value !== 'string' || value.length === 0) return value; + + if (typeof document !== 'undefined') { + const textarea = document.createElement('textarea'); + textarea.innerHTML = value; + return textarea.value; + } + + return value + .replaceAll(/&#x([0-9A-F]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replaceAll(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 10))) + .replaceAll('&', '&') + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); +}