From 3c23d87c95955d42f2ee97b653846f65a733b76d Mon Sep 17 00:00:00 2001
From: sealday <zhanglin@daoyoucloud.com>
Date: Tue, 18 Jun 2024 11:44:01 +0800
Subject: [PATCH] feat: support workflow load dump (#1199)

Reviewed-on: https://git.daoyoucloud.com/daoyoucloud/tachybase/pulls/1199
---
 .../@tachybase/plugin-workflow/package.json   |   2 +
 .../src/client/schemas/workflows.ts           | 111 +++++++++++++++++-
 .../src/server/actions/workflows.ts           | 109 ++++++++++++++++-
 pnpm-lock.yaml                                |  30 ++---
 4 files changed, 232 insertions(+), 20 deletions(-)

diff --git a/packages/plugins/@tachybase/plugin-workflow/package.json b/packages/plugins/@tachybase/plugin-workflow/package.json
index 088718111..c70de7191 100644
--- a/packages/plugins/@tachybase/plugin-workflow/package.json
+++ b/packages/plugins/@tachybase/plugin-workflow/package.json
@@ -20,10 +20,12 @@
     "@tachybase/components": "workspace:*",
     "@tachybase/plugin-workflow-test": "workspace:*",
     "@types/ejs": "^3.1.1",
+    "@types/file-saver": "^2.0.7",
     "@types/lodash": "^4.17.5",
     "antd": "5.18.1",
     "axios": "^1.6.2",
     "cron-parser": "4.4.0",
+    "file-saver": "^2.0.5",
     "lodash": "4.17.21",
     "lru-cache": "8.0.5",
     "react": "~18.3.1",
diff --git a/packages/plugins/@tachybase/plugin-workflow/src/client/schemas/workflows.ts b/packages/plugins/@tachybase/plugin-workflow/src/client/schemas/workflows.ts
index a566e507d..6534de743 100644
--- a/packages/plugins/@tachybase/plugin-workflow/src/client/schemas/workflows.ts
+++ b/packages/plugins/@tachybase/plugin-workflow/src/client/schemas/workflows.ts
@@ -1,8 +1,15 @@
 import React from 'react';
-import { useActionContext, useRecord, useResourceActionContext, useResourceContext } from '@tachybase/client';
+import {
+  useActionContext,
+  useAPIClient,
+  useRecord,
+  useResourceActionContext,
+  useResourceContext,
+} from '@tachybase/client';
 import { ISchema, useForm } from '@tachybase/schema';
 
 import { message } from 'antd';
+import { saveAs } from 'file-saver';
 import { useTranslation } from 'react-i18next';
 
 import { NAMESPACE } from '../locale';
@@ -198,6 +205,85 @@ export const workflowSchema: ISchema = {
                 },
               },
             },
+            load: {
+              type: 'void',
+              title: `{{t("Load", { ns: "${NAMESPACE}" })}}`,
+              'x-component': 'Action',
+              'x-component-props': {
+                icon: 'UploadOutlined',
+                openSize: 'small',
+              },
+              properties: {
+                modal: {
+                  type: 'void',
+                  title: `{{t("Load a workflow", { ns: "${NAMESPACE}" })}}`,
+                  'x-decorator': 'FormV2',
+                  'x-component': 'Action.Modal',
+                  properties: {
+                    title: {
+                      type: 'string',
+                      title: '{{t("Title")}}',
+                      'x-decorator': 'FormItem',
+                      'x-component': 'Input',
+                    },
+                    file: {
+                      type: 'object',
+                      title: '{{ t("File") }}',
+                      required: true,
+                      'x-decorator': 'FormItem',
+                      'x-component': 'Upload.Attachment',
+                      'x-component-props': {
+                        action: 'attachments:create',
+                        multiple: false,
+                      },
+                    },
+                    footer: {
+                      type: 'void',
+                      'x-component': 'Action.Modal.Footer',
+                      properties: {
+                        submit: {
+                          type: 'void',
+                          title: '{{t("Submit")}}',
+                          'x-component': 'Action',
+                          'x-component-props': {
+                            type: 'primary',
+                            useAction() {
+                              const { t } = useTranslation();
+                              const api = useAPIClient();
+                              const { refresh } = useResourceActionContext();
+                              const { resource, targetKey } = useResourceContext();
+                              const { setVisible } = useActionContext();
+                              const { [targetKey]: filterByTk } = useRecord();
+                              const { values } = useForm();
+                              return {
+                                async run() {
+                                  const { data } = await api.request({
+                                    url: values.file.url,
+                                    baseURL: '/',
+                                  });
+                                  await resource.load({ filterByTk, values: { ...values, workflow: data } });
+                                  message.success(t('Operation succeeded'));
+                                  refresh();
+                                  setVisible(false);
+                                },
+                              };
+                            },
+                          },
+                        },
+                        cancel: {
+                          type: 'void',
+                          title: '{{t("Cancel")}}',
+                          'x-component': 'Action',
+                          'x-component-props': {
+                            useAction: '{{ cm.useCancelAction }}',
+                          },
+                        },
+                      },
+                    },
+                  },
+                },
+              },
+            },
             create: {
               type: 'void',
               title: '{{t("Add new")}}',
@@ -460,6 +546,29 @@ export const workflowSchema: ISchema = {
                         useAction: '{{ cm.useDestroyActionAndRefreshCM }}',
                       },
                     },
+                    dump: {
+                      type: 'void',
+                      title: '{{ t("Dump") }}',
+                      'x-component': 'Action.Link',
+                      'x-component-props': {
+                        useAction() {
+                          const { t } = useTranslation();
+                          const { refresh } = useResourceActionContext();
+                          const { resource, targetKey } = useResourceContext();
+                          const { [targetKey]: filterByTk } = useRecord();
+                          const { values } = useForm();
+                          return {
+                            async run() {
+                              const { data } = await resource.dump({ filterByTk, values });
+                              const blob = new Blob([JSON.stringify(data.data, null, 2)], { type: 'application/json' });
+                              saveAs(blob, data.data.title + '-' + data.data.key + '.json');
+                              message.success(t('Operation succeeded'));
+                              refresh();
+                            },
+                          };
+                        },
+                      },
+                    },
                   },
                 },
               },
diff --git a/packages/plugins/@tachybase/plugin-workflow/src/server/actions/workflows.ts b/packages/plugins/@tachybase/plugin-workflow/src/server/actions/workflows.ts
index cd2553bbd..d2d9f8a1a 100644
--- a/packages/plugins/@tachybase/plugin-workflow/src/server/actions/workflows.ts
+++ b/packages/plugins/@tachybase/plugin-workflow/src/server/actions/workflows.ts
@@ -54,7 +54,114 @@ export async function destroy(context: Context, next) {
   next();
 }
 
-export async function revision(context: Context, next) {
+export async function dump(context: Context, next: Next) {
+  const repository = utils.getRepositoryFromParams(context);
+  const { filterByTk, filter = {}, values = {} } = context.action.params;
+
+  context.body = await context.db.sequelize.transaction(async (transaction) => {
+    const origin = await repository.findOne({
+      filterByTk,
+      filter,
+      appends: ['nodes'],
+      context,
+      transaction,
+    });
+
+    const revisionData = filter.key
+      ? {
+          key: filter.key,
+          title: origin.title,
+          triggerTitle: origin.triggerTitle,
+          allExecuted: origin.allExecuted,
+          sync: origin.sync,
+        }
+      : values;
+
+    const dumpOne = {
+      ...origin.toJSON(),
+      ...revisionData,
+    };
+
+    return dumpOne;
+  });
+
+  await next();
+}
+
+export async function load(context: Context, next: Next) {
+  const plugin = context.app.getPlugin(Plugin);
+  const repository = utils.getRepositoryFromParams(context);
+  const { values = {} } = context.action.params;
+
+  context.body = await context.db.sequelize.transaction(async (transaction) => {
+    const origin = values.workflow;
+
+    const trigger = plugin.triggers.get(origin.type);
+
+    const instance = await repository.create({
+      values: {
+        title: values.title,
+        description: origin.description,
+        type: origin.type,
+        triggerTitle: origin.triggerTitle,
+        allExecuted: origin.allExecuted,
+        sync: origin.sync,
+        config:
+          typeof trigger.duplicateConfig === 'function'
+            ? await trigger.duplicateConfig(origin, { transaction })
+            : origin.config,
+      },
+      transaction,
+    });
+
+    const originalNodesMap = new Map();
+    origin.nodes.forEach((node) => {
+      originalNodesMap.set(node.id, node);
+    });
+
+    const oldToNew = new Map();
+    const newToOld = new Map();
+    for await (const node of origin.nodes) {
+      const instruction = plugin.instructions.get(node.type);
+      const newNode = await instance.createNode(
+        {
+          type: node.type,
+          key: node.key,
+          config:
+            typeof instruction.duplicateConfig === 'function'
+              ? await instruction.duplicateConfig(node, { transaction })
+              : node.config,
+          title: node.title,
+          branchIndex: node.branchIndex,
+        },
+        { transaction },
+      );
+      // NOTE: keep original node references for later replacement
+      oldToNew.set(node.id, newNode);
+      newToOld.set(newNode.id, node);
+    }
+
+    for await (const [oldId, newNode] of oldToNew.entries()) {
+      const oldNode = originalNodesMap.get(oldId);
+      const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
+      const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
+
+      await newNode.update(
+        {
+          upstreamId: newUpstream?.id ?? null,
+          downstreamId: newDownstream?.id ?? null,
+        },
+        { transaction },
+      );
+    }
+
+    return instance;
+  });
+
+  await next();
+}
+
+export async function revision(context: Context, next: Next) {
   const plugin = context.app.getPlugin(Plugin);
   const repository = utils.getRepositoryFromParams(context);
   const { filterByTk, filter = {}, values = {} } = context.action.params;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a0cfb0c33..e37f95c97 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4019,6 +4019,9 @@ importers:
       '@types/ejs':
         specifier: ^3.1.1
         version: 3.1.5
+      '@types/file-saver':
+        specifier: ^2.0.7
+        version: 2.0.7
       '@types/lodash':
         specifier: ^4.17.5
         version: 4.17.5
@@ -4031,6 +4034,9 @@ importers:
       cron-parser:
         specifier: 4.4.0
         version: 4.4.0
+      file-saver:
+        specifier: ^2.0.5
+        version: 2.0.5
       lodash:
         specifier: 4.17.21
         version: 4.17.21
@@ -6360,7 +6366,7 @@ packages:
       '@babel/traverse': 7.24.5(supports-color@5.5.0)
       '@babel/types': 7.24.5
       convert-source-map: 2.0.0
-      debug: 4.3.4
+      debug: 4.3.5(supports-color@5.5.0)
       gensync: 1.0.0-beta.2
       json5: 2.2.3
       semver: 6.3.1
@@ -10276,8 +10282,8 @@ packages:
     engines: {node: '>=4.0'}
     dependencies:
       async: 2.6.4
-      axios: 0.21.4(debug@4.3.4)
-      debug: 4.3.4
+      axios: 0.21.4(debug@4.3.5)
+      debug: 4.3.5(supports-color@5.5.0)
       eventemitter2: 6.4.9
       ws: 7.5.9
     transitivePeerDependencies:
@@ -14962,10 +14968,10 @@ packages:
       is-buffer: 2.0.5
     dev: true
 
-  /axios@0.21.4(debug@4.3.4):
+  /axios@0.21.4(debug@4.3.5):
     resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==}
     dependencies:
-      follow-redirects: 1.15.6(debug@4.3.4)
+      follow-redirects: 1.15.6(debug@4.3.5)
     transitivePeerDependencies:
       - debug
     dev: false
@@ -14982,7 +14988,7 @@ packages:
   /axios@1.6.8:
     resolution: {integrity: sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==}
     dependencies:
-      follow-redirects: 1.15.6(debug@4.3.4)
+      follow-redirects: 1.15.6(debug@4.3.5)
       form-data: 4.0.0
       proxy-from-env: 1.1.0
     transitivePeerDependencies:
@@ -19135,17 +19141,6 @@ packages:
       debug:
         optional: true
 
-  /follow-redirects@1.15.6(debug@4.3.4):
-    resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
-    engines: {node: '>=4.0'}
-    peerDependencies:
-      debug: '*'
-    peerDependenciesMeta:
-      debug:
-        optional: true
-    dependencies:
-      debug: 4.3.4
-
   /follow-redirects@1.15.6(debug@4.3.5):
     resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
     engines: {node: '>=4.0'}
@@ -19156,7 +19151,6 @@ packages:
         optional: true
     dependencies:
       debug: 4.3.5(supports-color@5.5.0)
-    dev: false
 
   /fontkit@2.0.2:
     resolution: {integrity: sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==}