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, CheckboxGroupWithTooltip,
RadioWithTooltip, RadioWithTooltip,
useGetCollectionFields, useGetCollectionFields,
TriggerCollectionRecordSelect,
} from '@nocobase/plugin-workflow/client'; } from '@nocobase/plugin-workflow/client';
import { NAMESPACE, useLang } from '../locale'; import { NAMESPACE, useLang } from '../locale';
@ -194,6 +195,55 @@ export default class extends Trigger {
], ],
}, },
}; };
triggerFieldset = {
data: {
type: 'object',
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Choose a record of the collection to trigger.", { ns: "workflow" })}}`,
'x-decorator': 'FormItem',
'x-component': 'TriggerCollectionRecordSelect',
default: null,
required: true,
},
userId: {
type: 'number',
title: `{{t("User submitted action", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'nickname',
value: 'id',
},
service: {
resource: 'users',
},
manual: false,
},
default: null,
required: true,
},
roleName: {
type: 'string',
title: `{{t("Role of user submitted action", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RemoteSelect',
'x-component-props': {
fieldNames: {
label: 'title',
value: 'name',
},
service: {
resource: 'roles',
},
manual: false,
},
default: null,
},
};
validate(values) {
return values.collection;
}
scope = { scope = {
useCollectionDataSource, useCollectionDataSource,
useWorkflowAnyExecuted, useWorkflowAnyExecuted,
@ -201,6 +251,7 @@ export default class extends Trigger {
components = { components = {
RadioWithTooltip, RadioWithTooltip,
CheckboxGroupWithTooltip, CheckboxGroupWithTooltip,
TriggerCollectionRecordSelect,
}; };
isActionTriggerable = (config, context) => { isActionTriggerable = (config, context) => {
return !config.global && ['submit', 'customize:save', 'customize:update'].includes(context.buttonAction); return !config.global && ['submit', 'customize:save', 'customize:update'].includes(context.buttonAction);

View File

@ -7,13 +7,13 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { get } from 'lodash'; import { get, pick } from 'lodash';
import { BelongsTo, HasOne } from 'sequelize'; import { BelongsTo, HasOne } from 'sequelize';
import { Model, modelAssociationByKey } from '@nocobase/database'; import { Model, modelAssociationByKey } from '@nocobase/database';
import Application, { DefaultContext } from '@nocobase/server'; import Application, { DefaultContext } from '@nocobase/server';
import { Context as ActionContext, Next } from '@nocobase/actions'; import { Context as ActionContext, Next } from '@nocobase/actions';
import WorkflowPlugin, { Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow'; import WorkflowPlugin, { EventOptions, Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow';
import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager'; import { joinCollectionName, parseCollectionName } from '@nocobase/data-source-manager';
interface Context extends ActionContext, DefaultContext {} interface Context extends ActionContext, DefaultContext {}
@ -185,7 +185,46 @@ export default class extends Trigger {
} }
} }
on(workflow: WorkflowModel) {} async execute(workflow: WorkflowModel, context: Context, options: EventOptions) {
const { values } = context.action.params;
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
const { filterTargetKey, repository } = collectionManager.getCollection(collectionName);
const filterByTk = Array.isArray(filterTargetKey)
? pick(
values.data,
filterTargetKey.sort((a, b) => a.localeCompare(b)),
)
: values.data[filterTargetKey];
const UserRepo = context.app.db.getRepository('users');
const actor = await UserRepo.findOne({
filterByTk: values.userId,
appends: ['roles'],
});
if (!actor) {
throw new Error('user not found');
}
const { roles, ...user } = actor.desensitize().get();
const roleName = values.roleName || roles?.[0]?.name;
off(workflow: WorkflowModel) {} let { data } = values;
if (workflow.config.appends?.length) {
data = await repository.findOne({
filterByTk,
appends: workflow.config.appends,
});
}
return this.workflow.trigger(
workflow,
{
data,
user,
roleName,
},
{
...options,
httpContext: context,
},
);
}
} }

View File

@ -7,6 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { omit } from 'lodash';
import Database from '@nocobase/database'; import Database from '@nocobase/database';
import { EXECUTION_STATUS } from '@nocobase/plugin-workflow'; import { EXECUTION_STATUS } from '@nocobase/plugin-workflow';
import { getApp, sleep } from '@nocobase/plugin-workflow-test'; import { getApp, sleep } from '@nocobase/plugin-workflow-test';
@ -22,12 +23,14 @@ describe('workflow > action-trigger', () => {
let CategoryRepo; let CategoryRepo;
let WorkflowModel; let WorkflowModel;
let UserRepo; let UserRepo;
let root;
let rootAgent;
let users; let users;
let userAgents; let userAgents;
beforeEach(async () => { beforeEach(async () => {
app = await getApp({ app = await getApp({
plugins: ['users', 'auth', Plugin], plugins: ['users', 'auth', 'acl', 'data-source-manager', 'system-settings', Plugin],
}); });
await app.pm.get('auth').install(); await app.pm.get('auth').install();
agent = app.agent(); agent = app.agent();
@ -37,6 +40,9 @@ describe('workflow > action-trigger', () => {
CategoryRepo = db.getCollection('categories').repository; CategoryRepo = db.getCollection('categories').repository;
UserRepo = db.getCollection('users').repository; UserRepo = db.getCollection('users').repository;
root = await UserRepo.findOne({});
rootAgent = app.agent().login(root);
users = await UserRepo.create({ users = await UserRepo.create({
values: [ values: [
{ id: 2, nickname: 'a', roles: [{ name: 'root' }] }, { id: 2, nickname: 'a', roles: [{ name: 'root' }] },
@ -293,6 +299,9 @@ describe('workflow > action-trigger', () => {
}); });
}); });
/**
* @deprecated
*/
describe('directly trigger', () => { describe('directly trigger', () => {
it('no collection configured should not be triggered', async () => { it('no collection configured should not be triggered', async () => {
const workflow = await WorkflowModel.create({ const workflow = await WorkflowModel.create({
@ -509,6 +518,40 @@ describe('workflow > action-trigger', () => {
}); });
}); });
describe('manually execute', () => {
it('root execute', async () => {
const w1 = await WorkflowModel.create({
type: 'action',
config: {
collection: 'posts',
appends: ['category'],
},
});
const p1 = await PostRepo.create({
values: { title: 't1', category: { title: 'c1' } },
});
const { category, ...data } = p1.toJSON();
const res1 = await rootAgent.resource('workflows').execute({
filterByTk: w1.id,
values: {
data,
userId: users[1].id,
},
});
expect(res1.status).toBe(200);
expect(res1.body.data.execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const [e1] = await w1.getExecutions();
expect(e1.id).toBe(res1.body.data.execution.id);
expect(e1.context.data).toMatchObject({ id: data.id, categoryId: category.id, category: { title: 'c1' } });
expect(e1.context.user).toMatchObject(
omit(users[1].toJSON(), ['createdAt', 'updatedAt', 'createdById', 'updatedById']),
);
});
});
describe('workflow key', () => { describe('workflow key', () => {
it('revision', async () => { it('revision', async () => {
const w1 = await WorkflowModel.create({ const w1 = await WorkflowModel.create({

View File

@ -15,7 +15,7 @@ import { MockClusterOptions, MockServer, createMockCluster, createMockServer, mo
import functions from './functions'; import functions from './functions';
import triggers from './triggers'; import triggers from './triggers';
import instructions from './instructions'; import instructions from './instructions';
import { SequelizeDataSource } from '@nocobase/data-source-manager'; import { SequelizeCollectionManager, SequelizeDataSource } from '@nocobase/data-source-manager';
import { uid } from '@nocobase/utils'; import { uid } from '@nocobase/utils';
export { sleep } from '@nocobase/test'; export { sleep } from '@nocobase/test';
@ -70,8 +70,8 @@ export async function getApp({
}), }),
); );
const another = app.dataSourceManager.dataSources.get('another'); const another = app.dataSourceManager.dataSources.get('another');
// @ts-ignore
const anotherDB = another.collectionManager.db; const anotherDB = (another.collectionManager as SequelizeCollectionManager).db;
await anotherDB.import({ await anotherDB.import({
directory: path.resolve(__dirname, 'collections'), directory: path.resolve(__dirname, 'collections'),

View File

@ -111,8 +111,13 @@ function JobModal() {
'x-component': 'Input.JSON', 'x-component': 'Input.JSON',
'x-component-props': { 'x-component-props': {
className: styles.nodeJobResultClass, className: styles.nodeJobResultClass,
autoSize: {
minRows: 4,
maxRows: 32,
},
}, },
'x-read-pretty': true, // 'x-read-pretty': true,
'x-disabled': true,
}, },
}, },
}, },
@ -152,7 +157,7 @@ function ExecutionsDropdown(props) {
setExecutionsBefore(data.data); setExecutionsBefore(data.data);
}) })
.catch(() => {}); .catch(() => {});
}, [execution]); }, [execution.id]);
useEffect(() => { useEffect(() => {
if (!execution) { if (!execution) {
@ -175,7 +180,7 @@ function ExecutionsDropdown(props) {
setExecutionsAfter(data.data.reverse()); setExecutionsAfter(data.data.reverse());
}) })
.catch(() => {}); .catch(() => {});
}, [execution]); }, [execution.id]);
const onClick = useCallback( const onClick = useCallback(
({ key }) => { ({ key }) => {
@ -183,7 +188,7 @@ function ExecutionsDropdown(props) {
navigate(getWorkflowExecutionsPath(key)); navigate(getWorkflowExecutionsPath(key));
} }
}, },
[execution], [execution.id],
); );
return execution ? ( return execution ? (

View File

@ -7,32 +7,44 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { Alert, App, Breadcrumb, Button, Dropdown, Result, Spin, Switch, Tag, Tooltip } from 'antd';
import { DownOutlined, EllipsisOutlined, RightOutlined } from '@ant-design/icons'; import { DownOutlined, EllipsisOutlined, RightOutlined } from '@ant-design/icons';
import { import {
ActionContextProvider, ActionContextProvider,
ResourceActionProvider, ResourceActionProvider,
SchemaComponent, SchemaComponent,
cx, cx,
useActionContext,
useApp, useApp,
useCancelAction,
useDocumentTitle, useDocumentTitle,
useNavigateNoUpdate,
useResourceActionContext, useResourceActionContext,
useResourceContext, useResourceContext,
useCompile,
css,
usePlugin,
} from '@nocobase/client'; } from '@nocobase/client';
import { str2moment } from '@nocobase/utils/client'; import { dayjs } from '@nocobase/utils/client';
import { App, Breadcrumb, Button, Dropdown, Result, Spin, Switch, Tag, Tooltip, message } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { CanvasContent } from './CanvasContent'; import { CanvasContent } from './CanvasContent';
import { ExecutionStatusColumn } from './components/ExecutionStatus'; import { ExecutionStatusColumn } from './components/ExecutionStatus';
import { ExecutionLink } from './ExecutionLink'; import { ExecutionLink } from './ExecutionLink';
import { FlowContext, useFlowContext } from './FlowContext'; import { FlowContext, useFlowContext } from './FlowContext';
import { useRefreshActionProps } from './hooks/useRefreshActionProps'; import { lang, NAMESPACE } from './locale';
import { lang } from './locale';
import { executionSchema } from './schemas/executions'; import { executionSchema } from './schemas/executions';
import useStyles from './style'; import useStyles from './style';
import { getWorkflowDetailPath, linkNodes } from './utils'; import { linkNodes, getWorkflowDetailPath } from './utils';
import { Fieldset } from './components/Fieldset';
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
import { useTrigger } from './triggers';
import { useField, useForm } from '@formily/react';
import { ExecutionStatusOptionsMap } from './constants';
import PluginWorkflowClient from '.';
import { NoticeType } from 'antd/es/message/interface';
function ExecutionResourceProvider({ request, filter = {}, ...others }) { function ExecutionResourceProvider({ request, filter = {}, ...others }) {
const { workflow } = useFlowContext(); const { workflow } = useFlowContext();
@ -53,53 +65,192 @@ function ExecutionResourceProvider({ request, filter = {}, ...others }) {
return <ResourceActionProvider {...props} />; return <ResourceActionProvider {...props} />;
} }
export function WorkflowCanvas() { function ExecutedStatusMessage({ data, option }) {
const compile = useCompile();
const statusText = compile(option.label);
return (
<Trans ns={NAMESPACE} values={{ statusText }}>
{'Workflow executed, the result status is '}
<Tag color={option.color}>{'{{statusText}}'}</Tag>
<Link to={`/admin/workflow/executions/${data.id}`}>View the execution</Link>
</Trans>
);
}
function getExecutedStatusMessage({ id, status }) {
const option = ExecutionStatusOptionsMap[status];
if (!option) {
return null;
}
return {
type: 'info' as NoticeType,
content: <ExecutedStatusMessage data={{ id }} option={option} />,
};
}
function useExecuteConfirmAction() {
const { workflow } = useFlowContext();
const form = useForm();
const { resource } = useResourceContext();
const ctx = useActionContext();
const navigate = useNavigateNoUpdate();
const { message: messageApi } = App.useApp();
const { autoRevision, ...values } = form.values;
return {
async run() {
// Not executed, could choose to create new version (by default)
// Executed, stay in current version, and refresh
await form.submit();
const {
data: { data },
} = await resource.execute({
filterByTk: workflow.id,
values,
...(!workflow.executed && autoRevision ? { autoRevision: 1 } : {}),
});
form.reset();
ctx.setFormValueChanged(false);
ctx.setVisible(false);
messageApi?.open(getExecutedStatusMessage(data.execution));
if (data.newVersionId) {
navigate(`/admin/workflow/workflows/${data.newVersionId}`);
}
},
};
}
function ActionDisabledProvider({ children }) {
const field = useField<any>();
const { workflow } = useFlowContext();
const trigger = useTrigger();
const valid = trigger.validate(workflow.config);
let message = '';
switch (true) {
case !valid:
message = lang('The trigger is not configured correctly, please check the trigger configuration.');
break;
case !trigger.triggerFieldset:
message = lang('This type of trigger has not been supported to be executed manually.');
break;
default:
break;
}
field.setPattern(message ? 'disabled' : 'editable');
return message ? <Tooltip title={message}>{children}</Tooltip> : children;
}
function ExecuteActionButton() {
const { workflow } = useFlowContext();
const trigger = useTrigger();
return (
<SchemaComponent
components={{
Alert,
Fieldset,
ActionDisabledProvider,
...trigger.components,
}}
scope={{
useCancelAction,
useExecuteConfirmAction,
}}
schema={{
name: `trigger-modal-${workflow.type}-${workflow.id}`,
type: 'void',
'x-decorator': 'ActionDisabledProvider',
'x-component': 'Action',
'x-component-props': {
openSize: 'small',
},
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
properties: {
drawer: {
type: 'void',
'x-decorator': 'FormV2',
'x-component': 'Action.Modal',
title: `{{t('Execute manually', { ns: "${NAMESPACE}" })}}`,
properties: {
...(Object.keys(trigger.triggerFieldset ?? {}).length
? {
alert: {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: `{{t('Trigger variables need to be filled for executing.', { ns: "${NAMESPACE}" })}}`,
className: css`
margin-bottom: 1em;
`,
},
},
}
: {
description: {
type: 'void',
'x-component': 'p',
'x-content': `{{t('This will perform all the actions configured in the workflow. Are you sure you want to continue?', { ns: "${NAMESPACE}" })}}`,
},
}),
fieldset: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'Fieldset',
title: `{{t('Trigger variables', { ns: "${NAMESPACE}" })}}`,
properties: trigger.triggerFieldset,
},
...(workflow.executed
? {}
: {
autoRevision: {
type: 'boolean',
'x-decorator': 'FormItem',
'x-component': 'Checkbox',
'x-content': `{{t('Automatically create a new version after execution', { ns: "${NAMESPACE}" })}}`,
default: true,
},
}),
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
cancel: {
type: 'void',
title: `{{t('Cancel')}}`,
'x-component': 'Action',
'x-component-props': {
useAction: '{{useCancelAction}}',
},
},
submit: {
type: 'void',
title: `{{t('Confirm')}}`,
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{useExecuteConfirmAction}}',
},
},
},
},
},
},
},
}}
/>
);
}
function WorkflowMenu() {
const { workflow, revisions } = useFlowContext();
const [historyVisible, setHistoryVisible] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const app = useApp();
const { data, refresh, loading } = useResourceActionContext();
const { resource } = useResourceContext();
const { setTitle } = useDocumentTitle();
const [visible, setVisible] = useState(false);
const { styles } = useStyles();
const { modal } = App.useApp(); const { modal } = App.useApp();
const app = useApp();
const { resource } = useResourceContext();
const { message } = App.useApp();
useEffect(() => { const onRevision = useCallback(async () => {
const { title } = data?.data ?? {};
setTitle?.(`${lang('Workflow')}${title ? `: ${title}` : ''}`);
}, [data?.data, setTitle]);
if (!data?.data) {
if (loading) {
return <Spin />;
}
return (
<Result status="404" title="Not found" extra={<Button onClick={() => navigate(-1)}>{lang('Go back')}</Button>} />
);
}
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
linkNodes(nodes);
const entry = nodes.find((item) => !item.upstream);
function onSwitchVersion({ key }) {
if (key != workflow.id) {
navigate(getWorkflowDetailPath(key));
}
}
async function onToggle(value) {
await resource.update({
filterByTk: workflow.id,
values: {
enabled: value,
},
});
refresh();
}
async function onRevision() {
const { const {
data: { data: revision }, data: { data: revision },
} = await resource.revision({ } = await resource.revision({
@ -111,9 +262,9 @@ export function WorkflowCanvas() {
message.success(t('Operation succeeded')); message.success(t('Operation succeeded'));
navigate(`/admin/workflow/workflows/${revision.id}`); navigate(`/admin/workflow/workflows/${revision.id}`);
} }, [resource, workflow.id, workflow.key, message, t, navigate]);
async function onDelete() { const onDelete = useCallback(async () => {
const content = workflow.current const content = workflow.current
? lang('Delete a main version will cause all other revisions to be deleted too.') ? lang('Delete a main version will cause all other revisions to be deleted too.')
: ''; : '';
@ -133,30 +284,138 @@ export function WorkflowCanvas() {
); );
}, },
}); });
} }, [workflow, modal, t, resource, message, navigate, app.pluginSettingsManager, revisions]);
async function onMenuCommand({ key }) { const onMenuCommand = useCallback(
switch (key) { ({ key }) => {
case 'history': switch (key) {
setVisible(true); case 'history':
return; setHistoryVisible(true);
case 'revision': return;
return onRevision(); case 'revision':
case 'delete': return onRevision();
return onDelete(); case 'delete':
default: return onDelete();
break; default:
} break;
} }
},
[onDelete, onRevision],
);
const revisionable = const revisionable =
workflow.executed && workflow.executed &&
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt)); !revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
return (
<>
<Dropdown
menu={{
items: [
{
key: 'key',
label: `Key: ${workflow.key}`,
disabled: true,
},
{
type: 'divider',
},
{
role: 'button',
'aria-label': 'history',
key: 'history',
label: lang('Execution history'),
disabled: !workflow.allExecuted,
},
{
role: 'button',
'aria-label': 'revision',
key: 'revision',
label: lang('Copy to new version'),
disabled: !revisionable,
},
{
type: 'divider',
},
{ role: 'button', 'aria-label': 'delete', danger: true, key: 'delete', label: t('Delete') },
] as any[],
onClick: onMenuCommand,
}}
>
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
</Dropdown>
<ActionContextProvider value={{ visible: historyVisible, setVisible: setHistoryVisible }}>
<SchemaComponent
schema={executionSchema}
components={{
ExecutionResourceProvider,
ExecutionLink,
ExecutionStatusColumn,
}}
scope={{
useRefreshActionProps,
}}
/>
</ActionContextProvider>
</>
);
}
export function WorkflowCanvas() {
const navigate = useNavigate();
const app = useApp();
const { data, refresh, loading } = useResourceActionContext();
const { resource } = useResourceContext();
const { setTitle } = useDocumentTitle();
const { styles } = useStyles();
const workflowPlugin = usePlugin(PluginWorkflowClient);
const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
linkNodes(nodes);
useEffect(() => {
const { title } = data?.data ?? {};
setTitle?.(`${lang('Workflow')}${title ? `: ${title}` : ''}`);
}, [data?.data, setTitle]);
const onSwitchVersion = useCallback(
({ key }) => {
if (key != workflow.id) {
navigate(getWorkflowDetailPath(key));
}
},
[workflow.id, navigate],
);
const onToggle = useCallback(
async (value) => {
await resource.update({
filterByTk: workflow.id,
values: {
enabled: value,
},
});
refresh();
},
[resource, workflow.id, refresh],
);
if (!data?.data) {
if (loading) {
return <Spin />;
}
return (
<Result status="404" title="Not found" extra={<Button onClick={() => navigate(-1)}>{lang('Go back')}</Button>} />
);
}
const entry = nodes.find((item) => !item.upstream);
return ( return (
<FlowContext.Provider <FlowContext.Provider
value={{ value={{
workflow, workflow,
revisions,
nodes, nodes,
refresh, refresh,
}} }}
@ -175,13 +434,14 @@ export function WorkflowCanvas() {
}, },
]} ]}
/> />
</header>
<aside>
{workflow.sync ? ( {workflow.sync ? (
<Tag color="orange">{lang('Synchronously')}</Tag> <Tag color="orange">{lang('Synchronously')}</Tag>
) : ( ) : (
<Tag color="cyan">{lang('Asynchronously')}</Tag> <Tag color="cyan">{lang('Asynchronously')}</Tag>
)} )}
</header>
<aside>
<ExecuteActionButton />
<Dropdown <Dropdown
className="workflow-versions" className="workflow-versions"
trigger={['click']} trigger={['click']}
@ -204,7 +464,7 @@ export function WorkflowCanvas() {
label: ( label: (
<> <>
<strong>{`#${item.id}`}</strong> <strong>{`#${item.id}`}</strong>
<time>{str2moment(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}</time> <time>{dayjs(item.createdAt).fromNow()}</time>
</> </>
), ),
})), })),
@ -222,54 +482,7 @@ export function WorkflowCanvas() {
checkedChildren={lang('On')} checkedChildren={lang('On')}
unCheckedChildren={lang('Off')} unCheckedChildren={lang('Off')}
/> />
<Dropdown <WorkflowMenu />
menu={{
items: [
{
key: 'key',
label: `Key: ${workflow.key}`,
disabled: true,
},
{
type: 'divider',
},
{
role: 'button',
'aria-label': 'history',
key: 'history',
label: lang('Execution history'),
disabled: !workflow.allExecuted,
},
{
role: 'button',
'aria-label': 'revision',
key: 'revision',
label: lang('Copy to new version'),
disabled: !revisionable,
},
{
type: 'divider',
},
{ role: 'button', 'aria-label': 'delete', danger: true, key: 'delete', label: t('Delete') },
] as any[],
onClick: onMenuCommand,
}}
>
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
</Dropdown>
<ActionContextProvider value={{ visible, setVisible }}>
<SchemaComponent
schema={executionSchema}
components={{
ExecutionResourceProvider,
ExecutionLink,
ExecutionStatusColumn,
}}
scope={{
useRefreshActionProps,
}}
/>
</ActionContextProvider>
</aside> </aside>
</div> </div>
<CanvasContent entry={entry} /> <CanvasContent entry={entry} />

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 './renderEngineReference';
export * from './Calculation'; export * from './Calculation';
export * from './Fieldset'; export * from './Fieldset';
export * from './TriggerCollectionRecordSelect';

View File

@ -38,6 +38,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`, label: `{{t("Queueing", { ns: "${NAMESPACE}" })}}`,
color: 'blue', color: 'blue',
icon: <HourglassOutlined />, icon: <HourglassOutlined />,
statusType: 'info',
description: `{{t("Triggered but still waiting in queue to execute.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Triggered but still waiting in queue to execute.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -45,6 +46,7 @@ export const ExecutionStatusOptions = [
label: `{{t("On going", { ns: "${NAMESPACE}" })}}`, label: `{{t("On going", { ns: "${NAMESPACE}" })}}`,
color: 'gold', color: 'gold',
icon: <LoadingOutlined />, icon: <LoadingOutlined />,
statusType: 'warning',
description: `{{t("Started and executing, maybe waiting for an async callback (manual, delay etc.).", { ns: "${NAMESPACE}" })}}`, description: `{{t("Started and executing, maybe waiting for an async callback (manual, delay etc.).", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -52,6 +54,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`, label: `{{t("Resolved", { ns: "${NAMESPACE}" })}}`,
color: 'green', color: 'green',
icon: <CheckOutlined />, icon: <CheckOutlined />,
statusType: 'success',
description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Successfully finished.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -59,6 +62,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`, label: `{{t("Failed", { ns: "${NAMESPACE}" })}}`,
color: 'red', color: 'red',
icon: <ExclamationOutlined />, icon: <ExclamationOutlined />,
statusType: 'error',
description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Failed to satisfy node configurations.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -66,6 +70,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Error", { ns: "${NAMESPACE}" })}}`, label: `{{t("Error", { ns: "${NAMESPACE}" })}}`,
color: 'red', color: 'red',
icon: <CloseOutlined />, icon: <CloseOutlined />,
statusType: 'error',
description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Some node meets error.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -73,6 +78,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`, label: `{{t("Aborted", { ns: "${NAMESPACE}" })}}`,
color: 'red', color: 'red',
icon: <MinusOutlined rotate={90} />, icon: <MinusOutlined rotate={90} />,
statusType: 'error',
description: `{{t("Running of some node was aborted by program flow.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Running of some node was aborted by program flow.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -80,6 +86,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`, label: `{{t("Canceled", { ns: "${NAMESPACE}" })}}`,
color: 'volcano', color: 'volcano',
icon: <MinusOutlined rotate={45} />, icon: <MinusOutlined rotate={45} />,
statusType: 'error',
description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Manually canceled whole execution when waiting.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -87,6 +94,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`, label: `{{t("Rejected", { ns: "${NAMESPACE}" })}}`,
color: 'volcano', color: 'volcano',
icon: <MinusOutlined />, icon: <MinusOutlined />,
statusType: 'error',
description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`, description: `{{t("Rejected from a manual node.", { ns: "${NAMESPACE}" })}}`,
}, },
{ {
@ -94,6 +102,7 @@ export const ExecutionStatusOptions = [
label: `{{t("Retry needed", { ns: "${NAMESPACE}" })}}`, label: `{{t("Retry needed", { ns: "${NAMESPACE}" })}}`,
color: 'volcano', color: 'volcano',
icon: <RedoOutlined />, icon: <RedoOutlined />,
statusType: 'error',
description: `{{t("General failed but should do another try.", { ns: "${NAMESPACE}" })}}`, description: `{{t("General failed but should do another try.", { ns: "${NAMESPACE}" })}}`,
}, },
]; ];

View File

@ -45,12 +45,14 @@ export default class PluginWorkflowClient extends Plugin {
useTriggersOptions = () => { useTriggersOptions = () => {
const compile = useCompile(); const compile = useCompile();
return Array.from(this.triggers.getEntities()).map(([value, { title, ...options }]) => ({ return Array.from(this.triggers.getEntities())
value, .map(([value, { title, ...options }]) => ({
label: compile(title), value,
color: 'gold', label: compile(title),
options, color: 'gold',
})); options,
}))
.sort((a, b) => a.label.localeCompare(b.label));
}; };
isWorkflowSync(workflow) { isWorkflowSync(workflow) {
@ -92,11 +94,6 @@ export default class PluginWorkflowClient extends Plugin {
element: <ExecutionPage />, element: <ExecutionPage />,
}); });
this.app.addComponents({
WorkflowPage,
ExecutionPage,
});
this.app.pluginSettingsManager.add(NAMESPACE, { this.app.pluginSettingsManager.add(NAMESPACE, {
icon: 'PartitionOutlined', icon: 'PartitionOutlined',
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`, title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,

View File

@ -29,6 +29,7 @@ const useStyles = createStyles(({ css, token }) => {
header { header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1em;
} }
aside { aside {
@ -104,17 +105,18 @@ const useStyles = createStyles(({ css, token }) => {
strong { strong {
font-weight: normal; font-weight: normal;
} }
}
> .enabled { &.enabled {
strong { strong {
font-weight: bold; font-weight: bold;
}
} }
}
> .unexecuted { &.unexecuted {
strong { strong {
font-style: italic; font-style: italic;
} opacity: 0.75;
} }
} }
} }

View File

@ -15,6 +15,7 @@ import { appends, collection, filter } from '../schemas/collection';
import { getCollectionFieldOptions, useGetCollectionFields } from '../variable'; import { getCollectionFieldOptions, useGetCollectionFields } from '../variable';
import { useWorkflowAnyExecuted } from '../hooks'; import { useWorkflowAnyExecuted } from '../hooks';
import { Trigger } from '.'; import { Trigger } from '.';
import { TriggerCollectionRecordSelect } from '../components/TriggerCollectionRecordSelect';
const COLLECTION_TRIGGER_MODE = { const COLLECTION_TRIGGER_MODE = {
CREATED: 1, CREATED: 1,
@ -190,7 +191,22 @@ export default class extends Trigger {
}; };
components = { components = {
FieldsSelect, FieldsSelect,
TriggerCollectionRecordSelect,
}; };
triggerFieldset = {
data: {
type: 'object',
title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Choose a record of the collection to trigger.", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'TriggerCollectionRecordSelect',
default: null,
required: true,
},
};
validate(values) {
return values.collection && values.mode;
}
useVariables = useVariables; useVariables = useVariables;
useInitializers(config): SchemaInitializerItemType | null { useInitializers(config): SchemaInitializerItemType | null {
if (!config.collection) { if (!config.collection) {

View File

@ -67,7 +67,11 @@ export abstract class Trigger {
description?: string; description?: string;
// group: string; // group: string;
useVariables?(config: Record<string, any>, options?: UseVariableOptions): VariableOption[]; useVariables?(config: Record<string, any>, options?: UseVariableOptions): VariableOption[];
fieldset: { [key: string]: ISchema }; fieldset: Record<string, ISchema>;
triggerFieldset?: Record<string, ISchema>;
validate(config: Record<string, any>): boolean {
return true;
}
view?: ISchema; view?: ISchema;
scope?: { [key: string]: any }; scope?: { [key: string]: any };
components?: { [key: string]: any }; components?: { [key: string]: any };
@ -138,12 +142,13 @@ function TriggerExecution() {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input.JSON', 'x-component': 'Input.JSON',
'x-component-props': { 'x-component-props': {
className: css` className: styles.nodeJobResultClass,
padding: 1em; autoSize: {
background-color: #f3f3f3; minRows: 4,
`, maxRows: 32,
},
}, },
'x-read-pretty': true, 'x-disabled': true,
}, },
}, },
}, },

View File

@ -12,163 +12,11 @@ import { useForm, useFormEffects, ISchema } from '@formily/react';
import { css, SchemaComponent } from '@nocobase/client'; import { css, SchemaComponent } from '@nocobase/client';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { NAMESPACE } from '../../locale'; import { NAMESPACE } from '../../locale';
import { appends, collection } from '../../schemas/collection';
import { SCHEDULE_MODE } from './constants'; import { SCHEDULE_MODE } from './constants';
import { EndsByField } from './EndsByField'; import { EndsByField } from './EndsByField';
import { OnField } from './OnField'; import { OnField } from './OnField';
import { RepeatField } from './RepeatField'; import { RepeatField } from './RepeatField';
import { ScheduleModes } from './ScheduleModes';
const ModeFieldsets = {
[SCHEDULE_MODE.STATIC]: {
startsOn: {
type: 'datetime',
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
showTime: true,
},
required: true,
},
repeat: {
type: 'string',
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RepeatField',
'x-reactions': [
{
target: 'endsOn',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
{
target: 'limit',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
],
},
endsOn: {
type: 'datetime',
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component-props': {
showTime: true,
},
},
limit: {
type: 'number',
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
min: 0,
},
},
},
[SCHEDULE_MODE.DATE_FIELD]: {
collection: {
...collection,
'x-component-props': {
dataSourceFilter(item) {
return item.options.key === 'main' || item.options.isDBInstance;
},
},
'x-reactions': [
...collection['x-reactions'],
{
// only full path works
target: 'startsOn',
effects: ['onFieldValueChange'],
fulfill: {
state: {
visible: '{{!!$self.value}}',
value: '{{Object.create({})}}',
},
},
},
],
},
startsOn: {
type: 'object',
title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'OnField',
'x-reactions': [
{
target: 'repeat',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
],
required: true,
},
repeat: {
type: 'string',
title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'RepeatField',
'x-reactions': [
{
target: 'endsOn',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
{
target: 'limit',
fulfill: {
state: {
visible: '{{!!$self.value}}',
},
},
},
],
},
endsOn: {
type: 'object',
title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'EndsByField',
},
limit: {
type: 'number',
title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`,
min: 0,
},
},
appends: {
...appends,
'x-reactions': [
{
dependencies: ['mode', 'collection'],
fulfill: {
state: {
visible: `{{$deps[0] === ${SCHEDULE_MODE.DATE_FIELD} && $deps[1]}}`,
},
},
},
],
},
},
};
const scheduleModeOptions = [ const scheduleModeOptions = [
{ value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` }, { value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` },
@ -201,9 +49,7 @@ export const ScheduleConfig = () => {
name: 'mode', name: 'mode',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Radio.Group', 'x-component': 'Radio.Group',
'x-component-props': { enum: scheduleModeOptions,
options: scheduleModeOptions,
},
required: true, required: true,
default: SCHEDULE_MODE.STATIC, default: SCHEDULE_MODE.STATIC,
}} }}
@ -228,7 +74,7 @@ export const ScheduleConfig = () => {
} }
`, `,
}, },
properties: ModeFieldsets[mode], properties: ScheduleModes[mode]?.fieldset,
}, },
}, },
} as ISchema } as ISchema

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 { Trigger } from '..';
import { ScheduleConfig } from './ScheduleConfig'; import { ScheduleConfig } from './ScheduleConfig';
import { SCHEDULE_MODE } from './constants'; import { SCHEDULE_MODE } from './constants';
import { TriggerScheduleConfig } from './TriggerScheduleConfig';
import { ScheduleModes } from './ScheduleModes';
import { TriggerCollectionRecordSelect } from '../../components/TriggerCollectionRecordSelect';
function useVariables(config, opts) { function useVariables(config, opts) {
const [dataSourceName, collection] = parseCollectionName(config.collection); const [dataSourceName, collection] = parseCollectionName(config.collection);
@ -66,11 +69,26 @@ export default class extends Trigger {
'x-component-props': {}, 'x-component-props': {},
}, },
}; };
triggerFieldset = {
proxy: {
type: 'void',
'x-component': 'TriggerScheduleConfig',
},
};
validate(config) {
if (config.mode == null) {
return false;
}
const { validate } = ScheduleModes[config.mode];
return validate ? validate(config) : true;
}
scope = { scope = {
useCollectionDataSource, useCollectionDataSource,
}; };
components = { components = {
ScheduleConfig, ScheduleConfig,
TriggerScheduleConfig,
TriggerCollectionRecordSelect,
}; };
useVariables = useVariables; useVariables = useVariables;
useInitializers(config): SchemaInitializerItemType | null { useInitializers(config): SchemaInitializerItemType | null {

View File

@ -16,6 +16,15 @@
"Duplicate": "复制", "Duplicate": "复制",
"Duplicate to new workflow": "复制为新工作流", "Duplicate to new workflow": "复制为新工作流",
"Delete a main version will cause all other revisions to be deleted too.": "删除主版本将导致其他版本一并被删除。", "Delete a main version will cause all other revisions to be deleted too.": "删除主版本将导致其他版本一并被删除。",
"Execute manually": "手动执行",
"The trigger is not configured correctly, please check the trigger configuration.": "触发器配置不正确,请检查触发器配置。",
"This type of trigger has not been supported to be executed manually.": "该类型的触发器暂未支持手动执行。",
"Trigger variables need to be filled for executing.": "执行需要填写触发器变量。",
"A new version will be created automatically after execution if current version is not executed.": "如果当前版本还未执行过,将在执行后自动创建一个新版本。",
"This will perform all the actions configured in the workflow. Are you sure you want to continue?": "将按照工作流中配置的所有操作执行,确定继续吗?",
"Automatically create a new version after execution": "执行后自动创建新版本",
"Workflow executed, the result status is <1>{{statusText}}</1><2>View the execution</2>": "工作流已执行,结果状态为 <1>{{statusText}}</1><2>查看执行详情</2>",
"Loading": "加载中", "Loading": "加载中",
"Load failed": "加载失败", "Load failed": "加载失败",
"Use transaction": "启用事务", "Use transaction": "启用事务",
@ -64,6 +73,8 @@
"Preload associations": "预加载关联数据", "Preload associations": "预加载关联数据",
"Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.": "Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.":
"请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。", "请选中需要在后续节点中被访问的关系字段。超过两层的对多关联可能会导致性能问题,请谨慎使用。",
"Choose a record of the collection to trigger.": "选择数据表中的一行记录来触发。",
"Schedule event": "定时任务", "Schedule event": "定时任务",
"Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。", "Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.": "按预设的时间条件定时触发。适用于一次性或周期性的任务,如定时发送通知、清理数据等。",
"Trigger mode": "触发模式", "Trigger mode": "触发模式",
@ -92,6 +103,9 @@
"By field": "数据表字段", "By field": "数据表字段",
"By custom date": "自定义时间", "By custom date": "自定义时间",
"Advanced": "高级模式", "Advanced": "高级模式",
"Execute on": "执行时间",
"Current time": "当前时间",
"End": "结束", "End": "结束",
"Node result": "节点数据", "Node result": "节点数据",
"Variable key of node": "节点变量标识", "Variable key of node": "节点变量标识",

View File

@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
import LRUCache from 'lru-cache'; import LRUCache from 'lru-cache';
import { Op, Transaction, Transactionable } from '@nocobase/database'; import { Op, Transactionable } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { Registry } from '@nocobase/utils'; import { Registry } from '@nocobase/utils';
@ -34,15 +34,19 @@ import QueryInstruction from './instructions/QueryInstruction';
import UpdateInstruction from './instructions/UpdateInstruction'; import UpdateInstruction from './instructions/UpdateInstruction';
import type { ExecutionModel, JobModel, WorkflowModel } from './types'; import type { ExecutionModel, JobModel, WorkflowModel } from './types';
import WorkflowRepository from './repositories/WorkflowRepository';
import { Context } from '@nocobase/actions';
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
type ID = number | string; type ID = number | string;
type Pending = [ExecutionModel, JobModel?]; type Pending = [ExecutionModel, JobModel?];
type EventOptions = { export type EventOptions = {
eventKey?: string; eventKey?: string;
context?: any; context?: any;
deferred?: boolean; deferred?: boolean;
manually?: boolean;
[key: string]: any; [key: string]: any;
} & Transactionable; } & Transactionable;
@ -69,20 +73,6 @@ export default class PluginWorkflowServer extends Plugin {
if (instance.enabled) { if (instance.enabled) {
instance.set('current', true); instance.set('current', true);
} else if (!instance.current) {
const count = await Model.count({
where: {
key: instance.key,
},
transaction,
});
if (!count) {
instance.set('current', true);
}
}
if (!instance.changed('enabled') || !instance.enabled) {
return;
} }
const previous = await Model.findOne({ const previous = await Model.findOne({
@ -95,8 +85,11 @@ export default class PluginWorkflowServer extends Plugin {
}, },
transaction, transaction,
}); });
if (!previous) {
instance.set('current', true);
}
if (previous) { if (instance.current && previous) {
// NOTE: set to `null` but not `false` will not violate the unique index // NOTE: set to `null` but not `false` will not violate the unique index
await previous.update( await previous.update(
{ enabled: false, current: null }, { enabled: false, current: null },
@ -213,6 +206,12 @@ export default class PluginWorkflowServer extends Plugin {
} }
} }
async beforeLoad() {
this.db.registerRepositories({
WorkflowRepository,
});
}
/** /**
* @internal * @internal
*/ */
@ -367,22 +366,22 @@ export default class PluginWorkflowServer extends Plugin {
options: EventOptions = {}, options: EventOptions = {},
): void | Promise<Processor | null> { ): void | Promise<Processor | null> {
const logger = this.getLogger(workflow.id); const logger = this.getLogger(workflow.id);
if (!workflow.enabled) {
logger.warn(`workflow ${workflow.id} is not enabled, event will be ignored`);
return;
}
if (!this.ready) { if (!this.ready) {
logger.warn(`app is not ready, event of workflow ${workflow.id} will be ignored`); logger.warn(`app is not ready, event of workflow ${workflow.id} will be ignored`);
logger.debug(`ignored event data:`, context); logger.debug(`ignored event data:`, context);
return; return;
} }
if (!options.manually && !workflow.enabled) {
logger.warn(`workflow ${workflow.id} is not enabled, event will be ignored`);
return;
}
// `null` means not to trigger // `null` means not to trigger
if (context == null) { if (context == null) {
logger.warn(`workflow ${workflow.id} event data context is null, event will be ignored`); logger.warn(`workflow ${workflow.id} event data context is null, event will be ignored`);
return; return;
} }
if (this.isWorkflowSync(workflow)) { if (options.manually || this.isWorkflowSync(workflow)) {
return this.triggerSync(workflow, context, options); return this.triggerSync(workflow, context, options);
} }
@ -454,11 +453,13 @@ export default class PluginWorkflowServer extends Plugin {
context, context,
options: EventOptions, options: EventOptions,
): Promise<ExecutionModel | null> { ): Promise<ExecutionModel | null> {
const { transaction = await this.db.sequelize.transaction(), deferred } = options; const { deferred } = options;
const transaction = await this.useDataSourceTransaction('main', options.transaction, true);
const sameTransaction = options.transaction === transaction;
const trigger = this.triggers.get(workflow.type); const trigger = this.triggers.get(workflow.type);
const valid = await trigger.validateEvent(workflow, context, { ...options, transaction }); const valid = await trigger.validateEvent(workflow, context, { ...options, transaction });
if (!valid) { if (!valid) {
if (!options.transaction) { if (!sameTransaction) {
await transaction.commit(); await transaction.commit();
} }
return null; return null;
@ -476,7 +477,7 @@ export default class PluginWorkflowServer extends Plugin {
{ transaction }, { transaction },
); );
} catch (err) { } catch (err) {
if (!options.transaction) { if (!sameTransaction) {
await transaction.rollback(); await transaction.rollback();
} }
throw err; throw err;
@ -502,7 +503,7 @@ export default class PluginWorkflowServer extends Plugin {
}, },
); );
if (!options.transaction) { if (!sameTransaction) {
await transaction.commit(); await transaction.commit();
} }
@ -622,6 +623,17 @@ export default class PluginWorkflowServer extends Plugin {
return processor; return processor;
} }
async execute(workflow: WorkflowModel, context: Context, options: EventOptions = {}) {
const trigger = this.triggers.get(workflow.type);
if (!trigger) {
throw new Error(`trigger type "${workflow.type}" of workflow ${workflow.id} is not registered`);
}
if (!trigger.execute) {
throw new Error(`"execute" method of trigger ${workflow.type} is not implemented`);
}
return trigger.execute(workflow, context, options);
}
/** /**
* @experimental * @experimental
* @param {string} dataSourceName * @param {string} dataSourceName
@ -630,8 +642,8 @@ export default class PluginWorkflowServer extends Plugin {
* @returns {Trasaction} * @returns {Trasaction}
*/ */
useDataSourceTransaction(dataSourceName = 'main', transaction, create = false) { useDataSourceTransaction(dataSourceName = 'main', transaction, create = false) {
// @ts-ignore const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName)
const { db } = this.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager; .collectionManager as SequelizeCollectionManager;
if (!db) { if (!db) {
return; return;
} }

View File

@ -216,7 +216,6 @@ describe('workflow > instructions > condition', () => {
expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED);
const jobs = await execution.getJobs({ order: [['id', 'ASC']] }); const jobs = await execution.getJobs({ order: [['id', 'ASC']] });
console.log('------', jobs);
expect(jobs.length).toBe(3); expect(jobs.length).toBe(3);
expect(jobs[0].result).toBe(false); expect(jobs[0].result).toBe(false);
expect(jobs[1].result).toBe(false); expect(jobs[1].result).toBe(false);

View File

@ -7,20 +7,24 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { BelongsToRepository, MockDatabase, Op } from '@nocobase/database'; import { BelongsToRepository, MockDatabase } from '@nocobase/database';
import { getApp, sleep } from '@nocobase/plugin-workflow-test'; import { getApp, sleep } from '@nocobase/plugin-workflow-test';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { EXECUTION_STATUS } from '../../constants'; import { EXECUTION_STATUS } from '../../constants';
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
import PluginWorkflowServer from '../../Plugin';
describe('workflow > triggers > collection', () => { describe('workflow > triggers > collection', () => {
let app: MockServer; let app: MockServer;
let db: MockDatabase; let db: MockDatabase;
let plugin: PluginWorkflowServer;
let CategoryRepo; let CategoryRepo;
let PostRepo; let PostRepo;
let CommentRepo; let CommentRepo;
let TagRepo; let TagRepo;
let WorkflowModel; let WorkflowModel;
let agent;
beforeEach(async () => { beforeEach(async () => {
app = await getApp({ app = await getApp({
@ -28,11 +32,15 @@ describe('workflow > triggers > collection', () => {
}); });
db = app.db; db = app.db;
plugin = app.pm.get(PluginWorkflowServer) as PluginWorkflowServer;
WorkflowModel = db.getCollection('workflows').model; WorkflowModel = db.getCollection('workflows').model;
CategoryRepo = db.getCollection('categories').repository; CategoryRepo = db.getCollection('categories').repository;
PostRepo = db.getCollection('posts').repository; PostRepo = db.getCollection('posts').repository;
CommentRepo = db.getCollection('comments').repository; CommentRepo = db.getCollection('comments').repository;
TagRepo = db.getCollection('tags').repository; TagRepo = db.getCollection('tags').repository;
const user = await app.db.getRepository('users').findOne();
agent = app.agent().login(user);
}); });
afterEach(() => app.destroy()); afterEach(() => app.destroy());
@ -694,6 +702,40 @@ describe('workflow > triggers > collection', () => {
}); });
}); });
describe('execute', () => {
it('disabled could be executed', async () => {
const workflow = await WorkflowModel.create({
type: 'collection',
sync: true,
config: {
mode: 1,
collection: 'posts',
},
});
const p1 = await PostRepo.create({ values: { title: 't1' } });
const e1s = await workflow.getExecutions();
expect(e1s.length).toBe(0);
const {
status,
body: { data },
} = await agent.resource('workflows').execute({
filterByTk: workflow.id,
values: {
data: p1.toJSON(),
},
});
expect(status).toBe(200);
const e2s = await workflow.getExecutions();
expect(e2s.length).toBe(1);
expect(e2s[0].toJSON()).toMatchObject(data.execution);
expect(data.execution.status).toBe(EXECUTION_STATUS.RESOLVED);
});
});
describe('cycling trigger', () => { describe('cycling trigger', () => {
it('trigger should not be triggered more than once in same execution', async () => { it('trigger should not be triggered more than once in same execution', async () => {
const workflow = await WorkflowModel.create({ const workflow = await WorkflowModel.create({
@ -834,8 +876,7 @@ describe('workflow > triggers > collection', () => {
describe('multiple data source', () => { describe('multiple data source', () => {
let anotherDB: MockDatabase; let anotherDB: MockDatabase;
beforeEach(async () => { beforeEach(async () => {
// @ts-ignore anotherDB = (app.dataSourceManager.dataSources.get('another').collectionManager as SequelizeCollectionManager).db;
anotherDB = app.dataSourceManager.dataSources.get('another').collectionManager.db;
}); });
it('collection trigger on another', async () => { it('collection trigger on another', async () => {
@ -890,9 +931,6 @@ describe('workflow > triggers > collection', () => {
const e1s = await w1.getExecutions(); const e1s = await w1.getExecutions();
expect(e1s.length).toBe(1); expect(e1s.length).toBe(1);
const user = await app.db.getRepository('users').findOne();
const agent = app.agent().login(user);
const { body } = await agent.resource('workflows').revision({ const { body } = await agent.resource('workflows').revision({
filterByTk: w1.id, filterByTk: w1.id,
filter: { filter: {
@ -922,5 +960,36 @@ describe('workflow > triggers > collection', () => {
}); });
expect(e3s.length).toBe(1); expect(e3s.length).toBe(1);
}); });
it.skip('sync event on another', async () => {
const workflow = await WorkflowModel.create({
enabled: true,
type: 'collection',
sync: true,
config: {
mode: 1,
collection: 'another:posts',
},
});
const post = await PostRepo.create({ values: { title: 't1' } });
const e1s = await workflow.getExecutions();
expect(e1s.length).toBe(0);
const AnotherPostRepo = anotherDB.getRepository('posts');
const anotherPost = await AnotherPostRepo.create({ values: { title: 't2' } });
const e2s = await workflow.getExecutions();
expect(e2s.length).toBe(1);
expect(e2s[0].status).toBe(EXECUTION_STATUS.RESOLVED);
expect(e2s[0].context.data.title).toBe('t2');
const p1s = await PostRepo.find();
expect(p1s.length).toBe(1);
const p2s = await AnotherPostRepo.find();
expect(p2s.length).toBe(1);
});
}); });
}); });

View File

@ -11,6 +11,8 @@ import actions, { Context, utils } from '@nocobase/actions';
import { Op, Repository } from '@nocobase/database'; import { Op, Repository } from '@nocobase/database';
import Plugin from '../Plugin'; import Plugin from '../Plugin';
import Processor from '../Processor';
import WorkflowRepository from '../repositories/WorkflowRepository';
export async function update(context: Context, next) { export async function update(context: Context, next) {
const repository = utils.getRepositoryFromParams(context) as Repository; const repository = utils.getRepositoryFromParams(context) as Repository;
@ -63,87 +65,14 @@ export async function destroy(context: Context, next) {
} }
export async function revision(context: Context, next) { export async function revision(context: Context, next) {
const plugin = context.app.getPlugin(Plugin); const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
const repository = utils.getRepositoryFromParams(context);
const { filterByTk, filter = {}, values = {} } = context.action.params; const { filterByTk, filter = {}, values = {} } = context.action.params;
context.body = await context.db.sequelize.transaction(async (transaction) => { context.body = await repository.revision({
const origin = await repository.findOne({ filterByTk,
filterByTk, filter,
filter, values,
appends: ['nodes'], context,
context,
transaction,
});
const trigger = plugin.triggers.get(origin.type);
const revisionData = filter.key
? {
key: filter.key,
title: origin.title,
triggerTitle: origin.triggerTitle,
allExecuted: origin.allExecuted,
}
: values;
const instance = await repository.create({
values: {
title: `${origin.title} copy`,
description: origin.description,
...revisionData,
sync: origin.sync,
type: origin.type,
config:
typeof trigger.duplicateConfig === 'function'
? await trigger.duplicateConfig(origin, { transaction })
: origin.config,
},
transaction,
});
const originalNodesMap = new Map();
origin.nodes.forEach((node) => {
originalNodesMap.set(node.id, node);
});
const oldToNew = new Map();
const newToOld = new Map();
for await (const node of origin.nodes) {
const instruction = plugin.instructions.get(node.type);
const newNode = await instance.createNode(
{
type: node.type,
key: node.key,
config:
typeof instruction.duplicateConfig === 'function'
? await instruction.duplicateConfig(node, { transaction })
: node.config,
title: node.title,
branchIndex: node.branchIndex,
},
{ transaction },
);
// NOTE: keep original node references for later replacement
oldToNew.set(node.id, newNode);
newToOld.set(newNode.id, node);
}
for await (const [oldId, newNode] of oldToNew.entries()) {
const oldNode = originalNodesMap.get(oldId);
const newUpstream = oldNode.upstreamId ? oldToNew.get(oldNode.upstreamId) : null;
const newDownstream = oldNode.downstreamId ? oldToNew.get(oldNode.downstreamId) : null;
await newNode.update(
{
upstreamId: newUpstream?.id ?? null,
downstreamId: newDownstream?.id ?? null,
},
{ transaction },
);
}
return instance;
}); });
await next(); await next();
@ -169,6 +98,62 @@ export async function sync(context: Context, next) {
await next(); await next();
} }
/**
* @deprecated
* Keep for action trigger compatibility
*/
export async function trigger(context: Context, next) { export async function trigger(context: Context, next) {
return next(); return next();
} }
export async function execute(context: Context, next) {
const plugin = context.app.pm.get(Plugin) as Plugin;
const { filterByTk, autoRevision } = context.action.params;
if (!filterByTk) {
return context.throw(400, 'filterByTk is required');
}
const id = Number.parseInt(filterByTk, 10);
if (Number.isNaN(id)) {
return context.throw(400, 'filterByTk is invalid');
}
const repository = utils.getRepositoryFromParams(context) as WorkflowRepository;
const workflow = plugin.enabledCache.get(id) || (await repository.findOne({ filterByTk }));
if (!workflow) {
return context.throw(404, 'workflow not found');
}
const { executed } = workflow;
let processor;
try {
processor = (await plugin.execute(workflow, context, { manually: true })) as Processor;
if (!processor) {
return context.throw(400, 'workflow not triggered');
}
} catch (ex) {
return context.throw(400, ex.message);
}
context.action.mergeParams({
filter: { key: workflow.key },
});
let newVersion;
if (!executed && autoRevision) {
newVersion = await repository.revision({
filterByTk: workflow.id,
filter: { key: workflow.key },
values: {
current: workflow.current,
enabled: workflow.enabled,
},
context,
});
}
context.body = {
execution: {
id: processor.execution.id,
status: processor.execution.status,
},
newVersionId: newVersion?.id,
};
return next();
}

View File

@ -14,6 +14,7 @@ export default function () {
dumpRules: 'required', dumpRules: 'required',
name: 'workflows', name: 'workflows',
shared: true, shared: true,
repository: 'WorkflowRepository',
fields: [ fields: [
{ {
name: 'key', name: 'key',
@ -71,7 +72,6 @@ export default function () {
{ {
type: 'boolean', type: 'boolean',
name: 'current', name: 'current',
defaultValue: false,
}, },
{ {
type: 'boolean', type: 'boolean',

View File

@ -14,5 +14,5 @@ export * from './functions';
export * from './logicCalculate'; export * from './logicCalculate';
export { Trigger } from './triggers'; export { Trigger } from './triggers';
export { default as Processor } from './Processor'; export { default as Processor } from './Processor';
export { default } from './Plugin'; export { default, EventOptions } from './Plugin';
export * from './types'; export * from './types';

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. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { pick } from 'lodash';
import { isValidFilter } from '@nocobase/utils';
import { Collection, Model, Transactionable } from '@nocobase/database'; import { Collection, Model, Transactionable } from '@nocobase/database';
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager';
import Trigger from '.'; import Trigger from '.';
import { toJSON } from '../utils'; import { toJSON } from '../utils';
import type { WorkflowModel } from '../types'; import type { WorkflowModel } from '../types';
import { ICollection, parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager'; import type { EventOptions } from '../Plugin';
import { isValidFilter } from '@nocobase/utils'; import { Context } from '@nocobase/actions';
import { pick } from 'lodash';
export interface CollectionChangeTriggerConfig { export interface CollectionChangeTriggerConfig {
collection: string; collection: string;
@ -45,90 +48,98 @@ function getFieldRawName(collection: ICollection, name: string) {
return name; return name;
} }
// async function, should return promise
async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
const { condition, changed, mode, appends } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
const collection: Collection = (collectionManager as SequelizeCollectionManager).getCollection(collectionName);
const { transaction, context } = options;
const { repository, filterTargetKey } = collection;
// NOTE: if no configured fields changed, do not trigger
if (
changed &&
changed.length &&
changed
.filter((name) => {
const field = collection.getField(name);
return field && !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.options.type);
})
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
) {
return;
}
const filterByTk = Array.isArray(filterTargetKey)
? pick(data, filterTargetKey)
: { [filterTargetKey]: data[filterTargetKey] };
// NOTE: if no configured condition, or not match, do not trigger
if (isValidFilter(condition) && !(mode & MODE_BITMAP.DESTROY)) {
// TODO: change to map filter format to calculation format
// const calculation = toCalculation(condition);
const count = await repository.count({
filterByTk,
filter: condition,
context,
transaction,
});
if (!count) {
return;
}
}
let result = data;
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
const includeFields = appends.reduce((set, field) => {
set.add(field.split('.')[0]);
set.add(field);
return set;
}, new Set());
// @ts-ignore
result = await repository.findOne({
filterByTk,
appends: Array.from(includeFields),
transaction,
});
}
// TODO: `result.toJSON()` throws error
const json = toJSON(result);
if (workflow.sync) {
await this.workflow.trigger(
workflow,
{ data: json, stack: context?.stack },
{
transaction: this.workflow.useDataSourceTransaction(dataSourceName, transaction),
},
);
} else {
if (transaction) {
transaction.afterCommit(() => {
this.workflow.trigger(workflow, { data: json, stack: context?.stack });
});
} else {
this.workflow.trigger(workflow, { data: json, stack: context?.stack });
}
}
}
export default class CollectionTrigger extends Trigger { export default class CollectionTrigger extends Trigger {
events = new Map(); events = new Map();
// async function, should return promise
private static async handler(this: CollectionTrigger, workflow: WorkflowModel, data: Model, options) {
const [dataSourceName] = parseCollectionName(workflow.config.collection);
const transaction = this.workflow.useDataSourceTransaction(dataSourceName, options.transaction);
const ctx = await this.prepare(workflow, data, { ...options, transaction });
if (!ctx) {
return;
}
if (workflow.sync) {
await this.workflow.trigger(workflow, ctx, {
transaction,
});
} else {
if (transaction) {
transaction.afterCommit(() => {
this.workflow.trigger(workflow, ctx);
});
} else {
this.workflow.trigger(workflow, ctx);
}
}
}
async prepare(workflow: WorkflowModel, data: Model | Record<string, any>, options) {
const { condition, changed, mode, appends } = workflow.config;
const [dataSourceName, collectionName] = parseCollectionName(workflow.config.collection);
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
const collection: Collection = (collectionManager as SequelizeCollectionManager).getCollection(collectionName);
const { transaction, context } = options;
const { repository, filterTargetKey } = collection;
// NOTE: if no configured fields changed, do not trigger
if (
data instanceof Model &&
changed &&
changed.length &&
changed
.filter((name) => {
const field = collection.getField(name);
return field && !['linkTo', 'hasOne', 'hasMany', 'belongsToMany'].includes(field.options.type);
})
.every((name) => !data.changedWithAssociations(getFieldRawName(collection, name)))
) {
return null;
}
const filterByTk = Array.isArray(filterTargetKey)
? pick(data, filterTargetKey)
: { [filterTargetKey]: data[filterTargetKey] };
// NOTE: if no configured condition, or not match, do not trigger
if (isValidFilter(condition) && !(mode & MODE_BITMAP.DESTROY)) {
// TODO: change to map filter format to calculation format
// const calculation = toCalculation(condition);
const count = await repository.count({
filterByTk,
filter: condition,
context,
transaction,
});
if (!count) {
return null;
}
}
let result = data;
if (appends?.length && !(mode & MODE_BITMAP.DESTROY)) {
const includeFields = appends.reduce((set, field) => {
set.add(field.split('.')[0]);
set.add(field);
return set;
}, new Set());
// @ts-ignore
result = await repository.findOne({
filterByTk,
appends: Array.from(includeFields),
transaction,
});
}
return {
data: toJSON(result),
stack: context?.stack,
};
}
on(workflow: WorkflowModel) { on(workflow: WorkflowModel) {
const { collection, mode } = workflow.config; const { collection, mode } = workflow.config;
if (!collection) { if (!collection) {
@ -146,7 +157,7 @@ export default class CollectionTrigger extends Trigger {
const name = getHookId(workflow, `${collection}.${type}`); const name = getHookId(workflow, `${collection}.${type}`);
if (mode & key) { if (mode & key) {
if (!this.events.has(name)) { if (!this.events.has(name)) {
const listener = handler.bind(this, workflow); const listener = (<typeof CollectionTrigger>this.constructor).handler.bind(this, workflow);
this.events.set(name, listener); this.events.set(name, listener);
db.on(event, listener); db.on(event, listener);
} }
@ -206,4 +217,14 @@ export default class CollectionTrigger extends Trigger {
return true; return true;
} }
async execute(workflow: WorkflowModel, context: Context, options: EventOptions) {
const ctx = await this.prepare(workflow, context.action.params.values?.data, options);
const [dataSourceName] = parseCollectionName(workflow.config.collection);
const { transaction } = options;
return this.workflow.trigger(workflow, ctx, {
...options,
transaction: this.workflow.useDataSourceTransaction(dataSourceName, transaction),
});
}
} }

View File

@ -12,7 +12,7 @@ import parser from 'cron-parser';
import type Plugin from '../../Plugin'; import type Plugin from '../../Plugin';
import type { WorkflowModel } from '../../types'; import type { WorkflowModel } from '../../types';
import { parseDateWithoutMs, SCHEDULE_MODE } from './utils'; import { parseDateWithoutMs, SCHEDULE_MODE } from './utils';
import { parseCollectionName, SequelizeCollectionManager } from '@nocobase/data-source-manager'; import { parseCollectionName, SequelizeCollectionManager, SequelizeDataSource } from '@nocobase/data-source-manager';
export type ScheduleOnField = { export type ScheduleOnField = {
field: string; field: string;
@ -93,7 +93,7 @@ function getHookId(workflow, type: string) {
return `${type}#${workflow.id}`; return `${type}#${workflow.id}`;
} }
export default class ScheduleTrigger { export default class DateFieldScheduleTrigger {
events = new Map(); events = new Map();
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
@ -378,8 +378,9 @@ export default class ScheduleTrigger {
}; };
this.events.set(name, listener); this.events.set(name, listener);
// @ts-ignore const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager.db.on(event, listener); const { db } = dataSource.collectionManager as SequelizeCollectionManager;
db.on(event, listener);
} }
off(workflow: WorkflowModel) { off(workflow: WorkflowModel) {
@ -396,8 +397,8 @@ export default class ScheduleTrigger {
const name = getHookId(workflow, event); const name = getHookId(workflow, event);
const listener = this.events.get(name); const listener = this.events.get(name);
if (listener) { if (listener) {
// @ts-ignore const dataSource = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName) as SequelizeDataSource;
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager; const { db } = dataSource.collectionManager as SequelizeCollectionManager;
db.off(event, listener); db.off(event, listener);
this.events.delete(name); this.events.delete(name);
} }

View File

@ -7,6 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { Context } from '@nocobase/actions';
import Trigger from '..'; import Trigger from '..';
import type Plugin from '../../Plugin'; import type Plugin from '../../Plugin';
import DateFieldScheduleTrigger from './DateFieldScheduleTrigger'; import DateFieldScheduleTrigger from './DateFieldScheduleTrigger';
@ -45,6 +46,11 @@ export default class ScheduleTrigger extends Trigger {
} }
} }
async execute(workflow, context: Context, options) {
const { values } = context.action.params;
return this.workflow.trigger(workflow, { ...values, date: values?.date ?? new Date() }, options);
}
// async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> { // async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> {
// if (!context.date) { // if (!context.date) {
// return false; // return false;

View File

@ -10,16 +10,22 @@
import { Transactionable } from '@nocobase/database'; import { Transactionable } from '@nocobase/database';
import type Plugin from '../Plugin'; import type Plugin from '../Plugin';
import type { WorkflowModel } from '../types'; import type { WorkflowModel } from '../types';
import Processor from '../Processor';
export abstract class Trigger { export abstract class Trigger {
constructor(public readonly workflow: Plugin) {} constructor(public readonly workflow: Plugin) {}
abstract on(workflow: WorkflowModel): void; on(workflow: WorkflowModel): void {}
abstract off(workflow: WorkflowModel): void; off(workflow: WorkflowModel): void {}
validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): boolean | Promise<boolean> { validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): boolean | Promise<boolean> {
return true; return true;
} }
duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>; duplicateConfig?(workflow: WorkflowModel, options: Transactionable): object | Promise<object>;
sync?: boolean; sync?: boolean;
execute?(
workflow: WorkflowModel,
context: any,
options: Transactionable,
): void | Processor | Promise<void | Processor>;
} }
export default Trigger; export default Trigger;