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:
Junyi 2024-12-03 21:56:58 +08:00 committed by GitHub
parent 484eb28877
commit 45b8a56eb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1240 additions and 521 deletions

View File

@ -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);

View File

@ -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,
},
);
}
}

View File

@ -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({

View File

@ -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'),

View File

@ -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 ? (

View File

@ -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,12 +284,13 @@ export function WorkflowCanvas() {
);
},
});
}
}, [workflow, modal, t, resource, message, navigate, app.pluginSettingsManager, revisions]);
async function onMenuCommand({ key }) {
const onMenuCommand = useCallback(
({ key }) => {
switch (key) {
case 'history':
setVisible(true);
setHistoryVisible(true);
return;
case 'revision':
return onRevision();
@ -147,81 +299,16 @@ export function WorkflowCanvas() {
default:
break;
}
}
},
[onDelete, onRevision],
);
const revisionable =
workflow.executed &&
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
return (
<FlowContext.Provider
value={{
workflow,
nodes,
refresh,
}}
>
<div className="workflow-toolbar">
<header>
<Breadcrumb
items={[
{ title: <Link to={app.pluginSettingsManager.getRoutePath('workflow')}>{lang('Workflow')}</Link> },
{
title: (
<Tooltip title={`Key: ${workflow.key}`}>
<strong>{workflow.title}</strong>
</Tooltip>
),
},
]}
/>
</header>
<aside>
{workflow.sync ? (
<Tag color="orange">{lang('Synchronously')}</Tag>
) : (
<Tag color="cyan">{lang('Asynchronously')}</Tag>
)}
<Dropdown
className="workflow-versions"
trigger={['click']}
menu={{
onClick: onSwitchVersion,
defaultSelectedKeys: [`${workflow.id}`],
className: cx(styles.dropdownClass, styles.workflowVersionDropdownClass),
items: revisions
.sort((a, b) => b.id - a.id)
.map((item, index) => ({
role: 'button',
'aria-label': `version-${index}`,
key: `${item.id}`,
icon: item.current ? <RightOutlined /> : null,
className: cx({
executed: item.executed,
unexecuted: !item.executed,
enabled: item.enabled,
}),
label: (
<>
<strong>{`#${item.id}`}</strong>
<time>{str2moment(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</time>
</>
),
})),
}}
>
<Button type="text" aria-label="version">
<label>{lang('Version')}</label>
<span>{workflow?.id ? `#${workflow.id}` : null}</span>
<DownOutlined />
</Button>
</Dropdown>
<Switch
checked={workflow.enabled}
onChange={onToggle}
checkedChildren={lang('On')}
unCheckedChildren={lang('Off')}
/>
<Dropdown
menu={{
items: [
@ -257,7 +344,7 @@ export function WorkflowCanvas() {
>
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
</Dropdown>
<ActionContextProvider value={{ visible, setVisible }}>
<ActionContextProvider value={{ visible: historyVisible, setVisible: setHistoryVisible }}>
<SchemaComponent
schema={executionSchema}
components={{
@ -270,6 +357,132 @@ export function WorkflowCanvas() {
}}
/>
</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,
}}
>
<div className="workflow-toolbar">
<header>
<Breadcrumb
items={[
{ title: <Link to={app.pluginSettingsManager.getRoutePath('workflow')}>{lang('Workflow')}</Link> },
{
title: (
<Tooltip title={`Key: ${workflow.key}`}>
<strong>{workflow.title}</strong>
</Tooltip>
),
},
]}
/>
{workflow.sync ? (
<Tag color="orange">{lang('Synchronously')}</Tag>
) : (
<Tag color="cyan">{lang('Asynchronously')}</Tag>
)}
</header>
<aside>
<ExecuteActionButton />
<Dropdown
className="workflow-versions"
trigger={['click']}
menu={{
onClick: onSwitchVersion,
defaultSelectedKeys: [`${workflow.id}`],
className: cx(styles.dropdownClass, styles.workflowVersionDropdownClass),
items: revisions
.sort((a, b) => b.id - a.id)
.map((item, index) => ({
role: 'button',
'aria-label': `version-${index}`,
key: `${item.id}`,
icon: item.current ? <RightOutlined /> : null,
className: cx({
executed: item.executed,
unexecuted: !item.executed,
enabled: item.enabled,
}),
label: (
<>
<strong>{`#${item.id}`}</strong>
<time>{dayjs(item.createdAt).fromNow()}</time>
</>
),
})),
}}
>
<Button type="text" aria-label="version">
<label>{lang('Version')}</label>
<span>{workflow?.id ? `#${workflow.id}` : null}</span>
<DownOutlined />
</Button>
</Dropdown>
<Switch
checked={workflow.enabled}
onChange={onToggle}
checkedChildren={lang('On')}
unCheckedChildren={lang('Off')}
/>
<WorkflowMenu />
</aside>
</div>
<CanvasContent entry={entry} />

View File

@ -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}
/>
);
}

View File

@ -18,3 +18,4 @@ export * from './SimpleDesigner';
export * from './renderEngineReference';
export * from './Calculation';
export * from './Fieldset';
export * from './TriggerCollectionRecordSelect';

View File

@ -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}" })}}`,
},
];

View File

@ -45,12 +45,14 @@ export default class PluginWorkflowClient extends Plugin {
useTriggersOptions = () => {
const compile = useCompile();
return Array.from(this.triggers.getEntities()).map(([value, { title, ...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}" })}}`,

View File

@ -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 {
&.enabled {
strong {
font-weight: bold;
}
}
> .unexecuted {
&.unexecuted {
strong {
font-style: italic;
}
opacity: 0.75;
}
}
}

View File

@ -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) {

View File

@ -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,
},
},
},

View File

@ -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

View File

@ -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;
},
},
};

View File

@ -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,
}}
/>
);
}

View File

@ -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 {

View File

@ -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": "节点变量标识",

View File

@ -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;
}

View File

@ -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);

View File

@ -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);
});
});
});

View File

@ -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({
context.body = await repository.revision({
filterByTk,
filter,
appends: ['nodes'],
values,
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();
@ -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();
}

View File

@ -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',

View File

@ -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';

View File

@ -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;
});
}
}

View File

@ -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,8 +48,34 @@ function getFieldRawName(collection: ICollection, name: string) {
return name;
}
// async function, should return promise
async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
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);
@ -56,6 +85,7 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
// NOTE: if no configured fields changed, do not trigger
if (
data instanceof Model &&
changed &&
changed.length &&
changed
@ -65,7 +95,7 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
})
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
) {
return;
return null;
}
const filterByTk = Array.isArray(filterTargetKey)
@ -83,7 +113,7 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
});
if (!count) {
return;
return null;
}
}
@ -104,30 +134,11 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
});
}
// 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 });
return {
data: toJSON(result),
stack: context?.stack,
};
}
}
}
export default class CollectionTrigger extends Trigger {
events = new Map();
on(workflow: WorkflowModel) {
const { collection, mode } = workflow.config;
@ -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),
});
}
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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;