mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-02 03:02:19 +08:00
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:
parent
63ffe1a99d
commit
f26e501172
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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.": "每项任务的名称。默认为节点名称。"
|
||||
}
|
||||
|
@ -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}`);
|
||||
|
@ -37,6 +37,10 @@ export default defineCollection({
|
||||
foreignKey: 'userId',
|
||||
primaryKey: false,
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
},
|
||||
{
|
||||
type: 'belongsTo',
|
||||
name: 'execution',
|
||||
|
@ -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(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user