diff --git a/DSL/Ruuter/services/POST/services/edit.yml b/DSL/Ruuter/services/POST/services/edit.yml index a44f670ee..0b2815bda 100644 --- a/DSL/Ruuter/services/POST/services/edit.yml +++ b/DSL/Ruuter/services/POST/services/edit.yml @@ -36,6 +36,10 @@ declaration: - field: id type: string description: "Parameter 'id'" + headers: + - field: cookie + type: string + description: "Cookie field" extract_request_data: assign: @@ -169,6 +173,7 @@ assign_values: old_name: ${old_service_result.response.body[0].name} old_structure: ${old_service_result.response.body[0].structure} old_state: ${old_service_result.response.body[0].state} + service_type: ${old_service_result.response.body[0].type} check_new_structure: switch: @@ -209,6 +214,25 @@ service_edit: state: ${state ?? 'draft'} result: editedService +check for_state: + switch: + - condition: ${state === 'draft'} + next: change_state_to_draft + next: return_ok + +change_state_to_draft: + call: http.post + args: + url: "[#SERVICE_RUUTER]/services/status" + headers: + cookie: ${incoming.headers.cookie} + body: + id: ${id} + state: "draft" + type: ${service_type ?? 'POST'} + result: changeStateResult + next: return_ok + return_ok: reloadDsl: true status: 200 diff --git a/GUI/package.json b/GUI/package.json index 701c64edd..b04db787f 100644 --- a/GUI/package.json +++ b/GUI/package.json @@ -33,6 +33,7 @@ "howler": "^2.2.4", "i18next": "^22.4.9", "i18next-browser-languagedetector": "^7.0.1", + "jsoneditor": "^10.3.0", "moment": "^2.29.4", "msw": "^0.49.2", "node-html-markdown": "^1.3.0", @@ -83,6 +84,7 @@ "@types/d3-hierarchy": "^3.1.7", "@types/d3-timer": "^3.0.2", "@types/howler": "^2.2.11", + "@types/jsoneditor": "^9.9.6", "@types/react": "^18.2.0", "@types/react-datepicker": "^4.8.0", "@types/react-dom": "^18.2.0", diff --git a/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx b/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx index f45087c76..8cfd5a507 100644 --- a/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx +++ b/GUI/src/components/ApiEndpointCard/Endpoints/Custom/index.tsx @@ -72,7 +72,7 @@ const EndpointCustom: React.FC = ({ = ({ placeholder={t("newService.endpoint.type").toString()} options={options} disabled={selectedTab === EndpointEnv.Test} + style={{ fontSize: "15px" }} onSelectionChange={(selection) => { setOption(selection); endpoint.type = selection?.value as EndpointType; diff --git a/GUI/src/components/FlowElementsPopup/AssignBuilder/AssignElement.module.scss b/GUI/src/components/FlowElementsPopup/AssignBuilder/AssignElement.module.scss new file mode 100644 index 000000000..0ebd88a29 --- /dev/null +++ b/GUI/src/components/FlowElementsPopup/AssignBuilder/AssignElement.module.scss @@ -0,0 +1,5 @@ +@import "src/styles/tools/color"; + +.assignElement { + padding: 8px 16px 8px 16px; +} diff --git a/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.module.scss b/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.module.scss new file mode 100644 index 000000000..edac81955 --- /dev/null +++ b/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.module.scss @@ -0,0 +1,14 @@ +@import "src/styles/tools/color"; + +.dragHoverHighlight { + background-color: get-color(sea-green-1) !important; + border: 1px solid get-color(sea-green-10) !important; + border-radius: 4px !important; + box-shadow: 0 0 8px get-color(sea-green-3) !important; + transition: all 0.2s ease-in-out !important; +} + +.editor { + height: 200px; + padding-top: 8px; +} diff --git a/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.tsx b/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.tsx new file mode 100644 index 000000000..1a9c62bc7 --- /dev/null +++ b/GUI/src/components/FlowElementsPopup/AssignBuilder/ObjectEditor.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import JSONEditor from "jsoneditor"; +import "jsoneditor/dist/jsoneditor.css"; +import { getDragData } from "utils/component-util"; +import { isObject, updateValueAtPath } from "utils/object-util"; +import styles from "./ObjectEditor.module.scss"; +import { stringToTemplate } from "utils/string-util"; + +// Helper function to find the path to a node in the JSON structure +const findNodePath = (node: Element, data: Record): string | null => { + // Try to find by text content (for values) + const textContent = node.textContent?.trim(); + if (textContent) { + const path = searchInCollection(data, textContent); + return path; + } + + return null; +}; + +// Helper function to check if values match (handling different types) +const isValueMatch = (objValue: unknown, value: string): boolean => { + // Handle boolean conversion + let booleanValue: boolean | null = null; + if (value.toLowerCase() === "true") { + booleanValue = true; + } else if (value.toLowerCase() === "false") { + booleanValue = false; + } + + return ( + objValue === value || + (typeof objValue === "number" && !isNaN(Number(value)) && objValue === Number(value)) || + (typeof objValue === "boolean" && objValue === booleanValue) || + (objValue === null && value.toLowerCase() === "null") + ); +}; + +// Helper function to search for value in an array +const searchInArray = (array: unknown[], value: string, currentPath = ""): string | null => { + for (let index = 0; index < array.length; index++) { + const objValue = array[index]; + const newPath = currentPath ? `${currentPath}[${index}]` : `[${index}]`; + + if (isValueMatch(objValue, value)) return newPath; + + if (isObject(objValue)) { + const result = searchInCollection(objValue, value, newPath); + if (result) return result; + } + } + + return null; +}; + +// Helper function to search for value in an object +const searchInObject = (obj: Record, value: string, currentPath = ""): string | null => { + for (const [key, objValue] of Object.entries(obj)) { + const newPath = currentPath ? `${currentPath}.${key}` : String(key); + + if (isValueMatch(objValue, value)) return newPath; + + if (isObject(objValue)) { + const result = searchInCollection(objValue, value, newPath); + if (result) return result; + } + } + + return null; +}; + +// Helper function to search for value in a collection +const searchInCollection = (collection: object, value: string, currentPath = ""): string | null => { + if (Array.isArray(collection)) { + return searchInArray(collection, value, currentPath); + } + + return searchInObject(collection as Record, value, currentPath); +}; + +interface ObjectEditorProps { + onChange: (value: string) => void; + data: Record | unknown[]; +} + +const ObjectEditor: React.FC = ({ onChange, data }) => { + const { t, i18n } = useTranslation(); + const jsonEditor = t("objectEditor", { returnObjects: true }); + const editorRef = useRef(null); + const jsonEditorRef = useRef(null); + const [hoveredElement, setHoveredElement] = useState(null); + + useEffect(() => { + if (editorRef.current && !jsonEditorRef.current) { + const editor = new JSONEditor(editorRef.current, { + language: i18n.language, + languages: { + [i18n.language]: jsonEditor, + }, + onChange: () => { + onChange(stringToTemplate(JSON.stringify(jsonEditorRef.current?.get()))); + }, + }); + + editor.set(data); + jsonEditorRef.current = editor; + } + + return () => { + if (jsonEditorRef.current) { + jsonEditorRef.current.destroy(); + jsonEditorRef.current = null; + } + }; + }, []); + + // Cleanup effect to remove highlight when component unmounts + useEffect(() => { + return () => { + if (hoveredElement) { + hoveredElement.classList.remove(styles.dragHoverHighlight); + } + }; + }, [hoveredElement]); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Get the element under the cursor + const element = document.elementFromPoint(e.clientX, e.clientY); + if (element) { + const jsonNode = element.closest(".jsoneditor-value"); + + // Remove highlight from previously hovered element + if (hoveredElement && hoveredElement !== jsonNode) { + hoveredElement.classList.remove(styles.dragHoverHighlight); + } + + // Add highlight to currently hovered element + if (jsonNode && jsonNode !== hoveredElement) { + jsonNode.classList.add(styles.dragHoverHighlight); + setHoveredElement(jsonNode); + } + } + }; + + const handleDragLeave = () => { + // Remove highlight when leaving the drop zone + if (hoveredElement) { + hoveredElement.classList.remove(styles.dragHoverHighlight); + setHoveredElement(null); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + + // Clean up highlight + if (hoveredElement) { + hoveredElement.classList.remove(styles.dragHoverHighlight); + setHoveredElement(null); + } + + try { + const dragData = getDragData(e); + if (dragData && jsonEditorRef.current) { + // Extract just the value from the drag data + const valueToReplace = dragData.value; + + // Get the element under the cursor + const element = document.elementFromPoint(e.clientX, e.clientY); + if (element) { + const jsonNode = element.closest(".jsoneditor-value"); + + if (jsonNode) { + // Get the current JSON data + const currentData = jsonEditorRef.current.get(); + + // Try to find the path to the dropped node + const path = findNodePath(jsonNode, currentData); + + if (path) { + // Update the value at the specific path + const newData = updateValueAtPath(currentData, path, valueToReplace); + + jsonEditorRef.current.update(newData); + + onChange(stringToTemplate(JSON.stringify(newData))); + } + } + } + } + } catch (error) { + console.error("Error processing drop:", error); + } + }; + + return ( +
{ + // Handle keyboard interactions for accessibility + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + // Focus the editor for keyboard navigation + editorRef.current?.focus(); + } + }} + /> + ); +}; + +export default ObjectEditor; diff --git a/GUI/src/components/FlowElementsPopup/AssignBuilder/assignElement.tsx b/GUI/src/components/FlowElementsPopup/AssignBuilder/assignElement.tsx index 6d174bdb6..6b7d397e4 100644 --- a/GUI/src/components/FlowElementsPopup/AssignBuilder/assignElement.tsx +++ b/GUI/src/components/FlowElementsPopup/AssignBuilder/assignElement.tsx @@ -1,12 +1,22 @@ import React, { useState } from "react"; import { DragInput, FormInput, Icon, Tooltip, Track } from "components"; -import { MdDeleteOutline, MdEdit, MdMoveDown } from "react-icons/md"; +import { MdDataObject, MdDeleteOutline, MdEdit, MdMoveDown } from "react-icons/md"; import { Assign } from "../../../types/assign"; import "../styles.scss"; -import { stringToTemplate, templateToString } from "utils/string-util"; +import { isTemplate, stringToTemplate, templateToString } from "utils/string-util"; import { isArray, isObject } from "utils/object-util"; import { t } from "i18next"; import { getDragData } from "utils/component-util"; +import ObjectEditor from "./ObjectEditor"; +import styles from "./AssignElement.module.scss"; +import useToastStore from "store/toasts.store"; + +const showInvalidObjectError = () => { + useToastStore.getState().error({ + title: t("serviceFlow.apiElements.cannotOpenEditor"), + message: t("serviceFlow.apiElements.invalidObjectError"), + }); +}; interface AssignElementProps { element: Assign; @@ -18,10 +28,19 @@ interface AssignElementProps { valueStyle?: React.CSSProperties; } -const AssignElement: React.FC = ({ element, onRemove, onChange, manualEdit = false, isKeyEditable, keyStyle, valueStyle }) => { +const AssignElement: React.FC = ({ + element, + onRemove, + onChange, + manualEdit = false, + isKeyEditable, + keyStyle, + valueStyle, +}) => { const slots = element.slots ?? []; const [isSecondSlotOpen, setIsSecondSlotOpen] = useState(!!slots[1]); - const [isEditingManually, setIsEditingManually] = useState(manualEdit || element.value && !slots.length); + const [isEditingManually, setIsEditingManually] = useState(manualEdit || (element.value && !slots.length)); + const [isObjectEditorOpen, setIsObjectEditorOpen] = useState(false); const changeKey = (e: React.ChangeEvent) => { onChange({ ...element, key: e.target.value }); @@ -61,68 +80,118 @@ const AssignElement: React.FC = ({ element, onRemove, onChan onChange({ ...element, slots: undefined }); }; + const toggleObjectEditor = () => { + if (isObjectEditorOpen) { + setIsObjectEditorOpen(!isObjectEditorOpen); + setIsEditingManually(true); + } else { + // New empty element + if (element.value === "") { + setIsObjectEditorOpen(!isObjectEditorOpen); + return; + } + + if (!isTemplate(element.value)) { + showInvalidObjectError(); + return; + } + + try { + JSON.parse(templateToString(element.value)); + setIsObjectEditorOpen(!isObjectEditorOpen); + } catch (error) { + console.log("Error parsing input", error); + showInvalidObjectError(); + } + } + }; + return ( - - e.preventDefault()} - style={keyStyle} - label="" - hideLabel - /> - : - - {isEditingManually ? ( - - ) : ( - - - - {slots.length && isObject(slots[0].data) && !isArray(slots[0].data) ? ( - { - setIsSecondSlotOpen(!isSecondSlotOpen); - if (!isSecondSlotOpen) resetSecondSlot(); - }} - > -
- } /> -
-
- ) : null} - - {isSecondSlotOpen ? : null} - - )} - - {!isEditingManually ? ( - -
- } /> -
-
- ) : null} - {onRemove && ( - - )} + <> + + e.preventDefault()} + style={keyStyle} + label="" + hideLabel + /> + : + + {!isObjectEditorOpen && ( + <> + {isEditingManually ? ( + + ) : ( + + + + {slots.length && isObject(slots[0].data) && !isArray(slots[0].data) ? ( + { + setIsSecondSlotOpen(!isSecondSlotOpen); + if (!isSecondSlotOpen) resetSecondSlot(); + }} + > +
+ } /> +
+
+ ) : null} + + {isSecondSlotOpen ? ( + + ) : null} + + )} + + {!isEditingManually ? ( + +
+ } /> +
+
+ ) : null} + + )} + + {!isEditingManually && ( + +
+ } /> +
+
+ )} + + {onRemove && ( + + )} + - + + {isObjectEditorOpen && ( + onChange({ ...element, value })} + /> + )} + ); }; diff --git a/GUI/src/components/FlowElementsPopup/AssignBuilder/index.tsx b/GUI/src/components/FlowElementsPopup/AssignBuilder/index.tsx index 347384138..418871e62 100644 --- a/GUI/src/components/FlowElementsPopup/AssignBuilder/index.tsx +++ b/GUI/src/components/FlowElementsPopup/AssignBuilder/index.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Track } from "components"; -import AssignElement from "./assignElement"; +import AssignElement from "./AssignElement"; import { useAssignBuilder } from "./useAssignBuilder"; import "../styles.scss"; import { Assign } from "../../../types/assign"; @@ -19,8 +19,8 @@ const AssignBuilder: React.FC = ({ onChange, seedGroup }) => }); return ( - - + + - diff --git a/GUI/src/components/ServicesTable/index.tsx b/GUI/src/components/ServicesTable/index.tsx index 7078f9b5c..b47406842 100644 --- a/GUI/src/components/ServicesTable/index.tsx +++ b/GUI/src/components/ServicesTable/index.tsx @@ -6,7 +6,6 @@ import DataTable from "../DataTable"; import useServiceListStore from "store/services.store"; import ConnectServiceToIntentModel from "pages/Integration/ConnectServiceToIntentModel"; -import { Intent } from "types/Intent"; import { Trigger } from "types/Trigger"; import { getColumns } from "./columns"; import "../../styles/main.scss"; @@ -34,6 +33,7 @@ const ServicesTable: FC = ({ isCommon = false }) => { pageSize: 10, }); const [sorting, setSorting] = useState([{ id: "name", desc: false }]); + const [isActivating, setIsActivating] = useState(false); const loadServices = (paginationState: PaginationState, sortingState: SortingState) => { useServiceListStore.getState().loadServicesList(paginationState, sortingState); @@ -43,6 +43,8 @@ const ServicesTable: FC = ({ isCommon = false }) => { useServiceListStore.getState().loadCommonServicesList(paginationState, sortingState); }; + const [isDeletingService, setIsDeletingService] = useState(false); + useEffect(() => { if (isCommon) { loadCommonServices(pagination, sorting); @@ -95,43 +97,49 @@ const ServicesTable: FC = ({ isCommon = false }) => { ); const changeServiceState = (activate: boolean = false, draft: boolean = false) => { - useServiceListStore.getState().changeServiceState( - () => { - setIsReadyPopupVisible(false); - setIsStatePopupVisible(false); - }, - t("overview.service.toast.updated"), - t("overview.service.toast.failed.state"), - activate, - draft, - pagination, - sorting - ); - }; - - const deleteSelectedService = () => { useServiceListStore .getState() - .deleteSelectedService( - () => setIsDeletePopupVisible(false), - t("overview.service.toast.deleted"), - t("overview.service.toast.failed.delete"), + .changeServiceState( + () => { + setIsReadyPopupVisible(false); + setIsStatePopupVisible(false); + }, + t("overview.service.toast.updated"), + t("overview.service.toast.failed.state"), + activate, + draft, pagination, sorting - ); + ) + .then(() => { + setIsActivating(false); + }) + .catch(() => { + setIsActivating(false); + }); }; - const requestServiceIntentConnection = (intent: string) => { + const deleteSelectedService = () => { + setIsDeletingService(true); useServiceListStore .getState() - .requestServiceIntentConnection( - () => setIsIntentConnectionPopupVisible(false), - t("overview.service.toast.connectedToIntentSuccessfully"), - t("overview.service.toast.failed.failedToConnectToIntent"), - intent, + .deleteSelectedService( + async () => { + setIsDeletePopupVisible(false); + await useServiceListStore.getState().loadServicesList(pagination, sorting); + await useServiceListStore.getState().loadCommonServicesList(pagination, sorting); + }, + t("overview.service.toast.deleted"), + t("overview.service.toast.failed.delete"), pagination, sorting - ); + ) + .then(() => { + setIsDeletingService(false); + }) + .catch(() => { + setIsDeletingService(false); + }); }; const cancelConnectionRequest = () => { @@ -155,7 +163,17 @@ const ServicesTable: FC = ({ isCommon = false }) => { const getActiveAndConnectionButton = () => { if (readyPopupText === t("overview.popup.setActive")) { - return ; + return ( + + ); } if (readyPopupText === t("overview.popup.connectionPending")) { return ; @@ -180,7 +198,7 @@ const ServicesTable: FC = ({ isCommon = false }) => { - @@ -212,7 +230,8 @@ const ServicesTable: FC = ({ isCommon = false }) => { {t("overview.cancel")} {readyPopupText != t("overview.popup.connectionPending").toString() && - readyPopupText != t("overview.popup.setActive").toString() && ( + readyPopupText != t("overview.popup.setActive").toString() && + readyPopupText != t("overview.popup.intentNotConnected").toString() && ( )} {getActiveAndConnectionButton()} @@ -223,7 +242,7 @@ const ServicesTable: FC = ({ isCommon = false }) => { {isIntentConnectionPopupVisible && ( setIsIntentConnectionPopupVisible(false)} - onConnect={(intent: Intent) => requestServiceIntentConnection(intent.intent)} + onConnect={() => setIsIntentConnectionPopupVisible(false)} /> )} void; - onConnect: (intent: Intent) => void; + onConnect: () => void; canCancel?: boolean; canSkip?: boolean; onSkip?: () => void; @@ -27,6 +27,7 @@ const ConnectServiceToIntentModel: FC = ({ onM const [intents, setIntents] = useState(undefined); const [selectedIntent, setSelectedIntent] = useState(); const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); const loadAvailableIntents = (pagination: PaginationState, sorting: SortingState, search: string) => { useServiceStore @@ -130,8 +131,29 @@ const ConnectServiceToIntentModel: FC = ({ onM {t("global.no")}