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,
|
||||
RadioWithTooltip,
|
||||
useGetCollectionFields,
|
||||
TriggerCollectionRecordSelect,
|
||||
} from '@nocobase/plugin-workflow/client';
|
||||
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 = {
|
||||
useCollectionDataSource,
|
||||
useWorkflowAnyExecuted,
|
||||
@ -201,6 +251,7 @@ export default class extends Trigger {
|
||||
components = {
|
||||
RadioWithTooltip,
|
||||
CheckboxGroupWithTooltip,
|
||||
TriggerCollectionRecordSelect,
|
||||
};
|
||||
isActionTriggerable = (config, context) => {
|
||||
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.
|
||||
*/
|
||||
|
||||
import { get } from 'lodash';
|
||||
import { get, pick } from 'lodash';
|
||||
import { BelongsTo, HasOne } from 'sequelize';
|
||||
import { Model, modelAssociationByKey } from '@nocobase/database';
|
||||
import Application, { DefaultContext } from '@nocobase/server';
|
||||
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';
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { omit } from 'lodash';
|
||||
import Database from '@nocobase/database';
|
||||
import { EXECUTION_STATUS } from '@nocobase/plugin-workflow';
|
||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||
@ -22,12 +23,14 @@ describe('workflow > action-trigger', () => {
|
||||
let CategoryRepo;
|
||||
let WorkflowModel;
|
||||
let UserRepo;
|
||||
let root;
|
||||
let rootAgent;
|
||||
let users;
|
||||
let userAgents;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp({
|
||||
plugins: ['users', 'auth', Plugin],
|
||||
plugins: ['users', 'auth', 'acl', 'data-source-manager', 'system-settings', Plugin],
|
||||
});
|
||||
await app.pm.get('auth').install();
|
||||
agent = app.agent();
|
||||
@ -37,6 +40,9 @@ describe('workflow > action-trigger', () => {
|
||||
CategoryRepo = db.getCollection('categories').repository;
|
||||
UserRepo = db.getCollection('users').repository;
|
||||
|
||||
root = await UserRepo.findOne({});
|
||||
rootAgent = app.agent().login(root);
|
||||
|
||||
users = await UserRepo.create({
|
||||
values: [
|
||||
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] },
|
||||
@ -293,6 +299,9 @@ describe('workflow > action-trigger', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
describe('directly trigger', () => {
|
||||
it('no collection configured should not be triggered', async () => {
|
||||
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', () => {
|
||||
it('revision', async () => {
|
||||
const w1 = await WorkflowModel.create({
|
||||
|
@ -15,7 +15,7 @@ import { MockClusterOptions, MockServer, createMockCluster, createMockServer, mo
|
||||
import functions from './functions';
|
||||
import triggers from './triggers';
|
||||
import instructions from './instructions';
|
||||
import { SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||
import { SequelizeCollectionManager, SequelizeDataSource } from '@nocobase/data-source-manager';
|
||||
import { uid } from '@nocobase/utils';
|
||||
export { sleep } from '@nocobase/test';
|
||||
|
||||
@ -70,8 +70,8 @@ export async function getApp({
|
||||
}),
|
||||
);
|
||||
const another = app.dataSourceManager.dataSources.get('another');
|
||||
// @ts-ignore
|
||||
const anotherDB = another.collectionManager.db;
|
||||
|
||||
const anotherDB = (another.collectionManager as SequelizeCollectionManager).db;
|
||||
|
||||
await anotherDB.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
|
@ -111,8 +111,13 @@ function JobModal() {
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
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);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [execution]);
|
||||
}, [execution.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!execution) {
|
||||
@ -175,7 +180,7 @@ function ExecutionsDropdown(props) {
|
||||
setExecutionsAfter(data.data.reverse());
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [execution]);
|
||||
}, [execution.id]);
|
||||
|
||||
const onClick = useCallback(
|
||||
({ key }) => {
|
||||
@ -183,7 +188,7 @@ function ExecutionsDropdown(props) {
|
||||
navigate(getWorkflowExecutionsPath(key));
|
||||
}
|
||||
},
|
||||
[execution],
|
||||
[execution.id],
|
||||
);
|
||||
|
||||
return execution ? (
|
||||
|
@ -7,32 +7,44 @@
|
||||
* 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 {
|
||||
ActionContextProvider,
|
||||
ResourceActionProvider,
|
||||
SchemaComponent,
|
||||
cx,
|
||||
useActionContext,
|
||||
useApp,
|
||||
useCancelAction,
|
||||
useDocumentTitle,
|
||||
useNavigateNoUpdate,
|
||||
useResourceActionContext,
|
||||
useResourceContext,
|
||||
useCompile,
|
||||
css,
|
||||
usePlugin,
|
||||
} from '@nocobase/client';
|
||||
import { str2moment } 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 { dayjs } from '@nocobase/utils/client';
|
||||
|
||||
import { CanvasContent } from './CanvasContent';
|
||||
import { ExecutionStatusColumn } from './components/ExecutionStatus';
|
||||
import { ExecutionLink } from './ExecutionLink';
|
||||
import { FlowContext, useFlowContext } from './FlowContext';
|
||||
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
|
||||
import { lang } from './locale';
|
||||
import { lang, NAMESPACE } from './locale';
|
||||
import { executionSchema } from './schemas/executions';
|
||||
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 }) {
|
||||
const { workflow } = useFlowContext();
|
||||
@ -53,53 +65,192 @@ function ExecutionResourceProvider({ request, filter = {}, ...others }) {
|
||||
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 { 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 app = useApp();
|
||||
const { resource } = useResourceContext();
|
||||
const { message } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
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 onRevision = useCallback(async () => {
|
||||
const {
|
||||
data: { data: revision },
|
||||
} = await resource.revision({
|
||||
@ -111,9 +262,9 @@ export function WorkflowCanvas() {
|
||||
message.success(t('Operation succeeded'));
|
||||
|
||||
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
|
||||
? 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 }) {
|
||||
switch (key) {
|
||||
case 'history':
|
||||
setVisible(true);
|
||||
return;
|
||||
case 'revision':
|
||||
return onRevision();
|
||||
case 'delete':
|
||||
return onDelete();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const onMenuCommand = useCallback(
|
||||
({ key }) => {
|
||||
switch (key) {
|
||||
case 'history':
|
||||
setHistoryVisible(true);
|
||||
return;
|
||||
case 'revision':
|
||||
return onRevision();
|
||||
case 'delete':
|
||||
return onDelete();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onDelete, onRevision],
|
||||
);
|
||||
|
||||
const revisionable =
|
||||
workflow.executed &&
|
||||
!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 (
|
||||
<FlowContext.Provider
|
||||
value={{
|
||||
workflow,
|
||||
revisions,
|
||||
nodes,
|
||||
refresh,
|
||||
}}
|
||||
@ -175,13 +434,14 @@ export function WorkflowCanvas() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
<aside>
|
||||
{workflow.sync ? (
|
||||
<Tag color="orange">{lang('Synchronously')}</Tag>
|
||||
) : (
|
||||
<Tag color="cyan">{lang('Asynchronously')}</Tag>
|
||||
)}
|
||||
</header>
|
||||
<aside>
|
||||
<ExecuteActionButton />
|
||||
<Dropdown
|
||||
className="workflow-versions"
|
||||
trigger={['click']}
|
||||
@ -204,7 +464,7 @@ export function WorkflowCanvas() {
|
||||
label: (
|
||||
<>
|
||||
<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')}
|
||||
unCheckedChildren={lang('Off')}
|
||||
/>
|
||||
<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, setVisible }}>
|
||||
<SchemaComponent
|
||||
schema={executionSchema}
|
||||
components={{
|
||||
ExecutionResourceProvider,
|
||||
ExecutionLink,
|
||||
ExecutionStatusColumn,
|
||||
}}
|
||||
scope={{
|
||||
useRefreshActionProps,
|
||||
}}
|
||||
/>
|
||||
</ActionContextProvider>
|
||||
<WorkflowMenu />
|
||||
</aside>
|
||||
</div>
|
||||
<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 './Calculation';
|
||||
export * from './Fieldset';
|
||||
export * from './TriggerCollectionRecordSelect';
|
||||
|
@ -38,6 +38,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'blue',
|
||||
icon: <HourglassOutlined />,
|
||||
statusType: 'info',
|
||||
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}" })}}`,
|
||||
color: 'gold',
|
||||
icon: <LoadingOutlined />,
|
||||
statusType: 'warning',
|
||||
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}" })}}`,
|
||||
color: 'green',
|
||||
icon: <CheckOutlined />,
|
||||
statusType: 'success',
|
||||
description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
{
|
||||
@ -59,6 +62,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'red',
|
||||
icon: <ExclamationOutlined />,
|
||||
statusType: 'error',
|
||||
description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
{
|
||||
@ -66,6 +70,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Error", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'red',
|
||||
icon: <CloseOutlined />,
|
||||
statusType: 'error',
|
||||
description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
{
|
||||
@ -73,6 +78,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'red',
|
||||
icon: <MinusOutlined rotate={90} />,
|
||||
statusType: 'error',
|
||||
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}" })}}`,
|
||||
color: 'volcano',
|
||||
icon: <MinusOutlined rotate={45} />,
|
||||
statusType: 'error',
|
||||
description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
{
|
||||
@ -87,6 +94,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'volcano',
|
||||
icon: <MinusOutlined />,
|
||||
statusType: 'error',
|
||||
description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
{
|
||||
@ -94,6 +102,7 @@ export const ExecutionStatusOptions = [
|
||||
label: `{{t("Retry needed", { ns: "${NAMESPACE}" })}}`,
|
||||
color: 'volcano',
|
||||
icon: <RedoOutlined />,
|
||||
statusType: 'error',
|
||||
description: `{{t("General failed but should do another try.", { ns: "${NAMESPACE}" })}}`,
|
||||
},
|
||||
];
|
||||
|
@ -45,12 +45,14 @@ export default class PluginWorkflowClient extends Plugin {
|
||||
|
||||
useTriggersOptions = () => {
|
||||
const compile = useCompile();
|
||||
return Array.from(this.triggers.getEntities()).map(([value, { title, ...options }]) => ({
|
||||
value,
|
||||
label: compile(title),
|
||||
color: 'gold',
|
||||
options,
|
||||
}));
|
||||
return Array.from(this.triggers.getEntities())
|
||||
.map(([value, { title, ...options }]) => ({
|
||||
value,
|
||||
label: compile(title),
|
||||
color: 'gold',
|
||||
options,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
isWorkflowSync(workflow) {
|
||||
@ -92,11 +94,6 @@ export default class PluginWorkflowClient extends Plugin {
|
||||
element: <ExecutionPage />,
|
||||
});
|
||||
|
||||
this.app.addComponents({
|
||||
WorkflowPage,
|
||||
ExecutionPage,
|
||||
});
|
||||
|
||||
this.app.pluginSettingsManager.add(NAMESPACE, {
|
||||
icon: 'PartitionOutlined',
|
||||
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
|
||||
|
@ -29,6 +29,7 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
aside {
|
||||
@ -104,17 +105,18 @@ const useStyles = createStyles(({ css, token }) => {
|
||||
strong {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
> .enabled {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
&.enabled {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
> .unexecuted {
|
||||
strong {
|
||||
font-style: italic;
|
||||
}
|
||||
&.unexecuted {
|
||||
strong {
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import { appends, collection, filter } from '../schemas/collection';
|
||||
import { getCollectionFieldOptions, useGetCollectionFields } from '../variable';
|
||||
import { useWorkflowAnyExecuted } from '../hooks';
|
||||
import { Trigger } from '.';
|
||||
import { TriggerCollectionRecordSelect } from '../components/TriggerCollectionRecordSelect';
|
||||
|
||||
const COLLECTION_TRIGGER_MODE = {
|
||||
CREATED: 1,
|
||||
@ -190,7 +191,22 @@ export default class extends Trigger {
|
||||
};
|
||||
components = {
|
||||
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;
|
||||
useInitializers(config): SchemaInitializerItemType | null {
|
||||
if (!config.collection) {
|
||||
|
@ -67,7 +67,11 @@ export abstract class Trigger {
|
||||
description?: string;
|
||||
// group: string;
|
||||
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;
|
||||
scope?: { [key: string]: any };
|
||||
components?: { [key: string]: any };
|
||||
@ -138,12 +142,13 @@ function TriggerExecution() {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
className: css`
|
||||
padding: 1em;
|
||||
background-color: #f3f3f3;
|
||||
`,
|
||||
className: styles.nodeJobResultClass,
|
||||
autoSize: {
|
||||
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 React, { useState } from 'react';
|
||||
import { NAMESPACE } from '../../locale';
|
||||
import { appends, collection } from '../../schemas/collection';
|
||||
import { SCHEDULE_MODE } from './constants';
|
||||
import { EndsByField } from './EndsByField';
|
||||
import { OnField } from './OnField';
|
||||
import { RepeatField } from './RepeatField';
|
||||
|
||||
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]}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
import { ScheduleModes } from './ScheduleModes';
|
||||
|
||||
const scheduleModeOptions = [
|
||||
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
|
||||
@ -201,9 +49,7 @@ export const ScheduleConfig = () => {
|
||||
name: 'mode',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Radio.Group',
|
||||
'x-component-props': {
|
||||
options: scheduleModeOptions,
|
||||
},
|
||||
enum: scheduleModeOptions,
|
||||
required: true,
|
||||
default: SCHEDULE_MODE.STATIC,
|
||||
}}
|
||||
@ -228,7 +74,7 @@ export const ScheduleConfig = () => {
|
||||
}
|
||||
`,
|
||||
},
|
||||
properties: ModeFieldsets[mode],
|
||||
properties: ScheduleModes[mode]?.fieldset,
|
||||
},
|
||||
},
|
||||
} 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 { ScheduleConfig } from './ScheduleConfig';
|
||||
import { SCHEDULE_MODE } from './constants';
|
||||
import { TriggerScheduleConfig } from './TriggerScheduleConfig';
|
||||
import { ScheduleModes } from './ScheduleModes';
|
||||
import { TriggerCollectionRecordSelect } from '../../components/TriggerCollectionRecordSelect';
|
||||
|
||||
function useVariables(config, opts) {
|
||||
const [dataSourceName, collection] = parseCollectionName(config.collection);
|
||||
@ -66,11 +69,26 @@ export default class extends Trigger {
|
||||
'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 = {
|
||||
useCollectionDataSource,
|
||||
};
|
||||
components = {
|
||||
ScheduleConfig,
|
||||
TriggerScheduleConfig,
|
||||
TriggerCollectionRecordSelect,
|
||||
};
|
||||
useVariables = useVariables;
|
||||
useInitializers(config): SchemaInitializerItemType | null {
|
||||
|
@ -16,6 +16,15 @@
|
||||
"Duplicate": "复制",
|
||||
"Duplicate to new workflow": "复制为新工作流",
|
||||
"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": "加载中",
|
||||
"Load failed": "加载失败",
|
||||
"Use transaction": "启用事务",
|
||||
@ -64,6 +73,8 @@
|
||||
"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.":
|
||||
"请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。",
|
||||
"Choose a record of the collection to trigger.": "选择数据表中的一行记录来触发。",
|
||||
|
||||
"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.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。",
|
||||
"Trigger mode": "触发模式",
|
||||
@ -92,6 +103,9 @@
|
||||
"By field": "数据表字段",
|
||||
"By custom date": "自定义时间",
|
||||
"Advanced": "高级模式",
|
||||
"Execute on": "执行时间",
|
||||
"Current time": "当前时间",
|
||||
|
||||
"End": "结束",
|
||||
"Node result": "节点数据",
|
||||
"Variable key of node": "节点变量标识",
|
||||
|
@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import LRUCache from 'lru-cache';
|
||||
|
||||
import { Op, Transaction, Transactionable } from '@nocobase/database';
|
||||
import { Op, Transactionable } from '@nocobase/database';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { Registry } from '@nocobase/utils';
|
||||
|
||||
@ -34,15 +34,19 @@ import QueryInstruction from './instructions/QueryInstruction';
|
||||
import UpdateInstruction from './instructions/UpdateInstruction';
|
||||
|
||||
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 Pending = [ExecutionModel, JobModel?];
|
||||
|
||||
type EventOptions = {
|
||||
export type EventOptions = {
|
||||
eventKey?: string;
|
||||
context?: any;
|
||||
deferred?: boolean;
|
||||
manually?: boolean;
|
||||
[key: string]: any;
|
||||
} & Transactionable;
|
||||
|
||||
@ -69,20 +73,6 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
|
||||
if (instance.enabled) {
|
||||
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({
|
||||
@ -95,8 +85,11 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
},
|
||||
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
|
||||
await previous.update(
|
||||
{ enabled: false, current: null },
|
||||
@ -213,6 +206,12 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
async beforeLoad() {
|
||||
this.db.registerRepositories({
|
||||
WorkflowRepository,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -367,22 +366,22 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
options: EventOptions = {},
|
||||
): void | Promise<Processor | null> {
|
||||
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) {
|
||||
logger.warn(`app is not ready, event of workflow ${workflow.id} will be ignored`);
|
||||
logger.debug(`ignored event data:`, context);
|
||||
return;
|
||||
}
|
||||
if (!options.manually && !workflow.enabled) {
|
||||
logger.warn(`workflow ${workflow.id} is not enabled, event will be ignored`);
|
||||
return;
|
||||
}
|
||||
// `null` means not to trigger
|
||||
if (context == null) {
|
||||
logger.warn(`workflow ${workflow.id} event data context is null, event will be ignored`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isWorkflowSync(workflow)) {
|
||||
if (options.manually || this.isWorkflowSync(workflow)) {
|
||||
return this.triggerSync(workflow, context, options);
|
||||
}
|
||||
|
||||
@ -454,11 +453,13 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
context,
|
||||
options: EventOptions,
|
||||
): 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 valid = await trigger.validateEvent(workflow, context, { ...options, transaction });
|
||||
if (!valid) {
|
||||
if (!options.transaction) {
|
||||
if (!sameTransaction) {
|
||||
await transaction.commit();
|
||||
}
|
||||
return null;
|
||||
@ -476,7 +477,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
{ transaction },
|
||||
);
|
||||
} catch (err) {
|
||||
if (!options.transaction) {
|
||||
if (!sameTransaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
@ -502,7 +503,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
},
|
||||
);
|
||||
|
||||
if (!options.transaction) {
|
||||
if (!sameTransaction) {
|
||||
await transaction.commit();
|
||||
}
|
||||
|
||||
@ -622,6 +623,17 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
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
|
||||
* @param {string} dataSourceName
|
||||
@ -630,8 +642,8 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
* @returns {Trasaction}
|
||||
*/
|
||||
useDataSourceTransaction(dataSourceName = 'main', transaction, create = false) {
|
||||
// @ts-ignore
|
||||
const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
|
||||
const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName)
|
||||
.collectionManager as SequelizeCollectionManager;
|
||||
if (!db) {
|
||||
return;
|
||||
}
|
||||
|
@ -216,7 +216,6 @@ describe('workflow > instructions > condition', () => {
|
||||
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
|
||||
|
||||
const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
|
||||
console.log('------', jobs);
|
||||
expect(jobs.length).toBe(3);
|
||||
expect(jobs[0].result).toBe(false);
|
||||
expect(jobs[1].result).toBe(false);
|
||||
|
@ -7,20 +7,24 @@
|
||||
* 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 { MockServer } from '@nocobase/test';
|
||||
|
||||
import { EXECUTION_STATUS } from '../../constants';
|
||||
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
import PluginWorkflowServer from '../../Plugin';
|
||||
|
||||
describe('workflow > triggers > collection', () => {
|
||||
let app: MockServer;
|
||||
let db: MockDatabase;
|
||||
let plugin: PluginWorkflowServer;
|
||||
let CategoryRepo;
|
||||
let PostRepo;
|
||||
let CommentRepo;
|
||||
let TagRepo;
|
||||
let WorkflowModel;
|
||||
let agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp({
|
||||
@ -28,11 +32,15 @@ describe('workflow > triggers > collection', () => {
|
||||
});
|
||||
|
||||
db = app.db;
|
||||
plugin = app.pm.get(PluginWorkflowServer) as PluginWorkflowServer;
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
CategoryRepo = db.getCollection('categories').repository;
|
||||
PostRepo = db.getCollection('posts').repository;
|
||||
CommentRepo = db.getCollection('comments').repository;
|
||||
TagRepo = db.getCollection('tags').repository;
|
||||
|
||||
const user = await app.db.getRepository('users').findOne();
|
||||
agent = app.agent().login(user);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('trigger should not be triggered more than once in same execution', async () => {
|
||||
const workflow = await WorkflowModel.create({
|
||||
@ -834,8 +876,7 @@ describe('workflow > triggers > collection', () => {
|
||||
describe('multiple data source', () => {
|
||||
let anotherDB: MockDatabase;
|
||||
beforeEach(async () => {
|
||||
// @ts-ignore
|
||||
anotherDB = app.dataSourceManager.dataSources.get('another').collectionManager.db;
|
||||
anotherDB = (app.dataSourceManager.dataSources.get('another').collectionManager as SequelizeCollectionManager).db;
|
||||
});
|
||||
|
||||
it('collection trigger on another', async () => {
|
||||
@ -890,9 +931,6 @@ describe('workflow > triggers > collection', () => {
|
||||
const e1s = await w1.getExecutions();
|
||||
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({
|
||||
filterByTk: w1.id,
|
||||
filter: {
|
||||
@ -922,5 +960,36 @@ describe('workflow > triggers > collection', () => {
|
||||
});
|
||||
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 Plugin from '../Plugin';
|
||||
import Processor from '../Processor';
|
||||
import WorkflowRepository from '../repositories/WorkflowRepository';
|
||||
|
||||
export async function update(context: Context, next) {
|
||||
const repository = utils.getRepositoryFromParams(context) as Repository;
|
||||
@ -63,87 +65,14 @@ export async function destroy(context: Context, next) {
|
||||
}
|
||||
|
||||
export async function revision(context: Context, next) {
|
||||
const plugin = context.app.getPlugin(Plugin);
|
||||
const repository = utils.getRepositoryFromParams(context);
|
||||
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
|
||||
const { filterByTk, filter = {}, values = {} } = context.action.params;
|
||||
|
||||
context.body = await context.db.sequelize.transaction(async (transaction) => {
|
||||
const origin = await repository.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,
|
||||
}
|
||||
: 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;
|
||||
context.body = await repository.revision({
|
||||
filterByTk,
|
||||
filter,
|
||||
values,
|
||||
context,
|
||||
});
|
||||
|
||||
await next();
|
||||
@ -169,6 +98,62 @@ export async function sync(context: Context, next) {
|
||||
await next();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* Keep for action trigger compatibility
|
||||
*/
|
||||
export async function trigger(context: Context, 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',
|
||||
name: 'workflows',
|
||||
shared: true,
|
||||
repository: 'WorkflowRepository',
|
||||
fields: [
|
||||
{
|
||||
name: 'key',
|
||||
@ -71,7 +72,6 @@ export default function () {
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'current',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
|
@ -14,5 +14,5 @@ export * from './functions';
|
||||
export * from './logicCalculate';
|
||||
export { Trigger } from './triggers';
|
||||
export { default as Processor } from './Processor';
|
||||
export { default } from './Plugin';
|
||||
export { default, EventOptions } from './Plugin';
|
||||
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.
|
||||
*/
|
||||
|
||||
import { pick } from 'lodash';
|
||||
import { isValidFilter } from '@nocobase/utils';
|
||||
import { Collection, Model, Transactionable } from '@nocobase/database';
|
||||
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
|
||||
import Trigger from '.';
|
||||
import { toJSON } from '../utils';
|
||||
import type { WorkflowModel } from '../types';
|
||||
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
import { isValidFilter } from '@nocobase/utils';
|
||||
import { pick } from 'lodash';
|
||||
import type { EventOptions } from '../Plugin';
|
||||
import { Context } from '@nocobase/actions';
|
||||
|
||||
export interface CollectionChangeTriggerConfig {
|
||||
collection: string;
|
||||
@ -45,90 +48,98 @@ function getFieldRawName(collection: ICollection, name: string) {
|
||||
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 {
|
||||
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) {
|
||||
const { collection, mode } = workflow.config;
|
||||
if (!collection) {
|
||||
@ -146,7 +157,7 @@ export default class CollectionTrigger extends Trigger {
|
||||
const name = getHookId(workflow, `${collection}.${type}`);
|
||||
if (mode & key) {
|
||||
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);
|
||||
db.on(event, listener);
|
||||
}
|
||||
@ -206,4 +217,14 @@ export default class CollectionTrigger extends Trigger {
|
||||
|
||||
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 { WorkflowModel } from '../../types';
|
||||
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 = {
|
||||
field: string;
|
||||
@ -93,7 +93,7 @@ function getHookId(workflow, type: string) {
|
||||
return `${type}#${workflow.id}`;
|
||||
}
|
||||
|
||||
export default class ScheduleTrigger {
|
||||
export default class DateFieldScheduleTrigger {
|
||||
events = new Map();
|
||||
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
@ -378,8 +378,9 @@ export default class ScheduleTrigger {
|
||||
};
|
||||
|
||||
this.events.set(name, listener);
|
||||
// @ts-ignore
|
||||
this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager.db.on(event, listener);
|
||||
const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
|
||||
const { db } = dataSource.collectionManager as SequelizeCollectionManager;
|
||||
db.on(event, listener);
|
||||
}
|
||||
|
||||
off(workflow: WorkflowModel) {
|
||||
@ -396,8 +397,8 @@ export default class ScheduleTrigger {
|
||||
const name = getHookId(workflow, event);
|
||||
const listener = this.events.get(name);
|
||||
if (listener) {
|
||||
// @ts-ignore
|
||||
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
|
||||
const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
|
||||
const { db } = dataSource.collectionManager as SequelizeCollectionManager;
|
||||
db.off(event, listener);
|
||||
this.events.delete(name);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Context } from '@nocobase/actions';
|
||||
import Trigger from '..';
|
||||
import type Plugin from '../../Plugin';
|
||||
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> {
|
||||
// if (!context.date) {
|
||||
// return false;
|
||||
|
@ -10,16 +10,22 @@
|
||||
import { Transactionable } from '@nocobase/database';
|
||||
import type Plugin from '../Plugin';
|
||||
import type { WorkflowModel } from '../types';
|
||||
import Processor from '../Processor';
|
||||
|
||||
export abstract class Trigger {
|
||||
constructor(public readonly workflow: Plugin) {}
|
||||
abstract on(workflow: WorkflowModel): void;
|
||||
abstract off(workflow: WorkflowModel): void;
|
||||
on(workflow: WorkflowModel): void {}
|
||||
off(workflow: WorkflowModel): void {}
|
||||
validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): boolean | Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>;
|
||||
sync?: boolean;
|
||||
execute?(
|
||||
workflow: WorkflowModel,
|
||||
context: any,
|
||||
options: Transactionable,
|
||||
): void | Processor | Promise<void | Processor>;
|
||||
}
|
||||
|
||||
export default Trigger;
|
||||
|
Loading…
x
Reference in New Issue
Block a user