Junyi e5507d0758
refactor(plugin-workflow): change task center api and ui (#6272)
* refactor(plugin-workflow): change task center api and ui

* fix(client): add className property for Grid.Col

* refactor(plugin-workflow): adjust tasks menu style

* fix(plugin-workflow): fix menu title

* feat(plugin-workflow): automatically update tasks number

* fix(plugin-workflow): ignore ws if not exist

* fix(plugin-workflow): fix compatibility of no user approvals

* refactor(server): revert ws api back

* fix(plugin-workflow-manual): fix migration and renamed test cases

* fix(plugin-workflow): fix acl for task resource

* refactor(client): show badge number in toolbar

* fix(plugin-workflow): fix toolbar number

* fix(client): adjust badge font size

* refactor(plugin-workflow): adjust task center style and api

* fix(plugin-workflow-manual): fix constants

* refactor(plugin-workflow-manual): change legacy workflow todo block to list style

* test(plugin-workflow-manual): migrations

* refactor(plugin-workflow): add workflow title component

* fix(plugin-workflow-manual): fix e2e test cases

* fix(plugin-workflow): fix test kit
2025-03-10 19:58:33 +08:00

741 lines
21 KiB
TypeScript

/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { useField, useFieldSchema, useForm } from '@formily/react';
import { FormLayout } from '@formily/antd-v5';
import { Button, Card, ConfigProvider, Descriptions, Space, Spin, Tag } from 'antd';
import { TableOutlined } from '@ant-design/icons';
import { useAntdToken } from 'antd-style';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { get } from 'lodash';
import {
css,
PopupContextProvider,
SchemaInitializerItem,
useCollectionRecordData,
useCompile,
useOpenModeContext,
usePlugin,
useSchemaInitializer,
useSchemaInitializerItem,
SchemaComponent,
SchemaComponentContext,
useAPIClient,
useActionContext,
useCurrentUserContext,
useFormBlockContext,
useTableBlockContext,
List,
OpenModeProvider,
} from '@nocobase/client';
import WorkflowPlugin, {
DetailsBlockProvider,
FlowContext,
linkNodes,
useAvailableUpstreams,
useFlowContext,
EXECUTION_STATUS,
JOB_STATUS,
WorkflowTitle,
} from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale';
import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';
import { TaskStatusOptionsMap } from '../common/constants';
function TaskStatusColumn(props) {
const recordData = useCollectionRecordData();
const labelUnprocessed = useLang('Unprocessed');
if (recordData?.execution?.status && !recordData?.status) {
return <Tag>{labelUnprocessed}</Tag>;
}
return props.children;
}
function RecordTitle(props) {
const record = useCollectionRecordData();
if (Array.isArray(props.dataIndex)) {
for (const index of props.dataIndex) {
const title = get(record, index);
if (title) {
return title;
}
}
}
return get(record, props.dataIndex);
}
export const WorkflowTodo: React.FC & {
Initializer: React.FC;
Drawer: React.FC;
Decorator: React.FC;
// TaskBlock: React.FC;
} = (props) => {
const { defaultOpenMode } = useOpenModeContext();
const viewSchema = getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName: 'workflowManualTasks' });
return (
<SchemaComponentContext.Provider value={{ designable: false }}>
<SchemaComponent
scope={{
useCollectionRecordData,
}}
components={{
FormLayout,
// WorkflowColumn,
// UserColumn,
ContentDetailWithTitle,
}}
schema={{
type: 'void',
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
properties: {
filter: {
type: 'void',
title: '{{ t("Filter") }}',
'x-action': 'filter',
'x-designer': 'Filter.Action.Designer',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
'x-component-props': {
icon: 'FilterOutlined',
},
default: {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
},
'x-align': 'left',
},
refresher: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-action': 'refresh',
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
// 'x-designer': 'Action.Designer',
'x-toolbar': 'ActionSchemaToolbar',
'x-settings': 'actionSettings:refresh',
'x-component-props': {
icon: 'ReloadOutlined',
},
'x-align': 'right',
},
},
},
list: {
type: 'array',
'x-component': 'List',
// 'x-use-component-props': 'useListBlockProps',
properties: {
item: {
type: 'object',
'x-component': 'List.Item',
properties: {
content: {
type: 'void',
'x-decorator': 'FormLayout',
'x-decorator-props': {
layout: 'horizontal',
},
'x-component': 'ContentDetailWithTitle',
'x-component-props': {
// NOTE: component in schema can not work with popup
// title: (
// <SchemaComponent
// schema={{
// name: 'title',
// type: 'string',
// 'x-component': 'CollectionField',
// }}
// />
// ),
// extra: (
// <SchemaComponent
// schema={{
// name: 'workflow.title',
// type: 'string',
// 'x-component': 'CollectionField',
// }}
// />
// ),
},
},
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-use-component-props': 'useListActionBarProps',
'x-component-props': {
layout: 'one-column',
},
'x-align': 'left',
properties: {
view: viewSchema,
},
},
},
},
},
},
},
}}
/>
</SchemaComponentContext.Provider>
);
};
export function getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName }) {
return {
name: 'view',
type: 'void',
'x-component': 'Action.Link',
'x-component-props': {
openMode: defaultOpenMode,
},
title: '{{t("View")}}',
// 1. “弹窗 URL”需要 Schema 中必须包含 uid
// 2. 所以,在这里加上一个固定的 uid 用以支持“弹窗 URL”
// 3. 然后,把这段 Schema 完整的(加上弹窗的部分)保存到内存中,以便“弹窗 URL”可以直接使用
'x-uid': `${collectionName}-view`,
'x-action': 'view',
'x-action-context': {
dataSource: 'main',
collection: collectionName,
doNotUpdateContext: true,
},
properties: {
drawer: {
type: 'void',
'x-component': WorkflowTodo.Drawer,
},
},
};
}
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);
ManualActionStatusContext.displayName = 'ManualActionStatusContext';
function ManualActionStatusProvider({ value, children }) {
const { userJob, execution } = useFlowContext();
const button = useField();
const buttonSchema = useFieldSchema();
const compile = useCompile();
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}>
{execution.status || userJob.status ? (
<Button type="primary" disabled>
{compile(buttonSchema.title)}
</Button>
) : (
children
)}
</ManualActionStatusContext.Provider>
);
}
function useSubmit() {
const api = useAPIClient();
const { setVisible, setSubmitted } = useActionContext();
const { values, submit } = useForm();
const field = useField();
const buttonSchema = useFieldSchema();
const { service } = useTableBlockContext();
const { userJob, execution } = useFlowContext();
const { name: actionKey } = buttonSchema;
const { name: formKey } = buttonSchema.parent.parent;
const { assignedValues = {} } = buttonSchema?.['x-action-settings'] ?? {};
return {
async run() {
if (execution.status || userJob.status) {
return;
}
await submit();
field.data = field.data || {};
field.data.loading = true;
await api.resource('workflowManualTasks').submit({
filterByTk: userJob.id,
values: {
result: { [formKey]: { ...values, ...assignedValues.values }, _: actionKey },
},
});
field.data.loading = false;
setSubmitted(true);
setVisible(false);
service?.refresh();
},
};
}
function FlowContextProvider(props) {
const workflowPlugin = usePlugin(WorkflowPlugin);
const api = useAPIClient();
const { id } = useCollectionRecordData() || {};
const [flowContext, setFlowContext] = useState<any>(null);
const [node, setNode] = useState<any>(null);
useEffect(() => {
if (!id) {
return;
}
api
.resource('workflowManualTasks')
.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 recordData = useCollectionRecordData();
const { data: user } = useCurrentUserContext();
const { form } = useFormBlockContext();
const pattern =
execution.status || userJob.status
? recordData
? '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 } = useCollectionRecordData() || {};
const statusOption = TaskStatusOptionsMap[status];
return status ? (
<Space
className={css`
margin-bottom: 1em;
time {
margin-right: 0.5em;
}
`}
>
<time>{dayjs(updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
<Tag color={statusOption.color}>{compile(statusOption.label)}</Tag>
</Space>
) : null;
}
function Drawer() {
const ctx = useContext(SchemaComponentContext);
const { id, node, workflow, status } = useCollectionRecordData() || {};
return (
<SchemaComponentContext.Provider value={{ ...ctx, reset() {}, designable: false }}>
<SchemaComponent
components={{
FooterStatus,
FlowContextProvider,
}}
schema={{
type: 'void',
name: `drawer-${id}-${status}`,
'x-component': 'Action.Container',
'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.Container.Footer',
properties: {
content: {
type: 'void',
'x-component': 'FooterStatus',
},
},
},
},
}}
/>
</SchemaComponentContext.Provider>
);
}
function Decorator(props) {
const { params = {}, children } = props;
const blockProps = {
collection: 'workflowManualTasks',
resource: 'workflowManualTasks',
action: 'list',
params: {
pageSize: 20,
sort: ['-createdAt'],
...params,
filter: {
...params.filter,
},
appends: ['user', 'node', 'workflow', 'execution.status'],
except: ['node.config', 'workflow.config', 'workflow.options'],
},
};
return (
<OpenModeProvider defaultOpenMode="modal">
<List.Decorator {...blockProps}>{children}</List.Decorator>
</OpenModeProvider>
);
}
function Initializer() {
const itemConfig = useSchemaInitializerItem();
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerItem
icon={<TableOutlined />}
{...itemConfig}
onClick={() => {
insert({
type: 'void',
'x-decorator': 'WorkflowTodo.Decorator',
'x-decorator-props': {},
'x-component': 'CardItem',
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
properties: {
todos: {
type: 'void',
'x-component': 'WorkflowTodo',
},
},
});
}}
/>
);
}
WorkflowTodo.Initializer = Initializer;
WorkflowTodo.Drawer = Drawer;
WorkflowTodo.Decorator = Decorator;
function ContentDetail(props) {
const { t } = useTranslation();
const token = useAntdToken();
return (
<ConfigProvider
theme={{
token: {
fontSizeLG: 14,
},
}}
>
<Descriptions
{...props}
column={1}
items={[
{
key: 'createdAt',
label: t('Created at'),
children: (
<SchemaComponent
schema={{
name: 'createdAt',
type: 'string',
'x-component': 'CollectionField',
'x-read-pretty': true,
}}
/>
),
},
{
key: 'status',
label: t('Status', { ns: 'workflow' }),
children: (
<SchemaComponent
components={{ TaskStatusColumn }}
schema={{
name: 'status',
type: 'number',
'x-decorator': 'TaskStatusColumn',
'x-component': 'CollectionField',
'x-read-pretty': true,
}}
/>
),
},
]}
className={css`
.ant-descriptions-header {
margin-bottom: 0.5em;
.ant-descriptions-extra {
color: ${token.colorTextDescription};
}
}
.ant-descriptions-item-label {
width: 6em;
}
`}
/>
</ConfigProvider>
);
}
function ContentDetailWithTitle(props) {
return (
<ContentDetail
title={<RecordTitle dataIndex={['title', 'node.title']} />}
extra={<RecordTitle dataIndex={'workflow.title'} />}
/>
);
}
function TaskItem() {
const token = useAntdToken();
const [visible, setVisible] = useState(false);
const record = useCollectionRecordData();
const { t } = useTranslation();
// const { defaultOpenMode } = useOpenModeContext();
// const { openPopup } = usePopupUtils();
// const { isPopupVisibleControlledByURL } = usePopupSettings();
const onOpen = useCallback((e: React.MouseEvent) => {
const targetElement = e.target as Element; // 将事件目标转换为Element类型
const currentTargetElement = e.currentTarget as Element;
if (currentTargetElement.contains(targetElement)) {
setVisible(true);
// if (!isPopupVisibleControlledByURL()) {
// } else {
// openPopup({
// // popupUidUsedInURL: 'job',
// customActionSchema: {
// type: 'void',
// 'x-uid': 'job-view',
// 'x-action-context': {
// dataSource: 'main',
// collection: 'workflowManualTasks',
// doNotUpdateContext: true,
// },
// properties: {},
// },
// });
// }
}
e.stopPropagation();
}, []);
return (
<>
<Card
onClick={onOpen}
hoverable
size="small"
title={record.title}
extra={<WorkflowTitle {...record.workflow} />}
className={css`
.ant-card-extra {
color: ${token.colorTextDescription};
}
`}
>
<ContentDetail />
</Card>
<PopupContextProvider visible={visible} setVisible={setVisible} openMode="modal">
<Drawer />
</PopupContextProvider>
</>
);
}
const StatusFilterMap = {
pending: {
status: JOB_STATUS.PENDING,
'execution.status': EXECUTION_STATUS.STARTED,
},
completed: {
status: JOB_STATUS.RESOLVED,
},
};
function useTodoActionParams(status) {
const { data: user } = useCurrentUserContext();
const filter = StatusFilterMap[status] ?? {};
return {
filter: {
...filter,
userId: user?.data?.id,
},
appends: [
'job.id',
'job.status',
'job.result',
'workflow.id',
'workflow.title',
'workflow.enabled',
'execution.id',
'execution.status',
],
};
}
function TodoExtraActions() {
return (
<SchemaComponent
schema={{
name: 'actions',
type: 'void',
'x-component': 'ActionBar',
properties: {
refresh: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
icon: 'ReloadOutlined',
},
},
filter: {
type: 'void',
title: '{{t("Filter")}}',
'x-component': 'Filter.Action',
'x-use-component-props': 'useFilterActionProps',
'x-component-props': {
icon: 'FilterOutlined',
},
default: {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
},
},
},
}}
/>
);
}
export const manualTodo = {
title: `{{t("My manual tasks", { ns: "${NAMESPACE}" })}}`,
collection: 'workflowManualTasks',
useActionParams: useTodoActionParams,
component: TaskItem,
extraActions: TodoExtraActions,
};