From fb11c82b7941f4b8abea991dcaa24cebba88b5fa Mon Sep 17 00:00:00 2001 From: Junyi Date: Wed, 24 Jan 2024 21:09:48 +0800 Subject: [PATCH] feat(plugin-workflow): add cancel action for execution (#3425) * feat(plugin-workflow): add cancel action for execution * feat(plugin-workflow): add cancel button in execution canvas * fix(plugin-workflow-manual): fix canceled execution actions --- .../src/client/WorkflowTodo.tsx | 45 ++++++--- .../src/locale/zh-CN.json | 3 +- .../src/client/ExecutionCanvas.tsx | 36 ++++++- .../src/client/WorkflowCanvas.tsx | 2 + .../src/client/WorkflowPane.tsx | 3 +- .../src/client/components/ExecutionStatus.tsx | 99 +++++++++++++++++++ .../components/ExecutionStatusSelect.tsx | 54 ---------- .../src/client/schemas/executions.tsx | 28 +++++- .../plugin-workflow/src/locale/zh-CN.json | 3 + .../src/server/actions/executions.ts | 42 +++++++- 10 files changed, 237 insertions(+), 78 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatus.tsx delete mode 100644 packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatusSelect.tsx diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx index 696a9152d..b91c53601 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx @@ -31,7 +31,7 @@ import WorkflowPlugin, { import { DetailsBlockProvider } from './instruction/DetailsBlockProvider'; import { FormBlockProvider } from './instruction/FormBlockProvider'; import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig'; -import { NAMESPACE } from '../locale'; +import { NAMESPACE, useLang } from '../locale'; const nodeCollection = { title: `{{t("Task", { ns: "${NAMESPACE}" })}}`, @@ -211,6 +211,15 @@ const UserColumn = observer( { displayName: 'UserColumn' }, ); +function UserJobStatusColumn(props) { + const record = useRecord(); + const labelUnprocessed = useLang('Unprocessed'); + if (record.execution.status && !record.status) { + return {labelUnprocessed}; + } + return props.children; +} + export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => { return ( (null); function ManualActionStatusProvider({ value, children }) { - const { userJob } = useFlowContext(); + const { userJob, execution } = useFlowContext(); const button = useField(); const buttonSchema = useFieldSchema(); useEffect(() => { - if (userJob.status) { + if (execution.status || userJob.status) { button.disabled = true; button.visible = userJob.status === value && userJob.result._ === buttonSchema.name; } - }, [userJob, value, button, buttonSchema.name]); + }, [execution, userJob, value, button, buttonSchema.name]); return {children}; } @@ -420,12 +432,12 @@ function useSubmit() { const { values, submit } = useForm(); const buttonSchema = useFieldSchema(); const { service } = useTableBlockContext(); - const { userJob } = useFlowContext(); + const { userJob, execution } = useFlowContext(); const { name: actionKey } = buttonSchema; const { name: formKey } = buttonSchema.parent.parent; return { async run() { - if (userJob.status) { + if (execution.status || userJob.status) { return; } await submit(); @@ -456,7 +468,7 @@ function FlowContextProvider(props) { .resource('users_jobs') .get?.({ filterByTk: id, - appends: ['node', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'], + appends: ['node', 'job', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'], }) .then(({ data }) => { const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {}; @@ -517,18 +529,19 @@ function FlowContextProvider(props) { } function useFormBlockProps() { - const { userJob } = useFlowContext(); + const { userJob, execution } = useFlowContext(); const record = useRecord(); const { data: user } = useCurrentUserContext(); const { form } = useFormBlockContext(); - const pattern = userJob.status - ? record - ? 'readPretty' - : 'disabled' - : user?.data?.id !== userJob.userId - ? 'disabled' - : 'editable'; + const pattern = + execution.status || userJob.status + ? record + ? 'readPretty' + : 'disabled' + : user?.data?.id !== userJob.userId + ? 'disabled' + : 'editable'; useEffect(() => { form?.setPattern(pattern); @@ -613,7 +626,7 @@ function Decorator({ params = {}, children }) { pageSize: 20, sort: ['-createdAt'], ...params, - appends: ['user', 'node', 'workflow'], + appends: ['user', 'node', 'workflow', 'execution.status'], except: ['node.config', 'workflow.config', 'workflow.options'], }, rowKey: 'id', diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json index 22973e572..3d521662e 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/locale/zh-CN.json @@ -26,5 +26,6 @@ "Update record form": "更新数据表单", "Filter settings": "筛选设置", "Workflow todos": "工作流待办", - "Task node": "任务节点" + "Task node": "任务节点", + "Unprocessed": "未处理" } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx index b934eba23..b5c914e43 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { Breadcrumb, Dropdown, Result, Space, Spin, Tag } from 'antd'; +import { Breadcrumb, Button, Dropdown, message, Modal, Result, Space, Spin, Tag, Tooltip } from 'antd'; import { ActionContextProvider, @@ -22,9 +22,10 @@ import { FlowContext, useFlowContext } from './FlowContext'; import { lang, NAMESPACE } from './locale'; import useStyles from './style'; import { linkNodes } from './utils'; -import { DownOutlined } from '@ant-design/icons'; +import { DownOutlined, ExclamationCircleFilled, StopOutlined } from '@ant-design/icons'; import { StatusButton } from './components/StatusButton'; import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant'; +import { useTranslation } from 'react-i18next'; function attachJobs(nodes, jobs: any[] = []): void { const nodesMap = new Map(); @@ -210,16 +211,40 @@ function ExecutionsDropdown(props) { } export function ExecutionCanvas() { + const { t } = useTranslation(); const compile = useCompile(); - const { data, loading } = useResourceActionContext(); + const { data, loading, refresh } = useResourceActionContext(); const { setTitle } = useDocumentTitle(); const [viewJob, setViewJob] = useState(null); const app = useApp(); + const apiClient = useAPIClient(); useEffect(() => { const { workflow } = data?.data ?? {}; setTitle?.(`${workflow?.title ? `${workflow.title} - ` : ''}${lang('Execution history')}`); }, [data?.data]); + const onCancel = useCallback(() => { + Modal.confirm({ + title: lang('Cancel the execution'), + icon: , + content: lang('Are you sure you want to cancel the execution?'), + onOk: () => { + apiClient + .resource('executions') + .cancel({ + filterByTk: data?.data.id, + }) + .then(() => { + message.success(t('Operation succeeded')); + refresh(); + }) + .catch((response) => { + console.error(response.data.error); + }); + }, + }); + }, [data?.data]); + if (!data?.data) { if (loading) { return ; @@ -258,6 +283,11 @@ export function ExecutionCanvas() { diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx index 243c9e4ee..6b654fc6b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx @@ -23,6 +23,7 @@ import { executionSchema } from './schemas/executions'; import useStyles from './style'; import { linkNodes } from './utils'; import { getWorkflowDetailPath } from './constant'; +import { ExecutionStatusColumn } from './components/ExecutionStatus'; function ExecutionResourceProvider({ request, filter = {}, ...others }) { const { workflow } = useFlowContext(); @@ -230,6 +231,7 @@ export function WorkflowCanvas() { components={{ ExecutionResourceProvider, ExecutionLink, + ExecutionStatusColumn, }} /> diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowPane.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowPane.tsx index f2a1278c9..517b8627f 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowPane.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowPane.tsx @@ -6,7 +6,7 @@ import { ExecutionResourceProvider } from './ExecutionResourceProvider'; import { WorkflowLink } from './WorkflowLink'; import OpenDrawer from './components/OpenDrawer'; import { workflowSchema } from './schemas/workflows'; -import { ExecutionStatusSelect } from './components/ExecutionStatusSelect'; +import { ExecutionStatusSelect, ExecutionStatusColumn } from './components/ExecutionStatus'; import WorkflowPlugin from '.'; export function WorkflowPane() { @@ -23,6 +23,7 @@ export function WorkflowPane() { ExecutionLink, OpenDrawer, ExecutionStatusSelect, + ExecutionStatusColumn, }} scope={{ getTriggersOptions, diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatus.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatus.tsx new file mode 100644 index 000000000..148e10ffc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatus.tsx @@ -0,0 +1,99 @@ +import React, { useCallback } from 'react'; +import { Button, Modal, Select, Tag, Tooltip, message } from 'antd'; +import { ExclamationCircleFilled, StopOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; + +import { Action, css, useCompile, useRecord, useResourceActionContext, useResourceContext } from '@nocobase/client'; + +import { EXECUTION_STATUS, ExecutionStatusOptions, ExecutionStatusOptionsMap } from '../constants'; +import { lang } from '../locale'; + +function LabelTag(props) { + const compile = useCompile(); + const label = compile(props.label); + const onPreventMouseDown = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + const { color } = ExecutionStatusOptionsMap[props.value] ?? {}; + return ( + + {label} + + ); +} + +function ExecutionStatusOption(props) { + const compile = useCompile(); + return ( + <> + + {props.description ? {compile(props.description)} : null} + + ); +} + +export function ExecutionStatusSelect({ ...props }) { + const mode = props.multiple ? 'multiple' : null; + + return ( + + ); +} + +export function ExecutionStatusColumn(props) { + const { t } = useTranslation(); + const { refresh } = useResourceActionContext(); + const { resource } = useResourceContext(); + const record = useRecord(); + const onCancel = useCallback(() => { + Modal.confirm({ + title: lang('Cancel the execution'), + icon: , + content: lang('Are you sure you want to cancel the execution?'), + onOk: () => { + resource + .cancel({ + filterByTk: record.id, + }) + .then(() => { + message.success(t('Operation succeeded')); + refresh(); + }) + .catch((response) => { + console.error(response.data.error); + }); + }, + }); + }, [record]); + return ( +
+ {props.children} + {record.status ? null : ( + +
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatusSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatusSelect.tsx deleted file mode 100644 index 4eacd9717..000000000 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/ExecutionStatusSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Select, Tag } from 'antd'; -import React, { useCallback } from 'react'; - -import { useCompile } from '@nocobase/client'; -import { EXECUTION_STATUS, ExecutionStatusOptions, ExecutionStatusOptionsMap } from '../constants'; - -function LabelTag(props) { - const compile = useCompile(); - const label = compile(props.label); - const onPreventMouseDown = useCallback((event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }, []); - const { color } = ExecutionStatusOptionsMap[props.value] ?? {}; - return ( - - {label} - - ); -} - -function ExecutionStatusOption(props) { - const compile = useCompile(); - return ( - <> - - {props.description ? {compile(props.description)} : null} - - ); -} - -export function ExecutionStatusSelect({ ...props }) { - const mode = props.multiple ? 'multiple' : null; - - return ( - - ); -} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/schemas/executions.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/schemas/executions.tsx index 06d8ae50d..6b15e0a47 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/schemas/executions.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/schemas/executions.tsx @@ -11,10 +11,21 @@ import { getWorkflowDetailPath } from '../constant'; export const executionCollection = { name: 'executions', fields: [ + { + interface: 'id', + type: 'bigInt', + name: 'id', + uiSchema: { + type: 'number', + title: '{{t("ID")}}', + 'x-component': 'Input', + 'x-component-props': {}, + 'x-read-pretty': true, + } as ISchema, + }, { interface: 'createdAt', type: 'datetime', - // field: 'createdAt', name: 'createdAt', uiSchema: { type: 'datetime', @@ -120,6 +131,18 @@ export const executionSchema = { useDataSource: '{{ cm.useDataSourceFromRAC }}', }, properties: { + id: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + id: { + type: 'number', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, createdAt: { type: 'void', 'x-decorator': 'Table.Column.Decorator', @@ -151,9 +174,11 @@ export const executionSchema = { type: 'void', 'x-decorator': 'Table.Column.Decorator', 'x-component': 'Table.Column', + title: `{{t("Status", { ns: "${NAMESPACE}" })}}`, properties: { status: { type: 'number', + 'x-decorator': 'ExecutionStatusColumn', 'x-component': 'CollectionField', 'x-read-pretty': true, }, @@ -173,7 +198,6 @@ export const executionSchema = { properties: { link: { type: 'void', - title: `{{t("Details", { ns: "${NAMESPACE}" })}}`, 'x-component': 'ExecutionLink', }, }, diff --git a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json index e09f17598..446247507 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow/src/locale/zh-CN.json @@ -116,6 +116,9 @@ "Rejected from a manual node.": "被人工节点拒绝继续。", "General failed but should do another try.": "执行失败,需重试。", + "Cancel the execution": "取消执行", + "Are you sure you want to cancel the execution?": "确定要取消该执行吗?", + "Operations": "操作", "This node contains branches, deleting will also be preformed to them, are you sure?": "节点包含分支,将同时删除其所有分支下的子节点,确定继续?", diff --git a/packages/plugins/@nocobase/plugin-workflow/src/server/actions/executions.ts b/packages/plugins/@nocobase/plugin-workflow/src/server/actions/executions.ts index a757f7874..9ed81cb20 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/server/actions/executions.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/server/actions/executions.ts @@ -1,6 +1,6 @@ import actions, { Context } from '@nocobase/actions'; import { Op } from '@nocobase/database'; -import { EXECUTION_STATUS } from '../constants'; +import { EXECUTION_STATUS, JOB_STATUS } from '../constants'; export async function destroy(context: Context, next) { context.action.mergeParams({ @@ -13,3 +13,43 @@ export async function destroy(context: Context, next) { await actions.destroy(context, next); } + +export async function cancel(context: Context, next) { + const { filterByTk } = context.action.params; + const ExecutionRepo = context.db.getRepository('executions'); + const JobRepo = context.db.getRepository('jobs'); + const execution = await ExecutionRepo.findOne({ + filterByTk, + appends: ['jobs'], + }); + if (!execution) { + return context.throw(404); + } + if (execution.status) { + return context.throw(400); + } + + await context.db.sequelize.transaction(async (transaction) => { + await execution.update( + { + status: EXECUTION_STATUS.CANCELED, + }, + { transaction }, + ); + + const pendingJobs = execution.jobs.filter((job) => job.status === JOB_STATUS.PENDING); + await JobRepo.update({ + values: { + status: JOB_STATUS.CANCELED, + }, + filter: { + id: pendingJobs.map((job) => job.id), + }, + individualHooks: false, + transaction, + }); + }); + + context.body = execution; + await next(); +}