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
This commit is contained in:
Junyi 2025-03-10 19:58:33 +08:00 committed by GitHub
parent 4d1f28bf57
commit e5507d0758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1393 additions and 880 deletions

View File

@ -241,36 +241,6 @@
}
}
},
{
"key": "qe7b1rsct5h",
"name": "jobs",
"type": "belongsToMany",
"interface": null,
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"through": "users_jobs",
"foreignKey": "userId",
"sourceKey": "id",
"otherKey": "jobId",
"targetKey": "id",
"target": "jobs"
},
{
"key": "vt0n1l1ruyz",
"name": "usersJobs",
"type": "hasMany",
"interface": null,
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"target": "users_jobs",
"foreignKey": "userId",
"sourceKey": "id",
"targetKey": "id"
},
{
"key": "ekol7p60nry",
"name": "sortName",

View File

@ -20,6 +20,8 @@ import { PopupVisibleProvider, PopupVisibleProviderContext } from '../../schema-
export const PopupContextProvider: React.FC<{
visible?: boolean;
setVisible?: (visible: boolean) => void;
openMode?: string;
openSize?: string;
}> = (props) => {
const { visible: visibleFromProps, setVisible: setVisibleFromProps } = props;
const [visible, setVisible] = useState(false);
@ -37,8 +39,8 @@ export const PopupContextProvider: React.FC<{
},
[setVisibleFromProps, setVisibleWithURL],
);
const openMode = fieldSchema['x-component-props']?.['openMode'] || 'drawer';
const openSize = fieldSchema['x-component-props']?.['openSize'];
const openMode = props.openMode || fieldSchema['x-component-props']?.['openMode'] || 'drawer';
const openSize = props.openSize || fieldSchema['x-component-props']?.['openSize'];
return (
<PopupVisibleProvider visible={false}>

View File

@ -32,12 +32,41 @@ const pinnedPluginListClassName = css`
align-items: center;
.ant-btn {
border: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 46px;
width: 46px;
padding: 0;
border: 0;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.65);
vertical-align: middle;
a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.ant-badge {
color: rgba(255, 255, 255, 0.65);
.anticon {
display: inline-block;
vertical-align: middle;
line-height: 1em;
font-size: initial;
}
> sup {
height: 10px;
line-height: 10px;
font-size: 8px;
}
}
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
}

View File

@ -563,7 +563,7 @@ Grid.Col = observer(
return (
<GridColContext.Provider value={value}>
<div ref={setNodeRef} style={colStyle} className={cls('nb-grid-col')}>
<div ref={setNodeRef} style={colStyle} className={cls('nb-grid-col', props.className)}>
{props.children}
</div>
</GridColContext.Provider>

View File

@ -93,7 +93,7 @@ const useFullScreenHeight = (props?) => {
return pageReservedHeight;
};
const InternalWorkflowCollection = ['users_jobs', 'approvals', 'approvalRecords'];
const InternalWorkflowCollection = ['workflowManualTasks', 'approvals', 'approvalRecords'];
// 表格区块高度计算
const useTableHeight = () => {
const { token } = theme.useToken();

View File

@ -76,10 +76,10 @@ export class Gateway extends EventEmitter {
public server: http.Server | null = null;
public ipcSocketServer: IPCSocketServer | null = null;
public wsServer: WSServer;
loggers = new Registry<SystemLogger>();
private port: number = process.env.APP_PORT ? parseInt(process.env.APP_PORT) : null;
private host = '0.0.0.0';
private wsServer: WSServer;
private socketPath = resolve(process.cwd(), 'storage', 'gateway.sock');
private constructor() {

View File

@ -233,7 +233,7 @@ export class WSServer extends EventEmitter {
const client = this.webSocketClients.get(clientId);
// remove all tags with the given tagKey
client.tags.forEach((tag) => {
if (tag.startsWith(tagKey)) {
if (tag.startsWith(`${tagKey}#`)) {
client.tags.delete(tag);
}
});
@ -293,6 +293,16 @@ export class WSServer extends EventEmitter {
}
}
sendToAppUser(appName: string, userId: string, message: object) {
this.sendToConnectionsByTags(
[
{ tagName: 'userId', tagValue: `${userId}` },
{ tagName: 'app', tagValue: appName },
],
message,
);
}
loopThroughConnections(callback: (client: WebSocketClient) => void) {
this.webSocketClients.forEach((client) => {
callback(client);

View File

@ -241,36 +241,6 @@
}
}
},
{
"key": "qe7b1rsct5h",
"name": "jobs",
"type": "belongsToMany",
"interface": null,
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"through": "users_jobs",
"foreignKey": "userId",
"sourceKey": "id",
"otherKey": "jobId",
"targetKey": "id",
"target": "jobs"
},
{
"key": "vt0n1l1ruyz",
"name": "usersJobs",
"type": "hasMany",
"interface": null,
"description": null,
"collectionName": "users",
"parentKey": null,
"reverseKey": null,
"target": "users_jobs",
"foreignKey": "userId",
"sourceKey": "id",
"targetKey": "id"
},
{
"key": "ekol7p60nry",
"name": "sortName",

View File

@ -136,14 +136,11 @@ const InnerInbox = (props) => {
}}
>
<Tooltip title={t('Message')}>
<Badge count={unreadMsgsCountObs.value} size="small" offset={[-12, 14]}>
<Button
className={styles.button}
title={t('Message')}
icon={<Icon type={'BellOutlined'} />}
onClick={onIconClick}
/>
<Button className={styles.button} onClick={onIconClick}>
<Badge count={unreadMsgsCountObs.value} size="small">
<Icon type={'BellOutlined'} />
</Badge>
</Button>
</Tooltip>
<Drawer
title={DrawerTitle}

View File

@ -7,14 +7,114 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
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';
import { ExtendCollectionsProvider, storePopupContext } from '@nocobase/client';
import React, { FC } from 'react';
import { getWorkflowTodoViewActionSchema } from './WorkflowTodo';
import { TaskStatusOptions } from '../common/constants';
import { NAMESPACE } from '../locale';
const collections = [nodeCollection, workflowCollection, todoCollection];
const workflowCollection = {
title: `{{t("Workflow", { ns: "workflow" })}}`,
name: 'workflows',
fields: [
{
type: 'string',
name: 'title',
},
],
};
const ManualTaskCountRequestContext = createContext({});
const todoCollection = {
title: `{{t("Workflow todos", { ns: "${NAMESPACE}" })}}`,
name: 'workflowManualTasks',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
},
},
{
type: 'belongsTo',
name: 'workflow',
target: 'workflows',
foreignKey: 'workflowId',
interface: 'm2o',
isAssociation: true,
uiSchema: {
type: 'number',
title: `{{t("Workflow", { ns: "workflow" })}}`,
'x-component': 'AssociationField',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'id',
},
},
},
},
{
type: 'belongsTo',
name: 'user',
target: 'users',
foreignKey: 'userId',
interface: 'm2o',
isAssociation: true,
uiSchema: {
type: 'object',
title: `{{t("Assignee", { ns: "${NAMESPACE}" })}}`,
'x-component': 'AssociationField',
'x-component-props': {
fieldNames: {
label: 'nickname',
value: 'id',
},
},
},
},
{
type: 'integer',
name: 'status',
interface: 'select',
uiSchema: {
type: 'number',
title: `{{t("Status", { ns: "workflow" })}}`,
'x-component': 'Select',
enum: TaskStatusOptions,
},
},
{
name: 'createdAt',
type: 'date',
interface: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {
showTime: true,
},
},
},
{
name: 'updatedAt',
type: 'date',
interface: 'updatedAt',
uiSchema: {
type: 'datetime',
title: '{{t("Last updated at")}}',
'x-component': 'DatePicker',
'x-component-props': {
showTime: true,
},
},
},
],
};
/**
* 1. collection collection
@ -22,36 +122,19 @@ const ManualTaskCountRequestContext = createContext({});
* @returns
*/
export const WorkflowManualProvider: FC = (props) => {
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 collections={[workflowCollection, todoCollection]}>
{props.children}
</ExtendCollectionsProvider>
);
};
export function useCountRequest() {
return useContext(ManualTaskCountRequestContext);
}
/**
* 2. Schema Schema URL
*/
function cacheSchema(collectionNameList: string[]) {
collectionNameList.forEach((collectionName) => {
const defaultOpenMode = isMobile() ? 'drawer' : 'page';
const defaultOpenMode = isMobile() ? 'page' : 'modal';
const workflowTodoViewActionSchema = getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName });
storePopupContext(workflowTodoViewActionSchema['x-uid'], {
@ -61,7 +144,7 @@ function cacheSchema(collectionNameList: string[]) {
});
}
cacheSchema(Object.values(collections).map((collection) => collection.name));
cacheSchema([todoCollection.name]);
function isMobile() {
return window.location.pathname.startsWith('/m/');

View File

@ -7,13 +7,19 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { observer, useField, useFieldSchema, useForm } from '@formily/react';
import { Button, Space, Spin, Tag } from 'antd';
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 React, { createContext, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { get } from 'lodash';
import {
css,
PopupContextProvider,
SchemaInitializerItem,
useCollectionRecordData,
useCompile,
@ -21,221 +27,33 @@ import {
usePlugin,
useSchemaInitializer,
useSchemaInitializerItem,
} from '@nocobase/client';
import {
SchemaComponent,
SchemaComponentContext,
TableBlockProvider,
useAPIClient,
useActionContext,
useCurrentUserContext,
useFormBlockContext,
useTableBlockContext,
List,
OpenModeProvider,
} from '@nocobase/client';
import WorkflowPlugin, {
DetailsBlockProvider,
FlowContext,
JobStatusOptions,
JobStatusOptionsMap,
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 { TableOutlined } from '@ant-design/icons';
import { TaskStatusOptionsMap } from '../common/constants';
export 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',
},
},
],
};
export 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,
},
},
],
};
export 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: 'string',
name: 'title',
uiSchema: {
type: 'string',
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
},
},
{
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) {
function TaskStatusColumn(props) {
const recordData = useCollectionRecordData();
const labelUnprocessed = useLang('Unprocessed');
if (recordData?.execution?.status && !recordData?.status) {
@ -244,102 +62,39 @@ function UserJobStatusColumn(props) {
return props.children;
}
const tableColumns = {
title: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: null,
},
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
properties: {
title: {
'x-component': 'CollectionField',
'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 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<{ columns?: string[] }> & {
export const WorkflowTodo: React.FC & {
Initializer: React.FC;
Drawer: React.FC;
Decorator: React.FC;
TaskBlock: React.FC;
// TaskBlock: React.FC;
} = (props) => {
const { columns = Object.keys(tableColumns) } = props;
const { defaultOpenMode } = useOpenModeContext();
const viewSchema = getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName: 'workflowManualTasks' });
return (
<SchemaComponentContext.Provider value={{ designable: false }}>
<SchemaComponent
scope={{
useCollectionRecordData,
}}
components={{
NodeColumn,
WorkflowColumn,
UserColumn,
UserJobStatusColumn,
FormLayout,
// WorkflowColumn,
// UserColumn,
ContentDetailWithTitle,
}}
schema={{
type: 'void',
@ -363,6 +118,9 @@ export const WorkflowTodo: React.FC<{ columns?: string[] }> & {
'x-component-props': {
icon: 'FilterOutlined',
},
default: {
$and: [{ title: { $includes: '' } }, { 'workflow.title': { $includes: '' } }],
},
'x-align': 'left',
},
refresher: {
@ -381,35 +139,64 @@ export const WorkflowTodo: React.FC<{ columns?: string[] }> & {
},
},
},
table: {
list: {
type: 'array',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
},
'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-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component': 'ActionBar',
'x-use-component-props': 'useListActionBarProps',
'x-component-props': {
width: 60,
layout: 'one-column',
},
title: '{{t("Actions")}}',
'x-align': 'left',
properties: {
view: getWorkflowTodoViewActionSchema({ defaultOpenMode, collectionName: 'users_jobs' }),
view: viewSchema,
},
},
},
},
...columns.reduce((schema, key) => {
schema[key] = tableColumns[key];
return schema;
}, {}),
},
},
},
}}
/>
</SchemaComponentContext.Provider>
);
};
@ -518,7 +305,7 @@ function useSubmit() {
field.data = field.data || {};
field.data.loading = true;
await api.resource('users_jobs').submit({
await api.resource('workflowManualTasks').submit({
filterByTk: userJob.id,
values: {
result: { [formKey]: { ...values, ...assignedValues.values }, _: actionKey },
@ -545,7 +332,7 @@ function FlowContextProvider(props) {
return;
}
api
.resource('users_jobs')
.resource('workflowManualTasks')
.get?.({
filterByTk: id,
appends: ['node', 'job', 'workflow', 'workflow.nodes', 'execution', 'execution.jobs'],
@ -638,19 +425,18 @@ function useDetailsBlockProps() {
function FooterStatus() {
const compile = useCompile();
const { status, updatedAt } = useCollectionRecordData() || {};
const statusOption = JobStatusOptionsMap[status];
const statusOption = TaskStatusOptionsMap[status];
return status ? (
<Space>
<time
<Space
className={css`
margin-bottom: 1em;
time {
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>
<time>{dayjs(updatedAt).format('YYYY-MM-DD HH:mm:ss')}</time>
<Tag color={statusOption.color}>{compile(statusOption.label)}</Tag>
</Space>
) : null;
}
@ -699,8 +485,8 @@ function Drawer() {
function Decorator(props) {
const { params = {}, children } = props;
const blockProps = {
collection: 'users_jobs',
resource: 'users_jobs',
collection: 'workflowManualTasks',
resource: 'workflowManualTasks',
action: 'list',
params: {
pageSize: 20,
@ -712,15 +498,12 @@ function Decorator(props) {
appends: ['user', 'node', 'workflow', 'execution.status'],
except: ['node.config', 'workflow.config', 'workflow.options'],
},
rowKey: 'id',
showIndex: true,
dragSort: false,
};
return (
<TableBlockProvider name="workflow-todo" {...blockProps}>
{children}
</TableBlockProvider>
<OpenModeProvider defaultOpenMode="modal">
<List.Decorator {...blockProps}>{children}</List.Decorator>
</OpenModeProvider>
);
}
@ -754,22 +537,150 @@ function Initializer() {
WorkflowTodo.Initializer = Initializer;
WorkflowTodo.Drawer = Drawer;
WorkflowTodo.Decorator = Decorator;
WorkflowTodo.TaskBlock = TaskBlock;
function TaskBlock() {
const { data: user } = useCurrentUserContext();
function ContentDetail(props) {
const { t } = useTranslation();
const token = useAntdToken();
return (
<SchemaComponent
components={{
WorkflowTodo,
<ConfigProvider
theme={{
token: {
fontSizeLG: 14,
},
}}
>
<Descriptions
{...props}
column={1}
items={[
{
key: 'createdAt',
label: t('Created at'),
children: (
<SchemaComponent
schema={{
name: 'todos',
type: 'void',
'x-decorator': 'WorkflowTodo.Decorator',
'x-decorator-props': {
params: {
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: [
@ -782,15 +693,36 @@ function TaskBlock() {
'execution.id',
'execution.status',
],
},
},
'x-component': 'CardItem',
properties: {
todos: {
};
}
function TodoExtraActions() {
return (
<SchemaComponent
schema={{
name: 'actions',
type: 'void',
'x-component': 'WorkflowTodo',
'x-component': 'ActionBar',
properties: {
refresh: {
type: 'void',
title: '{{ t("Refresh") }}',
'x-component': 'Action',
'x-use-component-props': 'useRefreshActionProps',
'x-component-props': {
columns: ['title', 'workflow', 'node', 'status', 'createdAt'],
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: '' } }],
},
},
},
@ -798,3 +730,11 @@ function TaskBlock() {
/>
);
}
export const manualTodo = {
title: `{{t("My manual tasks", { ns: "${NAMESPACE}" })}}`,
collection: 'workflowManualTasks',
useActionParams: useTodoActionParams,
component: TaskItem,
extraActions: TodoExtraActions,
};

View File

@ -6,4 +6,3 @@
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/

View File

@ -130,7 +130,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// 定义获取2位小数
const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
@ -266,7 +266,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float();
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -401,7 +401,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByTestId('select-single').click();
await page.getByRole('option', { name: '存续' }).click();
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -130,7 +130,7 @@ test.describe('action button', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -271,7 +271,7 @@ test.describe('action button', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Terminate the process' }).click();
@ -412,7 +412,7 @@ test.describe('action button', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Save temporarily' }).click();

View File

@ -130,7 +130,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByTestId('select-multiple').click();
await page.getByRole('option', { name: '软件销售', exact: true }).click();
await page.getByRole('option', { name: '软件开发', exact: true }).click();
@ -273,7 +273,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByLabel('存续').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -407,7 +407,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByLabel('软件销售', { exact: true }).check();
await page.getByLabel('软件开发', { exact: true }).check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -548,7 +548,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = dayjs().format('YYYY-MM-DD');
await page.getByPlaceholder('Select date').click();
await page.getByTitle(manualNodeRecord.toString()).click();

View File

@ -130,7 +130,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -265,7 +265,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -400,7 +400,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -130,7 +130,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.internet.email();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -265,7 +265,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.int();
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -400,7 +400,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.float({ min: 0, max: 999999999, precision: 2 });
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -140,7 +140,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -279,7 +279,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -424,7 +424,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByTestId('select-single').click();
await page.getByRole('option', { name: '存续' }).click();

View File

@ -142,7 +142,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByTestId('select-multiple').click();
await page.getByRole('option', { name: '软件销售', exact: true }).click();
await page.getByRole('option', { name: '软件开发', exact: true }).click();
@ -295,7 +295,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByLabel('存续').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -440,7 +440,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByLabel('软件销售', { exact: true }).check();
await page.getByLabel('软件开发', { exact: true }).check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -585,7 +585,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = dayjs().format('YYYY-MM-DD');
await page.getByPlaceholder('Select date').click();
await page.getByTitle(manualNodeRecord.toString()).click();

View File

@ -136,7 +136,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -275,7 +275,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -414,7 +414,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -136,7 +136,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.internet.email();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -275,7 +275,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.int();
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -414,7 +414,7 @@ test.describe('field data entry', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.float();
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -145,7 +145,7 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(triggerNodeCollectionRecordOne)).toBeAttached();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);
@ -301,11 +301,11 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// await expect(page.getByText('8')).toBeAttached();
await expect(
page
.getByLabel(`block-item-CardItem-users_jobs-${preAggregateNodeTitle}`)
.getByLabel(`block-item-CardItem-workflowManualTasks-${preAggregateNodeTitle}`)
.locator('.ant-card-body')
.getByText('8'),
).toBeAttached();

View File

@ -174,18 +174,22 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: preManualNodeTitle }).getByLabel('action-Action.Link-View-view-').click();
await page
.locator('.ant-list', { hasText: preManualNodeTitle })
.getByLabel('action-Action.Link-View-view-')
.click();
const preManualNodeRecord = triggerNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(preManualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
await page.getByLabel('action-Filter.Action-Filter-filter-users_jobs-workflow-todo').click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Task right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Title' }).click();
await page.getByRole('textbox').fill(manualNodeName);
await page.waitForTimeout(300);
await page.getByLabel('action-Filter.Action-Filter-').click();
// await page.getByText('Add condition', { exact: true }).click();
// await page.getByRole('button', { name: 'Task title' }).click();
// await page.getByRole('menuitemcheckbox', { name: 'Task title' }).click();
await page.getByRole('textbox').first().fill(manualNodeName);
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.waitForTimeout(300);
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(preManualNodeRecord)).toBeAttached();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);
@ -330,18 +334,21 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: preManualNodeTitle }).getByLabel('action-Action.Link-View-view-').click();
await page
.locator('.ant-list', { hasText: preManualNodeTitle })
.getByLabel('action-Action.Link-View-view-')
.click();
const preManualNodeRecord = triggerNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(preManualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
await page.getByLabel('action-Filter.Action-Filter-filter-users_jobs-workflow-todo').click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Task right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Title' }).click();
await page.getByRole('textbox').fill(manualNodeName);
await page.getByLabel('action-Filter.Action-Filter-').click();
// await page.getByText('Add condition', { exact: true }).click();
// await page.getByTestId('select-filter-field').click();
// await page.getByRole('menuitemcheckbox', { name: 'Task title' }).click();
await page.getByRole('textbox').first().fill(manualNodeName);
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.waitForTimeout(300);
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(preManualNodeRecord)).toBeAttached();
const createNodeCollectionData = await apiGetList(preManualNodeCollectionName);
@ -535,18 +542,21 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: preManualNodeTitle }).getByLabel('action-Action.Link-View-view-').click();
await page
.locator('.ant-list', { hasText: preManualNodeTitle })
.getByLabel('action-Action.Link-View-view-')
.click();
const preManualNodeRecord = triggerNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(preManualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
await page.getByLabel('action-Filter.Action-Filter-filter-users_jobs-workflow-todo').click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Task right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Title' }).click();
await page.getByRole('textbox').fill(manualNodeName);
await page.getByLabel('action-Filter.Action-Filter-').click();
// await page.getByText('Add condition', { exact: true }).click();
// await page.getByTestId('select-filter-field').click();
// await page.getByRole('menuitemcheckbox', { name: 'Task title' }).click();
await page.getByRole('textbox').first().fill(manualNodeName);
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.waitForTimeout(300);
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(preManualNodeRecord)).toBeAttached();
const filter = `pageSize=20&page=1&filter={"$and":[{"orgname":{"$eq":"${preManualNodeRecord}"}}]}`;
const createNodeCollectionData = await apiFilterList(preManualNodeCollectionName, filter);

View File

@ -114,7 +114,7 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(triggerNodeCollectionRecordOne)).toBeAttached();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);
@ -222,7 +222,7 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(triggerNodeCollectionRecordOne)).toBeAttached();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);
@ -341,7 +341,7 @@ test.describe('field data', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await expect(page.getByText(triggerNodeCollectionRecordOne)).toBeAttached();
// 4、后置处理删除工作流
await apiDeleteWorkflow(workflowId);

View File

@ -181,7 +181,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -360,7 +360,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByRole('checkbox').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -539,7 +539,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByTestId('select-single').click();
await page.getByRole('option', { name: '存续' }).click();
@ -719,7 +719,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByTestId('select-multiple').click();
await page.getByRole('option', { name: '软件销售', exact: true }).click();

View File

@ -173,7 +173,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
// const manualNodeRecord = faker.number.float({ min: 0, max: 100, precision: 2 });
await page.getByLabel('存续').check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -352,7 +352,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.getByLabel('软件销售', { exact: true }).check();
await page.getByLabel('软件开发', { exact: true }).check();
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -537,7 +537,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = dayjs().format('YYYY-MM-DD');
await page.getByPlaceholder('Select date').click();
await page.getByTitle(manualNodeRecord.toString()).click();

View File

@ -157,7 +157,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -320,7 +320,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -483,7 +483,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = manualNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -157,7 +157,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.internet.email();
await page.getByRole('textbox').fill(manualNodeRecord);
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -320,7 +320,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.int();
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();
@ -483,7 +483,7 @@ test.describe('field data update', () => {
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.locator('tr', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
await page.locator('.ant-list', { hasText: manualNodeName }).getByLabel('action-Action.Link-View-view-').click();
const manualNodeRecord = faker.number.float();
await page.getByRole('spinbutton').fill(manualNodeRecord.toString());
await page.getByRole('button', { name: 'Continue the process' }).click();

View File

@ -121,12 +121,12 @@ test('filter task node', async ({ page, mockPage, mockCollections, mockRecords }
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.getByLabel('action-Filter.Action-Filter-filter-users_jobs-workflow-todo').click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Task right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Title' }).click();
await page.getByRole('textbox').fill(manualNodeName);
await page.getByLabel('action-Filter.Action-Filter-filter-').click();
// await page.getByText('Add condition', { exact: true }).click();
// await page.getByTestId('select-filter-field').click();
// await page.getByRole('menuitemcheckbox', { name: 'Task right' }).click();
// await page.getByRole('menuitemcheckbox', { name: 'Title' }).click();
await page.getByRole('textbox').first().fill(manualNodeName);
await page.getByRole('button', { name: 'Submit' }).click();
// 3、预期结果列表中出现筛选的工作流
@ -235,12 +235,12 @@ test('filter workflow name', async ({ page, mockPage, mockCollections, mockRecor
await page.getByRole('menuitem', { name: 'check-square Workflow todos' }).click();
await page.mouse.move(300, 0, { steps: 100 });
await page.waitForTimeout(300);
await page.getByLabel('action-Filter.Action-Filter-filter-users_jobs-workflow-todo').click();
await page.getByText('Add condition', { exact: true }).click();
await page.getByTestId('select-filter-field').click();
await page.getByRole('menuitemcheckbox', { name: 'Workflow right' }).click();
await page.getByRole('menuitemcheckbox', { name: 'Name' }).click();
await page.getByRole('textbox').fill(workFlowName);
await page.getByLabel('action-Filter.Action-Filter-filter-').click();
// await page.getByText('Add condition', { exact: true }).click();
// await page.getByTestId('select-filter-field').click();
// await page.getByRole('menuitemcheckbox', { name: 'Workflow right' }).click();
// await page.getByRole('menuitemcheckbox', { name: 'Name' }).click();
await page.getByRole('textbox').last().fill(workFlowName);
await page.getByRole('button', { name: 'Submit' }).click();
// 3、预期结果列表中出现筛选的工作流

View File

@ -13,8 +13,8 @@ import WorkflowPlugin from '@nocobase/plugin-workflow/client';
import Manual from './instruction';
import { NAMESPACE } from '../locale';
import { useCountRequest, WorkflowManualProvider } from './WorkflowManualProvider';
import { WorkflowTodo } from './WorkflowTodo';
import { WorkflowManualProvider } from './WorkflowManualProvider';
import { manualTodo, WorkflowTodo } from './WorkflowTodo';
import {
addActionButton,
addActionButton_deprecated,
@ -22,6 +22,7 @@ import {
addBlockButton_deprecated,
} from './instruction/SchemaConfig';
import { addCustomFormField, addCustomFormField_deprecated } from './instruction/forms/custom';
import { MANUAL_TASK_TYPE } from '../common/constants';
export default class extends Plugin {
async afterAdd() {
@ -37,11 +38,7 @@ export default class extends Plugin {
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,
});
workflow.registerTaskType(MANUAL_TASK_TYPE, manualTodo);
this.app.schemaInitializerManager.add(addBlockButton_deprecated);
this.app.schemaInitializerManager.add(addBlockButton);

View File

@ -445,13 +445,13 @@ export function SchemaConfig({ value, onChange }) {
type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'Form',
'x-component': 'Action.Drawer',
'x-component': 'Action.Container',
'x-component-props': {
className: css`
.ant-drawer-body {
background: var(--nb-box-bg);
}
`,
// className: css`
// .ant-drawer-body {
// background: var(--nb-box-bg);
// }
// `,
// Using ref to call refresh ensures accessing the latest refresh function
onClose: () => refreshRef.current(),
},

View File

@ -18,6 +18,7 @@ import {
CollectionBlockInitializer,
Instruction,
WorkflowVariableTextArea,
useNodeContext,
} from '@nocobase/plugin-workflow/client';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
@ -149,6 +150,7 @@ export default class extends Instruction {
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableTextArea',
description: `{{t("Title of each task item. Default to node title.", { ns: "${NAMESPACE}" })}}`,
default: '{{useNodeContext().title}}',
},
schema: {
type: 'void',
@ -168,6 +170,9 @@ export default class extends Instruction {
default: {},
},
};
scope = {
useNodeContext,
};
components = {
SchemaConfigButton,
SchemaConfig,

View File

@ -0,0 +1,41 @@
/**
* 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.
*/
export const NAMESPACE = 'workflow-manual';
export const MANUAL_TASK_TYPE = 'manual';
export const TASK_STATUS = {
PENDING: 0,
RESOLVED: 1,
REJECTED: -1,
};
export const TaskStatusOptions = [
{
value: TASK_STATUS.PENDING,
label: `{{t("Pending", { ns: "workflow" })}}`,
color: 'gold',
},
{
value: TASK_STATUS.RESOLVED,
label: `{{t("Resolved", { ns: "workflow" })}}`,
color: 'green',
},
{
value: TASK_STATUS.REJECTED,
label: `{{t("Rejected", { ns: "workflow" })}}`,
color: 'red',
},
];
export const TaskStatusOptionsMap = TaskStatusOptions.reduce(
(map, item) => Object.assign(map, { [item.value]: item }),
{},
);

View File

@ -8,8 +8,9 @@
*/
import { useTranslation } from 'react-i18next';
import { NAMESPACE } from '../common/constants';
export const NAMESPACE = 'workflow-manual';
export { NAMESPACE };
export function useLang(key: string, options = {}) {
const { t } = usePluginTranslation(options);

View File

@ -118,9 +118,9 @@ export default class extends Instruction {
}
const title = config.title ? processor.getParsedValue(config.title, node.id) : node.title;
// NOTE: batch create users jobs
const UserJobModel = this.workflow.app.db.getModel('users_jobs');
await UserJobModel.bulkCreate(
assignees.map((userId) => ({
const TaskRepo = this.workflow.app.db.getRepository('workflowManualTasks');
await TaskRepo.createMany({
records: assignees.map((userId) => ({
userId,
jobId: job.id,
nodeId: node.id,
@ -129,10 +129,8 @@ export default class extends Instruction {
status: JOB_STATUS.PENDING,
title,
})),
{
transaction: processor.mainTransaction,
},
);
});
return job;
}
@ -141,15 +139,15 @@ export default class extends Instruction {
// NOTE: check all users jobs related if all done then continue as parallel
const { mode } = node.config as ManualConfig;
const UserJobRepo = this.workflow.app.db.getRepository('users_jobs');
const jobs = await UserJobRepo.find({
const TaskRepo = this.workflow.app.db.getRepository('workflowManualTasks');
const tasks = await TaskRepo.find({
where: {
jobId: job.id,
},
transaction: processor.mainTransaction,
});
const assignees = [];
const distributionMap = jobs.reduce((result, item) => {
const distributionMap = tasks.reduce((result, item) => {
if (result[item.status] == null) {
result[item.status] = 0;
}
@ -162,9 +160,9 @@ export default class extends Instruction {
count: distributionMap[status],
}));
const submitted = jobs.reduce((count, item) => (item.status !== JOB_STATUS.PENDING ? count + 1 : count), 0);
const submitted = tasks.reduce((count, item) => (item.status !== JOB_STATUS.PENDING ? count + 1 : count), 0);
const status = job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING);
const result = mode ? (submitted || 0) / assignees.length : job.latestUserJob?.result ?? job.result;
const result = mode ? (submitted || 0) / assignees.length : job.latestTask?.result ?? job.result;
processor.logger.debug(`manual resume job and next status: ${status}`);
job.set({
status,

View File

@ -15,11 +15,20 @@ import WorkflowPlugin, { JOB_STATUS } from '@nocobase/plugin-workflow';
import * as jobActions from './actions';
import ManualInstruction from './ManualInstruction';
import { MANUAL_TASK_TYPE } from '../common/constants';
interface WorkflowManualTaskModel {
id: number;
userId: number;
workflowId: number;
executionId: number;
status: number;
}
export default class extends Plugin {
async load() {
this.app.resourceManager.define({
name: 'users_jobs',
name: 'workflowManualTasks',
actions: {
list: {
filter: {
@ -41,9 +50,22 @@ export default class extends Plugin {
},
});
this.app.acl.allow('users_jobs', ['list', 'get', 'submit', 'countMine'], 'loggedIn');
this.app.acl.allow('workflowManualTasks', ['list', 'get', 'submit'], 'loggedIn');
const workflowPlugin = this.app.pm.get(WorkflowPlugin) as WorkflowPlugin;
workflowPlugin.registerInstruction('manual', ManualInstruction);
this.db.on('workflowManualTasks.afterSave', async (task: WorkflowManualTaskModel, options) => {
await workflowPlugin.toggleTaskStatus(
{
type: MANUAL_TASK_TYPE,
key: `${task.id}`,
userId: task.userId,
workflowId: task.workflowId,
},
Boolean(task.status),
options,
);
});
}
}

View File

@ -37,7 +37,7 @@ describe('workflow > instructions > manual > assignees', () => {
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
UserJobModel = db.getModel('workflowManualTasks');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
@ -117,13 +117,13 @@ describe('workflow > instructions > manual > assignees', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await agent.resource('users_jobs').submit({
const res1 = await agent.resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: { result: { f1: {}, _: 'resolve' } },
});
expect(res1.status).toBe(401);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: {}, _: 'resolve' },
@ -131,7 +131,7 @@ describe('workflow > instructions > manual > assignees', () => {
});
expect(res2.status).toBe(403);
const res3 = await userAgents[0].resource('users_jobs').submit({
const res3 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -150,7 +150,7 @@ describe('workflow > instructions > manual > assignees', () => {
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res4 = await userAgents[0].resource('users_jobs').submit({
const res4 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2 }, _: 'resolve' },

View File

@ -37,7 +37,7 @@ describe('workflow > instructions > manual', () => {
PostRepo = db.getCollection('posts').repository;
AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts');
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
UserJobModel = db.getModel('workflowManualTasks');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
@ -80,13 +80,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't1' }, _: 'resolve' },
@ -130,13 +130,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't1' }, _: 'pending' },
@ -155,7 +155,7 @@ describe('workflow > instructions > manual', () => {
const c1 = await AnotherPostRepo.find();
expect(c1.length).toBe(0);
const res2 = await userAgents[0].resource('users_jobs').submit({
const res2 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't2' }, _: 'resolve' },
@ -201,13 +201,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't2' }, _: 'resolve' },

View File

@ -37,7 +37,7 @@ describe('workflow > instructions > manual', () => {
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
UserJobModel = db.getModel('workflowManualTasks');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
@ -85,7 +85,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -123,7 +123,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 } },
@ -167,7 +167,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -217,7 +217,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'reject' },
@ -267,7 +267,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'save' },
@ -323,7 +323,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].jobId).toBe(j1.id);
const now = new Date();
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2, id: 3 }, _: 'resolve' },
@ -371,13 +371,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { number: 1 }, _: 'resolve' },
@ -420,13 +420,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { number: 1 }, _: 'pending' },
@ -442,7 +442,7 @@ describe('workflow > instructions > manual', () => {
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toMatchObject({ f1: { number: 1 } });
const res2 = await userAgents[0].resource('users_jobs').submit({
const res2 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f2: { number: 2 }, _: 'pending' },
@ -461,7 +461,7 @@ describe('workflow > instructions > manual', () => {
f2: { number: 2 },
});
const res3 = await userAgents[0].resource('users_jobs').submit({
const res3 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f2: { number: 3 }, _: 'resolve' },
@ -500,13 +500,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { status: 1 }, _: 'resolve' },
@ -549,13 +549,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { status: 1 }, _: 'pending' },
@ -574,7 +574,7 @@ describe('workflow > instructions > manual', () => {
const c1 = await CommentRepo.find();
expect(c1.length).toBe(0);
const res2 = await userAgents[0].resource('users_jobs').submit({
const res2 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { status: 1 }, _: 'resolve' },
@ -615,13 +615,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(1);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { title: 't2' }, _: 'resolve' },

View File

@ -37,7 +37,7 @@ describe('workflow > instructions > manual', () => {
PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository;
UserModel = db.getCollection('users').model;
UserJobModel = db.getModel('users_jobs');
UserJobModel = db.getModel('workflowManualTasks');
users = await UserModel.bulkCreate([
{ id: 2, nickname: 'a' },
@ -87,13 +87,13 @@ describe('workflow > instructions > manual', () => {
expect(usersJobs[0].userId).toBe(users[0].id);
expect(usersJobs[0].jobId).toBe(j1.id);
const res1 = await agent.resource('users_jobs').submit({
const res1 = await agent.resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: { result: { f1: {}, _: 'resolve' } },
});
expect(res1.status).toBe(401);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: {}, _: 'resolve' },
@ -101,7 +101,7 @@ describe('workflow > instructions > manual', () => {
});
expect(res2.status).toBe(403);
const res3 = await userAgents[0].resource('users_jobs').submit({
const res3 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -120,7 +120,7 @@ describe('workflow > instructions > manual', () => {
expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED);
expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res4 = await userAgents[0].resource('users_jobs').submit({
const res4 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].id,
values: {
result: { f1: { a: 2 }, _: 'resolve' },
@ -151,9 +151,13 @@ describe('workflow > instructions > manual', () => {
const [j1] = await pending.getJobs();
expect(j1.status).toBe(JOB_STATUS.PENDING);
const usersJobs = await j1.getUsersJobs();
const usersJobs = await UserJobModel.findAll({
where: {
jobId: j1.id,
},
});
const res1 = await userAgents[1].resource('users_jobs').submit({
const res1 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: usersJobs.find((item) => item.userId === users[1].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -167,7 +171,7 @@ describe('workflow > instructions > manual', () => {
expect(j2.status).toBe(JOB_STATUS.RESOLVED);
expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' });
const res2 = await userAgents[0].resource('users_jobs').submit({
const res2 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs.find((item) => item.userId === users[0].id).id,
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -176,7 +180,7 @@ describe('workflow > instructions > manual', () => {
expect(res2.status).toBe(400);
});
it('also could submit to users_jobs api', async () => {
it('also could submit to workflowManualTasks api', async () => {
const n1 = await workflow.createNode({
type: 'manual',
config: {
@ -193,13 +197,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const usersJobs = await UserJobModel.findAll();
expect(usersJobs.length).toBe(1);
expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING);
expect(usersJobs[0].get('userId')).toBe(users[0].id);
const res = await userAgents[0].resource('users_jobs').submit({
const res = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: usersJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -236,13 +240,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -262,7 +266,7 @@ describe('workflow > instructions > manual', () => {
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 2 }, _: 'resolve' },
@ -297,13 +301,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -323,7 +327,7 @@ describe('workflow > instructions > manual', () => {
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -353,13 +357,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -379,7 +383,7 @@ describe('workflow > instructions > manual', () => {
});
expect(usersJobs1.length).toBe(2);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -419,13 +423,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -441,7 +445,7 @@ describe('workflow > instructions > manual', () => {
expect(j1.status).toBe(JOB_STATUS.RESOLVED);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -471,13 +475,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -493,7 +497,7 @@ describe('workflow > instructions > manual', () => {
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 1 }, _: 'resolve' },
@ -528,13 +532,13 @@ describe('workflow > instructions > manual', () => {
await sleep(500);
const UserJobModel = db.getModel('users_jobs');
const UserJobModel = db.getModel('workflowManualTasks');
const pendingJobs = await UserJobModel.findAll({
order: [['userId', 'ASC']],
});
expect(pendingJobs.length).toBe(2);
const res1 = await userAgents[0].resource('users_jobs').submit({
const res1 = await userAgents[0].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[0].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },
@ -550,7 +554,7 @@ describe('workflow > instructions > manual', () => {
expect(j1.status).toBe(JOB_STATUS.PENDING);
expect(j1.result).toBe(0.5);
const res2 = await userAgents[1].resource('users_jobs').submit({
const res2 = await userAgents[1].resource('workflowManualTasks').submit({
filterByTk: pendingJobs[1].get('id'),
values: {
result: { f1: { a: 0 }, _: 'reject' },

View File

@ -24,7 +24,7 @@ export async function submit(context: Context, next) {
const plugin: WorkflowPlugin = context.app.getPlugin(WorkflowPlugin);
const instruction = plugin.instructions.get('manual') as ManualInstruction;
const userJob = await repository.findOne({
const task = await repository.findOne({
filterByTk,
// filter: {
// userId: currentUser?.id
@ -33,40 +33,40 @@ export async function submit(context: Context, next) {
context,
});
if (!userJob) {
if (!task) {
return context.throw(404);
}
const { forms = {} } = userJob.node.config;
const { forms = {} } = task.node.config;
const [formKey] = Object.keys(values.result ?? {}).filter((key) => key !== '_');
const actionKey = values.result?._;
const actionItem = forms[formKey]?.actions?.find((item) => item.key === actionKey);
// NOTE: validate status
if (
userJob.status !== JOB_STATUS.PENDING ||
userJob.job.status !== JOB_STATUS.PENDING ||
userJob.execution.status !== EXECUTION_STATUS.STARTED ||
!userJob.workflow.enabled ||
task.status !== JOB_STATUS.PENDING ||
task.job.status !== JOB_STATUS.PENDING ||
task.execution.status !== EXECUTION_STATUS.STARTED ||
!task.workflow.enabled ||
!actionKey ||
actionItem?.status == null
) {
return context.throw(400);
}
userJob.execution.workflow = userJob.workflow;
const processor = plugin.createProcessor(userJob.execution);
task.execution.workflow = task.workflow;
const processor = plugin.createProcessor(task.execution);
await processor.prepare();
// NOTE: validate assignee
const assignees = processor
.getParsedValue(userJob.node.config.assignees ?? [], userJob.nodeId)
.getParsedValue(task.node.config.assignees ?? [], task.nodeId)
.flat()
.filter(Boolean);
if (!assignees.includes(currentUser.id) || userJob.userId !== currentUser.id) {
if (!assignees.includes(currentUser.id) || task.userId !== currentUser.id) {
return context.throw(403);
}
const presetValues = processor.getParsedValue(actionItem.values ?? {}, userJob.nodeId, {
const presetValues = processor.getParsedValue(actionItem.values ?? {}, task.nodeId, {
additionalScope: {
// @deprecated
currentUser: currentUser,
@ -82,56 +82,32 @@ export async function submit(context: Context, next) {
},
});
userJob.set({
task.set({
status: actionItem.status,
result: actionItem.status
? { [formKey]: Object.assign(values.result[formKey], presetValues), _: actionKey }
: Object.assign(userJob.result ?? {}, values.result),
: Object.assign(task.result ?? {}, values.result),
});
const handler = instruction.formTypes.get(forms[formKey].type);
if (handler && userJob.status) {
await handler.call(instruction, userJob, forms[formKey], processor);
if (handler && task.status) {
await handler.call(instruction, task, forms[formKey], processor);
}
await userJob.save();
await task.save();
await processor.exit();
context.body = userJob;
context.body = task;
context.status = 202;
await next();
userJob.job.execution = userJob.execution;
userJob.job.latestUserJob = userJob;
task.job.execution = task.execution;
task.job.latestTask = task;
// NOTE: resume the process and no `await` for quick returning
processor.logger.info(`manual node (${userJob.nodeId}) action trigger execution (${userJob.execution.id}) to resume`);
processor.logger.info(`manual node (${task.nodeId}) action trigger execution (${task.execution.id}) to resume`);
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();
plugin.resume(task.job);
}

View File

@ -1,28 +0,0 @@
/**
* 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 { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'jobs',
fields: [
{
type: 'belongsToMany',
name: 'users',
through: 'users_jobs',
},
{
type: 'hasMany',
name: 'usersJobs',
target: 'users_jobs',
foreignKey: 'jobId',
onDelete: 'CASCADE',
},
],
});

View File

@ -1,26 +0,0 @@
/**
* 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 { extendCollection } from '@nocobase/database';
export default extendCollection({
name: 'users',
fields: [
{
type: 'belongsToMany',
name: 'jobs',
through: 'users_jobs',
},
{
type: 'hasMany',
name: 'usersJobs',
target: 'users_jobs',
},
],
});

View File

@ -8,9 +8,10 @@
*/
import { defineCollection } from '@nocobase/database';
import { NAMESPACE } from '../../common/constants';
export default defineCollection({
name: 'users_jobs',
name: 'workflowManualTasks',
dumpRules: {
group: 'log',
},
@ -40,6 +41,12 @@ export default defineCollection({
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
},
},
{
type: 'belongsTo',
@ -53,6 +60,20 @@ export default defineCollection({
{
type: 'belongsTo',
name: 'workflow',
target: 'workflows',
foreignKey: 'workflowId',
interface: 'm2o',
uiSchema: {
type: 'object',
title: `{{t("Workflow", { ns: "workflow" })}}`,
'x-component': 'AssociationField',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'id',
},
},
},
},
{
type: 'integer',

View File

@ -14,7 +14,7 @@ export default class extends Migration {
async up() {
const { db } = this.context;
const NodeRepo = db.getRepository('flow_nodes');
const TaskRepo = db.getRepository('users_jobs');
const TaskRepo = db.getRepository('workflowManualTasks');
await db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {

View File

@ -0,0 +1,48 @@
/**
* 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 { Migration } from '@nocobase/server';
export default class extends Migration {
appVersion = '<1.6.0';
on = 'beforeLoad';
async up() {
const { db } = this.context;
const queryInterface = db.sequelize.getQueryInterface();
await db.sequelize.transaction(async (transaction) => {
const exists = await queryInterface.tableExists('users_jobs', { transaction });
if (exists) {
const newTableName = db.options.underscored ? 'workflow_manual_tasks' : 'workflowManualTasks';
await queryInterface.renameTable('users_jobs', newTableName, { transaction });
const indexes: any = await queryInterface.showIndex(newTableName, { transaction });
for (const item of indexes) {
if (item.name.startsWith('users_jobs')) {
if (this.db.isPostgresCompatibleDialect()) {
await db.sequelize.query(
`ALTER INDEX "${item.name}" RENAME TO "${item.name.replace('users_jobs', 'workflow_manual_tasks')}";`,
{ transaction },
);
} else if (this.db.isMySQLCompatibleDialect()) {
await db.sequelize.query(
`ALTER TABLE ${newTableName} RENAME INDEX ${item.name} TO ${item.name.replace(
'users_jobs',
'workflow_manual_tasks',
)};`,
{ transaction },
);
}
}
}
}
});
}
}

View File

@ -0,0 +1,42 @@
/**
* 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 { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
import { Migration } from '@nocobase/server';
import { MANUAL_TASK_TYPE } from '../../common/constants';
export default class extends Migration {
appVersion = '<1.6.0';
async up() {
const { db } = this.context;
const WorkflowTaskModel = db.getModel('workflowTasks');
const WorkflowManualTaskRepo = db.getRepository('workflowManualTasks');
await db.sequelize.transaction(async (transaction) => {
const tasks = await WorkflowManualTaskRepo.find({
filter: {
status: JOB_STATUS.PENDING,
'execution.status': EXECUTION_STATUS.STARTED,
'workflow.enabled': true,
},
transaction,
});
await WorkflowTaskModel.bulkCreate(
tasks.map((item) => ({
type: MANUAL_TASK_TYPE,
key: `${item.id}`,
userId: item.userId,
workflowId: item.workflowId,
})),
{
transaction,
},
);
});
}
}

View File

@ -169,7 +169,9 @@ export class ApprovalPassthroughModeNode {
this.node = page.getByLabel(`Approval-${nodeName}`, { exact: true });
this.nodeTitle = page.getByLabel(`Approval-${nodeName}`, { exact: true }).getByRole('textbox');
this.nodeConfigure = this.node.locator('>div').first();
this.addAssigneesButton = page.getByRole('button', { name: 'plus Add' });
this.addAssigneesButton = page
.getByLabel('block-item-ArrayItems-workflows-Assignees')
.getByRole('button', { name: 'plus Add' });
this.addSelectAssigneesMenu = page.getByRole('button', { name: 'Select assignees' });
this.addQueryAssigneesMenu = page.getByRole('button', { name: 'Query assignees' });
this.assigneesDropDown = page.getByTestId('select-single');
@ -183,14 +185,14 @@ export class ApprovalPassthroughModeNode {
this.sequentiallyRadio = page.getByLabel('Sequentially', { exact: true });
this.goToconfigureButton = page.getByRole('button', { name: 'Go to configure' });
this.addBlockButton = page.getByLabel('schema-initializer-Grid-ApprovalProcessAddBlockButton-workflows');
this.addDetailsMenu = page.getByRole('menuitem', { name: 'Details' });
this.addDetailsMenu = page.getByRole('menuitem', { name: 'Original application content' });
this.detailsConfigureFieldsButton = page.getByLabel(
`schema-initializer-Grid-details:configureFields-${collectionName}`,
);
this.addActionsMenu = page.getByRole('menuitem', { name: 'Actions' }).getByRole('switch');
this.addActionsMenu = page.getByRole('menuitem', { name: 'Process form' }).getByRole('switch');
this.actionsConfigureFieldsButton = page.getByLabel('schema-initializer-Grid-FormItemInitializers-approvalRecords');
this.actionsConfigureActionsButton = page.getByLabel(
'schema-initializer-ActionBar-ApprovalProcessAddActionButton-approvalRecords',
'schema-initializer-ActionBar-ApprovalProcessAddActionButton-',
);
this.addApproveButton = page.getByRole('menuitem', { name: 'Approve' }).getByRole('switch');
this.addRejectButton = page.getByRole('menuitem', { name: 'Reject' }).getByRole('switch');
@ -240,7 +242,9 @@ export class ApprovalBranchModeNode {
this.node = page.getByLabel(`Approval-${nodeName}`, { exact: true });
this.nodeTitle = page.getByLabel(`Approval-${nodeName}`, { exact: true }).getByRole('textbox');
this.nodeConfigure = this.node.locator('>div').first();
this.addAssigneesButton = page.getByRole('button', { name: 'plus Add' });
this.addAssigneesButton = page
.getByLabel('block-item-ArrayItems-workflows-Assignees')
.getByRole('button', { name: 'plus Add' });
this.addSelectAssigneesMenu = page.getByRole('button', { name: 'Select assignees' });
this.addQueryAssigneesMenu = page.getByRole('button', { name: 'Query assignees' });
this.assigneesDropDown = page.getByTestId('select-single');
@ -260,9 +264,7 @@ export class ApprovalBranchModeNode {
);
this.addActionsMenu = page.getByRole('menuitem', { name: 'Process form' }).getByRole('switch');
this.actionsConfigureFieldsButton = page.getByLabel('schema-initializer-Grid-FormItemInitializers-approvalRecords');
this.actionsConfigureActionsButton = page.getByLabel(
'schema-initializer-ActionBar-ApprovalProcessAddActionButton-approvalRecords',
);
this.actionsConfigureActionsButton = page.getByLabel('schema-initializer-ActionBar-');
this.addApproveButton = page.getByRole('menuitem', { name: 'Approve' }).getByRole('switch');
this.addRejectButton = page.getByRole('menuitem', { name: 'Reject' }).getByRole('switch');
this.addReturnButton = page.getByRole('menuitem', { name: 'Return' }).getByRole('switch');

View File

@ -415,7 +415,7 @@ export const apiGetWorkflowNodeExecutions = async (id: number) => {
const state = await api.storageState();
const headers = getHeaders(state);
const url = `/api/executions:list?appends[]=jobs&filter[workflowId]=${id}&fields=id,createdAt,updatedAt,key,status,workflowId,jobs`;
const url = `/api/executions:list?appends[]=jobs&filter[workflowId]=${id}&fields=id,createdAt,updatedAt,key,status,workflowId`;
const result = await api.get(url, {
headers,
});

View File

@ -6,9 +6,9 @@
* 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 React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button, Layout, Menu, Spin, Badge, theme, Tooltip } from 'antd';
import { Button, Layout, Menu, Badge, Tooltip, Tabs } from 'antd';
import { PageHeader } from '@ant-design/pro-layout';
import { CheckCircleOutlined } from '@ant-design/icons';
import classnames from 'classnames';
@ -16,17 +16,31 @@ import classnames from 'classnames';
import {
css,
PinnedPluginListProvider,
SchemaComponent,
SchemaComponentContext,
SchemaComponentOptions,
useApp,
useCompile,
useDocumentTitle,
usePlugin,
useRequest,
useToken,
} from '@nocobase/client';
import PluginWorkflowClient from '.';
import { lang } from './locale';
import { lang, NAMESPACE } from './locale';
const layoutClass = css`
height: 100%;
overflow: hidden;
`;
const sideClass = css`
height: calc(100vh - 46px);
overflow: auto;
position: sticky;
top: 0;
bottom: 0;
height: 100%;
.ant-layout-sider-children {
width: 200px;
@ -34,21 +48,29 @@ const sideClass = css`
}
`;
const contentClass = css`
padding: 24px;
min-height: 280px;
overflow: auto;
`;
export interface TaskTypeOptions {
title: string;
useCountRequest?: Function;
component?: React.ComponentType;
children?: TaskTypeOptions[];
collection: string;
useActionParams: Function;
component: React.ComponentType;
extraActions?: React.ComponentType;
// children?: TaskTypeOptions[];
}
const TasksCountsContext = createContext<{ counts: Record<string, number>; total: number }>({ counts: {}, total: 0 });
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]);
const { title } = workflowPlugin.taskTypes.get(type);
const { counts } = useContext(TasksCountsContext);
const typeTitle = compile(title);
return (
<Link
@ -57,24 +79,72 @@ function MenuLink({ type }: any) {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
overflow: hidden;
> span:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
`}
>
<span>{compile(title)}</span>
{loading ? <Spin /> : <Badge count={data?.data || 0} />}
<span>{typeTitle}</span>
<Badge count={counts[type] || 0} size="small" />
</Link>
);
}
export function WorkflowTasks() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const navigate = useNavigate();
const { taskType } = useParams();
const compile = useCompile();
const {
token: { colorBgContainer },
} = theme.useToken();
const TASK_STATUS = {
ALL: 'all',
PENDING: 'pending',
COMPLETED: 'completed',
};
const items = useMemo(
function StatusTabs() {
const navigate = useNavigate();
const { taskType, status = TASK_STATUS.PENDING } = useParams();
const type = useCurrentTaskType();
const { extraActions: ExtraActions } = type;
return (
<Tabs
activeKey={status}
onChange={(activeKey) => {
navigate(`/admin/workflow/tasks/${taskType}/${activeKey}`);
}}
className={css`
&.ant-tabs-top > .ant-tabs-nav {
margin-bottom: 0;
}
`}
items={[
{
key: TASK_STATUS.PENDING,
label: lang('Pending'),
},
{
key: TASK_STATUS.COMPLETED,
label: lang('Completed'),
},
{
key: TASK_STATUS.ALL,
label: lang('All'),
},
]}
tabBarExtraContent={
ExtraActions
? {
right: <ExtraActions />,
}
: {}
}
/>
);
}
function useTaskTypeItems() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
return useMemo(
() =>
Array.from(workflowPlugin.taskTypes.getKeys()).map((key: string) => {
return {
@ -84,37 +154,143 @@ export function WorkflowTasks() {
}),
[workflowPlugin.taskTypes],
);
}
const { title, component: Component } = useMemo<any>(
function useCurrentTaskType() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const { taskType } = useParams();
const items = useTaskTypeItems();
return useMemo<any>(
() => workflowPlugin.taskTypes.get(taskType ?? items[0]?.key) ?? {},
[items, taskType, workflowPlugin.taskTypes],
);
}
export function WorkflowTasks() {
const compile = useCompile();
const { setTitle } = useDocumentTitle();
const navigate = useNavigate();
const { taskType, status = TASK_STATUS.PENDING } = useParams();
const {
token: { colorBgContainer },
} = useToken();
const items = useTaskTypeItems();
const { title, collection, useActionParams, component: Component } = useCurrentTaskType();
const params = useActionParams(status);
useEffect(() => {
if (!taskType && items[0].key) {
navigate(`/admin/workflow/tasks/${items[0].key}`, { replace: true });
}
}, [items, navigate, taskType]);
setTitle?.(`${lang('Workflow todo')}${title ? `: ${compile(title)}` : ''}`);
}, [taskType, status, setTitle, title, compile]);
const key = taskType ?? items[0].key;
useEffect(() => {
if (!taskType) {
navigate(`/admin/workflow/tasks/${items[0].key}/${status}`, { replace: true });
}
}, [items, navigate, status, taskType]);
const typeKey = taskType ?? items[0].key;
return (
<Layout>
<Layout className={layoutClass}>
<Layout.Sider className={sideClass} theme="light">
<Menu mode="inline" selectedKeys={[key]} items={items} style={{ height: '100%' }} />
<Menu mode="inline" selectedKeys={[typeKey]} 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 }}>
<Layout
className={css`
> div {
height: 100%;
overflow: hidden;
> .ant-formily-layout {
height: 100%;
> div {
display: flex;
flex-direction: column;
height: 100%;
}
}
}
`}
>
<SchemaComponentContext.Provider value={{ designable: false }}>
{Component ? <Component /> : null}
<SchemaComponent
components={{
Layout,
PageHeader,
StatusTabs,
}}
schema={{
name: `${taskType}-${status}`,
type: 'void',
'x-decorator': 'List.Decorator',
'x-decorator-props': {
collection,
action: 'list',
params: {
pageSize: 20,
sort: ['-createdAt'],
...params,
},
},
properties: {
header: {
type: 'void',
'x-component': 'PageHeader',
'x-component-props': {
className: classnames('pageHeaderCss'),
style: {
background: colorBgContainer,
padding: '12px 24px 0 24px',
},
title,
},
properties: {
tabs: {
type: 'void',
'x-component': 'StatusTabs',
},
},
},
content: {
type: 'void',
'x-component': 'Layout.Content',
'x-component-props': {
className: contentClass,
},
properties: {
list: {
type: 'array',
'x-component': 'List',
'x-component-props': {
className: css`
> .itemCss:not(:last-child) {
border-bottom: none;
}
`,
locale: {
emptyText: `{{ t("No data yet", { ns: "${NAMESPACE}" }) }}`,
},
},
properties: {
item: {
type: 'object',
'x-decorator': 'List.Item',
'x-component': Component,
'x-read-pretty': true,
},
},
},
},
},
},
}}
/>
<Outlet />
</SchemaComponentContext.Provider>
</Layout.Content>
</Layout>
</Layout>
);
@ -122,40 +298,78 @@ export function WorkflowTasks() {
function WorkflowTasksLink() {
const workflowPlugin = usePlugin(PluginWorkflowClient);
const { total } = useContext(TasksCountsContext);
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">
<Button>
<Link to={`/admin/workflow/tasks/${types[0]}`}>
<Badge count={total} size="small">
<CheckCircleOutlined />
</Badge>
</Link>
</Button>
</Tooltip>
) : null;
}
function transform(detail) {
return detail.reduce((result, stats) => {
result[stats.type] = stats.count;
return result;
}, {});
}
export const TasksProvider = (props: any) => {
const app = useApp();
const [counts, setCounts] = useState<Record<string, number>>({});
const onTaskUpdate = useCallback(({ detail = [] }: CustomEvent) => {
setCounts((prev) => {
return {
...prev,
...transform(detail),
};
});
}, []);
const { runAsync } = useRequest(
{
resource: 'workflowTasks',
action: 'countMine',
},
{
manual: true,
},
);
useEffect(() => {
runAsync()
.then((res) => {
setCounts((prev) => {
return {
...prev,
...transform(res['data']),
};
});
})
.catch((err) => {
console.error(err);
});
}, [runAsync]);
useEffect(() => {
app.eventBus.addEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
return () => {
app.eventBus.removeEventListener('ws:message:workflow:tasks:updated', onTaskUpdate);
};
}, [app.eventBus, onTaskUpdate]);
const total = Object.values(counts).reduce((a, b) => a + b, 0) || 0;
return (
<TasksCountsContext.Provider value={{ total, counts }}>
<PinnedPluginListProvider
items={{
todo: { component: 'WorkflowTasksLink', pin: true, snippet: '*' },
@ -169,5 +383,6 @@ export const TasksProvider = (props: any) => {
{props.children}
</SchemaComponentOptions>
</PinnedPluginListProvider>
</TasksCountsContext.Provider>
);
};

View File

@ -0,0 +1,23 @@
/**
* 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 from 'react';
import { Tooltip } from 'antd';
import { lang } from '../locale';
export function WorkflowTitle(workflow) {
return workflow.enabled ? (
workflow.title
) : (
<Tooltip title={lang('New version enabled')}>
<span style={{ textDecoration: 'line-through' }}>{workflow.title}</span>
</Tooltip>
);
}

View File

@ -19,3 +19,4 @@ export * from './renderEngineReference';
export * from './Calculation';
export * from './Fieldset';
export * from './TriggerCollectionRecordSelect';
export * from './WorkflowTitle';

View File

@ -120,12 +120,12 @@ export default class PluginWorkflowClient extends Plugin {
});
this.router.add('admin.workflow.tasks', {
path: '/admin/workflow/tasks/:taskType?',
path: '/admin/workflow/tasks/:taskType/:status?',
Component: WorkflowTasks,
});
this.router.add('admin.workflow.tasks.popup', {
path: '/admin/workflow/tasks/:taskType/popups/*',
path: '/admin/workflow/tasks/:taskType/:status/popups/*',
Component: PagePopups,
});

View File

@ -133,6 +133,8 @@
"Canceled": "已取消",
"Rejected": "已拒绝",
"Retry needed": "需重试",
"Completed": "已完成",
"All": "全部",
"View result": "查看结果",
"Triggered but still waiting in queue to execute.": "已触发但仍在队列中等待执行。",
@ -231,5 +233,6 @@
"After end of branches": "分支结束后",
"Inside of branch": "分支内",
"Workflow todos": "流程待办"
"Workflow todos": "流程待办",
"New version enabled": "已启用新版本"
}

View File

@ -16,8 +16,9 @@ import LRUCache from 'lru-cache';
import { Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils';
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
import { Logger, LoggerOptions } from '@nocobase/logger';
import Processor from './Processor';
import initActions from './actions';
import { EXECUTION_STATUS } from './constants';
@ -34,10 +35,9 @@ import DestroyInstruction from './instructions/DestroyInstruction';
import QueryInstruction from './instructions/QueryInstruction';
import UpdateInstruction from './instructions/UpdateInstruction';
import type { ExecutionModel, JobModel, WorkflowModel } from './types';
import type { ExecutionModel, JobModel, WorkflowModel, WorkflowTaskModel } from './types';
import WorkflowRepository from './repositories/WorkflowRepository';
import { Context } from '@nocobase/actions';
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
import WorkflowTasksRepository from './repositories/WorkflowTasksRepository';
type ID = number | string;
@ -213,6 +213,7 @@ export default class PluginWorkflowServer extends Plugin {
async beforeLoad() {
this.db.registerRepositories({
WorkflowRepository,
WorkflowTasksRepository,
});
}
@ -262,6 +263,7 @@ export default class PluginWorkflowServer extends Plugin {
actions: ['workflows:list'],
});
this.app.acl.allow('workflowTasks', 'countMine', 'loggedIn');
this.app.acl.allow('*', ['trigger'], 'loggedIn');
this.db.addMigrations({
@ -733,4 +735,45 @@ export default class PluginWorkflowServer extends Plugin {
return db.sequelize.transaction();
}
}
/**
* @experimental
*/
public async toggleTaskStatus(task: WorkflowTaskModel, done: boolean, { transaction }: Transactionable) {
const { db } = this.app;
const repository = db.getRepository('workflowTasks') as WorkflowTasksRepository;
if (done) {
await repository.destroy({
filter: {
type: task.type,
key: `${task.key}`,
},
transaction,
});
} else {
await repository.updateOrCreate({
filterKeys: ['key', 'type'],
values: task,
transaction,
});
}
// NOTE:
// 1. `ws` not works in backend test cases for now.
// 2. `userId` here for compatibility of no user approvals (deprecated).
if (task.userId) {
const counts =
(await repository.countAll({
where: {
userId: task.userId,
},
transaction,
})) || [];
this.app.emit('ws:sendToTag', {
tagKey: 'userId',
tagValue: `${task.userId}`,
message: { type: 'workflow:tasks:updated', payload: counts },
});
}
}
}

View File

@ -10,6 +10,7 @@
import * as workflows from './workflows';
import * as nodes from './nodes';
import * as executions from './executions';
import * as workflowTasks from './workflowTasks';
function make(name, mod) {
return Object.keys(mod).reduce(
@ -33,5 +34,6 @@ export default function ({ app }) {
test: nodes.test,
}),
...make('executions', executions),
...make('workflowTasks', workflowTasks),
});
}

View File

@ -0,0 +1,24 @@
/**
* 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 { Context, utils } from '@nocobase/actions';
import WorkflowTasksRepository from '../repositories/WorkflowTasksRepository';
export async function countMine(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as WorkflowTasksRepository;
context.body =
(await repository.countAll({
where: {
userId: context.state.currentUser.id,
},
})) || [];
next();
}

View File

@ -8,14 +8,14 @@
*/
import actions, { Context, utils } from '@nocobase/actions';
import { Op, Repository } from '@nocobase/database';
import { Op } from '@nocobase/database';
import Plugin from '../Plugin';
import Processor from '../Processor';
import WorkflowRepository from '../repositories/WorkflowRepository';
export async function update(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as Repository;
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
const { filterByTk, values } = context.action.params;
context.action.mergeParams({
whitelist: ['title', 'description', 'enabled', 'triggerTitle', 'config', 'options'],
@ -31,7 +31,7 @@ export async function update(context: Context, next) {
}
export async function destroy(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as Repository;
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
const { filterByTk, filter } = context.action.params;
await context.db.sequelize.transaction(async (transaction) => {

View File

@ -0,0 +1,44 @@
/**
* 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 { CollectionOptions } from '@nocobase/database';
export default function () {
return {
dumpRules: 'required',
migrationRules: ['overwrite', 'schema-only'],
name: 'workflowTasks',
shared: true,
repository: 'WorkflowTasksRepository',
fields: [
{
name: 'user',
type: 'belongsTo',
},
{
name: 'workflow',
type: 'belongsTo',
},
{
type: 'string',
name: 'type',
},
{
type: 'string',
name: 'key',
},
],
indexes: [
{
unique: true,
fields: ['type', 'key'],
},
],
} as CollectionOptions;
}

View File

@ -24,8 +24,14 @@ export default function () {
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
title: '{{t("Name")}}',
type: 'string',
'x-component': 'Input',
required: true,
},
},
{
type: 'boolean',
name: 'enabled',

View File

@ -0,0 +1,21 @@
/**
* 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 { Repository } from '@nocobase/database';
export default class WorkflowTasksRepository extends Repository {
async countAll(options) {
const db = this.database;
return this.collection.model.findAll({
...options,
attributes: ['type', [db.sequelize.fn('COUNT', db.sequelize.col('type')), 'count']],
group: ['type'],
});
}
}

View File

@ -0,0 +1,17 @@
/**
* 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.
*/
export default interface WorkflowTaskModel {
type: string;
key: string;
userId: number;
workflowId: number;
}

View File

@ -11,3 +11,4 @@ export type { default as WorkflowModel } from './Workflow';
export type { default as FlowNodeModel } from './FlowNode';
export type { default as ExecutionModel } from './Execution';
export type { default as JobModel } from './Job';
export type { default as WorkflowTaskModel } from './WorkflowTask';

View File

@ -426,9 +426,9 @@ export default {
},
},
},
'/users_jobs:list': {
'/workflowManualTasks:list': {
get: {
tags: ['users_jobs'],
tags: ['workflowManualTasks'],
description: 'List manual jobs',
parameters: [],
responses: {
@ -449,9 +449,9 @@ export default {
},
},
},
'/users_jobs:get': {
'/workflowManualTasks:get': {
get: {
tags: ['users_jobs'],
tags: ['workflowManualTasks'],
description: 'Single user job',
parameters: [],
responses: {
@ -480,9 +480,9 @@ export default {
},
},
},
'/users_jobs:submit': {
'/workflowManualTasks:submit': {
post: {
tags: ['users_jobs'],
tags: ['workflowManualTasks'],
description: '',
parameters: [
{