refactor(plugin-workflow): support any context in processor as options (#3473)
* refactor(plugin-workflow): support any context in processor as options * fix(plugin-workflow): fix collection trigger * fix(plugin-workflow): fix collection trigger and instructions * fix(plugin-workflow): fix bind workflow configuration on button * fix(plugin-workflow): fix e2e test cases
This commit is contained in:
parent
cd09649e1c
commit
4d4a70b2f4
@ -3,7 +3,7 @@ import { Field, onFieldValueChange } from '@formily/core';
|
||||
import { ISchema, connect, mapProps, useField, useFieldSchema, useForm, useFormEffects } from '@formily/react';
|
||||
import { isValid, uid } from '@formily/shared';
|
||||
import { Alert, Tree as AntdTree, ModalProps } from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RemoteSelect, useCompile, useDesignable } from '../..';
|
||||
import { useApp } from '../../../application';
|
||||
@ -28,6 +28,7 @@ import {
|
||||
import { DefaultValueProvider } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue';
|
||||
import { useLinkageAction } from './hooks';
|
||||
import { requestSettingsSchema } from './utils';
|
||||
import { useFormBlockContext } from '../../../block-provider';
|
||||
|
||||
const Tree = connect(
|
||||
AntdTree,
|
||||
@ -447,13 +448,26 @@ function RemoveButton(
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowSelect({ types, ...props }) {
|
||||
function WorkflowSelect({ actionType, ...props }) {
|
||||
const { t } = useTranslation();
|
||||
const index = ArrayTable.useIndex();
|
||||
const { setValuesIn } = useForm();
|
||||
const baseCollection = useCollection();
|
||||
const { getCollection } = useCollectionManager();
|
||||
const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name);
|
||||
|
||||
const workflowPlugin = usePlugin('workflow') as any;
|
||||
const workflowTypes = useMemo(
|
||||
() =>
|
||||
workflowPlugin
|
||||
.getTriggersOptions()
|
||||
.filter((item) => {
|
||||
return typeof item.options.isActionTriggerable === 'function' || item.options.isActionTriggerable === true;
|
||||
})
|
||||
.map((item) => item.value),
|
||||
[workflowPlugin],
|
||||
);
|
||||
|
||||
useFormEffects(() => {
|
||||
onFieldValueChange(`group[${index}].context`, (field) => {
|
||||
let collection: CollectionOptions = baseCollection;
|
||||
@ -472,6 +486,20 @@ function WorkflowSelect({ types, ...props }) {
|
||||
});
|
||||
});
|
||||
|
||||
const optionFilter = useCallback(
|
||||
({ type, config }) => {
|
||||
const trigger = workflowPlugin.triggers.get(type);
|
||||
if (trigger.isActionTriggerable === true) {
|
||||
return true;
|
||||
}
|
||||
if (typeof trigger.isActionTriggerable === 'function') {
|
||||
return trigger.isActionTriggerable(config, { action: actionType });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[workflowPlugin, actionType],
|
||||
);
|
||||
|
||||
return (
|
||||
<RemoteSelect
|
||||
manual={false}
|
||||
@ -485,12 +513,13 @@ function WorkflowSelect({ types, ...props }) {
|
||||
action: 'list',
|
||||
params: {
|
||||
filter: {
|
||||
type: types,
|
||||
type: workflowTypes,
|
||||
enabled: true,
|
||||
'config.collection': workflowCollection,
|
||||
},
|
||||
},
|
||||
}}
|
||||
optionFilter={optionFilter}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@ -501,23 +530,26 @@ function WorkflowConfig() {
|
||||
const { t } = useTranslation();
|
||||
const fieldSchema = useFieldSchema();
|
||||
const { name: collection } = useCollection();
|
||||
const workflowPlugin = usePlugin('workflow') as any;
|
||||
const workflowTypes = workflowPlugin.getTriggersOptions().filter((item) => {
|
||||
return typeof item.options.useActionTriggerable === 'function'
|
||||
? item.options.useActionTriggerable()
|
||||
: Boolean(item.options.useActionTriggerable);
|
||||
});
|
||||
// TODO(refactor): should refactor for getting certain action type, better from 'x-action'.
|
||||
const formBlock = useFormBlockContext();
|
||||
const actionType = formBlock?.type || fieldSchema['x-action'];
|
||||
|
||||
const description = {
|
||||
submit: t('Workflow will be triggered after submitting succeeded.', { ns: 'workflow' }),
|
||||
'customize:save': t('Workflow will be triggered after saving succeeded.', { ns: 'workflow' }),
|
||||
submit: t('Workflow will be triggered before or after submitting succeeded based on workflow type.', {
|
||||
ns: 'workflow',
|
||||
}),
|
||||
'customize:save': t('Workflow will be triggered before or after saving succeeded based on workflow type.', {
|
||||
ns: 'workflow',
|
||||
}),
|
||||
'customize:triggerWorkflows': t(
|
||||
'Workflow will be triggered directly once the button clicked, without data saving.',
|
||||
{ ns: 'workflow' },
|
||||
),
|
||||
destroy: t('Workflow will be triggered before deleting succeeded.', { ns: 'workflow' }),
|
||||
}[fieldSchema?.['x-action']];
|
||||
|
||||
return (
|
||||
<SchemaSettingsModalItem
|
||||
<SchemaSettingsActionModalItem
|
||||
title={t('Bind workflows', { ns: 'workflow' })}
|
||||
scope={{
|
||||
fieldFilter(field) {
|
||||
@ -573,6 +605,7 @@ function WorkflowConfig() {
|
||||
value: '',
|
||||
},
|
||||
allowClear: false,
|
||||
loadData: actionType === 'destroy' ? null : undefined,
|
||||
},
|
||||
default: '',
|
||||
},
|
||||
@ -590,7 +623,8 @@ function WorkflowConfig() {
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'WorkflowSelect',
|
||||
'x-component-props': {
|
||||
types: workflowTypes.map((item) => item.value),
|
||||
placeholder: t('Select workflow', { ns: 'workflow' }),
|
||||
actionType,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { CloseCircleFilled } from '@ant-design/icons';
|
||||
import { Tag, TreeSelect } from 'antd';
|
||||
import type { DefaultOptionType } from 'rc-tree-select/es/TreeSelect';
|
||||
import type { DefaultOptionType, TreeSelectProps } from 'rc-tree-select/es/TreeSelect';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CollectionFieldOptions, useCollectionManager, useCompile } from '../../..';
|
||||
@ -72,7 +72,7 @@ function getCollectionFieldOptions(this: CallScope, collection, parentNode?): Tr
|
||||
});
|
||||
}
|
||||
|
||||
export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProps> = (props) => {
|
||||
const {
|
||||
title,
|
||||
value: propsValue,
|
||||
@ -81,6 +81,7 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
useCollection = usePropsCollection,
|
||||
filter = trueFilter,
|
||||
rootOption,
|
||||
loadData: propsLoadData,
|
||||
...restProps
|
||||
} = props;
|
||||
const { getCollectionFields } = useCollectionManager();
|
||||
@ -98,6 +99,9 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
|
||||
const loadData = useCallback(
|
||||
async (option) => {
|
||||
if (propsLoadData !== null) {
|
||||
return propsLoadData(option);
|
||||
}
|
||||
if (!option.isLeaf && option.loadChildren) {
|
||||
const children = option.loadChildren(option);
|
||||
setOptionsMap((prev) => {
|
||||
@ -105,7 +109,7 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[setOptionsMap],
|
||||
[propsLoadData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -119,17 +123,16 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
isLeaf: false,
|
||||
}
|
||||
: null;
|
||||
const treeData = getCollectionFieldOptions.call(
|
||||
{ compile, getCollectionFields, filter },
|
||||
baseCollection,
|
||||
parentNode,
|
||||
);
|
||||
const treeData =
|
||||
propsLoadData === null
|
||||
? []
|
||||
: getCollectionFieldOptions.call({ compile, getCollectionFields, filter }, baseCollection, parentNode);
|
||||
const map = treeData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {});
|
||||
if (parentNode) {
|
||||
map[parentNode.value] = parentNode;
|
||||
}
|
||||
setOptionsMap(map);
|
||||
}, [collection, baseCollection, rootOption, filter]);
|
||||
}, [collection, baseCollection, rootOption, filter, propsLoadData]);
|
||||
|
||||
useEffect(() => {
|
||||
const arr = (props.multiple ? propsValue : propsValue ? [propsValue] : []) as string[];
|
||||
@ -217,8 +220,8 @@ export const AppendsTreeSelect: React.FC<AppendsTreeSelectProps> = (props) => {
|
||||
const valueKeys: string[] = props.multiple
|
||||
? (propsValue as string[])
|
||||
: propsValue != null
|
||||
? [propsValue as string]
|
||||
: [];
|
||||
? [propsValue as string]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
|
@ -22,6 +22,7 @@ export type RemoteSelectProps<P = any> = SelectProps<P, any> & {
|
||||
targetField?: any;
|
||||
service: ResourceActionOptions<P>;
|
||||
CustomDropdownRender?: (v: any) => any;
|
||||
optionFilter?: (option: any) => boolean;
|
||||
};
|
||||
|
||||
const InternalRemoteSelect = connect(
|
||||
@ -37,6 +38,7 @@ const InternalRemoteSelect = connect(
|
||||
mapOptions,
|
||||
targetField: _targetField,
|
||||
CustomDropdownRender,
|
||||
optionFilter,
|
||||
...others
|
||||
} = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
@ -192,8 +194,9 @@ const InternalRemoteSelect = connect(
|
||||
}
|
||||
const valueOptions =
|
||||
(v != null && (Array.isArray(v) ? v : [{ ...v, [fieldNames.value]: v[fieldNames.value] || v }])) || [];
|
||||
return uniqBy(data?.data?.concat(valueOptions ?? []), fieldNames.value);
|
||||
}, [value, defaultValue, data?.data, fieldNames.value]);
|
||||
const filtered = typeof optionFilter === 'function' ? data.data.filter(optionFilter) : data.data;
|
||||
return uniqBy(filtered.concat(valueOptions ?? []), fieldNames.value);
|
||||
}, [value, defaultValue, data?.data, fieldNames.value, optionFilter]);
|
||||
const onDropdownVisibleChange = (visible) => {
|
||||
setOpen(visible);
|
||||
searchData.current = null;
|
||||
|
@ -22,6 +22,9 @@ export const BulkDestroyActionInitializer = (props) => {
|
||||
},
|
||||
useProps: '{{ useBulkDestroyActionProps }}',
|
||||
},
|
||||
'x-action-settings': {
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
};
|
||||
if (collection) {
|
||||
schema['x-acl-action'] = `${collection.name}:destroy`;
|
||||
|
@ -16,6 +16,9 @@ export const DestroyActionInitializer = (props) => {
|
||||
},
|
||||
useProps: '{{ useDestroyActionProps }}',
|
||||
},
|
||||
'x-action-settings': {
|
||||
triggerWorkflows: [],
|
||||
},
|
||||
};
|
||||
return <ActionInitializer {...props} schema={schema} />;
|
||||
};
|
||||
|
@ -59,7 +59,7 @@ export default class extends Trigger {
|
||||
scope = {
|
||||
useCollectionDataSource,
|
||||
};
|
||||
useActionTriggerable = true;
|
||||
isActionTriggerable = true;
|
||||
useVariables(config, options) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const compile = useCompile();
|
||||
|
@ -93,7 +93,7 @@ test.describe('Configuration page to configure the Trigger node', () => {
|
||||
await page.getByRole('button', { name: 'plus Add workflow' }).click();
|
||||
await page.getByRole('button', { name: 'Select workflow' }).click();
|
||||
await page.getByRole('option', { name: workFlowName }).click();
|
||||
await page.getByRole('button', { name: 'OK', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||
|
||||
// 2、测试步骤:进入“数据区块”-“添加”按钮,填写表单,点击“确定”按钮
|
||||
const fieldData = triggerNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
|
||||
@ -187,7 +187,7 @@ test.describe('Configuration page to configure the Trigger node', () => {
|
||||
await page.getByRole('button', { name: 'plus Add workflow' }).click();
|
||||
await page.getByRole('button', { name: 'Select workflow' }).click();
|
||||
await page.getByRole('option', { name: workFlowName }).click();
|
||||
await page.getByRole('button', { name: 'OK', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Submit', exact: true }).click();
|
||||
|
||||
// 2、测试步骤:进入“数据区块”-“添加”按钮,填写表单,点击“确定”按钮
|
||||
const fieldData = triggerNodeFieldDisplayName + dayjs().format('YYYYMMDDHHmmss.SSS').toString();
|
||||
|
@ -116,14 +116,7 @@ export default class extends Trigger {
|
||||
}
|
||||
|
||||
for (const event of syncGroup) {
|
||||
await this.workflow.trigger(event[0], event[1]);
|
||||
// if (processor.execution.status < EXECUTION_STATUS.STARTED) {
|
||||
// // error handling
|
||||
// return context.throw(
|
||||
// 500,
|
||||
// 'Your data saved, but some workflow on your action failed, please contact the administrator.',
|
||||
// );
|
||||
// }
|
||||
await this.workflow.trigger(event[0], event[1], { httpContext: context });
|
||||
}
|
||||
|
||||
for (const event of asyncGroup) {
|
||||
|
@ -0,0 +1,40 @@
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { css, useCompile } from '@nocobase/client';
|
||||
import { Checkbox, Space, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
export interface CheckboxGroupWithTooltipOption {
|
||||
value: any;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function CheckboxGroupWithTooltip(props) {
|
||||
const { options = [], direction, ...other } = props;
|
||||
const compile = useCompile();
|
||||
|
||||
return (
|
||||
<Checkbox.Group {...other}>
|
||||
<Space direction={direction}>
|
||||
{options.map((option) => (
|
||||
<Checkbox key={option.value} value={option.value}>
|
||||
<span
|
||||
className={css`
|
||||
& + .anticon {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{compile(option.label)}
|
||||
</span>
|
||||
{option.tooltip && (
|
||||
<Tooltip title={compile(option.tooltip)}>
|
||||
<QuestionCircleOutlined style={{ color: '#666' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
@ -2,4 +2,5 @@ export * from './CollectionBlockInitializer';
|
||||
export * from './FieldsSelect';
|
||||
export * from './FilterDynamicComponent';
|
||||
export * from './RadioWithTooltip';
|
||||
export * from './CheckboxGroupWithTooltip';
|
||||
export * from './ValueBlock';
|
||||
|
@ -65,7 +65,7 @@ export abstract class Trigger {
|
||||
components?: { [key: string]: any };
|
||||
useInitializers?(config): SchemaInitializerItemType | null;
|
||||
initializers?: any;
|
||||
useActionTriggerable?: boolean | (() => boolean);
|
||||
isActionTriggerable?: boolean | ((config: object, context?: object) => boolean);
|
||||
}
|
||||
|
||||
function TriggerExecution() {
|
||||
|
@ -190,6 +190,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
'workflows.nodes:*',
|
||||
'executions:list',
|
||||
'executions:get',
|
||||
'executions:cancel',
|
||||
'flow_nodes:update',
|
||||
'flow_nodes:destroy',
|
||||
],
|
||||
@ -239,8 +240,8 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
});
|
||||
|
||||
this.app.on('beforeStop', async () => {
|
||||
const collection = db.getCollection('workflows');
|
||||
const workflows = await collection.repository.find({
|
||||
const repository = db.getRepository('workflows');
|
||||
const workflows = await repository.find({
|
||||
filter: { enabled: true },
|
||||
});
|
||||
|
||||
@ -280,7 +281,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
public trigger(
|
||||
workflow: WorkflowModel,
|
||||
context: object,
|
||||
options: { context?: any } & Transactionable = {},
|
||||
options: { [key: string]: any } & Transactionable = {},
|
||||
): void | Promise<Processor | null> {
|
||||
const logger = this.getLogger(workflow.id);
|
||||
if (!this.ready) {
|
||||
@ -298,7 +299,8 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
return this.triggerSync(workflow, context, options);
|
||||
}
|
||||
|
||||
this.events.push([workflow, context, { context: options.context }]);
|
||||
const { transaction, ...rest } = options;
|
||||
this.events.push([workflow, context, rest]);
|
||||
this.eventsCount = this.events.length;
|
||||
|
||||
logger.info(`new event triggered, now events: ${this.events.length}`);
|
||||
@ -317,7 +319,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
private async triggerSync(
|
||||
workflow: WorkflowModel,
|
||||
context: object,
|
||||
options: { context?: any } & Transactionable = {},
|
||||
options: { [key: string]: any } & Transactionable = {},
|
||||
): Promise<Processor | null> {
|
||||
let execution;
|
||||
try {
|
||||
|
@ -9,6 +9,7 @@ import type { ExecutionModel, FlowNodeModel, JobModel } from './types';
|
||||
|
||||
export interface ProcessorOptions extends Transactionable {
|
||||
plugin: Plugin;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export default class Processor {
|
||||
|
@ -10,6 +10,7 @@ export class CreateInstruction extends Instruction {
|
||||
|
||||
const { repository, model } = (<typeof FlowNodeModel>node.constructor).database.getCollection(collection);
|
||||
const options = processor.getParsedValue(params, node.id);
|
||||
|
||||
const created = await repository.create({
|
||||
...options,
|
||||
context: {
|
||||
|
@ -88,20 +88,13 @@ async function handler(this: CollectionTrigger, workflow: WorkflowModel, data: M
|
||||
if (workflow.sync) {
|
||||
await this.workflow.trigger(
|
||||
workflow,
|
||||
{ data: json },
|
||||
{ data: json, stack: context?.stack },
|
||||
{
|
||||
context,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.workflow.trigger(
|
||||
workflow,
|
||||
{ data: json },
|
||||
{
|
||||
context,
|
||||
},
|
||||
);
|
||||
this.workflow.trigger(workflow, { data: json, stack: context?.stack });
|
||||
}
|
||||
}
|
||||
|
||||
@ -155,15 +148,11 @@ export default class CollectionTrigger extends Trigger {
|
||||
}
|
||||
}
|
||||
|
||||
async validateEvent(
|
||||
workflow: WorkflowModel,
|
||||
context: any,
|
||||
options: { context?: { stack?: number[] } } & Transactionable,
|
||||
): Promise<boolean> {
|
||||
if (options.context?.stack) {
|
||||
async validateEvent(workflow: WorkflowModel, context: any, options: Transactionable): Promise<boolean> {
|
||||
if (context.stack) {
|
||||
const existed = await workflow.countExecutions({
|
||||
where: {
|
||||
id: options.context.stack,
|
||||
id: context.stack,
|
||||
},
|
||||
transaction: options.transaction,
|
||||
});
|
||||
@ -172,13 +161,11 @@ export default class CollectionTrigger extends Trigger {
|
||||
this.workflow
|
||||
.getLogger(workflow.id)
|
||||
.warn(
|
||||
`workflow ${workflow.id} has already been triggered in stack executions (${options.context.stack}), and newly triggering will be skipped.`,
|
||||
`workflow ${workflow.id} has already been triggered in stack executions (${context.stack}), and newly triggering will be skipped.`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
context.stack = options.context.stack;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
Loading…
Reference in New Issue
Block a user