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==}