feat(plugin-workflow): add workflow task panel in toolbar (#5858)

* feat(plugin-workflow): add workflow task panel in toolbar

* fix(plugin-workflow): fix type

* fix(plugin-workflow): fix manual task list filter

* fix(plugin-workflow): add tooltip for task button
This commit is contained in:
Junyi 2024-12-26 10:44:34 +08:00 committed by GitHub
parent 631cea1df9
commit 56c03f3fef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 543 additions and 107 deletions

View File

@ -7,21 +7,45 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ExtendCollectionsProvider, storePopupContext } from '@nocobase/client';
import React, { FC } from 'react';
import { ExtendCollectionsProvider, storePopupContext, useRequest } from '@nocobase/client';
import React, { createContext, FC, useContext } from 'react';
import { getWorkflowTodoViewActionSchema, nodeCollection, todoCollection, workflowCollection } from './WorkflowTodo';
import { JOB_STATUS } from '@nocobase/plugin-workflow/client';
const collections = [nodeCollection, workflowCollection, todoCollection];
const ManualTaskCountRequestContext = createContext({});
/**
* 1. collection collection
* @param props
* @returns
*/
export const WorkflowManualProvider: FC = (props) => {
return <ExtendCollectionsProvider collections={collections}>{props.children}</ExtendCollectionsProvider>;
const request = useRequest<any>(
{
resource: 'users_jobs',
action: 'countMine',
params: {
filter: {
status: JOB_STATUS.PENDING,
},
},
},
{ manual: true },
);
return (
<ExtendCollectionsProvider collections={collections}>
<ManualTaskCountRequestContext.Provider value={request}>{props.children}</ManualTaskCountRequestContext.Provider>
</ExtendCollectionsProvider>
);
};
export function useCountRequest() {
return useContext(ManualTaskCountRequestContext);
}
/**
* 2. Schema Schema URL
*/

View File

@ -14,11 +14,13 @@ import React, { createContext, useContext, useEffect, useState } from 'react';
import {
css,
useCollection,
SchemaInitializerItem,
useCollectionRecordData,
useCompile,
useOpenModeContext,
usePlugin,
useSchemaInitializer,
useSchemaInitializerItem,
} from '@nocobase/client';
import {
@ -44,6 +46,7 @@ import WorkflowPlugin, {
import { NAMESPACE, useLang } from '../locale';
import { FormBlockProvider } from './instruction/FormBlockProvider';
import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig';
import { TableOutlined } from '@ant-design/icons';
export const nodeCollection = {
title: `{{t("Task", { ns: "${NAMESPACE}" })}}`,
@ -232,9 +235,94 @@ function UserJobStatusColumn(props) {
return props.children;
}
export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC } = () => {
const tableColumns = {
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,
},
},
},
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,
},
},
},
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,
},
},
},
};
export const WorkflowTodo: React.FC<{ columns?: string[] }> & {
Initializer: React.FC;
Drawer: React.FC;
Decorator: React.FC;
TaskBlock: React.FC;
} = (props) => {
const { columns = Object.keys(tableColumns) } = props;
const { defaultOpenMode } = useOpenModeContext();
const collection = useCollection();
return (
<SchemaComponent
@ -301,86 +389,13 @@ export const WorkflowTodo: React.FC & { Drawer: React.FC; Decorator: React.FC }
},
title: '{{t("Actions")}}',
properties: {
view: getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName: collection.name }),
},
},
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,
},
view: getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName: 'users_jobs' }),
},
},
...columns.reduce((schema, key) => {
schema[key] = tableColumns[key];
return schema;
}, {}),
},
},
},
@ -410,6 +425,7 @@ export function getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionNam
},
properties: {
drawer: {
type: 'void',
'x-component': WorkflowTodo.Drawer,
},
},
@ -671,7 +687,8 @@ function Drawer() {
);
}
function Decorator({ params = {}, children }) {
function Decorator(props) {
const { params = {}, children } = props;
const blockProps = {
collection: 'users_jobs',
resource: 'users_jobs',
@ -680,6 +697,9 @@ function Decorator({ params = {}, children }) {
pageSize: 20,
sort: ['-createdAt'],
...params,
filter: {
...params.filter,
},
appends: ['user', 'node', 'workflow', 'execution.status'],
except: ['node.config', 'workflow.config', 'workflow.options'],
},
@ -695,5 +715,79 @@ function Decorator({ params = {}, children }) {
);
}
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;
WorkflowTodo.TaskBlock = TaskBlock;
function TaskBlock() {
const { data: user } = useCurrentUserContext();
return (
<SchemaComponent
components={{
WorkflowTodo,
}}
schema={{
name: 'todos',
type: 'void',
'x-decorator': 'WorkflowTodo.Decorator',
'x-decorator-props': {
params: {
filter: {
userId: user?.data?.id,
},
appends: [
'node.id',
'node.title',
'job.id',
'job.status',
'job.result',
'workflow.id',
'workflow.title',
'workflow.enabled',
'execution.id',
'execution.status',
],
},
},
'x-component': 'CardItem',
properties: {
todos: {
type: 'void',
'x-component': 'WorkflowTodo',
'x-component-props': {
columns: ['workflow', 'node', 'status', 'createdAt'],
},
},
},
}}
/>
);
}

View File

@ -13,9 +13,8 @@ import WorkflowPlugin from '@nocobase/plugin-workflow/client';
import Manual from './instruction';
import { NAMESPACE } from '../locale';
import { WorkflowManualProvider } from './WorkflowManualProvider';
import { useCountRequest, WorkflowManualProvider } from './WorkflowManualProvider';
import { WorkflowTodo } from './WorkflowTodo';
import { WorkflowTodoBlockInitializer } from './WorkflowTodoBlockInitializer';
import {
addActionButton,
addActionButton_deprecated,
@ -33,10 +32,17 @@ export default class extends Plugin {
async load() {
this.addComponents();
// this.app.addProvider(Provider);
this.app.addProvider(WorkflowManualProvider);
const workflow = this.app.pm.get('workflow') as WorkflowPlugin;
workflow.registerInstruction('manual', Manual);
workflow.registerTaskType('manual', {
title: `{{t("My manual tasks", { ns: "${NAMESPACE}" })}}`,
useCountRequest,
component: WorkflowTodo.TaskBlock,
});
this.app.schemaInitializerManager.add(addBlockButton_deprecated);
this.app.schemaInitializerManager.add(addBlockButton);
this.app.schemaInitializerManager.add(addActionButton_deprecated);
@ -47,23 +53,20 @@ export default class extends Plugin {
const blockInitializers = this.app.schemaInitializerManager.get('page:addBlock');
blockInitializers.add('otherBlocks.workflowTodos', {
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
Component: 'WorkflowTodoBlockInitializer',
Component: 'WorkflowTodo.Initializer',
icon: 'CheckSquareOutlined',
});
this.app.schemaInitializerManager.addItem('mobile:addBlock', 'otherBlocks.workflowTodos', {
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
Component: 'WorkflowTodoBlockInitializer',
Component: 'WorkflowTodo.Initializer',
icon: 'CheckSquareOutlined',
});
this.app.addProvider(WorkflowManualProvider);
}
addComponents() {
this.app.addComponents({
WorkflowTodo,
WorkflowTodoBlockInitializer,
});
}
}

View File

@ -28,5 +28,6 @@
"Workflow todos": "工作流待办",
"Task node": "任务节点",
"Unprocessed": "未处理",
"Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。"
"Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。",
"My manual tasks": "我的人工任务"
}

View File

@ -12,16 +12,13 @@ import actions from '@nocobase/actions';
import { HandlerType } from '@nocobase/resourcer';
import WorkflowPlugin, { JOB_STATUS } from '@nocobase/plugin-workflow';
import path from 'path';
import { submit } from './actions';
import * as jobActions from './actions';
import ManualInstruction from './ManualInstruction';
export default class extends Plugin {
async load() {
await this.importCollections(path.resolve(__dirname, 'collections'));
this.app.resource({
this.app.resourceManager.define({
name: 'users_jobs',
actions: {
list: {
@ -40,11 +37,11 @@ export default class extends Plugin {
},
handler: actions.list as HandlerType,
},
submit,
...jobActions,
},
});
this.app.acl.allow('users_jobs', ['list', 'get', 'submit'], 'loggedIn');
this.app.acl.allow('users_jobs', ['list', 'get', 'submit', 'countMine'], 'loggedIn');
const workflowPlugin = this.app.pm.get(WorkflowPlugin) as WorkflowPlugin;
workflowPlugin.registerInstruction('manual', ManualInstruction);

View File

@ -112,3 +112,27 @@ export async function submit(context: Context, next) {
plugin.resume(userJob.job);
}
export async function countMine(context: Context, next) {
const repository = utils.getRepositoryFromParams(context);
const { currentUser } = context.state;
const count = await repository.count({
filter: {
$and: [
{
'workflow.enabled': true,
},
context.action.params.filter ?? {},
{
userId: currentUser.id,
},
],
},
context,
});
context.body = count;
await next();
}

View File

@ -0,0 +1,173 @@
/**
* 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, { useEffect, useMemo } from 'react';
import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button, Layout, Menu, Spin, Badge, theme, Tooltip } from 'antd';
import { PageHeader } from '@ant-design/pro-layout';
import { CheckCircleOutlined } from '@ant-design/icons';
import classnames from 'classnames';
import {
css,
PinnedPluginListProvider,
SchemaComponentContext,
SchemaComponentOptions,
useCompile,
usePlugin,
} from '@nocobase/client';
import PluginWorkflowClient from '.';
import { lang } from './locale';
const sideClass = css`
height: calc(100vh - 46px);
.ant-layout-sider-children {
width: 200px;
height: 100%;
}
`;
export interface TaskTypeOptions {
title: string;
useCountRequest?: Function;
component?: React.ComponentType;
children?: TaskTypeOptions[];
}
function MenuLink({ type }: any) {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const compile = useCompile();
const { title, useCountRequest } = workflowPlugin.taskTypes.get(type);
const { data, loading, run } = useCountRequest?.() || { loading: false };
useEffect(() => {
run?.();
}, [run]);
return (
<Link
to={`/admin/workflow/tasks/${type}`}
className={css`
display: flex;
align-items: center;
justify-content: space-between;
`}
>
<span>{compile(title)}</span>
{loading ? <Spin /> : <Badge count={data?.data || 0} />}
</Link>
);
}
export function WorkflowTasks() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const navigate = useNavigate();
const { taskType } = useParams();
const compile = useCompile();
const {
token: { colorBgContainer },
} = theme.useToken();
const items = useMemo(
() =>
Array.from(workflowPlugin.taskTypes.getKeys()).map((key: string) => {
return {
key,
label: <MenuLink type={key} />,
};
}),
[workflowPlugin.taskTypes],
);
const { title, component: Component } = useMemo<any>(
() => workflowPlugin.taskTypes.get(taskType ?? items[0]?.key) ?? {},
[items, taskType, workflowPlugin.taskTypes],
);
useEffect(() => {
if (!taskType && items[0].key) {
navigate(`/admin/workflow/tasks/${items[0].key}`, { replace: true });
}
}, [items, navigate, taskType]);
const key = taskType ?? items[0].key;
return (
<Layout>
<Layout.Sider className={sideClass} theme="light">
<Menu mode="inline" selectedKeys={[key]} items={items} style={{ height: '100%' }} />
</Layout.Sider>
<Layout>
<PageHeader
className={classnames('pageHeaderCss', 'height0')}
style={{ background: colorBgContainer, padding: '12px 24px 0 24px' }}
title={compile(title)}
/>
<Layout.Content style={{ padding: '24px', minHeight: 280 }}>
<SchemaComponentContext.Provider value={{ designable: false }}>
{Component ? <Component /> : null}
<Outlet />
</SchemaComponentContext.Provider>
</Layout.Content>
</Layout>
</Layout>
);
}
function WorkflowTasksLink() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const types = Array.from(workflowPlugin.taskTypes.getKeys());
return types.length ? (
<Tooltip title={lang('Workflow todos')}>
<Button
className={css`
padding: 0;
display: inline-flex;
vertical-align: middle;
a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.anticon {
display: inline-block;
vertical-align: middle;
line-height: 1em;
}
}
`}
>
<Link to="/admin/workflow/tasks">
<CheckCircleOutlined />
</Link>
</Button>
</Tooltip>
) : null;
}
export const TasksProvider = (props: any) => {
return (
<PinnedPluginListProvider
items={{
todo: { component: 'WorkflowTasksLink', pin: true, snippet: '*' },
}}
>
<SchemaComponentOptions
components={{
WorkflowTasksLink,
}}
>
{props.children}
</SchemaComponentOptions>
</PinnedPluginListProvider>
);
};

View File

@ -11,7 +11,7 @@ import React from 'react';
import { useFieldSchema } from '@formily/react';
import { isValid } from '@formily/shared';
import { Plugin, useCompile, WorkflowConfig } from '@nocobase/client';
import { PagePopups, Plugin, useCompile, WorkflowConfig } from '@nocobase/client';
import { Registry } from '@nocobase/utils/client';
// import { ExecutionPage } from './ExecutionPage';
@ -37,12 +37,15 @@ import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './utils';
import { lang, NAMESPACE } from './locale';
import { customizeSubmitToWorkflowActionSettings } from './settings/customizeSubmitToWorkflowActionSettings';
import { VariableOption } from './variable';
import { WorkflowTasks, TasksProvider, TaskTypeOptions } from './WorkflowTasks';
export default class PluginWorkflowClient extends Plugin {
triggers = new Registry<Trigger>();
instructions = new Registry<Instruction>();
systemVariables = new Registry<VariableOption>();
taskTypes = new Registry<TaskTypeOptions>();
useTriggersOptions = () => {
const compile = useCompile();
return Array.from(this.triggers.getEntities())
@ -83,15 +86,29 @@ export default class PluginWorkflowClient extends Plugin {
this.systemVariables.register(option.key, option);
}
registerTaskType(key: string, option: TaskTypeOptions) {
this.taskTypes.register(key, option);
}
async load() {
this.app.router.add('admin.workflow.workflows.id', {
this.router.add('admin.workflow.workflows.id', {
path: getWorkflowDetailPath(':id'),
element: <WorkflowPage />,
Component: WorkflowPage,
});
this.app.router.add('admin.workflow.executions.id', {
this.router.add('admin.workflow.executions.id', {
path: getWorkflowExecutionsPath(':id'),
element: <ExecutionPage />,
Component: ExecutionPage,
});
this.router.add('admin.workflow.tasks', {
path: '/admin/workflow/tasks/:taskType?',
Component: WorkflowTasks,
});
this.router.add('admin.workflow.tasks.popup', {
path: '/admin/workflow/tasks/:taskType/popups/*',
Component: PagePopups,
});
this.app.pluginSettingsManager.add(NAMESPACE, {
@ -101,6 +118,8 @@ export default class PluginWorkflowClient extends Plugin {
aclSnippet: 'pm.workflow.workflows',
});
this.app.use(TasksProvider);
this.app.schemaSettingsManager.add(customizeSubmitToWorkflowActionSettings);
this.app.schemaSettingsManager.addItem('actionSettings:delete', 'workflowConfig', {

View File

@ -0,0 +1,34 @@
/**
* 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 { Instruction } from '.';
import { NAMESPACE } from '../locale';
import { WorkflowVariableInput } from '../variable';
export default class extends Instruction {
title = `{{t("Assign output variable", { ns: "${NAMESPACE}" })}}`;
type = 'output';
group = 'control';
description = `{{t("Assign variables for workflow output, which could be used in other workflows as result of subflow.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
result: {
type: 'object',
title: `{{t("Value", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableInput',
'x-component-props': {
useTypedConstant: true,
},
required: true,
},
};
components = {
WorkflowVariableInput,
};
}

View File

@ -0,0 +1,65 @@
/**
* 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 { Instruction } from '.';
import { NAMESPACE } from '../locale';
import { JOB_STATUS } from '../constants';
import { TriggerCollectionRecordSelect } from '../components/TriggerCollectionRecordSelect';
import { Fieldset } from '../components/Fieldset';
export default class extends Instruction {
title = `{{t("Call another workflow", { ns: "${NAMESPACE}" })}}`;
type = 'subflow';
group = 'control';
description = `{{t("Run another workflow and use its output as variables.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
workflow: {
type: 'number',
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Select',
enum: [
{ label: `{{t("Post added", { ns: "${NAMESPACE}" })}}`, value: 1 },
{ label: `{{t("Some other action", { ns: "${NAMESPACE}" })}}`, value: 2 },
],
required: true,
},
context: {
type: 'object',
title: `{{t("Context", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Fieldset',
properties: {
data: {
type: 'object',
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Choose a record of the collection to trigger.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'TriggerCollectionRecordSelect',
default: null,
required: true,
},
},
'x-reactions': [
{
dependencies: ['workflow'],
fulfill: {
state: {
visible: '{{workflow === 1}}',
},
},
},
],
},
};
components = {
TriggerCollectionRecordSelect,
Fieldset,
};
}

View File

@ -227,5 +227,7 @@
"Add node": "添加节点",
"Move all downstream nodes to": "将所有下游节点移至",
"After end of branches": "分支结束后",
"Inside of branch": "分支内"
"Inside of branch": "分支内",
"Workflow todos": "流程待办"
}