Junyi fb11c82b79
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
2024-01-24 21:09:48 +08:00

651 lines
17 KiB
TypeScript

import React, { createContext, useContext, useEffect, useState } from 'react';
import { observer, useField, useFieldSchema, useForm } from '@formily/react';
import { Space, Spin, Tag } from 'antd';
import dayjs from 'dayjs';
import { css, usePlugin } from '@nocobase/client';
import {
CollectionManagerProvider,
SchemaComponent,
SchemaComponentContext,
TableBlockProvider,
useAPIClient,
useActionContext,
useCollectionManager,
useCompile,
useCurrentUserContext,
useFormBlockContext,
useRecord,
useTableBlockContext,
} from '@nocobase/client';
import WorkflowPlugin, {
useAvailableUpstreams,
FlowContext,
useFlowContext,
JobStatusOptions,
JobStatusOptionsMap,
linkNodes,
} from '@nocobase/plugin-workflow/client';
import { DetailsBlockProvider } from './instruction/DetailsBlockProvider';
import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';
import { NAMESPACE, useLang } from '../locale';
const nodeCollection = {
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
name: 'flow_nodes',
fields: [
{
type: 'bigInt',
name: 'id',
interface: 'm2o',
uiSchema: {
type: 'number',
title: 'ID',
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'id',
},
service: {
resource: 'flow_nodes',
params: {
filter: {
type: 'manual',
},
},
},
},
},
},
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
title: '{{t("Title")}}',
'x-component': 'Input',
},
},
],
};
const workflowCollection = {
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
name: 'workflows',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Name")}}',
type: 'string',
'x-component': 'Input',
required: true,
},
},
],
};
const todoCollection = {
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
name: 'users_jobs',
fields: [
{
type: 'belongsTo',
name: 'user',
target: 'users',
foreignKey: 'userId',
interface: 'm2o',
uiSchema: {
type: 'number',
title: '{{t("User")}}',
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'nickname',
value: 'id',
},
service: {
resource: 'users',
},
},
},
},
{
type: 'belongsTo',
name: 'node',
target: 'flow_nodes',
foreignKey: 'nodeId',
interface: 'm2o',
isAssociation: true,
uiSchema: {
type: 'number',
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'id',
},
service: {
resource: 'flow_nodes',
},
},
},
},
{
type: 'belongsTo',
name: 'workflow',
target: 'workflows',
foreignKey: 'workflowId',
interface: 'm2o',
uiSchema: {
type: 'number',
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'id',
},
service: {
resource: 'workflows',
},
},
},
},
{
type: 'integer',
name: 'status',
interface: 'select',
uiSchema: {
type: 'number',
title: `{{t("Status", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Select',
enum: JobStatusOptions,
},
},
{
name: 'createdAt',
type: 'date',
interface: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {
showTime: true,
},
},
},
],
};
const NodeColumn = observer(
() => {
const field = useField<any>();
return field?.value?.title ?? `#${field.value?.id}`;
},
{ displayName: 'NodeColumn' },
);
const WorkflowColumn = observer(
() => {
const field = useField<any>();
return field?.value?.title ?? `#${field.value?.id}`;
},
{ displayName: 'WorkflowColumn' },
);
const UserColumn = observer(
() => {
const field = useField<any>();
return field?.value?.nickname ?? field.value?.id;
},
{ displayName: 'UserColumn' },
);
function UserJobStatusColumn(props) {
const record = useRecord();
const labelUnprocessed = useLang('Unprocessed');
if (record.execution.status && !record.status) {
return <Tag>{labelUnprocessed}</Tag>;
}
return props.children;
}
export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => {
return (
<SchemaComponent
components={{
NodeColumn,
WorkflowColumn,
UserColumn,
UserJobStatusColumn,
}}
schema={{
type: 'void',
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 16,
},
},
properties: {
filter: {
type: 'void',
title: '{{ t("Filter") }}',
'x-action': 'filter',
'x-designer': 'Filter.Action.Designer',
'x-component': 'Filter.Action',
'x-component-props': {
icon: 'FilterOutlined',
useProps: '{{ useFilterActionProps }}',
},
'x-align': 'left',
},
refresher: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-action': 'refresh',
'x-component': 'Action',
'x-designer': 'Action.Designer',
'x-component-props': {
icon: 'ReloadOutlined',
useProps: '{{ useRefreshActionProps }}',
},
'x-align': 'right',
},
},
},
table: {
type: 'array',
'x-component': 'TableV2',
'x-component-props': {
rowKey: 'id',
useProps: '{{ useTableBlockProps }}',
},
properties: {
actions: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 60,
},
title: '{{t("Actions")}}',
properties: {
view: {
type: 'void',
'x-component': 'Action.Link',
title: '{{t("View")}}',
properties: {
drawer: {
'x-component': 'WorkflowTodo.Drawer',
},
},
},
},
},
node: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: null,
},
title: `{{t("Task node", { ns: "${NAMESPACE}" })}}`,
properties: {
node: {
'x-component': 'NodeColumn',
'x-read-pretty': true,
},
},
},
workflow: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: null,
},
title: `{{t("Workflow", { ns: "workflow" })}}`,
properties: {
workflow: {
'x-component': 'WorkflowColumn',
'x-read-pretty': true,
},
},
},
status: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 100,
},
title: `{{t("Status", { ns: "workflow" })}}`,
properties: {
status: {
type: 'number',
'x-decorator': 'UserJobStatusColumn',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
user: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 140,
},
title: `{{t("Assignee", { ns: "${NAMESPACE}" })}}`,
properties: {
user: {
'x-component': 'UserColumn',
'x-read-pretty': true,
},
},
},
createdAt: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: 160,
},
properties: {
createdAt: {
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
},
},
},
},
},
},
}}
/>
);
};
function ActionBarProvider(props) {
// * status is done:
// 1. form is this form: show action button, and emphasis used status button
// 2. form is not this form: hide action bar
// * status is not done:
// 1. current user: show action bar
// 2. not current user: disabled action bar
const { data: user } = useCurrentUserContext();
const { userJob } = useFlowContext();
const { status, result, userId } = userJob;
const buttonSchema = useFieldSchema();
const { name } = buttonSchema.parent.toJSON();
let { children: content } = props;
if (status) {
if (!result[name]) {
content = null;
}
} else {
if (user?.data?.id !== userId) {
content = null;
}
}
return content;
}
const ManualActionStatusContext = createContext<number | null>(null);
function ManualActionStatusProvider({ value, children }) {
const { userJob, execution } = useFlowContext();
const button = useField();
const buttonSchema = useFieldSchema();
useEffect(() => {
if (execution.status || userJob.status) {
button.disabled = true;
button.visible = userJob.status === value && userJob.result._ === buttonSchema.name;
}
}, [execution, userJob, value, button, buttonSchema.name]);
return <ManualActionStatusContext.Provider value={value}>{children}</ManualActionStatusContext.Provider>;
}
function useSubmit() {
const api = useAPIClient();
const { setVisible } = useActionContext();
const { values, submit } = useForm();
const buttonSchema = useFieldSchema();
const { service } = useTableBlockContext();
const { userJob, execution } = useFlowContext();
const { name: actionKey } = buttonSchema;
const { name: formKey } = buttonSchema.parent.parent;
return {
async run() {
if (execution.status || userJob.status) {
return;
}
await submit();
await api.resource('users_jobs').submit({
filterByTk: userJob.id,
values: {
result: { [formKey]: values, _: actionKey },
},
});
setVisible(false);
service.refresh();
},
};
}
function FlowContextProvider(props) {
const workflowPlugin = usePlugin(WorkflowPlugin);
const api = useAPIClient();
const { id } = useRecord();
const [flowContext, setFlowContext] = useState<any>(null);
const [node, setNode] = useState<any>(null);
useEffect(() => {
if (!id) {
return;
}
api
.resource('users_jobs')
.get?.({
filterByTk: id,
appends: ['node', 'job', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
})
.then(({ data }) => {
const { node, workflow: { nodes = [], ...workflow } = {}, execution, ...userJob } = data?.data ?? {};
linkNodes(nodes);
setNode(node);
setFlowContext({
userJob,
workflow,
nodes,
execution,
});
return;
});
}, [api, id]);
const upstreams = useAvailableUpstreams(flowContext?.nodes.find((item) => item.id === node.id));
const nodeComponents = upstreams.reduce(
(components, { type }) => Object.assign(components, workflowPlugin.instructions.get(type).components),
{},
);
return node && flowContext ? (
<FlowContext.Provider value={flowContext}>
<SchemaComponent
components={{
FormBlockProvider,
DetailsBlockProvider,
ActionBarProvider,
ManualActionStatusProvider,
// @ts-ignore
...Array.from(manualFormTypes.getValues()).reduce(
(result, item: ManualFormType) => Object.assign(result, item.block.components),
{},
),
...nodeComponents,
}}
scope={{
useSubmit,
useFormBlockProps,
useDetailsBlockProps,
// @ts-ignore
...Array.from(manualFormTypes.getValues()).reduce(
(result, item: ManualFormType) => Object.assign(result, item.block.scope),
{},
),
}}
schema={{
type: 'void',
name: 'tabs',
'x-component': 'Tabs',
properties: node.config?.schema,
}}
/>
</FlowContext.Provider>
) : (
<Spin />
);
}
function useFormBlockProps() {
const { userJob, execution } = useFlowContext();
const record = useRecord();
const { data: user } = useCurrentUserContext();
const { form } = useFormBlockContext();
const pattern =
execution.status || userJob.status
? record
? 'readPretty'
: 'disabled'
: user?.data?.id !== userJob.userId
? 'disabled'
: 'editable';
useEffect(() => {
form?.setPattern(pattern);
}, [pattern, form]);
return { form };
}
function useDetailsBlockProps() {
const { form } = useFormBlockContext();
return { form };
}
function FooterStatus() {
const compile = useCompile();
const { status, updatedAt } = useRecord();
const statusOption = JobStatusOptionsMap[status];
return status ? (
<Space>
<time
className={css`
margin-right: 0.5em;
`}
>
{dayjs(updatedAt).format('YYYY-MM-DD HH:mm:ss')}
</time>
<Tag icon={statusOption.icon} color={statusOption.color}>
{compile(statusOption.label)}
</Tag>
</Space>
) : null;
}
function Drawer() {
const ctx = useContext(SchemaComponentContext);
const { id, node, workflow, status } = useRecord();
return (
<SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
<SchemaComponent
components={{
FooterStatus,
FlowContextProvider,
}}
schema={{
type: 'void',
name: `drawer-${id}-${status}`,
'x-component': 'Action.Drawer',
'x-component-props': {
className: 'nb-action-popup',
},
title: `${workflow.title} - ${node.title ?? `#${node.id}`}`,
properties: {
tabs: {
type: 'void',
'x-component': 'FlowContextProvider',
},
footer: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
content: {
type: 'void',
'x-component': 'FooterStatus',
},
},
},
},
}}
/>
</SchemaComponentContext.Provider>
);
}
function Decorator({ params = {}, children }) {
const { collections, ...cm } = useCollectionManager();
const blockProps = {
collection: 'users_jobs',
resource: 'users_jobs',
action: 'list',
params: {
pageSize: 20,
sort: ['-createdAt'],
...params,
appends: ['user', 'node', 'workflow', 'execution.status'],
except: ['node.config', 'workflow.config', 'workflow.options'],
},
rowKey: 'id',
showIndex: true,
dragSort: false,
};
return (
<CollectionManagerProvider
{...cm}
collections={[...collections, nodeCollection, workflowCollection, todoCollection]}
>
<TableBlockProvider name="workflow-todo" {...blockProps}>
{children}
</TableBlockProvider>
</CollectionManagerProvider>
);
}
WorkflowTodo.Drawer = Drawer;
WorkflowTodo.Decorator = Decorator;