Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions DSL/Resql/services/POST/add-services.sql
Original file line number Diff line number Diff line change
@@ -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);
19 changes: 19 additions & 0 deletions DSL/Resql/services/POST/get-import-names.sql
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 71 additions & 0 deletions DSL/Ruuter/services/POST/services/import-services.yml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion GUI/src/components/ExportServicesModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ const ExportServicesModal: FC<ExportServicesModalProps> = ({ isVisible, onClose
if (!isVisible) return null;

return (
<Modal title={t('overview.exportManyTitle')} onClose={handleCancel}>
<Modal title={t('overview.exportMany')} onClose={handleCancel}>
<div className="export-services-modal">
<Track
direction="vertical"
Expand Down
6 changes: 1 addition & 5 deletions GUI/src/components/Flow/Controls/ImportExportControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@ import { AiOutlineExport, AiOutlineImport } from 'react-icons/ai';
import { updateFlowInputRules } from 'services/flow-builder';
import useServiceStore from 'store/new-services.store';
import useToastStore from 'store/toasts.store';
import { FlowData } from 'types/service-flow';
import { removeTrailingUnderscores } from 'utils/string-util';

interface FlowData {
nodes: any[];
edges: any[];
}

const ImportExportControls: FC = () => {
const { getNodes, getEdges, setNodes, setEdges } = useReactFlow();
const { t } = useTranslation();
Expand Down
10 changes: 9 additions & 1 deletion GUI/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -531,6 +536,9 @@
"ROLE_UNAUTHENTICATED": "Unauthenticated"
},
"chat": {
"unanswered": "Unanswered",
"forwarded": "Forwarded",
"pending": "Pending",
"service-test-error": {
"title": "Service test error",
"dslName": "Service file",
Expand Down
10 changes: 9 additions & 1 deletion GUI/src/i18n/et/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -532,6 +537,9 @@
"ROLE_UNAUTHENTICATED": "Autentimata"
},
"chat": {
"unanswered": "Vastamata",
"forwarded": "Suunatud",
"pending": "Ootel",
"service-test-error": {
"title": "Teenuse testimise viga",
"dslName": "Teenuse fail",
Expand Down
19 changes: 18 additions & 1 deletion GUI/src/pages/OverviewPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,13 +12,29 @@ import { ROUTES } from '../resources/routes-constants';
const OverviewPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isExportModalVisible, setIsExportModalVisible] = useState(false);

const triggerFileInput = useCallback(() => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}, []);

return (
<>
<Track justify="between">
<h1>{t('overview.services')}</h1>
<Track gap={16}>
<input
type="file"
ref={fileInputRef}
onChange={importServices}
accept=".json"
style={{ display: 'none' }}
multiple
/>
<Button onClick={triggerFileInput}>{t('overview.importMany')}</Button>
<Button onClick={() => setIsExportModalVisible(true)}>{t('overview.exportMany')}</Button>
<Button onClick={() => navigate(ROUTES.NEWSERVICE_ROUTE)}>{t('overview.create')}</Button>
</Track>
Expand Down
1 change: 1 addition & 0 deletions GUI/src/resources/api-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
5 changes: 3 additions & 2 deletions GUI/src/services/service-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeDataProps>[],
edges: Edge[],
name: string,
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions GUI/src/types/service-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const EDGE_LENGTH = 5 * GRID_UNIT;
const startNodeId = generateUniqueId();
const ghostNodeId = generateUniqueId();

export interface FlowData {
nodes: Node<NodeDataProps>[];
edges: Edge[];
}

export type NodeDataProps = {
label: string;
onDelete: (id: string) => void;
Expand Down
92 changes: 92 additions & 0 deletions GUI/src/utils/service-import.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>,
): 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<HTMLInputElement>) => {
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 = '';
};