diff --git a/DSL/Resql/services/POST/add-services.sql b/DSL/Resql/services/POST/add-services.sql new file mode 100644 index 000000000..055970c7f --- /dev/null +++ b/DSL/Resql/services/POST/add-services.sql @@ -0,0 +1,11 @@ +INSERT INTO services (name, description, service_id, ruuter_type, structure) +SELECT + name, + '', + gen_random_uuid(), + 'POST'::ruuter_request_type, + structure +FROM UNNEST( + ARRAY[:names]::text[], + ARRAY[:structures]::json[] +) AS t(name, structure); diff --git a/DSL/Resql/services/POST/get-import-names.sql b/DSL/Resql/services/POST/get-import-names.sql new file mode 100644 index 000000000..0e12c7a6e --- /dev/null +++ b/DSL/Resql/services/POST/get-import-names.sql @@ -0,0 +1,19 @@ +WITH input_names AS ( + SELECT TRIM(UNNEST(string_to_array(:names, ','))) AS name +), +processed_names AS ( + SELECT + CASE + WHEN EXISTS ( + SELECT 1 + FROM services s + WHERE s.name = iname.name + AND NOT s.deleted + ) + THEN iname.name || '_' || to_char(NOW() AT TIME ZONE :timezone, 'YYYY_MM_DD_HH24_MI_SS') + ELSE iname.name + END AS processed_name + FROM input_names iname +) +SELECT string_agg(processed_name, ',') AS names +FROM processed_names; diff --git a/DSL/Ruuter/services/POST/services/import-services.yml b/DSL/Ruuter/services/POST/services/import-services.yml new file mode 100644 index 000000000..89a51644b --- /dev/null +++ b/DSL/Ruuter/services/POST/services/import-services.yml @@ -0,0 +1,71 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'IMPORT-SERVICES'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: services + type: object + description: "Body field 'services'" + - field: timezone + type: string + description: "Body field 'timezone'" + +extract_request_data: + assign: + services: ${incoming.body.services ?? []} + names: ${services.map(s => s.fileName).join(",") ?? []} + timezone: ${incoming.body.timezone} + +get_import_names: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-import-names" + body: + names: ${names} + timezone: ${timezone} + result: import_names_res + +assign_imported_names: + assign: + imported_names: ${import_names_res.response.body[0].names.split(",")} + services: "$=services.map((s, i) => ({ ...s, fileName: imported_names[i] }))=" + file_names: ${services.map(s => s.fileName)} + +insert_services: + call: http.post + args: + url: "[#SERVICE_RESQL]/add-services" + body: + names: ${file_names} + structures: ${services.map(s => s.flowData)} + result: insert_services_res + +convert_json_content_to_yml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data_multiple" + body: + data: ${services.map(s => s.content)} + result: ymls_res + +prepare_files: + assign: + file_paths: "$=file_names.map(name => `[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp`)=" + yaml_contents: ${ymls_res.response.body.yamls} + +add_dsls: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create_multiple" + body: + file_paths: ${file_paths} + contents: ${yaml_contents} + result: add_dsls_res + +return_result: + return: "Services imported successfully" diff --git a/GUI/src/components/ExportServicesModal/index.tsx b/GUI/src/components/ExportServicesModal/index.tsx index 0459eed78..5c7836a7c 100644 --- a/GUI/src/components/ExportServicesModal/index.tsx +++ b/GUI/src/components/ExportServicesModal/index.tsx @@ -147,7 +147,7 @@ const ExportServicesModal: FC = ({ isVisible, onClose if (!isVisible) return null; return ( - +
{ const { getNodes, getEdges, setNodes, setEdges } = useReactFlow(); const { t } = useTranslation(); diff --git a/GUI/src/i18n/en/common.json b/GUI/src/i18n/en/common.json index 3bfed9bbf..7839280af 100644 --- a/GUI/src/i18n/en/common.json +++ b/GUI/src/i18n/en/common.json @@ -146,10 +146,15 @@ "edit": "Edit", "delete": "Delete", "cancel": "Cancel", + "importMany": "Import many", + "import": { + "importSuccess": "{{count}} service{{lengthCheck}} imported successfully", + "importFailure": "Could not import the following files (Wrong format or corrupted): {{files}}", + "failedToImport": "Failed to import services" + }, "exportMany": "Export many", "exportAll": "Export All", "export": "Export", - "exportManyTitle": "Export many", "exportAllConfirmation": "Are you sure you want to export all services?", "noServicesFound": "No services found", "error": { @@ -531,6 +536,9 @@ "ROLE_UNAUTHENTICATED": "Unauthenticated" }, "chat": { + "unanswered": "Unanswered", + "forwarded": "Forwarded", + "pending": "Pending", "service-test-error": { "title": "Service test error", "dslName": "Service file", diff --git a/GUI/src/i18n/et/common.json b/GUI/src/i18n/et/common.json index b969838f2..fdf9c20df 100644 --- a/GUI/src/i18n/et/common.json +++ b/GUI/src/i18n/et/common.json @@ -146,10 +146,15 @@ "edit": "Muuda", "delete": "Kustuta", "cancel": "Tühista", + "importMany": "Impordi mitu", + "import": { + "importSuccess": "{{count}} teenus{{lengthCheck}} edukalt imporditud", + "importFailure": "Järgmisi faile ei õnnestunud importida (vale vorming või rikutud): {{files}}", + "failedToImport": "Teenuste importimine ebaõnnestus" + }, "exportMany": "Ekspordi mitu", "exportAll": "Ekspordi kõik", "export": "Ekspordi", - "exportManyTitle": "Ekspordi mitu", "exportAllConfirmation": "Kas olete kindel, et soovite eksportida kõik teenused?", "noServicesFound": "Ühtegi teenust ei leitud", "error": { @@ -532,6 +537,9 @@ "ROLE_UNAUTHENTICATED": "Autentimata" }, "chat": { + "unanswered": "Vastamata", + "forwarded": "Suunatud", + "pending": "Ootel", "service-test-error": { "title": "Teenuse testimise viga", "dslName": "Teenuse fail", diff --git a/GUI/src/pages/OverviewPage.tsx b/GUI/src/pages/OverviewPage.tsx index 143b24f31..ba45df739 100644 --- a/GUI/src/pages/OverviewPage.tsx +++ b/GUI/src/pages/OverviewPage.tsx @@ -1,7 +1,8 @@ import withAuthorization, { ROLES } from 'hoc/with-authorization'; -import React, { useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { importServices } from 'utils/service-import'; import { Button, ExportServicesModal, Track } from '../components'; import ServicesTable from '../components/ServicesTable'; @@ -11,13 +12,29 @@ import { ROUTES } from '../resources/routes-constants'; const OverviewPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const fileInputRef = useRef(null); const [isExportModalVisible, setIsExportModalVisible] = useState(false); + const triggerFileInput = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, []); + return ( <>

{t('overview.services')}

+ + diff --git a/GUI/src/resources/api-constants.ts b/GUI/src/resources/api-constants.ts index c20c1829c..7a269731b 100644 --- a/GUI/src/resources/api-constants.ts +++ b/GUI/src/resources/api-constants.ts @@ -35,3 +35,4 @@ export const deleteEndpoint = (): string => `${baseUrl}/services/delete-endpoint export const getSlots = (): string => `${baseUrl}/slots`; export const userStepPreferences = (): string => `${baseUrl}/steps/preferences`; export const getCommonEndpoints = (): string => `${baseUrl}/endpoints/common`; +export const importMultipleServices = (): string => `${baseUrl}/services/import-services`; diff --git a/GUI/src/services/service-builder.ts b/GUI/src/services/service-builder.ts index 3c8a9088c..38ce27863 100644 --- a/GUI/src/services/service-builder.ts +++ b/GUI/src/services/service-builder.ts @@ -288,7 +288,7 @@ export const validateCondition = (node: NodeDataProps | undefined) => { return isInvalid ? (i18next.t('toast.missing-condition-rules') ?? 'Error') : null; }; -function getYamlContent( +export function getYamlContent( nodes: Node[], edges: Edge[], name: string, @@ -353,7 +353,8 @@ function getYamlContent( finishedFlow.set('declaration', { call: 'declare', version: 0.1, - description: description ?? `Description placeholder for '${name ?? ''}'`, + description: + description && description.trim().length > 0 ? description : `Description placeholder for '${name ?? ''}'`, method: 'post', accepts: 'json', returns: 'json', diff --git a/GUI/src/types/service-flow.ts b/GUI/src/types/service-flow.ts index b29c943b3..cbca36261 100644 --- a/GUI/src/types/service-flow.ts +++ b/GUI/src/types/service-flow.ts @@ -14,6 +14,11 @@ export const EDGE_LENGTH = 5 * GRID_UNIT; const startNodeId = generateUniqueId(); const ghostNodeId = generateUniqueId(); +export interface FlowData { + nodes: Node[]; + edges: Edge[]; +} + export type NodeDataProps = { label: string; onDelete: (id: string) => void; diff --git a/GUI/src/utils/service-import.ts b/GUI/src/utils/service-import.ts new file mode 100644 index 000000000..58f310238 --- /dev/null +++ b/GUI/src/utils/service-import.ts @@ -0,0 +1,92 @@ +import i18n from 'i18n'; +import { t } from 'i18next'; +import { ChangeEvent } from 'react'; +import { importMultipleServices } from 'resources/api-constants'; +import api from 'services/api'; +import { getYamlContent } from 'services/service-builder'; +import useServiceListStore from 'store/services.store'; +import useToastStore from 'store/toasts.store'; +import { FlowData } from 'types/service-flow'; + +const isValidFlowData = (data: any): data is FlowData => + data?.nodes && data?.edges && Array.isArray(data.nodes) && Array.isArray(data.edges); + +const handleImportServices = async ( + event: ChangeEvent, +): Promise<{ + validFiles: Array<{ fileName: string; flowData: string; content: any }>; + corruptedFiles: string[]; +}> => { + const files = event.target.files; + if (!files) return { validFiles: [], corruptedFiles: [] }; + + const validFiles: Array<{ fileName: string; flowData: string; content: any }> = []; + const corruptedFiles: string[] = []; + + const fileProcessingPromises = Array.from(files).map(async (file) => { + const name = file.name.replaceAll(/\s+/g, '_').replace(/\.[^/.]+$/, ''); + try { + const content = await file.text(); + const flowData = JSON.parse(content) as FlowData; + + if (!isValidFlowData(flowData)) { + throw new Error('Invalid flow data structure'); + } + + validFiles.push({ + fileName: name, + flowData: JSON.stringify({ nodes: flowData.nodes, edges: flowData.edges }), + content: getYamlContent(flowData.nodes, flowData.edges, name, '', false), + }); + } catch (error) { + corruptedFiles.push(name); + console.error(`Error processing file ${name}:`, error); + } + }); + + await Promise.all(fileProcessingPromises); + + return { validFiles, corruptedFiles }; +}; + +export const importServices = async (event: ChangeEvent) => { + const { validFiles, corruptedFiles } = await handleImportServices(event); + + if (corruptedFiles.length > 0) { + useToastStore.getState().error({ + title: t('global.notificationError'), + message: t('overview.import.importFailure', { files: corruptedFiles.join(', ') }), + }); + } + + if (validFiles.length > 0) { + api + .post(importMultipleServices(), { + services: validFiles, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + .then(async () => { + const lengthCheck = i18n.language === 'en' ? 's' : 'ed'; + useToastStore.getState().success({ + title: t('newService.toast.success'), + message: t('overview.import.importSuccess', { + count: validFiles.length, + lengthCheck: validFiles.length === 1 ? '' : lengthCheck, + }), + }); + const pagination = { pageIndex: 0, pageSize: 10 }; + const sorting = [{ id: 'name', desc: false }]; + await useServiceListStore.getState().loadServicesList(pagination, sorting); + await useServiceListStore.getState().loadCommonServicesList(pagination, sorting); + }) + .catch((error) => { + console.error('Error importing services:', error); + useToastStore.getState().error({ + title: t('global.notificationError'), + message: t('overview.import.failedToImport'), + }); + }); + } + + event.target.value = ''; +};