feat(plugin-workflow-manual): add task title column (#6051)

* feat(plugin-workflow-manual): add task title column

* fix(plugin-workflow-manual): fix distribution and assignee logic
This commit is contained in:
Junyi 2025-01-16 10:26:27 +08:00 committed by GitHub
parent 63ffe1a99d
commit f26e501172
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 144 additions and 68 deletions

View File

@ -132,6 +132,15 @@ export const todoCollection = {
},
},
},
{
type: 'string',
name: 'title',
uiSchema: {
type: 'string',
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
'x-component': 'Input',
},
},
{
type: 'belongsTo',
name: 'node',
@ -236,6 +245,21 @@ function UserJobStatusColumn(props) {
}
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',
@ -251,21 +275,6 @@ const tableColumns = {
},
},
},
node: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
'x-component': 'TableV2.Column',
'x-component-props': {
width: null,
},
title: `{{t("Task node", { ns: "${NAMESPACE}" })}}`,
properties: {
node: {
'x-component': 'NodeColumn',
'x-read-pretty': true,
},
},
},
status: {
type: 'void',
'x-decorator': 'TableV2.Column.Decorator',
@ -764,8 +773,6 @@ function TaskBlock() {
userId: user?.data?.id,
},
appends: [
'node.id',
'node.title',
'job.id',
'job.status',
'job.result',
@ -783,7 +790,7 @@ function TaskBlock() {
type: 'void',
'x-component': 'WorkflowTodo',
'x-component-props': {
columns: ['workflow', 'node', 'status', 'createdAt'],
columns: ['title', 'workflow', 'node', 'status', 'createdAt'],
},
},
},

View File

@ -14,6 +14,7 @@ import {
getCollectionFieldOptions,
CollectionBlockInitializer,
Instruction,
WorkflowVariableTextArea,
} from '@nocobase/plugin-workflow/client';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
@ -70,65 +71,7 @@ function useVariables({ key, title, config }, { types, fieldNames = defaultField
: null;
}
export default class extends Instruction {
title = `{{t("Manual", { ns: "${NAMESPACE}" })}}`;
type = 'manual';
group = 'manual';
description = `{{t("Could be used for manually submitting data, and determine whether to continue or exit. Workflow will generate a todo item for assigned user when it reaches a manual node, and continue processing after user submits the form.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
assignees: {
type: 'array',
title: `{{t("Assignees", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'AssigneesSelect',
'x-component-props': {
// multiple: true,
},
required: true,
default: [],
},
mode: {
type: 'number',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'ModeConfig',
default: 1,
'x-reactions': {
dependencies: ['assignees'],
fulfill: {
state: {
visible: '{{$deps[0].length > 1}}',
},
},
},
},
schema: {
type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'SchemaConfigButton',
properties: {
schema: {
type: 'object',
'x-component': 'SchemaConfig',
default: null,
},
},
},
forms: {
type: 'object',
default: {},
},
};
components = {
SchemaConfigButton,
SchemaConfig,
ModeConfig,
AssigneesSelect,
};
useVariables = useVariables;
useInitializers(node): SchemaInitializerItemType | null {
// eslint-disable-next-line react-hooks/rules-of-hooks
function useInitializers(node): SchemaInitializerItemType | null {
const { getCollection } = useCollectionManager_deprecated();
const formKeys = Object.keys(node.config.forms ?? {});
if (!formKeys.length || node.config.mode) {
@ -163,6 +106,73 @@ export default class extends Instruction {
}
: null;
}
export default class extends Instruction {
title = `{{t("Manual", { ns: "${NAMESPACE}" })}}`;
type = 'manual';
group = 'manual';
description = `{{t("Could be used for manually submitting data, and determine whether to continue or exit. Workflow will generate a todo item for assigned user when it reaches a manual node, and continue processing after user submits the form.", { ns: "${NAMESPACE}" })}}`;
fieldset = {
assignees: {
type: 'array',
title: `{{t("Assignees", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'AssigneesSelect',
'x-component-props': {
// multiple: true,
},
required: true,
default: [],
},
mode: {
type: 'number',
title: `{{t("Mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'ModeConfig',
default: 1,
'x-reactions': {
dependencies: ['assignees'],
fulfill: {
state: {
visible: '{{$deps[0].length > 1}}',
},
},
},
},
title: {
type: 'string',
title: `{{t("Task title", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'WorkflowVariableTextArea',
description: `{{t("Title of each task item. Default to node title.", { ns: "${NAMESPACE}" })}}`,
},
schema: {
type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'SchemaConfigButton',
properties: {
schema: {
type: 'object',
'x-component': 'SchemaConfig',
default: null,
},
},
},
forms: {
type: 'object',
default: {},
},
};
components = {
SchemaConfigButton,
SchemaConfig,
ModeConfig,
AssigneesSelect,
WorkflowVariableTextArea,
};
useVariables = useVariables;
useInitializers = useInitializers;
isAvailable({ engine, workflow, upstream, branchIndex }) {
return !engine.isWorkflowSync(workflow);
}

View File

@ -29,5 +29,7 @@
"Task node": "任务节点",
"Unprocessed": "未处理",
"Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。",
"My manual tasks": "我的人工任务"
"My manual tasks": "我的人工任务",
"Task title": "任务名称",
"Title of each task item. Default to node title.": "每项任务的名称。默认为节点名称。"
}

View File

@ -25,6 +25,7 @@ export interface ManualConfig {
forms: { [key: string]: FormType };
assignees?: (number | string)[];
mode?: number;
title?: string;
}
const MULTIPLE_ASSIGNED_MODE = {
@ -115,7 +116,7 @@ export default class extends Instruction {
if (!assignees.length) {
return job;
}
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(
@ -126,9 +127,10 @@ export default class extends Instruction {
executionId: job.executionId,
workflowId: node.workflowId,
status: JOB_STATUS.PENDING,
title,
})),
{
// transaction: processor.transaction,
transaction: processor.mainTransaction,
},
);
@ -138,21 +140,29 @@ export default class extends Instruction {
async resume(node, job, processor: Processor) {
// NOTE: check all users jobs related if all done then continue as parallel
const { mode } = node.config as ManualConfig;
const assignees = [...new Set(processor.getParsedValue(node.config.assignees, node.id).flat().filter(Boolean))];
const UserJobModel = this.workflow.app.db.getModel('users_jobs');
const distribution = await UserJobModel.count({
const UserJobRepo = this.workflow.app.db.getRepository('users_jobs');
const jobs = await UserJobRepo.find({
where: {
jobId: job.id,
},
group: ['status'],
// transaction: processor.transaction,
transaction: processor.mainTransaction,
});
const assignees = [];
const distributionMap = jobs.reduce((result, item) => {
if (result[item.status] == null) {
result[item.status] = 0;
}
result[item.status] += 1;
assignees.push(item.userId);
return result;
}, {});
const distribution = Object.keys(distributionMap).map((status) => ({
status: Number.parseInt(status, 10),
count: distributionMap[status],
}));
const submitted = distribution.reduce(
(count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count),
0,
);
const submitted = jobs.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;
processor.logger.debug(`manual resume job and next status: ${status}`);

View File

@ -37,6 +37,10 @@ export default defineCollection({
foreignKey: 'userId',
primaryKey: false,
},
{
type: 'string',
name: 'title',
},
{
type: 'belongsTo',
name: 'execution',

View File

@ -0,0 +1,45 @@
/**
* 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-alpha.13';
async up() {
const { db } = this.context;
const NodeRepo = db.getRepository('flow_nodes');
const TaskRepo = db.getRepository('users_jobs');
await db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {
type: 'manual',
},
transaction,
});
await nodes.reduce(
(promise, node) =>
promise.then(() => {
return TaskRepo.update({
filter: {
nodeId: node.id,
},
values: {
title: node.title,
},
individualHooks: false,
silent: true,
transaction,
});
}),
Promise.resolve(),
);
});
}
}

View File

@ -7,8 +7,6 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Context } from '@nocobase/actions';
import { parseCollectionName } from '@nocobase/data-source-manager';
import Trigger from '..';
import type Plugin from '../../Plugin';
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';