feat: add support for importing and exporting flows (#1869)

# Description

- Add new components for importing and exporting flows: ImportFlowModal
and ExportFlowModal.
- Update the necessary API functions in request.ts to handle flow import
and export.
- Update i18n.ts to include success messages for flow export and import.
- Improve error handling and display appropriate messages in case of
failures.
This commit is contained in:
Dreammy23
2024-08-23 00:49:33 +08:00
committed by GitHub
9 changed files with 366 additions and 149 deletions

View File

@@ -205,6 +205,8 @@ const en = {
flow_name_required: 'Please enter the flow name', flow_name_required: 'Please enter the flow name',
flow_description_required: 'Please enter the flow description', flow_description_required: 'Please enter the flow description',
save_flow_success: 'Save flow success', save_flow_success: 'Save flow success',
export_flow_success: 'Export flow success',
import_flow_success: 'Import flow success',
delete_flow_confirm: 'Are you sure you want to delete this flow?', delete_flow_confirm: 'Are you sure you want to delete this flow?',
related_nodes: 'Related Nodes', related_nodes: 'Related Nodes',
add_resource: 'Add Resource', add_resource: 'Add Resource',
@@ -441,6 +443,8 @@ const zh: Resources['translation'] = {
flow_name_required: '请输入工作流名称', flow_name_required: '请输入工作流名称',
flow_description_required: '请输入工作流描述', flow_description_required: '请输入工作流描述',
save_flow_success: '保存工作流成功', save_flow_success: '保存工作流成功',
export_flow_success: '导出工作流成功',
import_flow_success: '导入工作流成功',
delete_flow_confirm: '确定删除该工作流吗?', delete_flow_confirm: '确定删除该工作流吗?',
related_nodes: '关联节点', related_nodes: '关联节点',
language_select_tips: '请选择语言', language_select_tips: '请选择语言',

View File

@@ -307,7 +307,7 @@ export const debugFlow = (data: any) => {
}; };
export const exportFlow = (data: IFlowExportParams) => { export const exportFlow = (data: IFlowExportParams) => {
return GET<IFlowExportParams, any>('/api/v2/serve/awel/flow/export', data); return GET<IFlowExportParams, any>(`/api/v2/serve/awel/flow/export/${data.uid}`, data);
}; };
export const importFlow = (data: IFlowImportParams) => { export const importFlow = (data: IFlowImportParams) => {

View File

@@ -0,0 +1,90 @@
import { Modal, Form, Input, Button, Space, Radio, message } from 'antd';
import { IFlowData, IFlowUpdateParam } from '@/types/flow';
import { apiInterceptors, exportFlow } from '@/client/api';
import { ReactFlowInstance } from 'reactflow';
import { useTranslation } from 'react-i18next';
type Props = {
reactFlow: ReactFlowInstance<any, any>;
flowInfo?: IFlowUpdateParam;
isExportFlowModalOpen: boolean;
setIsExportFlowModalOpen: (value: boolean) => void;
};
export const ExportFlowModal: React.FC<Props> = ({ reactFlow, flowInfo, isExportFlowModalOpen, setIsExportFlowModalOpen }) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
const onFlowExport = async (values: any) => {
const flowData = reactFlow.toObject() as IFlowData;
const blob = new Blob([JSON.stringify(flowData)], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = values.file_name || 'flow.json';
a.click();
const [, , res] = await apiInterceptors(exportFlow(values));
if (res?.success) {
messageApi.success(t('export_flow_success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
setIsExportFlowModalOpen(false);
};
return (
<>
<Modal title="Export Flow" open={isExportFlowModalOpen} onCancel={() => setIsExportFlowModalOpen(false)} footer={null}>
<Form
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
initialValues={{
export_type: 'json',
format: 'file',
file_name: 'flow.json',
uid: flowInfo?.uid,
}}
onFinish={onFlowExport}
>
<Form.Item label="File Name" name="file_name" rules={[{ required: true, message: 'Please input file name!' }]}>
<Input placeholder="file.json" />
</Form.Item>
<Form.Item label="Export Type" name="export_type">
<Radio.Group>
<Radio value="json">JSON</Radio>
<Radio value="dbgpts">DBGPTS</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="Format" name="format">
<Radio.Group>
<Radio value="file">File</Radio>
<Radio value="json">JSON</Radio>
</Radio.Group>
</Form.Item>
<Form.Item hidden name="uid">
<Input />
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button onClick={() => setIsExportFlowModalOpen(false)}>Cancel</Button>
<Button type="primary" htmlType="submit">
Export
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -0,0 +1,82 @@
import { Modal, Form, Button, Space, message, Checkbox, Upload } from 'antd';
import { apiInterceptors, importFlow } from '@/client/api';
import { Node, Edge } from 'reactflow';
import { UploadOutlined } from '@mui/icons-material';
import { t } from 'i18next';
type Props = {
isImportModalOpen: boolean;
setNodes: React.Dispatch<React.SetStateAction<Node<any, string | undefined>[]>>;
setEdges: React.Dispatch<React.SetStateAction<Edge<any>[]>>;
setIsImportFlowModalOpen: (value: boolean) => void;
};
export const ImportFlowModal: React.FC<Props> = ({ setNodes, setEdges, isImportModalOpen, setIsImportFlowModalOpen }) => {
const [form] = Form.useForm();
const [messageApi, contextHolder] = message.useMessage();
// TODO: Implement onFlowImport
const onFlowImport = async (values: any) => {
// const input = document.createElement('input');
// input.type = 'file';
// input.accept = '.json';
// input.onchange = async (e: any) => {
// const file = e.target.files[0];
// const reader = new FileReader();
// reader.onload = async (event) => {
// const flowData = JSON.parse(event.target?.result as string) as IFlowData;
// setNodes(flowData.nodes);
// setEdges(flowData.edges);
// };
// reader.readAsText(file);
// };
// input.click;
console.log(values);
values.file = values.file?.[0];
const [, , res] = await apiInterceptors(importFlow(values));
if (res?.success) {
messageApi.success(t('export_flow_success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
setIsImportFlowModalOpen(false);
};
return (
<>
<Modal title="Import Flow" open={isImportModalOpen} onCancel={() => setIsImportFlowModalOpen(false)} footer={null}>
<Form form={form} labelCol={{ span: 6 }} wrapperCol={{ span: 16 }} onFinish={onFlowImport}>
<Form.Item
name="file"
label="File"
valuePropName="fileList"
getValueFromEvent={(e) => (Array.isArray(e) ? e : e && e.fileList)}
rules={[{ required: true, message: 'Please upload a file' }]}
>
<Upload accept=".json,.zip" beforeUpload={() => false} maxCount={1}>
<Button icon={<UploadOutlined />}>Click to Upload</Button>
</Upload>
</Form.Item>
<Form.Item label="save flow" name="save_flow" valuePropName="checked">
<Checkbox />
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button onClick={() => setIsImportFlowModalOpen(false)}>Cancel</Button>
<Button type="primary" htmlType="submit">
Import
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -0,0 +1,3 @@
export * from './save-flow-modal';
export * from './export-flow-modal';
export * from './import-flow-modal';

View File

@@ -0,0 +1,152 @@
import { useState } from 'react';
import { Modal, Form, Input, Button, Space, message, Checkbox } from 'antd';
import { IFlowData, IFlowUpdateParam } from '@/types/flow';
import { apiInterceptors, addFlow, updateFlowById } from '@/client/api';
import { mapHumpToUnderline } from '@/utils/flow';
import { useTranslation } from 'react-i18next';
import { ReactFlowInstance } from 'reactflow';
import { useSearchParams } from 'next/navigation';
const { TextArea } = Input;
type Props = {
reactFlow: ReactFlowInstance<any, any>;
flowInfo?: IFlowUpdateParam;
isSaveFlowModalOpen: boolean;
setIsSaveFlowModalOpen: (value: boolean) => void;
};
export const SaveFlowModal: React.FC<Props> = ({ reactFlow, isSaveFlowModalOpen, flowInfo, setIsSaveFlowModalOpen }) => {
const [deploy, setDeploy] = useState(true);
const { t } = useTranslation();
const searchParams = useSearchParams();
const id = searchParams?.get('id') || '';
const [form] = Form.useForm<IFlowUpdateParam>();
const [messageApi, contextHolder] = message.useMessage();
function onLabelChange(e: React.ChangeEvent<HTMLInputElement>) {
const label = e.target.value;
// replace spaces with underscores, convert uppercase letters to lowercase, remove characters other than digits, letters, _, and -.
let result = label
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '')
.toLowerCase();
result = result;
form.setFieldsValue({ name: result });
}
async function onSaveFlow() {
const { name, label, description = '', editable = false, state = 'deployed' } = form.getFieldsValue();
console.log(form.getFieldsValue());
const reactFlowObject = mapHumpToUnderline(reactFlow.toObject() as IFlowData);
if (id) {
const [, , res] = await apiInterceptors(updateFlowById(id, { name, label, description, editable, uid: id, flow_data: reactFlowObject, state }));
if (res?.success) {
messageApi.success(t('save_flow_success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
} else {
const [_, res] = await apiInterceptors(addFlow({ name, label, description, editable, flow_data: reactFlowObject, state }));
if (res?.uid) {
messageApi.success(t('save_flow_success'));
const history = window.history;
history.pushState(null, '', `/flow/canvas?id=${res.uid}`);
}
}
setIsSaveFlowModalOpen(false);
}
return (
<>
<Modal
title={t('flow_modal_title')}
open={isSaveFlowModalOpen}
onCancel={() => {
setIsSaveFlowModalOpen(false);
}}
cancelButtonProps={{ className: 'hidden' }}
okButtonProps={{ className: 'hidden' }}
>
<Form
name="flow_form"
form={form}
labelCol={{ span: 6 }}
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }}
initialValues={{ remember: true }}
onFinish={onSaveFlow}
autoComplete="off"
>
<Form.Item label="Title" name="label" initialValue={flowInfo?.label} rules={[{ required: true, message: 'Please input flow title!' }]}>
<Input onChange={onLabelChange} />
</Form.Item>
<Form.Item
label="Name"
name="name"
initialValue={flowInfo?.name}
rules={[
{ required: true, message: 'Please input flow name!' },
() => ({
validator(_, value) {
const regex = /^[a-zA-Z0-9_\-]+$/;
if (!regex.test(value)) {
return Promise.reject('Can only contain numbers, letters, underscores, and dashes');
}
return Promise.resolve();
},
}),
]}
>
<Input />
</Form.Item>
<Form.Item label="Description" initialValue={flowInfo?.description} name="description">
<TextArea rows={3} />
</Form.Item>
<Form.Item label="Editable" name="editable" initialValue={flowInfo?.editable} valuePropName="checked">
<Checkbox />
</Form.Item>
<Form.Item hidden name="state">
<Input />
</Form.Item>
<Form.Item label="Deploy">
<Checkbox
defaultChecked={flowInfo?.state === 'deployed' || flowInfo?.state === 'running'}
checked={deploy}
onChange={(e) => {
const val = e.target.checked;
form.setFieldValue('state', val ? 'deployed' : 'developing');
setDeploy(val);
}}
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 14, span: 8 }}>
<Space>
<Button
htmlType="button"
onClick={() => {
setIsSaveFlowModalOpen(false);
}}
>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</>
);
};

View File

@@ -1,4 +1,4 @@
import { IFlowNode, IFlowRefreshParams } from '@/types/flow'; import { IFlowNode } from '@/types/flow';
import Image from 'next/image'; import Image from 'next/image';
import NodeParamHandler from './node-param-handler'; import NodeParamHandler from './node-param-handler';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@@ -1,19 +1,18 @@
import { addFlow, apiInterceptors, getFlowById, updateFlowById } from '@/client/api'; import { apiInterceptors, getFlowById, importFlow } from '@/client/api';
import MuiLoading from '@/components/common/loading'; import MuiLoading from '@/components/common/loading';
import AddNodes from '@/components/flow/add-nodes'; import AddNodes from '@/components/flow/add-nodes';
import ButtonEdge from '@/components/flow/button-edge'; import ButtonEdge from '@/components/flow/button-edge';
import CanvasNode from '@/components/flow/canvas-node'; import CanvasNode from '@/components/flow/canvas-node';
import { IFlowData, IFlowUpdateParam } from '@/types/flow'; import { IFlowData, IFlowUpdateParam } from '@/types/flow';
import { checkFlowDataRequied, getUniqueNodeId, mapHumpToUnderline, mapUnderlineToHump } from '@/utils/flow'; import { checkFlowDataRequied, getUniqueNodeId, mapUnderlineToHump } from '@/utils/flow';
import { ExportOutlined, FrownOutlined, ImportOutlined, SaveOutlined } from '@ant-design/icons'; import { ExportOutlined, FrownOutlined, ImportOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Checkbox, Divider, Form, Input, Modal, Space, Tooltip, message, notification } from 'antd'; import { Divider, Space, Tooltip, message, notification } from 'antd';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import React, { DragEvent, useCallback, useEffect, useRef, useState } from 'react'; import React, { DragEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactFlow, { Background, Connection, Controls, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, Node } from 'reactflow'; import ReactFlow, { Background, Connection, Controls, ReactFlowProvider, addEdge, useEdgesState, useNodesState, useReactFlow, Node } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { SaveFlowModal, ExportFlowModal, ImportFlowModal } from '@/components/flow/canvas-modal';
const { TextArea } = Input;
interface Props { interface Props {
// Define your component props here // Define your component props here
@@ -23,8 +22,7 @@ const edgeTypes = { buttonedge: ButtonEdge };
const Canvas: React.FC<Props> = () => { const Canvas: React.FC<Props> = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const [form] = Form.useForm<IFlowUpdateParam>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const id = searchParams?.get('id') || ''; const id = searchParams?.get('id') || '';
const reactFlow = useReactFlow(); const reactFlow = useReactFlow();
@@ -33,9 +31,10 @@ const Canvas: React.FC<Props> = () => {
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [isModalVisible, setIsModalVisible] = useState(false);
const [flowInfo, setFlowInfo] = useState<IFlowUpdateParam>(); const [flowInfo, setFlowInfo] = useState<IFlowUpdateParam>();
const [deploy, setDeploy] = useState(true); const [isSaveFlowModalOpen, setIsSaveFlowModalOpen] = useState(false);
const [isExportFlowModalOpen, setIsExportFlowModalOpen] = useState(false);
const [isImportModalOpen, setIsImportFlowModalOpen] = useState(false);
async function getFlowData() { async function getFlowData() {
setLoading(true); setLoading(true);
@@ -139,17 +138,6 @@ const Canvas: React.FC<Props> = () => {
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
}, []); }, []);
function labelChange(e: React.ChangeEvent<HTMLInputElement>) {
const label = e.target.value;
// replace spaces with underscores, convert uppercase letters to lowercase, remove characters other than digits, letters, _, and -.
let result = label
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '')
.toLowerCase();
result = result;
form.setFieldsValue({ name: result });
}
function onSave() { function onSave() {
const flowData = reactFlow.toObject() as IFlowData; const flowData = reactFlow.toObject() as IFlowData;
const [check, node, message] = checkFlowDataRequied(flowData); const [check, node, message] = checkFlowDataRequied(flowData);
@@ -172,60 +160,16 @@ const Canvas: React.FC<Props> = () => {
); );
return notification.error({ message: 'Error', description: message, icon: <FrownOutlined className="text-red-600" /> }); return notification.error({ message: 'Error', description: message, icon: <FrownOutlined className="text-red-600" /> });
} }
setIsModalVisible(true); setIsSaveFlowModalOpen(true);
} }
// TODO: EXport flow data
function onExport() { function onExport() {
const flowData = reactFlow.toObject() as IFlowData; setIsExportFlowModalOpen(true);
const blob = new Blob([JSON.stringify(flowData)], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'flow.json';
a.click();
} }
// TODO: Import flow data // TODO: Import flow data
function onImport() { function onImport() {
const input = document.createElement('input'); setIsImportFlowModalOpen(true);
input.type = 'file';
input.accept = '.json';
input.onchange = async (e: any) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = async (event) => {
const flowData = JSON.parse(event.target?.result as string) as IFlowData;
setNodes(flowData.nodes);
setEdges(flowData.edges);
};
reader.readAsText(file);
};
input.click;
}
async function handleSaveFlow() {
const { name, label, description = '', editable = false, state = 'deployed' } = form.getFieldsValue();
console.log(form.getFieldsValue());
const reactFlowObject = mapHumpToUnderline(reactFlow.toObject() as IFlowData);
if (id) {
const [, , res] = await apiInterceptors(updateFlowById(id, { name, label, description, editable, uid: id, flow_data: reactFlowObject, state }));
setIsModalVisible(false);
if (res?.success) {
messageApi.success(t('save_flow_success'));
} else if (res?.err_msg) {
messageApi.error(res?.err_msg);
}
} else {
const [_, res] = await apiInterceptors(addFlow({ name, label, description, editable, flow_data: reactFlowObject, state }));
if (res?.uid) {
messageApi.success(t('save_flow_success'));
const history = window.history;
history.pushState(null, '', `/flow/canvas?id=${res.uid}`);
}
setIsModalVisible(false);
}
} }
return ( return (
@@ -269,86 +213,27 @@ const Canvas: React.FC<Props> = () => {
<AddNodes /> <AddNodes />
</ReactFlow> </ReactFlow>
</div> </div>
<Modal <SaveFlowModal
title={t('flow_modal_title')} reactFlow={reactFlow}
open={isModalVisible} flowInfo={flowInfo}
onCancel={() => { isSaveFlowModalOpen={isSaveFlowModalOpen}
setIsModalVisible(false); setIsSaveFlowModalOpen={setIsSaveFlowModalOpen}
}} />
cancelButtonProps={{ className: 'hidden' }}
okButtonProps={{ className: 'hidden' }} <ExportFlowModal
> reactFlow={reactFlow}
<Form flowInfo={flowInfo}
name="flow_form" isExportFlowModalOpen={isExportFlowModalOpen}
form={form} setIsExportFlowModalOpen={setIsExportFlowModalOpen}
labelCol={{ span: 8 }} />
wrapperCol={{ span: 16 }}
style={{ maxWidth: 600 }} <ImportFlowModal
initialValues={{ remember: true }} setNodes={setNodes}
onFinish={handleSaveFlow} setEdges={setEdges}
autoComplete="off" isImportModalOpen={isImportModalOpen}
> setIsImportFlowModalOpen={setIsImportFlowModalOpen}
<Form.Item label="Title" name="label" initialValue={flowInfo?.label} rules={[{ required: true, message: 'Please input flow title!' }]}> />
<Input onChange={labelChange} />
</Form.Item>
<Form.Item
label="Name"
name="name"
initialValue={flowInfo?.name}
rules={[
{ required: true, message: 'Please input flow name!' },
() => ({
validator(_, value) {
const regex = /^[a-zA-Z0-9_\-]+$/;
if (!regex.test(value)) {
return Promise.reject('Can only contain numbers, letters, underscores, and dashes');
}
return Promise.resolve();
},
}),
]}
>
<Input />
</Form.Item>
<Form.Item label="Description" initialValue={flowInfo?.description} name="description">
<TextArea rows={3} />
</Form.Item>
<Form.Item label="Editable" name="editable" initialValue={flowInfo?.editable} valuePropName="checked">
<Checkbox />
</Form.Item>
<Form.Item hidden name="state">
<Input />
</Form.Item>
<Form.Item label="Deploy">
<Checkbox
defaultChecked={flowInfo?.state === 'deployed' || flowInfo?.state === 'running'}
value={deploy}
onChange={(e) => {
const val = e.target.checked;
form.setFieldValue('state', val ? 'deployed' : 'developing');
setDeploy(val);
}}
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Space>
<Button
htmlType="button"
onClick={() => {
setIsModalVisible(false);
}}
>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Submit
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{contextHolder}
</> </>
); );
}; };

View File

@@ -172,6 +172,7 @@ export type IFlowData = {
}; };
export type IFlowExportParams = { export type IFlowExportParams = {
uid: string;
export_type?: 'json' | 'dbgpts'; export_type?: 'json' | 'dbgpts';
format?: 'json' | 'file'; format?: 'json' | 'file';
file_name?: string; file_name?: string;