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 ( + <> + + } /> + ); + } + + const entry = nodes.find((item) => !item.upstream); + return ( - -