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', type: 'belongsTo',
name: 'node', name: 'node',
@ -236,6 +245,21 @@ function UserJobStatusColumn(props) {
} }
const tableColumns = { 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: { workflow: {
type: 'void', type: 'void',
'x-decorator': 'TableV2.Column.Decorator', '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: { status: {
type: 'void', type: 'void',
'x-decorator': 'TableV2.Column.Decorator', 'x-decorator': 'TableV2.Column.Decorator',
@ -764,8 +773,6 @@ function TaskBlock() {
userId: user?.data?.id, userId: user?.data?.id,
}, },
appends: [ appends: [
'node.id',
'node.title',
'job.id', 'job.id',
'job.status', 'job.status',
'job.result', 'job.result',
@ -783,7 +790,7 @@ function TaskBlock() {
type: 'void', type: 'void',
'x-component': 'WorkflowTodo', 'x-component': 'WorkflowTodo',
'x-component-props': { 'x-component-props': {
columns: ['workflow', 'node', 'status', 'createdAt'], columns: ['title', 'workflow', 'node', 'status', 'createdAt'],
}, },
}, },
}, },

View File

@ -14,6 +14,7 @@ import {
getCollectionFieldOptions, getCollectionFieldOptions,
CollectionBlockInitializer, CollectionBlockInitializer,
Instruction, Instruction,
WorkflowVariableTextArea,
} from '@nocobase/plugin-workflow/client'; } from '@nocobase/plugin-workflow/client';
import { SchemaConfig, SchemaConfigButton } from './SchemaConfig'; import { SchemaConfig, SchemaConfigButton } from './SchemaConfig';
@ -70,6 +71,42 @@ function useVariables({ key, title, config }, { types, fieldNames = defaultField
: null; : null;
} }
function useInitializers(node): SchemaInitializerItemType | null {
const { getCollection } = useCollectionManager_deprecated();
const formKeys = Object.keys(node.config.forms ?? {});
if (!formKeys.length || node.config.mode) {
return null;
}
const forms = formKeys
.map((formKey) => {
const form = node.config.forms[formKey];
const { fields = [] } = getCollection(form.collection);
return fields.length
? ({
name: form.title ?? formKey,
type: 'item',
title: form.title ?? formKey,
Component: CollectionBlockInitializer,
collection: form.collection,
dataPath: `$jobsMapByNodeKey.${node.key}.${formKey}`,
} as SchemaInitializerItemType)
: null;
})
.filter(Boolean);
return forms.length
? {
name: `#${node.id}`,
key: 'forms',
type: 'subMenu',
title: node.title,
children: forms,
}
: null;
}
export default class extends Instruction { export default class extends Instruction {
title = `{{t("Manual", { ns: "${NAMESPACE}" })}}`; title = `{{t("Manual", { ns: "${NAMESPACE}" })}}`;
type = 'manual'; type = 'manual';
@ -102,6 +139,13 @@ export default class extends Instruction {
}, },
}, },
}, },
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: { schema: {
type: 'void', type: 'void',
title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`, title: `{{t("User interface", { ns: "${NAMESPACE}" })}}`,
@ -125,44 +169,10 @@ export default class extends Instruction {
SchemaConfig, SchemaConfig,
ModeConfig, ModeConfig,
AssigneesSelect, AssigneesSelect,
WorkflowVariableTextArea,
}; };
useVariables = useVariables; useVariables = useVariables;
useInitializers(node): SchemaInitializerItemType | null { useInitializers = useInitializers;
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getCollection } = useCollectionManager_deprecated();
const formKeys = Object.keys(node.config.forms ?? {});
if (!formKeys.length || node.config.mode) {
return null;
}
const forms = formKeys
.map((formKey) => {
const form = node.config.forms[formKey];
const { fields = [] } = getCollection(form.collection);
return fields.length
? ({
name: form.title ?? formKey,
type: 'item',
title: form.title ?? formKey,
Component: CollectionBlockInitializer,
collection: form.collection,
dataPath: `$jobsMapByNodeKey.${node.key}.${formKey}`,
} as SchemaInitializerItemType)
: null;
})
.filter(Boolean);
return forms.length
? {
name: `#${node.id}`,
key: 'forms',
type: 'subMenu',
title: node.title,
children: forms,
}
: null;
}
isAvailable({ engine, workflow, upstream, branchIndex }) { isAvailable({ engine, workflow, upstream, branchIndex }) {
return !engine.isWorkflowSync(workflow); return !engine.isWorkflowSync(workflow);
} }

View File

@ -29,5 +29,7 @@
"Task node": "任务节点", "Task node": "任务节点",
"Unprocessed": "未处理", "Unprocessed": "未处理",
"Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。", "Please check one of your update record form, and add at least one filter condition in form settings.": "请检查您的其中的更新数据表单,并在表单设置中至少添加一个筛选条件。",
"My manual tasks": "我的人工任务" "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 }; forms: { [key: string]: FormType };
assignees?: (number | string)[]; assignees?: (number | string)[];
mode?: number; mode?: number;
title?: string;
} }
const MULTIPLE_ASSIGNED_MODE = { const MULTIPLE_ASSIGNED_MODE = {
@ -115,7 +116,7 @@ export default class extends Instruction {
if (!assignees.length) { if (!assignees.length) {
return job; return job;
} }
const title = config.title ? processor.getParsedValue(config.title, node.id) : node.title;
// NOTE: batch create users jobs // NOTE: batch create users jobs
const UserJobModel = this.workflow.app.db.getModel('users_jobs'); const UserJobModel = this.workflow.app.db.getModel('users_jobs');
await UserJobModel.bulkCreate( await UserJobModel.bulkCreate(
@ -126,9 +127,10 @@ export default class extends Instruction {
executionId: job.executionId, executionId: job.executionId,
workflowId: node.workflowId, workflowId: node.workflowId,
status: JOB_STATUS.PENDING, 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) { async resume(node, job, processor: Processor) {
// NOTE: check all users jobs related if all done then continue as parallel // NOTE: check all users jobs related if all done then continue as parallel
const { mode } = node.config as ManualConfig; 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 UserJobRepo = this.workflow.app.db.getRepository('users_jobs');
const distribution = await UserJobModel.count({ const jobs = await UserJobRepo.find({
where: { where: {
jobId: job.id, jobId: job.id,
}, },
group: ['status'], transaction: processor.mainTransaction,
// transaction: processor.transaction,
}); });
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( const submitted = jobs.reduce((count, item) => (item.status !== JOB_STATUS.PENDING ? count + 1 : count), 0);
(count, item) => (item.status !== JOB_STATUS.PENDING ? count + item.count : count),
0,
);
const status = job.status || (getMode(mode).getStatus(distribution, assignees) ?? JOB_STATUS.PENDING); 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.latestUserJob?.result ?? job.result;
processor.logger.debug(`manual resume job and next status: ${status}`); processor.logger.debug(`manual resume job and next status: ${status}`);

View File

@ -37,6 +37,10 @@ export default defineCollection({
foreignKey: 'userId', foreignKey: 'userId',
primaryKey: false, primaryKey: false,
}, },
{
type: 'string',
name: 'title',
},
{ {
type: 'belongsTo', type: 'belongsTo',
name: 'execution', 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. * 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 Trigger from '..';
import type Plugin from '../../Plugin'; import type Plugin from '../../Plugin';
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger'; import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';