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:
Junyi 2024-02-02 10:32:27 +08:00 committed by GitHub
parent cd09649e1c
commit 4d4a70b2f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 133 additions and 62 deletions

View File

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

View File

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

View File

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

View File

@ -22,6 +22,9 @@ export const BulkDestroyActionInitializer = (props) => {
},
useProps: '{{ useBulkDestroyActionProps }}',
},
'x-action-settings': {
triggerWorkflows: [],
},
};
if (collection) {
schema['x-acl-action'] = `${collection.name}:destroy`;

View File

@ -16,6 +16,9 @@ export const DestroyActionInitializer = (props) => {
},
useProps: '{{ useDestroyActionProps }}',
},
'x-action-settings': {
triggerWorkflows: [],
},
};
return <ActionInitializer {...props} schema={schema} />;
};

View File

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

View File

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

View File

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

View File

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

View File

@ -2,4 +2,5 @@ export * from './CollectionBlockInitializer';
export * from './FieldsSelect';
export * from './FilterDynamicComponent';
export * from './RadioWithTooltip';
export * from './CheckboxGroupWithTooltip';
export * from './ValueBlock';

View File

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

View File

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

View File

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

View File

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

View File

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