mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-07 22:49:26 +08:00
feat(plugin-workflow): add manual execute workflow (#5664)
* feat(plugin-workflow): add manual execute workflow * refactor(plugin-workflow): adjust ui and type * feat(plugin-workflow-action-trigger): add manually execute * fix(plugin-workflow): keep trigger action in workflows for action trigger * fix(plugin-workflow): fix type * fix(plugin-workflow): collection trigger transaction * fix(plugin-workflow): fix type * test(plugin-workflow): skip failed test case * fix(plugin-workflow): fix transaction * fix(plugin-workflow): fix schedule mode field bug * fix(plugin-workflow): collection trigger executing error * fix(plugin-workflow-action-trigger): fix payload and appends * fix(plugin-workflow): skip changed logic when execute * fix(plugin-workflow): fix collection field schedule context when execute manually * refactor(plugin-workflow): change manually option name * fix(plugin-workflow-action-trigger): fix test case
This commit is contained in:
parent
484eb28877
commit
45b8a56eb7
@ -18,6 +18,7 @@ import {
|
|||||||
CheckboxGroupWithTooltip,
|
CheckboxGroupWithTooltip,
|
||||||
RadioWithTooltip,
|
RadioWithTooltip,
|
||||||
useGetCollectionFields,
|
useGetCollectionFields,
|
||||||
|
TriggerCollectionRecordSelect,
|
||||||
} from '@nocobase/plugin-workflow/client';
|
} from '@nocobase/plugin-workflow/client';
|
||||||
import { NAMESPACE, useLang } from '../locale';
|
import { NAMESPACE, useLang } from '../locale';
|
||||||
|
|
||||||
@ -194,6 +195,55 @@ export default class extends Trigger {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
triggerFieldset = {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
description: `{{t("Choose a record of the collection to trigger.", { ns: "workflow" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'TriggerCollectionRecordSelect',
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
userId: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("User submitted action", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'nickname',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'users',
|
||||||
|
},
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
roleName: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Role of user submitted action", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RemoteSelect',
|
||||||
|
'x-component-props': {
|
||||||
|
fieldNames: {
|
||||||
|
label: 'title',
|
||||||
|
value: 'name',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
resource: 'roles',
|
||||||
|
},
|
||||||
|
manual: false,
|
||||||
|
},
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
validate(values) {
|
||||||
|
return values.collection;
|
||||||
|
}
|
||||||
scope = {
|
scope = {
|
||||||
useCollectionDataSource,
|
useCollectionDataSource,
|
||||||
useWorkflowAnyExecuted,
|
useWorkflowAnyExecuted,
|
||||||
@ -201,6 +251,7 @@ export default class extends Trigger {
|
|||||||
components = {
|
components = {
|
||||||
RadioWithTooltip,
|
RadioWithTooltip,
|
||||||
CheckboxGroupWithTooltip,
|
CheckboxGroupWithTooltip,
|
||||||
|
TriggerCollectionRecordSelect,
|
||||||
};
|
};
|
||||||
isActionTriggerable = (config, context) => {
|
isActionTriggerable = (config, context) => {
|
||||||
return !config.global && ['submit', 'customize:save', 'customize:update'].includes(context.buttonAction);
|
return !config.global && ['submit', 'customize:save', 'customize:update'].includes(context.buttonAction);
|
||||||
|
@ -7,13 +7,13 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { get } from 'lodash';
|
import { get, pick } from 'lodash';
|
||||||
import { BelongsTo, HasOne } from 'sequelize';
|
import { BelongsTo, HasOne } from 'sequelize';
|
||||||
import { Model, modelAssociationByKey } from '@nocobase/database';
|
import { Model, modelAssociationByKey } from '@nocobase/database';
|
||||||
import Application, { DefaultContext } from '@nocobase/server';
|
import Application, { DefaultContext } from '@nocobase/server';
|
||||||
import { Context as ActionContext, Next } from '@nocobase/actions';
|
import { Context as ActionContext, Next } from '@nocobase/actions';
|
||||||
|
|
||||||
import WorkflowPlugin, { Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow';
|
import WorkflowPlugin, { EventOptions, Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow';
|
||||||
import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager';
|
import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager';
|
||||||
|
|
||||||
interface Context extends ActionContext, DefaultContext {}
|
interface Context extends ActionContext, DefaultContext {}
|
||||||
@ -185,7 +185,46 @@ export default class extends Trigger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on(workflow: WorkflowModel) {}
|
async execute(workflow: WorkflowModel, context: Context, options: EventOptions) {
|
||||||
|
const { values } = context.action.params;
|
||||||
|
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
|
||||||
|
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
|
||||||
|
const { filterTargetKey, repository } = collectionManager.getCollection(collectionName);
|
||||||
|
const filterByTk = Array.isArray(filterTargetKey)
|
||||||
|
? pick(
|
||||||
|
values.data,
|
||||||
|
filterTargetKey.sort((a, b) => a.localeCompare(b)),
|
||||||
|
)
|
||||||
|
: values.data[filterTargetKey];
|
||||||
|
const UserRepo = context.app.db.getRepository('users');
|
||||||
|
const actor = await UserRepo.findOne({
|
||||||
|
filterByTk: values.userId,
|
||||||
|
appends: ['roles'],
|
||||||
|
});
|
||||||
|
if (!actor) {
|
||||||
|
throw new Error('user not found');
|
||||||
|
}
|
||||||
|
const { roles, ...user } = actor.desensitize().get();
|
||||||
|
const roleName = values.roleName || roles?.[0]?.name;
|
||||||
|
|
||||||
off(workflow: WorkflowModel) {}
|
let { data } = values;
|
||||||
|
if (workflow.config.appends?.length) {
|
||||||
|
data = await repository.findOne({
|
||||||
|
filterByTk,
|
||||||
|
appends: workflow.config.appends,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.workflow.trigger(
|
||||||
|
workflow,
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
user,
|
||||||
|
roleName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
httpContext: context,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { omit } from 'lodash';
|
||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { EXECUTION_STATUS } from '@nocobase/plugin-workflow';
|
import { EXECUTION_STATUS } from '@nocobase/plugin-workflow';
|
||||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||||
@ -22,12 +23,14 @@ describe('workflow > action-trigger', () => {
|
|||||||
let CategoryRepo;
|
let CategoryRepo;
|
||||||
let WorkflowModel;
|
let WorkflowModel;
|
||||||
let UserRepo;
|
let UserRepo;
|
||||||
|
let root;
|
||||||
|
let rootAgent;
|
||||||
let users;
|
let users;
|
||||||
let userAgents;
|
let userAgents;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = await getApp({
|
app = await getApp({
|
||||||
plugins: ['users', 'auth', Plugin],
|
plugins: ['users', 'auth', 'acl', 'data-source-manager', 'system-settings', Plugin],
|
||||||
});
|
});
|
||||||
await app.pm.get('auth').install();
|
await app.pm.get('auth').install();
|
||||||
agent = app.agent();
|
agent = app.agent();
|
||||||
@ -37,6 +40,9 @@ describe('workflow > action-trigger', () => {
|
|||||||
CategoryRepo = db.getCollection('categories').repository;
|
CategoryRepo = db.getCollection('categories').repository;
|
||||||
UserRepo = db.getCollection('users').repository;
|
UserRepo = db.getCollection('users').repository;
|
||||||
|
|
||||||
|
root = await UserRepo.findOne({});
|
||||||
|
rootAgent = app.agent().login(root);
|
||||||
|
|
||||||
users = await UserRepo.create({
|
users = await UserRepo.create({
|
||||||
values: [
|
values: [
|
||||||
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] },
|
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] },
|
||||||
@ -293,6 +299,9 @@ describe('workflow > action-trigger', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
describe('directly trigger', () => {
|
describe('directly trigger', () => {
|
||||||
it('no collection configured should not be triggered', async () => {
|
it('no collection configured should not be triggered', async () => {
|
||||||
const workflow = await WorkflowModel.create({
|
const workflow = await WorkflowModel.create({
|
||||||
@ -509,6 +518,40 @@ describe('workflow > action-trigger', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('manually execute', () => {
|
||||||
|
it('root execute', async () => {
|
||||||
|
const w1 = await WorkflowModel.create({
|
||||||
|
type: 'action',
|
||||||
|
config: {
|
||||||
|
collection: 'posts',
|
||||||
|
appends: ['category'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1 = await PostRepo.create({
|
||||||
|
values: { title: 't1', category: { title: 'c1' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { category, ...data } = p1.toJSON();
|
||||||
|
const res1 = await rootAgent.resource('workflows').execute({
|
||||||
|
filterByTk: w1.id,
|
||||||
|
values: {
|
||||||
|
data,
|
||||||
|
userId: users[1].id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res1.status).toBe(200);
|
||||||
|
expect(res1.body.data.execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
const [e1] = await w1.getExecutions();
|
||||||
|
expect(e1.id).toBe(res1.body.data.execution.id);
|
||||||
|
expect(e1.context.data).toMatchObject({ id: data.id, categoryId: category.id, category: { title: 'c1' } });
|
||||||
|
expect(e1.context.user).toMatchObject(
|
||||||
|
omit(users[1].toJSON(), ['createdAt', 'updatedAt', 'createdById', 'updatedById']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('workflow key', () => {
|
describe('workflow key', () => {
|
||||||
it('revision', async () => {
|
it('revision', async () => {
|
||||||
const w1 = await WorkflowModel.create({
|
const w1 = await WorkflowModel.create({
|
||||||
|
@ -15,7 +15,7 @@ import { MockClusterOptions, MockServer, createMockCluster, createMockServer, mo
|
|||||||
import functions from './functions';
|
import functions from './functions';
|
||||||
import triggers from './triggers';
|
import triggers from './triggers';
|
||||||
import instructions from './instructions';
|
import instructions from './instructions';
|
||||||
import { SequelizeDataSource } from '@nocobase/data-source-manager';
|
import { SequelizeCollectionManager, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||||
import { uid } from '@nocobase/utils';
|
import { uid } from '@nocobase/utils';
|
||||||
export { sleep } from '@nocobase/test';
|
export { sleep } from '@nocobase/test';
|
||||||
|
|
||||||
@ -70,8 +70,8 @@ export async function getApp({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const another = app.dataSourceManager.dataSources.get('another');
|
const another = app.dataSourceManager.dataSources.get('another');
|
||||||
// @ts-ignore
|
|
||||||
const anotherDB = another.collectionManager.db;
|
const anotherDB = (another.collectionManager as SequelizeCollectionManager).db;
|
||||||
|
|
||||||
await anotherDB.import({
|
await anotherDB.import({
|
||||||
directory: path.resolve(__dirname, 'collections'),
|
directory: path.resolve(__dirname, 'collections'),
|
||||||
|
@ -111,8 +111,13 @@ function JobModal() {
|
|||||||
'x-component': 'Input.JSON',
|
'x-component': 'Input.JSON',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
className: styles.nodeJobResultClass,
|
className: styles.nodeJobResultClass,
|
||||||
|
autoSize: {
|
||||||
|
minRows: 4,
|
||||||
|
maxRows: 32,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'x-read-pretty': true,
|
// 'x-read-pretty': true,
|
||||||
|
'x-disabled': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -152,7 +157,7 @@ function ExecutionsDropdown(props) {
|
|||||||
setExecutionsBefore(data.data);
|
setExecutionsBefore(data.data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [execution]);
|
}, [execution.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!execution) {
|
if (!execution) {
|
||||||
@ -175,7 +180,7 @@ function ExecutionsDropdown(props) {
|
|||||||
setExecutionsAfter(data.data.reverse());
|
setExecutionsAfter(data.data.reverse());
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [execution]);
|
}, [execution.id]);
|
||||||
|
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
({ key }) => {
|
({ key }) => {
|
||||||
@ -183,7 +188,7 @@ function ExecutionsDropdown(props) {
|
|||||||
navigate(getWorkflowExecutionsPath(key));
|
navigate(getWorkflowExecutionsPath(key));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[execution],
|
[execution.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return execution ? (
|
return execution ? (
|
||||||
|
@ -7,32 +7,44 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { Alert, App, Breadcrumb, Button, Dropdown, Result, Spin, Switch, Tag, Tooltip } from 'antd';
|
||||||
import { DownOutlined, EllipsisOutlined, RightOutlined } from '@ant-design/icons';
|
import { DownOutlined, EllipsisOutlined, RightOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
ActionContextProvider,
|
ActionContextProvider,
|
||||||
ResourceActionProvider,
|
ResourceActionProvider,
|
||||||
SchemaComponent,
|
SchemaComponent,
|
||||||
cx,
|
cx,
|
||||||
|
useActionContext,
|
||||||
useApp,
|
useApp,
|
||||||
|
useCancelAction,
|
||||||
useDocumentTitle,
|
useDocumentTitle,
|
||||||
|
useNavigateNoUpdate,
|
||||||
useResourceActionContext,
|
useResourceActionContext,
|
||||||
useResourceContext,
|
useResourceContext,
|
||||||
|
useCompile,
|
||||||
|
css,
|
||||||
|
usePlugin,
|
||||||
} from '@nocobase/client';
|
} from '@nocobase/client';
|
||||||
import { str2moment } from '@nocobase/utils/client';
|
import { dayjs } from '@nocobase/utils/client';
|
||||||
import { App, Breadcrumb, Button, Dropdown, Result, Spin, Switch, Tag, Tooltip, message } from 'antd';
|
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { CanvasContent } from './CanvasContent';
|
import { CanvasContent } from './CanvasContent';
|
||||||
import { ExecutionStatusColumn } from './components/ExecutionStatus';
|
import { ExecutionStatusColumn } from './components/ExecutionStatus';
|
||||||
import { ExecutionLink } from './ExecutionLink';
|
import { ExecutionLink } from './ExecutionLink';
|
||||||
import { FlowContext, useFlowContext } from './FlowContext';
|
import { FlowContext, useFlowContext } from './FlowContext';
|
||||||
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
|
import { lang, NAMESPACE } from './locale';
|
||||||
import { lang } from './locale';
|
|
||||||
import { executionSchema } from './schemas/executions';
|
import { executionSchema } from './schemas/executions';
|
||||||
import useStyles from './style';
|
import useStyles from './style';
|
||||||
import { getWorkflowDetailPath, linkNodes } from './utils';
|
import { linkNodes, getWorkflowDetailPath } from './utils';
|
||||||
|
import { Fieldset } from './components/Fieldset';
|
||||||
|
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
|
||||||
|
import { useTrigger } from './triggers';
|
||||||
|
import { useField, useForm } from '@formily/react';
|
||||||
|
import { ExecutionStatusOptionsMap } from './constants';
|
||||||
|
import PluginWorkflowClient from '.';
|
||||||
|
import { NoticeType } from 'antd/es/message/interface';
|
||||||
|
|
||||||
function ExecutionResourceProvider({ request, filter = {}, ...others }) {
|
function ExecutionResourceProvider({ request, filter = {}, ...others }) {
|
||||||
const { workflow } = useFlowContext();
|
const { workflow } = useFlowContext();
|
||||||
@ -53,53 +65,192 @@ function ExecutionResourceProvider({ request, filter = {}, ...others }) {
|
|||||||
return <ResourceActionProvider {...props} />;
|
return <ResourceActionProvider {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowCanvas() {
|
function ExecutedStatusMessage({ data, option }) {
|
||||||
|
const compile = useCompile();
|
||||||
|
const statusText = compile(option.label);
|
||||||
|
return (
|
||||||
|
<Trans ns={NAMESPACE} values={{ statusText }}>
|
||||||
|
{'Workflow executed, the result status is '}
|
||||||
|
<Tag color={option.color}>{'{{statusText}}'}</Tag>
|
||||||
|
<Link to={`/admin/workflow/executions/${data.id}`}>View the execution</Link>
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExecutedStatusMessage({ id, status }) {
|
||||||
|
const option = ExecutionStatusOptionsMap[status];
|
||||||
|
if (!option) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'info' as NoticeType,
|
||||||
|
content: <ExecutedStatusMessage data={{ id }} option={option} />,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useExecuteConfirmAction() {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const form = useForm();
|
||||||
|
const { resource } = useResourceContext();
|
||||||
|
const ctx = useActionContext();
|
||||||
|
const navigate = useNavigateNoUpdate();
|
||||||
|
const { message: messageApi } = App.useApp();
|
||||||
|
const { autoRevision, ...values } = form.values;
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
// Not executed, could choose to create new version (by default)
|
||||||
|
// Executed, stay in current version, and refresh
|
||||||
|
await form.submit();
|
||||||
|
const {
|
||||||
|
data: { data },
|
||||||
|
} = await resource.execute({
|
||||||
|
filterByTk: workflow.id,
|
||||||
|
values,
|
||||||
|
...(!workflow.executed && autoRevision ? { autoRevision: 1 } : {}),
|
||||||
|
});
|
||||||
|
form.reset();
|
||||||
|
ctx.setFormValueChanged(false);
|
||||||
|
ctx.setVisible(false);
|
||||||
|
messageApi?.open(getExecutedStatusMessage(data.execution));
|
||||||
|
if (data.newVersionId) {
|
||||||
|
navigate(`/admin/workflow/workflows/${data.newVersionId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionDisabledProvider({ children }) {
|
||||||
|
const field = useField<any>();
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const trigger = useTrigger();
|
||||||
|
const valid = trigger.validate(workflow.config);
|
||||||
|
let message = '';
|
||||||
|
switch (true) {
|
||||||
|
case !valid:
|
||||||
|
message = lang('The trigger is not configured correctly, please check the trigger configuration.');
|
||||||
|
break;
|
||||||
|
case !trigger.triggerFieldset:
|
||||||
|
message = lang('This type of trigger has not been supported to be executed manually.');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
field.setPattern(message ? 'disabled' : 'editable');
|
||||||
|
return message ? <Tooltip title={message}>{children}</Tooltip> : children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExecuteActionButton() {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const trigger = useTrigger();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaComponent
|
||||||
|
components={{
|
||||||
|
Alert,
|
||||||
|
Fieldset,
|
||||||
|
ActionDisabledProvider,
|
||||||
|
...trigger.components,
|
||||||
|
}}
|
||||||
|
scope={{
|
||||||
|
useCancelAction,
|
||||||
|
useExecuteConfirmAction,
|
||||||
|
}}
|
||||||
|
schema={{
|
||||||
|
name: `trigger-modal-${workflow.type}-${workflow.id}`,
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'ActionDisabledProvider',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
openSize: 'small',
|
||||||
|
},
|
||||||
|
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: {
|
||||||
|
drawer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FormV2',
|
||||||
|
'x-component': 'Action.Modal',
|
||||||
|
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: {
|
||||||
|
...(Object.keys(trigger.triggerFieldset ?? {}).length
|
||||||
|
? {
|
||||||
|
alert: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Alert',
|
||||||
|
'x-component-props': {
|
||||||
|
message: `{{t('Trigger variables need to be filled for executing.', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
className: css`
|
||||||
|
margin-bottom: 1em;
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
description: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'p',
|
||||||
|
'x-content': `{{t('This will perform all the actions configured in the workflow. Are you sure you want to continue?', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fieldset: {
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Fieldset',
|
||||||
|
title: `{{t('Trigger variables', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
properties: trigger.triggerFieldset,
|
||||||
|
},
|
||||||
|
...(workflow.executed
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
autoRevision: {
|
||||||
|
type: 'boolean',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
'x-content': `{{t('Automatically create a new version after execution', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'Action.Modal.Footer',
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t('Cancel')}}`,
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{useCancelAction}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
type: 'void',
|
||||||
|
title: `{{t('Confirm')}}`,
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
useAction: '{{useExecuteConfirmAction}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkflowMenu() {
|
||||||
|
const { workflow, revisions } = useFlowContext();
|
||||||
|
const [historyVisible, setHistoryVisible] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const app = useApp();
|
|
||||||
const { data, refresh, loading } = useResourceActionContext();
|
|
||||||
const { resource } = useResourceContext();
|
|
||||||
const { setTitle } = useDocumentTitle();
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const { styles } = useStyles();
|
|
||||||
const { modal } = App.useApp();
|
const { modal } = App.useApp();
|
||||||
|
const app = useApp();
|
||||||
|
const { resource } = useResourceContext();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
|
||||||
useEffect(() => {
|
const onRevision = useCallback(async () => {
|
||||||
const { title } = data?.data ?? {};
|
|
||||||
setTitle?.(`${lang('Workflow')}${title ? `: ${title}` : ''}`);
|
|
||||||
}, [data?.data, setTitle]);
|
|
||||||
|
|
||||||
if (!data?.data) {
|
|
||||||
if (loading) {
|
|
||||||
return <Spin />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Result status="404" title="Not found" extra={<Button onClick={() => navigate(-1)}>{lang('Go back')}</Button>} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
|
|
||||||
linkNodes(nodes);
|
|
||||||
|
|
||||||
const entry = nodes.find((item) => !item.upstream);
|
|
||||||
|
|
||||||
function onSwitchVersion({ key }) {
|
|
||||||
if (key != workflow.id) {
|
|
||||||
navigate(getWorkflowDetailPath(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onToggle(value) {
|
|
||||||
await resource.update({
|
|
||||||
filterByTk: workflow.id,
|
|
||||||
values: {
|
|
||||||
enabled: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRevision() {
|
|
||||||
const {
|
const {
|
||||||
data: { data: revision },
|
data: { data: revision },
|
||||||
} = await resource.revision({
|
} = await resource.revision({
|
||||||
@ -111,9 +262,9 @@ export function WorkflowCanvas() {
|
|||||||
message.success(t('Operation succeeded'));
|
message.success(t('Operation succeeded'));
|
||||||
|
|
||||||
navigate(`/admin/workflow/workflows/${revision.id}`);
|
navigate(`/admin/workflow/workflows/${revision.id}`);
|
||||||
}
|
}, [resource, workflow.id, workflow.key, message, t, navigate]);
|
||||||
|
|
||||||
async function onDelete() {
|
const onDelete = useCallback(async () => {
|
||||||
const content = workflow.current
|
const content = workflow.current
|
||||||
? lang('Delete a main version will cause all other revisions to be deleted too.')
|
? lang('Delete a main version will cause all other revisions to be deleted too.')
|
||||||
: '';
|
: '';
|
||||||
@ -133,30 +284,138 @@ export function WorkflowCanvas() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}, [workflow, modal, t, resource, message, navigate, app.pluginSettingsManager, revisions]);
|
||||||
|
|
||||||
async function onMenuCommand({ key }) {
|
const onMenuCommand = useCallback(
|
||||||
switch (key) {
|
({ key }) => {
|
||||||
case 'history':
|
switch (key) {
|
||||||
setVisible(true);
|
case 'history':
|
||||||
return;
|
setHistoryVisible(true);
|
||||||
case 'revision':
|
return;
|
||||||
return onRevision();
|
case 'revision':
|
||||||
case 'delete':
|
return onRevision();
|
||||||
return onDelete();
|
case 'delete':
|
||||||
default:
|
return onDelete();
|
||||||
break;
|
default:
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[onDelete, onRevision],
|
||||||
|
);
|
||||||
|
|
||||||
const revisionable =
|
const revisionable =
|
||||||
workflow.executed &&
|
workflow.executed &&
|
||||||
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
|
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'key',
|
||||||
|
label: `Key: ${workflow.key}`,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'button',
|
||||||
|
'aria-label': 'history',
|
||||||
|
key: 'history',
|
||||||
|
label: lang('Execution history'),
|
||||||
|
disabled: !workflow.allExecuted,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'button',
|
||||||
|
'aria-label': 'revision',
|
||||||
|
key: 'revision',
|
||||||
|
label: lang('Copy to new version'),
|
||||||
|
disabled: !revisionable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
{ role: 'button', 'aria-label': 'delete', danger: true, key: 'delete', label: t('Delete') },
|
||||||
|
] as any[],
|
||||||
|
onClick: onMenuCommand,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
|
||||||
|
</Dropdown>
|
||||||
|
<ActionContextProvider value={{ visible: historyVisible, setVisible: setHistoryVisible }}>
|
||||||
|
<SchemaComponent
|
||||||
|
schema={executionSchema}
|
||||||
|
components={{
|
||||||
|
ExecutionResourceProvider,
|
||||||
|
ExecutionLink,
|
||||||
|
ExecutionStatusColumn,
|
||||||
|
}}
|
||||||
|
scope={{
|
||||||
|
useRefreshActionProps,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionContextProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowCanvas() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const app = useApp();
|
||||||
|
const { data, refresh, loading } = useResourceActionContext();
|
||||||
|
const { resource } = useResourceContext();
|
||||||
|
const { setTitle } = useDocumentTitle();
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const workflowPlugin = usePlugin(PluginWorkflowClient);
|
||||||
|
|
||||||
|
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
|
||||||
|
linkNodes(nodes);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { title } = data?.data ?? {};
|
||||||
|
setTitle?.(`${lang('Workflow')}${title ? `: ${title}` : ''}`);
|
||||||
|
}, [data?.data, setTitle]);
|
||||||
|
|
||||||
|
const onSwitchVersion = useCallback(
|
||||||
|
({ key }) => {
|
||||||
|
if (key != workflow.id) {
|
||||||
|
navigate(getWorkflowDetailPath(key));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[workflow.id, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToggle = useCallback(
|
||||||
|
async (value) => {
|
||||||
|
await resource.update({
|
||||||
|
filterByTk: workflow.id,
|
||||||
|
values: {
|
||||||
|
enabled: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
[resource, workflow.id, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data?.data) {
|
||||||
|
if (loading) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Result status="404" title="Not found" extra={<Button onClick={() => navigate(-1)}>{lang('Go back')}</Button>} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = nodes.find((item) => !item.upstream);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlowContext.Provider
|
<FlowContext.Provider
|
||||||
value={{
|
value={{
|
||||||
workflow,
|
workflow,
|
||||||
|
revisions,
|
||||||
nodes,
|
nodes,
|
||||||
refresh,
|
refresh,
|
||||||
}}
|
}}
|
||||||
@ -175,13 +434,14 @@ export function WorkflowCanvas() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</header>
|
|
||||||
<aside>
|
|
||||||
{workflow.sync ? (
|
{workflow.sync ? (
|
||||||
<Tag color="orange">{lang('Synchronously')}</Tag>
|
<Tag color="orange">{lang('Synchronously')}</Tag>
|
||||||
) : (
|
) : (
|
||||||
<Tag color="cyan">{lang('Asynchronously')}</Tag>
|
<Tag color="cyan">{lang('Asynchronously')}</Tag>
|
||||||
)}
|
)}
|
||||||
|
</header>
|
||||||
|
<aside>
|
||||||
|
<ExecuteActionButton />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className="workflow-versions"
|
className="workflow-versions"
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
@ -204,7 +464,7 @@ export function WorkflowCanvas() {
|
|||||||
label: (
|
label: (
|
||||||
<>
|
<>
|
||||||
<strong>{`#${item.id}`}</strong>
|
<strong>{`#${item.id}`}</strong>
|
||||||
<time>{str2moment(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</time>
|
<time>{dayjs(item.createdAt).fromNow()}</time>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
@ -222,54 +482,7 @@ export function WorkflowCanvas() {
|
|||||||
checkedChildren={lang('On')}
|
checkedChildren={lang('On')}
|
||||||
unCheckedChildren={lang('Off')}
|
unCheckedChildren={lang('Off')}
|
||||||
/>
|
/>
|
||||||
<Dropdown
|
<WorkflowMenu />
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'key',
|
|
||||||
label: `Key: ${workflow.key}`,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'button',
|
|
||||||
'aria-label': 'history',
|
|
||||||
key: 'history',
|
|
||||||
label: lang('Execution history'),
|
|
||||||
disabled: !workflow.allExecuted,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'button',
|
|
||||||
'aria-label': 'revision',
|
|
||||||
key: 'revision',
|
|
||||||
label: lang('Copy to new version'),
|
|
||||||
disabled: !revisionable,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider',
|
|
||||||
},
|
|
||||||
{ role: 'button', 'aria-label': 'delete', danger: true, key: 'delete', label: t('Delete') },
|
|
||||||
] as any[],
|
|
||||||
onClick: onMenuCommand,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
<ActionContextProvider value={{ visible, setVisible }}>
|
|
||||||
<SchemaComponent
|
|
||||||
schema={executionSchema}
|
|
||||||
components={{
|
|
||||||
ExecutionResourceProvider,
|
|
||||||
ExecutionLink,
|
|
||||||
ExecutionStatusColumn,
|
|
||||||
}}
|
|
||||||
scope={{
|
|
||||||
useRefreshActionProps,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ActionContextProvider>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<CanvasContent entry={entry} />
|
<CanvasContent entry={entry} />
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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 { parseCollectionName, RemoteSelect, useApp } from '@nocobase/client';
|
||||||
|
|
||||||
|
import { useFlowContext } from '../FlowContext';
|
||||||
|
|
||||||
|
export function TriggerCollectionRecordSelect(props) {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const app = useApp();
|
||||||
|
|
||||||
|
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
|
||||||
|
const { collectionManager } = app.dataSourceManager.getDataSource(dataSourceName);
|
||||||
|
const collection = collectionManager.getCollection(collectionName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemoteSelect
|
||||||
|
objectValue
|
||||||
|
dataSource={dataSourceName}
|
||||||
|
fieldNames={{
|
||||||
|
label: collection.titleField,
|
||||||
|
value: 'id',
|
||||||
|
}}
|
||||||
|
service={{
|
||||||
|
resource: collectionName,
|
||||||
|
}}
|
||||||
|
manual={false}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -18,3 +18,4 @@ export * from './SimpleDesigner';
|
|||||||
export * from './renderEngineReference';
|
export * from './renderEngineReference';
|
||||||
export * from './Calculation';
|
export * from './Calculation';
|
||||||
export * from './Fieldset';
|
export * from './Fieldset';
|
||||||
|
export * from './TriggerCollectionRecordSelect';
|
||||||
|
@ -38,6 +38,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'blue',
|
color: 'blue',
|
||||||
icon: <HourglassOutlined />,
|
icon: <HourglassOutlined />,
|
||||||
|
statusType: 'info',
|
||||||
description: `{{t("Triggered but still waiting in queue to execute.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Triggered but still waiting in queue to execute.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -45,6 +46,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("On going", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("On going", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
icon: <LoadingOutlined />,
|
icon: <LoadingOutlined />,
|
||||||
|
statusType: 'warning',
|
||||||
description: `{{t("Started and executing, maybe waiting for an async callback (manual, delay etc.).", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Started and executing, maybe waiting for an async callback (manual, delay etc.).", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -52,6 +54,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'green',
|
color: 'green',
|
||||||
icon: <CheckOutlined />,
|
icon: <CheckOutlined />,
|
||||||
|
statusType: 'success',
|
||||||
description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -59,6 +62,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <ExclamationOutlined />,
|
icon: <ExclamationOutlined />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -66,6 +70,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Error", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Error", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <CloseOutlined />,
|
icon: <CloseOutlined />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -73,6 +78,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'red',
|
color: 'red',
|
||||||
icon: <MinusOutlined rotate={90} />,
|
icon: <MinusOutlined rotate={90} />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("Running of some node was aborted by program flow.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Running of some node was aborted by program flow.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -80,6 +86,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'volcano',
|
color: 'volcano',
|
||||||
icon: <MinusOutlined rotate={45} />,
|
icon: <MinusOutlined rotate={45} />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -87,6 +94,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'volcano',
|
color: 'volcano',
|
||||||
icon: <MinusOutlined />,
|
icon: <MinusOutlined />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -94,6 +102,7 @@ export const ExecutionStatusOptions = [
|
|||||||
label: `{{t("Retry needed", { ns: "${NAMESPACE}" })}}`,
|
label: `{{t("Retry needed", { ns: "${NAMESPACE}" })}}`,
|
||||||
color: 'volcano',
|
color: 'volcano',
|
||||||
icon: <RedoOutlined />,
|
icon: <RedoOutlined />,
|
||||||
|
statusType: 'error',
|
||||||
description: `{{t("General failed but should do another try.", { ns: "${NAMESPACE}" })}}`,
|
description: `{{t("General failed but should do another try.", { ns: "${NAMESPACE}" })}}`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -45,12 +45,14 @@ export default class PluginWorkflowClient extends Plugin {
|
|||||||
|
|
||||||
useTriggersOptions = () => {
|
useTriggersOptions = () => {
|
||||||
const compile = useCompile();
|
const compile = useCompile();
|
||||||
return Array.from(this.triggers.getEntities()).map(([value, { title, ...options }]) => ({
|
return Array.from(this.triggers.getEntities())
|
||||||
value,
|
.map(([value, { title, ...options }]) => ({
|
||||||
label: compile(title),
|
value,
|
||||||
color: 'gold',
|
label: compile(title),
|
||||||
options,
|
color: 'gold',
|
||||||
}));
|
options,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
};
|
};
|
||||||
|
|
||||||
isWorkflowSync(workflow) {
|
isWorkflowSync(workflow) {
|
||||||
@ -92,11 +94,6 @@ export default class PluginWorkflowClient extends Plugin {
|
|||||||
element: <ExecutionPage />,
|
element: <ExecutionPage />,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.addComponents({
|
|
||||||
WorkflowPage,
|
|
||||||
ExecutionPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.pluginSettingsManager.add(NAMESPACE, {
|
this.app.pluginSettingsManager.add(NAMESPACE, {
|
||||||
icon: 'PartitionOutlined',
|
icon: 'PartitionOutlined',
|
||||||
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
@ -29,6 +29,7 @@ const useStyles = createStyles(({ css, token }) => {
|
|||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
aside {
|
aside {
|
||||||
@ -104,17 +105,18 @@ const useStyles = createStyles(({ css, token }) => {
|
|||||||
strong {
|
strong {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .enabled {
|
&.enabled {
|
||||||
strong {
|
strong {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
> .unexecuted {
|
&.unexecuted {
|
||||||
strong {
|
strong {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ import { appends, collection, filter } from '../schemas/collection';
|
|||||||
import { getCollectionFieldOptions, useGetCollectionFields } from '../variable';
|
import { getCollectionFieldOptions, useGetCollectionFields } from '../variable';
|
||||||
import { useWorkflowAnyExecuted } from '../hooks';
|
import { useWorkflowAnyExecuted } from '../hooks';
|
||||||
import { Trigger } from '.';
|
import { Trigger } from '.';
|
||||||
|
import { TriggerCollectionRecordSelect } from '../components/TriggerCollectionRecordSelect';
|
||||||
|
|
||||||
const COLLECTION_TRIGGER_MODE = {
|
const COLLECTION_TRIGGER_MODE = {
|
||||||
CREATED: 1,
|
CREATED: 1,
|
||||||
@ -190,7 +191,22 @@ export default class extends Trigger {
|
|||||||
};
|
};
|
||||||
components = {
|
components = {
|
||||||
FieldsSelect,
|
FieldsSelect,
|
||||||
|
TriggerCollectionRecordSelect,
|
||||||
};
|
};
|
||||||
|
triggerFieldset = {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
description: `{{t("Choose a record of the collection to trigger.", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'TriggerCollectionRecordSelect',
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
validate(values) {
|
||||||
|
return values.collection && values.mode;
|
||||||
|
}
|
||||||
useVariables = useVariables;
|
useVariables = useVariables;
|
||||||
useInitializers(config): SchemaInitializerItemType | null {
|
useInitializers(config): SchemaInitializerItemType | null {
|
||||||
if (!config.collection) {
|
if (!config.collection) {
|
||||||
|
@ -67,7 +67,11 @@ export abstract class Trigger {
|
|||||||
description?: string;
|
description?: string;
|
||||||
// group: string;
|
// group: string;
|
||||||
useVariables?(config: Record<string, any>, options?: UseVariableOptions): VariableOption[];
|
useVariables?(config: Record<string, any>, options?: UseVariableOptions): VariableOption[];
|
||||||
fieldset: { [key: string]: ISchema };
|
fieldset: Record<string, ISchema>;
|
||||||
|
triggerFieldset?: Record<string, ISchema>;
|
||||||
|
validate(config: Record<string, any>): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
view?: ISchema;
|
view?: ISchema;
|
||||||
scope?: { [key: string]: any };
|
scope?: { [key: string]: any };
|
||||||
components?: { [key: string]: any };
|
components?: { [key: string]: any };
|
||||||
@ -138,12 +142,13 @@ function TriggerExecution() {
|
|||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Input.JSON',
|
'x-component': 'Input.JSON',
|
||||||
'x-component-props': {
|
'x-component-props': {
|
||||||
className: css`
|
className: styles.nodeJobResultClass,
|
||||||
padding: 1em;
|
autoSize: {
|
||||||
background-color: #f3f3f3;
|
minRows: 4,
|
||||||
`,
|
maxRows: 32,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'x-read-pretty': true,
|
'x-disabled': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -12,163 +12,11 @@ import { useForm, useFormEffects, ISchema } from '@formily/react';
|
|||||||
import { css, SchemaComponent } from '@nocobase/client';
|
import { css, SchemaComponent } from '@nocobase/client';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NAMESPACE } from '../../locale';
|
import { NAMESPACE } from '../../locale';
|
||||||
import { appends, collection } from '../../schemas/collection';
|
|
||||||
import { SCHEDULE_MODE } from './constants';
|
import { SCHEDULE_MODE } from './constants';
|
||||||
import { EndsByField } from './EndsByField';
|
import { EndsByField } from './EndsByField';
|
||||||
import { OnField } from './OnField';
|
import { OnField } from './OnField';
|
||||||
import { RepeatField } from './RepeatField';
|
import { RepeatField } from './RepeatField';
|
||||||
|
import { ScheduleModes } from './ScheduleModes';
|
||||||
const ModeFieldsets = {
|
|
||||||
[SCHEDULE_MODE.STATIC]: {
|
|
||||||
startsOn: {
|
|
||||||
type: 'datetime',
|
|
||||||
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'DatePicker',
|
|
||||||
'x-component-props': {
|
|
||||||
showTime: true,
|
|
||||||
},
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
repeat: {
|
|
||||||
type: 'string',
|
|
||||||
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'RepeatField',
|
|
||||||
'x-reactions': [
|
|
||||||
{
|
|
||||||
target: 'endsOn',
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: 'limit',
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
endsOn: {
|
|
||||||
type: 'datetime',
|
|
||||||
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'DatePicker',
|
|
||||||
'x-component-props': {
|
|
||||||
showTime: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: 'number',
|
|
||||||
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'InputNumber',
|
|
||||||
'x-component-props': {
|
|
||||||
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[SCHEDULE_MODE.DATE_FIELD]: {
|
|
||||||
collection: {
|
|
||||||
...collection,
|
|
||||||
'x-component-props': {
|
|
||||||
dataSourceFilter(item) {
|
|
||||||
return item.options.key === 'main' || item.options.isDBInstance;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'x-reactions': [
|
|
||||||
...collection['x-reactions'],
|
|
||||||
{
|
|
||||||
// only full path works
|
|
||||||
target: 'startsOn',
|
|
||||||
effects: ['onFieldValueChange'],
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
value: '{{Object.create({})}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
startsOn: {
|
|
||||||
type: 'object',
|
|
||||||
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'OnField',
|
|
||||||
'x-reactions': [
|
|
||||||
{
|
|
||||||
target: 'repeat',
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
repeat: {
|
|
||||||
type: 'string',
|
|
||||||
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'RepeatField',
|
|
||||||
'x-reactions': [
|
|
||||||
{
|
|
||||||
target: 'endsOn',
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
target: 'limit',
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: '{{!!$self.value}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
endsOn: {
|
|
||||||
type: 'object',
|
|
||||||
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'EndsByField',
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: 'number',
|
|
||||||
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
'x-decorator': 'FormItem',
|
|
||||||
'x-component': 'InputNumber',
|
|
||||||
'x-component-props': {
|
|
||||||
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
appends: {
|
|
||||||
...appends,
|
|
||||||
'x-reactions': [
|
|
||||||
{
|
|
||||||
dependencies: ['mode', 'collection'],
|
|
||||||
fulfill: {
|
|
||||||
state: {
|
|
||||||
visible: `{{$deps[0] === ${SCHEDULE_MODE.DATE_FIELD} && $deps[1]}}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleModeOptions = [
|
const scheduleModeOptions = [
|
||||||
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
|
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
|
||||||
@ -201,9 +49,7 @@ export const ScheduleConfig = () => {
|
|||||||
name: 'mode',
|
name: 'mode',
|
||||||
'x-decorator': 'FormItem',
|
'x-decorator': 'FormItem',
|
||||||
'x-component': 'Radio.Group',
|
'x-component': 'Radio.Group',
|
||||||
'x-component-props': {
|
enum: scheduleModeOptions,
|
||||||
options: scheduleModeOptions,
|
|
||||||
},
|
|
||||||
required: true,
|
required: true,
|
||||||
default: SCHEDULE_MODE.STATIC,
|
default: SCHEDULE_MODE.STATIC,
|
||||||
}}
|
}}
|
||||||
@ -228,7 +74,7 @@ export const ScheduleConfig = () => {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
properties: ModeFieldsets[mode],
|
properties: ScheduleModes[mode]?.fieldset,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as ISchema
|
} as ISchema
|
||||||
|
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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 { NAMESPACE } from '../../locale';
|
||||||
|
import { appends, collection } from '../../schemas/collection';
|
||||||
|
import { SCHEDULE_MODE } from './constants';
|
||||||
|
|
||||||
|
export const ScheduleModes = {
|
||||||
|
[SCHEDULE_MODE.STATIC]: {
|
||||||
|
fieldset: {
|
||||||
|
startsOn: {
|
||||||
|
type: 'datetime',
|
||||||
|
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'DatePicker',
|
||||||
|
'x-component-props': {
|
||||||
|
showTime: true,
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
repeat: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RepeatField',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
target: 'endsOn',
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'limit',
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
endsOn: {
|
||||||
|
type: 'datetime',
|
||||||
|
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'DatePicker',
|
||||||
|
'x-component-props': {
|
||||||
|
showTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'InputNumber',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
triggerFieldset: {
|
||||||
|
date: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t('Execute on', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'DatePicker',
|
||||||
|
'x-component-props': {
|
||||||
|
showTime: true,
|
||||||
|
placeholder: `{{t('Current time', { ns: "${NAMESPACE}" })}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[SCHEDULE_MODE.DATE_FIELD]: {
|
||||||
|
fieldset: {
|
||||||
|
collection: {
|
||||||
|
...collection,
|
||||||
|
'x-component-props': {
|
||||||
|
dataSourceFilter(item) {
|
||||||
|
return item.options.key === 'main' || item.options.isDBInstance;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'x-reactions': [
|
||||||
|
...collection['x-reactions'],
|
||||||
|
{
|
||||||
|
// only full path works
|
||||||
|
target: 'startsOn',
|
||||||
|
effects: ['onFieldValueChange'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
value: '{{Object.create({})}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
startsOn: {
|
||||||
|
type: 'object',
|
||||||
|
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'OnField',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
target: 'repeat',
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
repeat: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'RepeatField',
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
target: 'endsOn',
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: 'limit',
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: '{{!!$self.value}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
endsOn: {
|
||||||
|
type: 'object',
|
||||||
|
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'EndsByField',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'InputNumber',
|
||||||
|
'x-component-props': {
|
||||||
|
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
appends: {
|
||||||
|
...appends,
|
||||||
|
'x-reactions': [
|
||||||
|
{
|
||||||
|
dependencies: ['mode', 'collection'],
|
||||||
|
fulfill: {
|
||||||
|
state: {
|
||||||
|
visible: `{{$deps[0] === ${SCHEDULE_MODE.DATE_FIELD} && $deps[1]}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
triggerFieldset: {
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
description: `{{t("Choose a record of the collection to trigger.", { ns: "${NAMESPACE}" })}}`,
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'TriggerCollectionRecordSelect',
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate(config) {
|
||||||
|
return config.collection && config.startsOn;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 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 { SchemaComponent } from '@nocobase/client';
|
||||||
|
import { useFlowContext } from '../../FlowContext';
|
||||||
|
import { useTrigger } from '..';
|
||||||
|
import { ScheduleModes } from './ScheduleModes';
|
||||||
|
|
||||||
|
export function TriggerScheduleConfig() {
|
||||||
|
const { workflow } = useFlowContext();
|
||||||
|
const trigger = useTrigger();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SchemaComponent
|
||||||
|
components={trigger.components}
|
||||||
|
schema={{
|
||||||
|
type: 'void',
|
||||||
|
properties: ScheduleModes[workflow.config.mode].triggerFieldset,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -15,6 +15,9 @@ import { getCollectionFieldOptions, useGetCollectionFields } from '../../variabl
|
|||||||
import { Trigger } from '..';
|
import { Trigger } from '..';
|
||||||
import { ScheduleConfig } from './ScheduleConfig';
|
import { ScheduleConfig } from './ScheduleConfig';
|
||||||
import { SCHEDULE_MODE } from './constants';
|
import { SCHEDULE_MODE } from './constants';
|
||||||
|
import { TriggerScheduleConfig } from './TriggerScheduleConfig';
|
||||||
|
import { ScheduleModes } from './ScheduleModes';
|
||||||
|
import { TriggerCollectionRecordSelect } from '../../components/TriggerCollectionRecordSelect';
|
||||||
|
|
||||||
function useVariables(config, opts) {
|
function useVariables(config, opts) {
|
||||||
const [dataSourceName, collection] = parseCollectionName(config.collection);
|
const [dataSourceName, collection] = parseCollectionName(config.collection);
|
||||||
@ -66,11 +69,26 @@ export default class extends Trigger {
|
|||||||
'x-component-props': {},
|
'x-component-props': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
triggerFieldset = {
|
||||||
|
proxy: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'TriggerScheduleConfig',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
validate(config) {
|
||||||
|
if (config.mode == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { validate } = ScheduleModes[config.mode];
|
||||||
|
return validate ? validate(config) : true;
|
||||||
|
}
|
||||||
scope = {
|
scope = {
|
||||||
useCollectionDataSource,
|
useCollectionDataSource,
|
||||||
};
|
};
|
||||||
components = {
|
components = {
|
||||||
ScheduleConfig,
|
ScheduleConfig,
|
||||||
|
TriggerScheduleConfig,
|
||||||
|
TriggerCollectionRecordSelect,
|
||||||
};
|
};
|
||||||
useVariables = useVariables;
|
useVariables = useVariables;
|
||||||
useInitializers(config): SchemaInitializerItemType | null {
|
useInitializers(config): SchemaInitializerItemType | null {
|
||||||
|
@ -16,6 +16,15 @@
|
|||||||
"Duplicate": "复制",
|
"Duplicate": "复制",
|
||||||
"Duplicate to new workflow": "复制为新工作流",
|
"Duplicate to new workflow": "复制为新工作流",
|
||||||
"Delete a main version will cause all other revisions to be deleted too.": "删除主版本将导致其他版本一并被删除。",
|
"Delete a main version will cause all other revisions to be deleted too.": "删除主版本将导致其他版本一并被删除。",
|
||||||
|
"Execute manually": "手动执行",
|
||||||
|
"The trigger is not configured correctly, please check the trigger configuration.": "触发器配置不正确,请检查触发器配置。",
|
||||||
|
"This type of trigger has not been supported to be executed manually.": "该类型的触发器暂未支持手动执行。",
|
||||||
|
"Trigger variables need to be filled for executing.": "执行需要填写触发器变量。",
|
||||||
|
"A new version will be created automatically after execution if current version is not executed.": "如果当前版本还未执行过,将在执行后自动创建一个新版本。",
|
||||||
|
"This will perform all the actions configured in the workflow. Are you sure you want to continue?": "将按照工作流中配置的所有操作执行,确定继续吗?",
|
||||||
|
"Automatically create a new version after execution": "执行后自动创建新版本",
|
||||||
|
"Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>": "工作流已执行,结果状态为 <1>{{statusText}}</1><2>查看执行详情</2>",
|
||||||
|
|
||||||
"Loading": "加载中",
|
"Loading": "加载中",
|
||||||
"Load failed": "加载失败",
|
"Load failed": "加载失败",
|
||||||
"Use transaction": "启用事务",
|
"Use transaction": "启用事务",
|
||||||
@ -64,6 +73,8 @@
|
|||||||
"Preload associations": "预加载关联数据",
|
"Preload associations": "预加载关联数据",
|
||||||
"Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.":
|
"Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.":
|
||||||
"请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。",
|
"请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。",
|
||||||
|
"Choose a record of the collection to trigger.": "选择数据表中的一行记录来触发。",
|
||||||
|
|
||||||
"Schedule event": "定时任务",
|
"Schedule event": "定时任务",
|
||||||
"Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。",
|
"Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。",
|
||||||
"Trigger mode": "触发模式",
|
"Trigger mode": "触发模式",
|
||||||
@ -92,6 +103,9 @@
|
|||||||
"By field": "数据表字段",
|
"By field": "数据表字段",
|
||||||
"By custom date": "自定义时间",
|
"By custom date": "自定义时间",
|
||||||
"Advanced": "高级模式",
|
"Advanced": "高级模式",
|
||||||
|
"Execute on": "执行时间",
|
||||||
|
"Current time": "当前时间",
|
||||||
|
|
||||||
"End": "结束",
|
"End": "结束",
|
||||||
"Node result": "节点数据",
|
"Node result": "节点数据",
|
||||||
"Variable key of node": "节点变量标识",
|
"Variable key of node": "节点变量标识",
|
||||||
|
@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
|
|||||||
|
|
||||||
import LRUCache from 'lru-cache';
|
import LRUCache from 'lru-cache';
|
||||||
|
|
||||||
import { Op, Transaction, Transactionable } from '@nocobase/database';
|
import { Op, Transactionable } from '@nocobase/database';
|
||||||
import { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
import { Registry } from '@nocobase/utils';
|
import { Registry } from '@nocobase/utils';
|
||||||
|
|
||||||
@ -34,15 +34,19 @@ import QueryInstruction from './instructions/QueryInstruction';
|
|||||||
import UpdateInstruction from './instructions/UpdateInstruction';
|
import UpdateInstruction from './instructions/UpdateInstruction';
|
||||||
|
|
||||||
import type { ExecutionModel, JobModel, WorkflowModel } from './types';
|
import type { ExecutionModel, JobModel, WorkflowModel } from './types';
|
||||||
|
import WorkflowRepository from './repositories/WorkflowRepository';
|
||||||
|
import { Context } from '@nocobase/actions';
|
||||||
|
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||||
|
|
||||||
type ID = number | string;
|
type ID = number | string;
|
||||||
|
|
||||||
type Pending = [ExecutionModel, JobModel?];
|
type Pending = [ExecutionModel, JobModel?];
|
||||||
|
|
||||||
type EventOptions = {
|
export type EventOptions = {
|
||||||
eventKey?: string;
|
eventKey?: string;
|
||||||
context?: any;
|
context?: any;
|
||||||
deferred?: boolean;
|
deferred?: boolean;
|
||||||
|
manually?: boolean;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
} & Transactionable;
|
} & Transactionable;
|
||||||
|
|
||||||
@ -69,20 +73,6 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
|
|
||||||
if (instance.enabled) {
|
if (instance.enabled) {
|
||||||
instance.set('current', true);
|
instance.set('current', true);
|
||||||
} else if (!instance.current) {
|
|
||||||
const count = await Model.count({
|
|
||||||
where: {
|
|
||||||
key: instance.key,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
if (!count) {
|
|
||||||
instance.set('current', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!instance.changed('enabled') || !instance.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = await Model.findOne({
|
const previous = await Model.findOne({
|
||||||
@ -95,8 +85,11 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
},
|
},
|
||||||
transaction,
|
transaction,
|
||||||
});
|
});
|
||||||
|
if (!previous) {
|
||||||
|
instance.set('current', true);
|
||||||
|
}
|
||||||
|
|
||||||
if (previous) {
|
if (instance.current && previous) {
|
||||||
// NOTE: set to `null` but not `false` will not violate the unique index
|
// NOTE: set to `null` but not `false` will not violate the unique index
|
||||||
await previous.update(
|
await previous.update(
|
||||||
{ enabled: false, current: null },
|
{ enabled: false, current: null },
|
||||||
@ -213,6 +206,12 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async beforeLoad() {
|
||||||
|
this.db.registerRepositories({
|
||||||
|
WorkflowRepository,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@ -367,22 +366,22 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
options: EventOptions = {},
|
options: EventOptions = {},
|
||||||
): void | Promise<Processor | null> {
|
): void | Promise<Processor | null> {
|
||||||
const logger = this.getLogger(workflow.id);
|
const logger = this.getLogger(workflow.id);
|
||||||
if (!workflow.enabled) {
|
|
||||||
logger.warn(`workflow ${workflow.id} is not enabled, event will be ignored`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
logger.warn(`app is not ready, event of workflow ${workflow.id} will be ignored`);
|
logger.warn(`app is not ready, event of workflow ${workflow.id} will be ignored`);
|
||||||
logger.debug(`ignored event data:`, context);
|
logger.debug(`ignored event data:`, context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!options.manually && !workflow.enabled) {
|
||||||
|
logger.warn(`workflow ${workflow.id} is not enabled, event will be ignored`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// `null` means not to trigger
|
// `null` means not to trigger
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
logger.warn(`workflow ${workflow.id} event data context is null, event will be ignored`);
|
logger.warn(`workflow ${workflow.id} event data context is null, event will be ignored`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isWorkflowSync(workflow)) {
|
if (options.manually || this.isWorkflowSync(workflow)) {
|
||||||
return this.triggerSync(workflow, context, options);
|
return this.triggerSync(workflow, context, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,11 +453,13 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
context,
|
context,
|
||||||
options: EventOptions,
|
options: EventOptions,
|
||||||
): Promise<ExecutionModel | null> {
|
): Promise<ExecutionModel | null> {
|
||||||
const { transaction = await this.db.sequelize.transaction(), deferred } = options;
|
const { deferred } = options;
|
||||||
|
const transaction = await this.useDataSourceTransaction('main', options.transaction, true);
|
||||||
|
const sameTransaction = options.transaction === transaction;
|
||||||
const trigger = this.triggers.get(workflow.type);
|
const trigger = this.triggers.get(workflow.type);
|
||||||
const valid = await trigger.validateEvent(workflow, context, { ...options, transaction });
|
const valid = await trigger.validateEvent(workflow, context, { ...options, transaction });
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
if (!options.transaction) {
|
if (!sameTransaction) {
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -476,7 +477,7 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!options.transaction) {
|
if (!sameTransaction) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@ -502,7 +503,7 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!options.transaction) {
|
if (!sameTransaction) {
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -622,6 +623,17 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
return processor;
|
return processor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async execute(workflow: WorkflowModel, context: Context, options: EventOptions = {}) {
|
||||||
|
const trigger = this.triggers.get(workflow.type);
|
||||||
|
if (!trigger) {
|
||||||
|
throw new Error(`trigger type "${workflow.type}" of workflow ${workflow.id} is not registered`);
|
||||||
|
}
|
||||||
|
if (!trigger.execute) {
|
||||||
|
throw new Error(`"execute" method of trigger ${workflow.type} is not implemented`);
|
||||||
|
}
|
||||||
|
return trigger.execute(workflow, context, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @experimental
|
* @experimental
|
||||||
* @param {string} dataSourceName
|
* @param {string} dataSourceName
|
||||||
@ -630,8 +642,8 @@ export default class PluginWorkflowServer extends Plugin {
|
|||||||
* @returns {Trasaction}
|
* @returns {Trasaction}
|
||||||
*/
|
*/
|
||||||
useDataSourceTransaction(dataSourceName = 'main', transaction, create = false) {
|
useDataSourceTransaction(dataSourceName = 'main', transaction, create = false) {
|
||||||
// @ts-ignore
|
const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName)
|
||||||
const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
|
.collectionManager as SequelizeCollectionManager;
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -216,7 +216,6 @@ describe('workflow > instructions > condition', () => {
|
|||||||
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
|
||||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||||
console.log('------', jobs);
|
|
||||||
expect(jobs.length).toBe(3);
|
expect(jobs.length).toBe(3);
|
||||||
expect(jobs[0].result).toBe(false);
|
expect(jobs[0].result).toBe(false);
|
||||||
expect(jobs[1].result).toBe(false);
|
expect(jobs[1].result).toBe(false);
|
||||||
|
@ -7,20 +7,24 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BelongsToRepository, MockDatabase, Op } from '@nocobase/database';
|
import { BelongsToRepository, MockDatabase } from '@nocobase/database';
|
||||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
|
|
||||||
import { EXECUTION_STATUS } from '../../constants';
|
import { EXECUTION_STATUS } from '../../constants';
|
||||||
|
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||||
|
import PluginWorkflowServer from '../../Plugin';
|
||||||
|
|
||||||
describe('workflow > triggers > collection', () => {
|
describe('workflow > triggers > collection', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
let db: MockDatabase;
|
let db: MockDatabase;
|
||||||
|
let plugin: PluginWorkflowServer;
|
||||||
let CategoryRepo;
|
let CategoryRepo;
|
||||||
let PostRepo;
|
let PostRepo;
|
||||||
let CommentRepo;
|
let CommentRepo;
|
||||||
let TagRepo;
|
let TagRepo;
|
||||||
let WorkflowModel;
|
let WorkflowModel;
|
||||||
|
let agent;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = await getApp({
|
app = await getApp({
|
||||||
@ -28,11 +32,15 @@ describe('workflow > triggers > collection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
db = app.db;
|
db = app.db;
|
||||||
|
plugin = app.pm.get(PluginWorkflowServer) as PluginWorkflowServer;
|
||||||
WorkflowModel = db.getCollection('workflows').model;
|
WorkflowModel = db.getCollection('workflows').model;
|
||||||
CategoryRepo = db.getCollection('categories').repository;
|
CategoryRepo = db.getCollection('categories').repository;
|
||||||
PostRepo = db.getCollection('posts').repository;
|
PostRepo = db.getCollection('posts').repository;
|
||||||
CommentRepo = db.getCollection('comments').repository;
|
CommentRepo = db.getCollection('comments').repository;
|
||||||
TagRepo = db.getCollection('tags').repository;
|
TagRepo = db.getCollection('tags').repository;
|
||||||
|
|
||||||
|
const user = await app.db.getRepository('users').findOne();
|
||||||
|
agent = app.agent().login(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => app.destroy());
|
afterEach(() => app.destroy());
|
||||||
@ -694,6 +702,40 @@ describe('workflow > triggers > collection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('disabled could be executed', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
type: 'collection',
|
||||||
|
sync: true,
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'posts',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const p1 = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
const e1s = await workflow.getExecutions();
|
||||||
|
expect(e1s.length).toBe(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
body: { data },
|
||||||
|
} = await agent.resource('workflows').execute({
|
||||||
|
filterByTk: workflow.id,
|
||||||
|
values: {
|
||||||
|
data: p1.toJSON(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const e2s = await workflow.getExecutions();
|
||||||
|
expect(e2s.length).toBe(1);
|
||||||
|
expect(e2s[0].toJSON()).toMatchObject(data.execution);
|
||||||
|
expect(data.execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cycling trigger', () => {
|
describe('cycling trigger', () => {
|
||||||
it('trigger should not be triggered more than once in same execution', async () => {
|
it('trigger should not be triggered more than once in same execution', async () => {
|
||||||
const workflow = await WorkflowModel.create({
|
const workflow = await WorkflowModel.create({
|
||||||
@ -834,8 +876,7 @@ describe('workflow > triggers > collection', () => {
|
|||||||
describe('multiple data source', () => {
|
describe('multiple data source', () => {
|
||||||
let anotherDB: MockDatabase;
|
let anotherDB: MockDatabase;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// @ts-ignore
|
anotherDB = (app.dataSourceManager.dataSources.get('another').collectionManager as SequelizeCollectionManager).db;
|
||||||
anotherDB = app.dataSourceManager.dataSources.get('another').collectionManager.db;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collection trigger on another', async () => {
|
it('collection trigger on another', async () => {
|
||||||
@ -890,9 +931,6 @@ describe('workflow > triggers > collection', () => {
|
|||||||
const e1s = await w1.getExecutions();
|
const e1s = await w1.getExecutions();
|
||||||
expect(e1s.length).toBe(1);
|
expect(e1s.length).toBe(1);
|
||||||
|
|
||||||
const user = await app.db.getRepository('users').findOne();
|
|
||||||
const agent = app.agent().login(user);
|
|
||||||
|
|
||||||
const { body } = await agent.resource('workflows').revision({
|
const { body } = await agent.resource('workflows').revision({
|
||||||
filterByTk: w1.id,
|
filterByTk: w1.id,
|
||||||
filter: {
|
filter: {
|
||||||
@ -922,5 +960,36 @@ describe('workflow > triggers > collection', () => {
|
|||||||
});
|
});
|
||||||
expect(e3s.length).toBe(1);
|
expect(e3s.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.skip('sync event on another', async () => {
|
||||||
|
const workflow = await WorkflowModel.create({
|
||||||
|
enabled: true,
|
||||||
|
type: 'collection',
|
||||||
|
sync: true,
|
||||||
|
config: {
|
||||||
|
mode: 1,
|
||||||
|
collection: 'another:posts',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = await PostRepo.create({ values: { title: 't1' } });
|
||||||
|
|
||||||
|
const e1s = await workflow.getExecutions();
|
||||||
|
expect(e1s.length).toBe(0);
|
||||||
|
|
||||||
|
const AnotherPostRepo = anotherDB.getRepository('posts');
|
||||||
|
const anotherPost = await AnotherPostRepo.create({ values: { title: 't2' } });
|
||||||
|
|
||||||
|
const e2s = await workflow.getExecutions();
|
||||||
|
expect(e2s.length).toBe(1);
|
||||||
|
expect(e2s[0].status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||||
|
expect(e2s[0].context.data.title).toBe('t2');
|
||||||
|
|
||||||
|
const p1s = await PostRepo.find();
|
||||||
|
expect(p1s.length).toBe(1);
|
||||||
|
|
||||||
|
const p2s = await AnotherPostRepo.find();
|
||||||
|
expect(p2s.length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -11,6 +11,8 @@ import actions, { Context, utils } from '@nocobase/actions';
|
|||||||
import { Op, Repository } from '@nocobase/database';
|
import { Op, Repository } from '@nocobase/database';
|
||||||
|
|
||||||
import Plugin from '../Plugin';
|
import Plugin from '../Plugin';
|
||||||
|
import Processor from '../Processor';
|
||||||
|
import WorkflowRepository from '../repositories/WorkflowRepository';
|
||||||
|
|
||||||
export async function update(context: Context, next) {
|
export async function update(context: Context, next) {
|
||||||
const repository = utils.getRepositoryFromParams(context) as Repository;
|
const repository = utils.getRepositoryFromParams(context) as Repository;
|
||||||
@ -63,87 +65,14 @@ export async function destroy(context: Context, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function revision(context: Context, next) {
|
export async function revision(context: Context, next) {
|
||||||
const plugin = context.app.getPlugin(Plugin);
|
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
|
||||||
const repository = utils.getRepositoryFromParams(context);
|
|
||||||
const { filterByTk, filter = {}, values = {} } = context.action.params;
|
const { filterByTk, filter = {}, values = {} } = context.action.params;
|
||||||
|
|
||||||
context.body = await context.db.sequelize.transaction(async (transaction) => {
|
context.body = await repository.revision({
|
||||||
const origin = await repository.findOne({
|
filterByTk,
|
||||||
filterByTk,
|
filter,
|
||||||
filter,
|
values,
|
||||||
appends: ['nodes'],
|
context,
|
||||||
context,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trigger = plugin.triggers.get(origin.type);
|
|
||||||
|
|
||||||
const revisionData = filter.key
|
|
||||||
? {
|
|
||||||
key: filter.key,
|
|
||||||
title: origin.title,
|
|
||||||
triggerTitle: origin.triggerTitle,
|
|
||||||
allExecuted: origin.allExecuted,
|
|
||||||
}
|
|
||||||
: values;
|
|
||||||
|
|
||||||
const instance = await repository.create({
|
|
||||||
values: {
|
|
||||||
title: `${origin.title} copy`,
|
|
||||||
description: origin.description,
|
|
||||||
...revisionData,
|
|
||||||
sync: origin.sync,
|
|
||||||
type: origin.type,
|
|
||||||
config:
|
|
||||||
typeof trigger.duplicateConfig === 'function'
|
|
||||||
? await trigger.duplicateConfig(origin, { transaction })
|
|
||||||
: origin.config,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalNodesMap = new Map();
|
|
||||||
origin.nodes.forEach((node) => {
|
|
||||||
originalNodesMap.set(node.id, node);
|
|
||||||
});
|
|
||||||
|
|
||||||
const oldToNew = new Map();
|
|
||||||
const newToOld = new Map();
|
|
||||||
for await (const node of origin.nodes) {
|
|
||||||
const instruction = plugin.instructions.get(node.type);
|
|
||||||
const newNode = await instance.createNode(
|
|
||||||
{
|
|
||||||
type: node.type,
|
|
||||||
key: node.key,
|
|
||||||
config:
|
|
||||||
typeof instruction.duplicateConfig === 'function'
|
|
||||||
? await instruction.duplicateConfig(node, { transaction })
|
|
||||||
: node.config,
|
|
||||||
title: node.title,
|
|
||||||
branchIndex: node.branchIndex,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
// NOTE: keep original node references for later replacement
|
|
||||||
oldToNew.set(node.id, newNode);
|
|
||||||
newToOld.set(newNode.id, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const [oldId, newNode] of oldToNew.entries()) {
|
|
||||||
const oldNode = originalNodesMap.get(oldId);
|
|
||||||
const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
|
|
||||||
const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
|
|
||||||
|
|
||||||
await newNode.update(
|
|
||||||
{
|
|
||||||
upstreamId: newUpstream?.id ?? null,
|
|
||||||
downstreamId: newDownstream?.id ?? null,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
@ -169,6 +98,62 @@ export async function sync(context: Context, next) {
|
|||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* Keep for action trigger compatibility
|
||||||
|
*/
|
||||||
export async function trigger(context: Context, next) {
|
export async function trigger(context: Context, next) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function execute(context: Context, next) {
|
||||||
|
const plugin = context.app.pm.get(Plugin) as Plugin;
|
||||||
|
const { filterByTk, autoRevision } = context.action.params;
|
||||||
|
if (!filterByTk) {
|
||||||
|
return context.throw(400, 'filterByTk is required');
|
||||||
|
}
|
||||||
|
const id = Number.parseInt(filterByTk, 10);
|
||||||
|
if (Number.isNaN(id)) {
|
||||||
|
return context.throw(400, 'filterByTk is invalid');
|
||||||
|
}
|
||||||
|
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
|
||||||
|
const workflow = plugin.enabledCache.get(id) || (await repository.findOne({ filterByTk }));
|
||||||
|
if (!workflow) {
|
||||||
|
return context.throw(404, 'workflow not found');
|
||||||
|
}
|
||||||
|
const { executed } = workflow;
|
||||||
|
let processor;
|
||||||
|
try {
|
||||||
|
processor = (await plugin.execute(workflow, context, { manually: true })) as Processor;
|
||||||
|
if (!processor) {
|
||||||
|
return context.throw(400, 'workflow not triggered');
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
return context.throw(400, ex.message);
|
||||||
|
}
|
||||||
|
context.action.mergeParams({
|
||||||
|
filter: { key: workflow.key },
|
||||||
|
});
|
||||||
|
let newVersion;
|
||||||
|
if (!executed && autoRevision) {
|
||||||
|
newVersion = await repository.revision({
|
||||||
|
filterByTk: workflow.id,
|
||||||
|
filter: { key: workflow.key },
|
||||||
|
values: {
|
||||||
|
current: workflow.current,
|
||||||
|
enabled: workflow.enabled,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
context.body = {
|
||||||
|
execution: {
|
||||||
|
id: processor.execution.id,
|
||||||
|
status: processor.execution.status,
|
||||||
|
},
|
||||||
|
newVersionId: newVersion?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
@ -14,6 +14,7 @@ export default function () {
|
|||||||
dumpRules: 'required',
|
dumpRules: 'required',
|
||||||
name: 'workflows',
|
name: 'workflows',
|
||||||
shared: true,
|
shared: true,
|
||||||
|
repository: 'WorkflowRepository',
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: 'key',
|
name: 'key',
|
||||||
@ -71,7 +72,6 @@ export default function () {
|
|||||||
{
|
{
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
name: 'current',
|
name: 'current',
|
||||||
defaultValue: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
@ -14,5 +14,5 @@ export * from './functions';
|
|||||||
export * from './logicCalculate';
|
export * from './logicCalculate';
|
||||||
export { Trigger } from './triggers';
|
export { Trigger } from './triggers';
|
||||||
export { default as Processor } from './Processor';
|
export { default as Processor } from './Processor';
|
||||||
export { default } from './Plugin';
|
export { default, EventOptions } from './Plugin';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
import PluginWorkflowServer from '../Plugin';
|
||||||
|
|
||||||
|
export default class WorkflowRepository extends Repository {
|
||||||
|
async revision(options) {
|
||||||
|
const { filterByTk, filter, values, context } = options;
|
||||||
|
const plugin = context.app.pm.get(PluginWorkflowServer) as PluginWorkflowServer;
|
||||||
|
return this.database.sequelize.transaction(async (transaction) => {
|
||||||
|
const origin = await this.findOne({
|
||||||
|
filterByTk,
|
||||||
|
filter,
|
||||||
|
appends: ['nodes'],
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = plugin.triggers.get(origin.type);
|
||||||
|
|
||||||
|
const revisionData = filter.key
|
||||||
|
? {
|
||||||
|
key: filter.key,
|
||||||
|
title: origin.title,
|
||||||
|
triggerTitle: origin.triggerTitle,
|
||||||
|
allExecuted: origin.allExecuted,
|
||||||
|
current: null,
|
||||||
|
...values,
|
||||||
|
}
|
||||||
|
: values;
|
||||||
|
|
||||||
|
const instance = await this.create({
|
||||||
|
values: {
|
||||||
|
title: `${origin.title} copy`,
|
||||||
|
description: origin.description,
|
||||||
|
...revisionData,
|
||||||
|
sync: origin.sync,
|
||||||
|
type: origin.type,
|
||||||
|
config:
|
||||||
|
typeof trigger.duplicateConfig === 'function'
|
||||||
|
? await trigger.duplicateConfig(origin, { transaction })
|
||||||
|
: origin.config,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalNodesMap = new Map();
|
||||||
|
origin.nodes.forEach((node) => {
|
||||||
|
originalNodesMap.set(node.id, node);
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldToNew = new Map();
|
||||||
|
const newToOld = new Map();
|
||||||
|
for await (const node of origin.nodes) {
|
||||||
|
const instruction = plugin.instructions.get(node.type);
|
||||||
|
const newNode = await instance.createNode(
|
||||||
|
{
|
||||||
|
type: node.type,
|
||||||
|
key: node.key,
|
||||||
|
config:
|
||||||
|
typeof instruction.duplicateConfig === 'function'
|
||||||
|
? await instruction.duplicateConfig(node, { transaction })
|
||||||
|
: node.config,
|
||||||
|
title: node.title,
|
||||||
|
branchIndex: node.branchIndex,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
// NOTE: keep original node references for later replacement
|
||||||
|
oldToNew.set(node.id, newNode);
|
||||||
|
newToOld.set(newNode.id, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const [oldId, newNode] of oldToNew.entries()) {
|
||||||
|
const oldNode = originalNodesMap.get(oldId);
|
||||||
|
const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
|
||||||
|
const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
|
||||||
|
|
||||||
|
await newNode.update(
|
||||||
|
{
|
||||||
|
upstreamId: newUpstream?.id ?? null,
|
||||||
|
downstreamId: newDownstream?.id ?? null,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -7,13 +7,16 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import { isValidFilter } from '@nocobase/utils';
|
||||||
import { Collection, Model, Transactionable } from '@nocobase/database';
|
import { Collection, Model, Transactionable } from '@nocobase/database';
|
||||||
|
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||||
|
|
||||||
import Trigger from '.';
|
import Trigger from '.';
|
||||||
import { toJSON } from '../utils';
|
import { toJSON } from '../utils';
|
||||||
import type { WorkflowModel } from '../types';
|
import type { WorkflowModel } from '../types';
|
||||||
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
import type { EventOptions } from '../Plugin';
|
||||||
import { isValidFilter } from '@nocobase/utils';
|
import { Context } from '@nocobase/actions';
|
||||||
import { pick } from 'lodash';
|
|
||||||
|
|
||||||
export interface CollectionChangeTriggerConfig {
|
export interface CollectionChangeTriggerConfig {
|
||||||
collection: string;
|
collection: string;
|
||||||
@ -45,90 +48,98 @@ function getFieldRawName(collection: ICollection, name: string) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// async function, should return promise
|
|
||||||
async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
|
|
||||||
const { condition, changed, mode, appends } = workflow.config;
|
|
||||||
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
|
|
||||||
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
|
|
||||||
const collection: Collection = (collectionManager as SequelizeCollectionManager).getCollection(collectionName);
|
|
||||||
const { transaction, context } = options;
|
|
||||||
const { repository, filterTargetKey } = collection;
|
|
||||||
|
|
||||||
// NOTE: if no configured fields changed, do not trigger
|
|
||||||
if (
|
|
||||||
changed &&
|
|
||||||
changed.length &&
|
|
||||||
changed
|
|
||||||
.filter((name) => {
|
|
||||||
const field = collection.getField(name);
|
|
||||||
return field && !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.options.type);
|
|
||||||
})
|
|
||||||
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterByTk = Array.isArray(filterTargetKey)
|
|
||||||
? pick(data, filterTargetKey)
|
|
||||||
: { [filterTargetKey]: data[filterTargetKey] };
|
|
||||||
// NOTE: if no configured condition, or not match, do not trigger
|
|
||||||
if (isValidFilter(condition) && !(mode & MODE_BITMAP.DESTROY)) {
|
|
||||||
// TODO: change to map filter format to calculation format
|
|
||||||
// const calculation = toCalculation(condition);
|
|
||||||
const count = await repository.count({
|
|
||||||
filterByTk,
|
|
||||||
filter: condition,
|
|
||||||
context,
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!count) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = data;
|
|
||||||
|
|
||||||
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
|
|
||||||
const includeFields = appends.reduce((set, field) => {
|
|
||||||
set.add(field.split('.')[0]);
|
|
||||||
set.add(field);
|
|
||||||
return set;
|
|
||||||
}, new Set());
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
result = await repository.findOne({
|
|
||||||
filterByTk,
|
|
||||||
appends: Array.from(includeFields),
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: `result.toJSON()` throws error
|
|
||||||
const json = toJSON(result);
|
|
||||||
|
|
||||||
if (workflow.sync) {
|
|
||||||
await this.workflow.trigger(
|
|
||||||
workflow,
|
|
||||||
{ data: json, stack: context?.stack },
|
|
||||||
{
|
|
||||||
transaction: this.workflow.useDataSourceTransaction(dataSourceName, transaction),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (transaction) {
|
|
||||||
transaction.afterCommit(() => {
|
|
||||||
this.workflow.trigger(workflow, { data: json, stack: context?.stack });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.workflow.trigger(workflow, { data: json, stack: context?.stack });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class CollectionTrigger extends Trigger {
|
export default class CollectionTrigger extends Trigger {
|
||||||
events = new Map();
|
events = new Map();
|
||||||
|
|
||||||
|
// async function, should return promise
|
||||||
|
private static async handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
|
||||||
|
const [dataSourceName] = parseCollectionName(workflow.config.collection);
|
||||||
|
const transaction = this.workflow.useDataSourceTransaction(dataSourceName, options.transaction);
|
||||||
|
const ctx = await this.prepare(workflow, data, { ...options, transaction });
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflow.sync) {
|
||||||
|
await this.workflow.trigger(workflow, ctx, {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (transaction) {
|
||||||
|
transaction.afterCommit(() => {
|
||||||
|
this.workflow.trigger(workflow, ctx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.workflow.trigger(workflow, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepare(workflow: WorkflowModel, data: Model | Record<string, any>, options) {
|
||||||
|
const { condition, changed, mode, appends } = workflow.config;
|
||||||
|
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
|
||||||
|
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
|
||||||
|
const collection: Collection = (collectionManager as SequelizeCollectionManager).getCollection(collectionName);
|
||||||
|
const { transaction, context } = options;
|
||||||
|
const { repository, filterTargetKey } = collection;
|
||||||
|
|
||||||
|
// NOTE: if no configured fields changed, do not trigger
|
||||||
|
if (
|
||||||
|
data instanceof Model &&
|
||||||
|
changed &&
|
||||||
|
changed.length &&
|
||||||
|
changed
|
||||||
|
.filter((name) => {
|
||||||
|
const field = collection.getField(name);
|
||||||
|
return field && !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.options.type);
|
||||||
|
})
|
||||||
|
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterByTk = Array.isArray(filterTargetKey)
|
||||||
|
? pick(data, filterTargetKey)
|
||||||
|
: { [filterTargetKey]: data[filterTargetKey] };
|
||||||
|
// NOTE: if no configured condition, or not match, do not trigger
|
||||||
|
if (isValidFilter(condition) && !(mode & MODE_BITMAP.DESTROY)) {
|
||||||
|
// TODO: change to map filter format to calculation format
|
||||||
|
// const calculation = toCalculation(condition);
|
||||||
|
const count = await repository.count({
|
||||||
|
filterByTk,
|
||||||
|
filter: condition,
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!count) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = data;
|
||||||
|
|
||||||
|
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
|
||||||
|
const includeFields = appends.reduce((set, field) => {
|
||||||
|
set.add(field.split('.')[0]);
|
||||||
|
set.add(field);
|
||||||
|
return set;
|
||||||
|
}, new Set());
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
result = await repository.findOne({
|
||||||
|
filterByTk,
|
||||||
|
appends: Array.from(includeFields),
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: toJSON(result),
|
||||||
|
stack: context?.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
on(workflow: WorkflowModel) {
|
on(workflow: WorkflowModel) {
|
||||||
const { collection, mode } = workflow.config;
|
const { collection, mode } = workflow.config;
|
||||||
if (!collection) {
|
if (!collection) {
|
||||||
@ -146,7 +157,7 @@ export default class CollectionTrigger extends Trigger {
|
|||||||
const name = getHookId(workflow, `${collection}.${type}`);
|
const name = getHookId(workflow, `${collection}.${type}`);
|
||||||
if (mode & key) {
|
if (mode & key) {
|
||||||
if (!this.events.has(name)) {
|
if (!this.events.has(name)) {
|
||||||
const listener = handler.bind(this, workflow);
|
const listener = (<typeof CollectionTrigger>this.constructor).handler.bind(this, workflow);
|
||||||
this.events.set(name, listener);
|
this.events.set(name, listener);
|
||||||
db.on(event, listener);
|
db.on(event, listener);
|
||||||
}
|
}
|
||||||
@ -206,4 +217,14 @@ export default class CollectionTrigger extends Trigger {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async execute(workflow: WorkflowModel, context: Context, options: EventOptions) {
|
||||||
|
const ctx = await this.prepare(workflow, context.action.params.values?.data, options);
|
||||||
|
const [dataSourceName] = parseCollectionName(workflow.config.collection);
|
||||||
|
const { transaction } = options;
|
||||||
|
return this.workflow.trigger(workflow, ctx, {
|
||||||
|
...options,
|
||||||
|
transaction: this.workflow.useDataSourceTransaction(dataSourceName, transaction),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import parser from 'cron-parser';
|
|||||||
import type Plugin from '../../Plugin';
|
import type Plugin from '../../Plugin';
|
||||||
import type { WorkflowModel } from '../../types';
|
import type { WorkflowModel } from '../../types';
|
||||||
import { parseDateWithoutMs, SCHEDULE_MODE } from './utils';
|
import { parseDateWithoutMs, SCHEDULE_MODE } from './utils';
|
||||||
import { parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
import { parseCollectionName, SequelizeCollectionManager, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||||
|
|
||||||
export type ScheduleOnField = {
|
export type ScheduleOnField = {
|
||||||
field: string;
|
field: string;
|
||||||
@ -93,7 +93,7 @@ function getHookId(workflow, type: string) {
|
|||||||
return `${type}#${workflow.id}`;
|
return `${type}#${workflow.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ScheduleTrigger {
|
export default class DateFieldScheduleTrigger {
|
||||||
events = new Map();
|
events = new Map();
|
||||||
|
|
||||||
private timer: NodeJS.Timeout | null = null;
|
private timer: NodeJS.Timeout | null = null;
|
||||||
@ -378,8 +378,9 @@ export default class ScheduleTrigger {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.events.set(name, listener);
|
this.events.set(name, listener);
|
||||||
// @ts-ignore
|
const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
|
||||||
this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager.db.on(event, listener);
|
const { db } = dataSource.collectionManager as SequelizeCollectionManager;
|
||||||
|
db.on(event, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
off(workflow: WorkflowModel) {
|
off(workflow: WorkflowModel) {
|
||||||
@ -396,8 +397,8 @@ export default class ScheduleTrigger {
|
|||||||
const name = getHookId(workflow, event);
|
const name = getHookId(workflow, event);
|
||||||
const listener = this.events.get(name);
|
const listener = this.events.get(name);
|
||||||
if (listener) {
|
if (listener) {
|
||||||
// @ts-ignore
|
const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
|
||||||
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
|
const { db } = dataSource.collectionManager as SequelizeCollectionManager;
|
||||||
db.off(event, listener);
|
db.off(event, listener);
|
||||||
this.events.delete(name);
|
this.events.delete(name);
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
* 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 Trigger from '..';
|
import Trigger from '..';
|
||||||
import type Plugin from '../../Plugin';
|
import type Plugin from '../../Plugin';
|
||||||
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';
|
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';
|
||||||
@ -45,6 +46,11 @@ export default class ScheduleTrigger extends Trigger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async execute(workflow, context: Context, options) {
|
||||||
|
const { values } = context.action.params;
|
||||||
|
return this.workflow.trigger(workflow, { ...values, date: values?.date ?? new Date() }, options);
|
||||||
|
}
|
||||||
|
|
||||||
// async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> {
|
// async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> {
|
||||||
// if (!context.date) {
|
// if (!context.date) {
|
||||||
// return false;
|
// return false;
|
||||||
|
@ -10,16 +10,22 @@
|
|||||||
import { Transactionable } from '@nocobase/database';
|
import { Transactionable } from '@nocobase/database';
|
||||||
import type Plugin from '../Plugin';
|
import type Plugin from '../Plugin';
|
||||||
import type { WorkflowModel } from '../types';
|
import type { WorkflowModel } from '../types';
|
||||||
|
import Processor from '../Processor';
|
||||||
|
|
||||||
export abstract class Trigger {
|
export abstract class Trigger {
|
||||||
constructor(public readonly workflow: Plugin) {}
|
constructor(public readonly workflow: Plugin) {}
|
||||||
abstract on(workflow: WorkflowModel): void;
|
on(workflow: WorkflowModel): void {}
|
||||||
abstract off(workflow: WorkflowModel): void;
|
off(workflow: WorkflowModel): void {}
|
||||||
validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): boolean | Promise<boolean> {
|
validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): boolean | Promise<boolean> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>;
|
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>;
|
||||||
sync?: boolean;
|
sync?: boolean;
|
||||||
|
execute?(
|
||||||
|
workflow: WorkflowModel,
|
||||||
|
context: any,
|
||||||
|
options: Transactionable,
|
||||||
|
): void | Processor | Promise<void | Processor>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Trigger;
|
export default Trigger;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user