diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx
index a1b8fa25d8..8febd10089 100644
--- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx
@@ -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);
diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts
index 00389090a2..e7f5f2768d 100644
--- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts
@@ -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,
+ },
+ );
+ }
}
diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts
index 47bb72c9a4..d45959ad98 100644
--- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts
@@ -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({
diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts
index 786f1dc722..9e4057f3ec 100644
--- a/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts
+++ b/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts
@@ -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'),
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx
index 685490fdc3..87aa1509ec 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionCanvas.tsx
@@ -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 ? (
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx
index 469b464d4d..2844ee2617 100644
--- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx
+++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx
@@ -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 ;
}
-export function WorkflowCanvas() {
+function ExecutedStatusMessage({ data, option }) {
+ const compile = useCompile();
+ const statusText = compile(option.label);
+ return (
+
+ {'Workflow executed, the result status is '}
+ {'{{statusText}}'}
+ View the execution
+
+ );
+}
+
+function getExecutedStatusMessage({ id, status }) {
+ const option = ExecutionStatusOptionsMap[status];
+ if (!option) {
+ return null;
+ }
+ return {
+ type: 'info' as NoticeType,
+ content: ,
+ };
+}
+
+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();
+ 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 ? {children} : children;
+}
+
+function ExecuteActionButton() {
+ const { workflow } = useFlowContext();
+ const trigger = useTrigger();
+
+ return (
+
+ );
+}
+
+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 ;
- }
- return (
- navigate(-1)}>{lang('Go back')}} />
- );
- }
-
- const { nodes = [], revisions = [], ...workflow } = data?.data ?? {};
- linkNodes(nodes);
-
- const entry = nodes.find((item) => !item.upstream);
-
- function onSwitchVersion({ key }) {
- if (key != workflow.id) {
- navigate(getWorkflowDetailPath(key));
- }
- }
-
- async function onToggle(value) {
- await resource.update({
- filterByTk: workflow.id,
- values: {
- enabled: value,
- },
- });
- refresh();
- }
-
- async function onRevision() {
+ const onRevision = useCallback(async () => {
const {
data: { data: revision },
} = await resource.revision({
@@ -111,9 +262,9 @@ export function WorkflowCanvas() {
message.success(t('Operation succeeded'));
navigate(`/admin/workflow/workflows/${revision.id}`);
- }
+ }, [resource, workflow.id, workflow.key, message, t, navigate]);
- async function onDelete() {
+ const onDelete = useCallback(async () => {
const content = workflow.current
? lang('Delete a main version will cause all other revisions to be deleted too.')
: '';
@@ -133,30 +284,138 @@ export function WorkflowCanvas() {
);
},
});
- }
+ }, [workflow, modal, t, resource, message, navigate, app.pluginSettingsManager, revisions]);
- async function onMenuCommand({ key }) {
- switch (key) {
- case 'history':
- setVisible(true);
- return;
- case 'revision':
- return onRevision();
- case 'delete':
- return onDelete();
- default:
- break;
- }
- }
+ const onMenuCommand = useCallback(
+ ({ key }) => {
+ switch (key) {
+ case 'history':
+ setHistoryVisible(true);
+ return;
+ case 'revision':
+ return onRevision();
+ case 'delete':
+ return onDelete();
+ default:
+ break;
+ }
+ },
+ [onDelete, onRevision],
+ );
const revisionable =
workflow.executed &&
!revisions.find((item) => !item.executed && new Date(item.createdAt) > new Date(workflow.createdAt));
+ return (
+ <>
+
+ } />
+
+
+
+
+ >
+ );
+}
+
+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 ;
+ }
+ return (
+ navigate(-1)}>{lang('Go back')}} />
+ );
+ }
+
+ const entry = nodes.find((item) => !item.upstream);
+
return (
-
-