mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-12-03 12:47:44 +08:00
feat(plugin-workflow): add test run for nodes (#5407)
* feat(plugin-workflow): add test run for nodes * fix(plugin-workflow): fix locale * test(plugin-workflow): add test cases
This commit is contained in:
parent
05b9703101
commit
379ae83862
@ -480,6 +480,7 @@ export function Input(props: VariableInputProps) {
|
||||
margin-left: -1px;
|
||||
`)}
|
||||
type={variable ? 'primary' : 'default'}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Cascader>
|
||||
|
@ -25,7 +25,7 @@ function setNativeInputValue(input, value) {
|
||||
|
||||
export function RawTextArea(props): JSX.Element {
|
||||
const inputRef = useRef<any>(null);
|
||||
const { changeOnSelect, component: Component = Input.TextArea, fieldNames, scope, ...others } = props;
|
||||
const { changeOnSelect, component: Component = Input.TextArea, fieldNames, scope, buttonClass, ...others } = props;
|
||||
const dataScope = typeof scope === 'function' ? scope() : scope;
|
||||
const [options, setOptions] = useState(dataScope ? dataScope : []);
|
||||
|
||||
@ -66,7 +66,7 @@ export function RawTextArea(props): JSX.Element {
|
||||
>
|
||||
<VariableSelect
|
||||
className={
|
||||
props.buttonClass ??
|
||||
buttonClass ??
|
||||
css`
|
||||
&:not(:hover) {
|
||||
border-right-color: transparent;
|
||||
|
@ -350,7 +350,7 @@ export default class extends Instruction {
|
||||
},
|
||||
ignoreFail: {
|
||||
type: 'boolean',
|
||||
title: `{{t("Ignore failed request and continue workflow", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-content': `{{t("Ignore failed request and continue workflow", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Checkbox',
|
||||
},
|
||||
@ -390,4 +390,5 @@ export default class extends Instruction {
|
||||
],
|
||||
};
|
||||
}
|
||||
testable = true;
|
||||
}
|
||||
|
@ -17,10 +17,10 @@ export interface Header {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
|
||||
headers: Array<Header>;
|
||||
export type RequestInstructionConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
|
||||
headers?: Header[];
|
||||
contentType: string;
|
||||
ignoreFail: boolean;
|
||||
ignoreFail?: boolean;
|
||||
onlyData?: boolean;
|
||||
};
|
||||
|
||||
@ -103,7 +103,7 @@ function responseFailure(error) {
|
||||
|
||||
export default class extends Instruction {
|
||||
async run(node: FlowNodeModel, prevJob, processor: Processor) {
|
||||
const config = processor.getParsedValue(node.config, node.id) as RequestConfig;
|
||||
const config = processor.getParsedValue(node.config, node.id) as RequestInstructionConfig;
|
||||
|
||||
const { workflow } = processor.execution;
|
||||
const sync = this.workflow.isWorkflowSync(workflow);
|
||||
@ -171,10 +171,25 @@ export default class extends Instruction {
|
||||
}
|
||||
|
||||
async resume(node: FlowNodeModel, job, processor: Processor) {
|
||||
const { ignoreFail } = node.config as RequestConfig;
|
||||
const { ignoreFail } = node.config as RequestInstructionConfig;
|
||||
if (ignoreFail) {
|
||||
job.set('status', JOB_STATUS.RESOLVED);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async test(config: RequestInstructionConfig) {
|
||||
try {
|
||||
const response = await request(config);
|
||||
return {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: responseSuccess(response, config.onlyData),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: config.ignoreFail ? JOB_STATUS.RESOLVED : JOB_STATUS.FAILED,
|
||||
result: error.isAxiosError ? error.toJSON() : error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import { MockServer } from '@nocobase/test';
|
||||
import PluginWorkflow, { EXECUTION_STATUS, JOB_STATUS, Processor } from '@nocobase/plugin-workflow';
|
||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||
|
||||
import { RequestConfig } from '../RequestInstruction';
|
||||
import RequestInstruction, { RequestConfig } from '../RequestInstruction';
|
||||
|
||||
const HOST = 'localhost';
|
||||
|
||||
@ -111,6 +111,7 @@ describe('workflow > instructions > request', () => {
|
||||
let WorkflowModel;
|
||||
let workflow;
|
||||
let api: MockAPI;
|
||||
let instruction: RequestInstruction;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = new MockAPI();
|
||||
@ -123,6 +124,9 @@ describe('workflow > instructions > request', () => {
|
||||
});
|
||||
|
||||
db = app.db;
|
||||
|
||||
instruction = (app.pm.get(PluginWorkflow) as PluginWorkflow).instructions.get('request') as RequestInstruction;
|
||||
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
PostCollection = db.getCollection('posts');
|
||||
PostRepo = PostCollection.repository;
|
||||
@ -606,4 +610,68 @@ describe('workflow > instructions > request', () => {
|
||||
expect(job.result.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test run', () => {
|
||||
it('invalid config', async () => {
|
||||
const { status, result } = await instruction.test(Object.create({}));
|
||||
expect(status).toBe(JOB_STATUS.FAILED);
|
||||
expect(result).toBe("Cannot read properties of null (reading 'replace')");
|
||||
});
|
||||
|
||||
it('data url', async () => {
|
||||
const { status, result } = await instruction.test({
|
||||
url: api.URL_DATA,
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: { a: 1 },
|
||||
});
|
||||
expect(status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.data).toEqual({ meta: {}, data: { a: 1 } });
|
||||
});
|
||||
|
||||
it('404', async () => {
|
||||
const { status, result } = await instruction.test({
|
||||
url: api.URL_404,
|
||||
method: 'GET',
|
||||
contentType: '',
|
||||
});
|
||||
expect(status).toBe(JOB_STATUS.FAILED);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('timeout', async () => {
|
||||
const { status, result } = await instruction.test({
|
||||
url: api.URL_TIMEOUT,
|
||||
method: 'GET',
|
||||
timeout: 1000,
|
||||
contentType: '',
|
||||
});
|
||||
expect(status).toBe(JOB_STATUS.FAILED);
|
||||
expect(result.code).toBe('ECONNABORTED');
|
||||
});
|
||||
|
||||
it('ignoreFail', async () => {
|
||||
const { status, result } = await instruction.test({
|
||||
url: api.URL_404,
|
||||
method: 'GET',
|
||||
ignoreFail: true,
|
||||
contentType: '',
|
||||
});
|
||||
expect(status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(result.status).toBe(404);
|
||||
});
|
||||
|
||||
it('timeout and ignoreFail', async () => {
|
||||
const { status, result } = await instruction.test({
|
||||
url: api.URL_TIMEOUT,
|
||||
method: 'GET',
|
||||
timeout: 1000,
|
||||
ignoreFail: true,
|
||||
contentType: '',
|
||||
});
|
||||
expect(status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(result.code).toBe('ECONNABORTED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -80,4 +80,5 @@ export default class extends Instruction {
|
||||
[fieldNames.label]: title,
|
||||
};
|
||||
}
|
||||
testable = true;
|
||||
}
|
||||
|
@ -7,14 +7,20 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { SequelizeCollectionManager } from '@nocobase/data-source-manager';
|
||||
import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow';
|
||||
|
||||
export type SQLInstructionConfig = {
|
||||
dataSource?: string;
|
||||
sql?: string;
|
||||
withMeta?: boolean;
|
||||
};
|
||||
|
||||
export default class extends Instruction {
|
||||
async run(node: FlowNodeModel, input, processor: Processor) {
|
||||
const dataSourceName = node.config.dataSource || 'main';
|
||||
// @ts-ignore
|
||||
const { db } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName).collectionManager;
|
||||
if (!db) {
|
||||
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
|
||||
if (!(collectionManager instanceof SequelizeCollectionManager)) {
|
||||
throw new Error(`type of data source "${node.config.dataSource}" is not database`);
|
||||
}
|
||||
const sql = processor.getParsedValue(node.config.sql || '', node.id).trim();
|
||||
@ -25,7 +31,7 @@ export default class extends Instruction {
|
||||
}
|
||||
|
||||
const [result = null, meta = null] =
|
||||
(await db.sequelize.query(sql, {
|
||||
(await collectionManager.db.sequelize.query(sql, {
|
||||
transaction: this.workflow.useDataSourceTransaction(dataSourceName, processor.transaction),
|
||||
// plain: true,
|
||||
// model: db.getCollection(node.config.collection).model
|
||||
@ -36,4 +42,33 @@ export default class extends Instruction {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
};
|
||||
}
|
||||
|
||||
async test({ dataSource, sql, withMeta }: SQLInstructionConfig = {}) {
|
||||
if (!sql) {
|
||||
return {
|
||||
result: null,
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
};
|
||||
}
|
||||
|
||||
const dataSourceName = dataSource || 'main';
|
||||
const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName);
|
||||
if (!(collectionManager instanceof SequelizeCollectionManager)) {
|
||||
throw new Error(`type of data source "${dataSource}" is not database`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [result = null, meta = null] = (await collectionManager.db.sequelize.query(sql)) ?? [];
|
||||
|
||||
return {
|
||||
result: withMeta ? [result, meta] : result,
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result: error.message,
|
||||
status: JOB_STATUS.ERROR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,10 +9,11 @@
|
||||
|
||||
import Database, { fn } from '@nocobase/database';
|
||||
import { Application } from '@nocobase/server';
|
||||
import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
|
||||
import WorkflowPluginServer, { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow';
|
||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||
|
||||
import Plugin from '..';
|
||||
import SQLInstruction from '../SQLInstruction';
|
||||
|
||||
const mysql = process.env.DB_DIALECT === 'mysql' ? describe : describe.skip;
|
||||
|
||||
@ -24,12 +25,13 @@ describe('workflow > instructions > sql', () => {
|
||||
let ReplyRepo;
|
||||
let WorkflowModel;
|
||||
let workflow;
|
||||
let instruction: SQLInstruction;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp({
|
||||
plugins: [Plugin],
|
||||
});
|
||||
|
||||
instruction = (app.pm.get(WorkflowPluginServer) as WorkflowPluginServer).instructions.get('sql') as SQLInstruction;
|
||||
db = app.db;
|
||||
WorkflowModel = db.getCollection('workflows').model;
|
||||
PostCollection = db.getCollection('posts');
|
||||
@ -334,4 +336,23 @@ describe('workflow > instructions > sql', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('test', () => {
|
||||
it('empty sql', async () => {
|
||||
const { status, result } = await instruction.test({});
|
||||
expect(status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('invalid sql', async () => {
|
||||
const { status, result } = await instruction.test({ sql: '1' });
|
||||
expect(status).toBe(JOB_STATUS.ERROR);
|
||||
});
|
||||
|
||||
it('valid sql', async () => {
|
||||
const { status, result } = await instruction.test({ sql: 'select 1 as a' });
|
||||
expect(status).toBe(JOB_STATUS.RESOLVED);
|
||||
expect(result).toEqual([{ a: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -17,6 +17,12 @@ export default {
|
||||
result: config.path == null ? result : lodash.get(result, config.path),
|
||||
};
|
||||
},
|
||||
test(config = {}) {
|
||||
return {
|
||||
status: 1,
|
||||
result: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
echoVariable: {
|
||||
@ -40,6 +46,11 @@ export default {
|
||||
status: 0,
|
||||
};
|
||||
},
|
||||
test() {
|
||||
return {
|
||||
status: 0,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
prompt: {
|
||||
|
@ -8,12 +8,12 @@
|
||||
*/
|
||||
|
||||
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { createForm } from '@formily/core';
|
||||
import { createForm, Field } from '@formily/core';
|
||||
import { toJS } from '@formily/reactive';
|
||||
import { ISchema, useForm } from '@formily/react';
|
||||
import { Alert, App, Button, Dropdown, Input, Tag, Tooltip, message } from 'antd';
|
||||
import { cloneDeep, get } from 'lodash';
|
||||
import React, { useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { ISchema, observer, useField, useForm } from '@formily/react';
|
||||
import { Alert, App, Button, Dropdown, Empty, Input, Space, Tag, Tooltip, message } from 'antd';
|
||||
import { cloneDeep, get, set } from 'lodash';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@ -21,10 +21,12 @@ import {
|
||||
FormProvider,
|
||||
SchemaComponent,
|
||||
SchemaInitializerItemType,
|
||||
Variable,
|
||||
css,
|
||||
cx,
|
||||
useAPIClient,
|
||||
useActionContext,
|
||||
useCancelAction,
|
||||
useCompile,
|
||||
usePlugin,
|
||||
useResourceActionContext,
|
||||
@ -40,7 +42,7 @@ import { JobStatusOptionsMap } from '../constants';
|
||||
import { useGetAriaLabelOfAddButton } from '../hooks/useGetAriaLabelOfAddButton';
|
||||
import { lang } from '../locale';
|
||||
import useStyles from '../style';
|
||||
import { UseVariableOptions, VariableOption } from '../variable';
|
||||
import { UseVariableOptions, VariableOption, WorkflowVariableInput } from '../variable';
|
||||
|
||||
export type NodeAvailableContext = {
|
||||
engine: WorkflowPlugin;
|
||||
@ -58,7 +60,7 @@ export abstract class Instruction {
|
||||
* @experimental
|
||||
*/
|
||||
options?: { label: string; value: any; key: string }[];
|
||||
fieldset: { [key: string]: ISchema };
|
||||
fieldset: Record<string, ISchema>;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
@ -80,6 +82,7 @@ export abstract class Instruction {
|
||||
*/
|
||||
isAvailable?(ctx: NodeAvailableContext): boolean;
|
||||
end?: boolean | ((node) => boolean);
|
||||
testable?: boolean;
|
||||
}
|
||||
|
||||
function useUpdateAction() {
|
||||
@ -304,6 +307,194 @@ function useFormProviderProps() {
|
||||
return { form: useForm() };
|
||||
}
|
||||
|
||||
const useRunAction = () => {
|
||||
const { values, query } = useForm();
|
||||
const node = useNodeContext();
|
||||
const api = useAPIClient();
|
||||
const ctx = useActionContext();
|
||||
const field = useField<Field>();
|
||||
return {
|
||||
async run() {
|
||||
const template = parse(node.config);
|
||||
const config = template(toJS(values.config));
|
||||
const resultField = query('result').take() as Field;
|
||||
resultField.setValue(null);
|
||||
resultField.setFeedback({});
|
||||
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { data },
|
||||
} = await api.resource('flow_nodes').test({
|
||||
values: {
|
||||
config,
|
||||
type: node.type,
|
||||
},
|
||||
});
|
||||
|
||||
resultField.setFeedback({
|
||||
type: data.status > 0 ? 'success' : 'error',
|
||||
messages: data.status > 0 ? [lang('Resolved')] : [lang('Failed')],
|
||||
});
|
||||
resultField.setValue(data.result);
|
||||
} catch (err) {
|
||||
resultField.setFeedback({
|
||||
type: 'error',
|
||||
messages: err.message,
|
||||
});
|
||||
}
|
||||
field.data.loading = false;
|
||||
ctx.setFormValueChanged(false);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const VariableKeysContext = createContext<string[]>([]);
|
||||
|
||||
function VariableReplacer({ name, value, onChange }) {
|
||||
return (
|
||||
<Space>
|
||||
<WorkflowVariableInput variableOptions={{}} value={`{{${name}}}`} disabled />
|
||||
<Variable.Input useTypedConstant={['string', 'number', 'boolean', 'date']} value={value} onChange={onChange} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function TestFormFieldset({ value, onChange }) {
|
||||
const keys = useContext(VariableKeysContext);
|
||||
|
||||
return keys.length ? (
|
||||
<>
|
||||
{keys.map((key) => (
|
||||
<VariableReplacer
|
||||
key={key}
|
||||
name={key}
|
||||
value={get(value, key)}
|
||||
onChange={(v) => {
|
||||
set(value, key, v);
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={lang('No variable')} style={{ margin: '1em' }} />
|
||||
);
|
||||
}
|
||||
|
||||
function TestButton() {
|
||||
const node = useNodeContext();
|
||||
const { values } = useForm();
|
||||
const template = parse(values);
|
||||
const keys = template.parameters.map((item) => item.key);
|
||||
const form = useMemo(() => createForm(), []);
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={{ type: node.type, config: values }}>
|
||||
<VariableKeysContext.Provider value={keys}>
|
||||
<SchemaComponent
|
||||
components={{
|
||||
Alert,
|
||||
}}
|
||||
scope={{
|
||||
useCancelAction,
|
||||
useRunAction,
|
||||
}}
|
||||
schema={{
|
||||
type: 'void',
|
||||
name: 'testButton',
|
||||
title: '{{t("Test run")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
icon: 'CaretRightOutlined',
|
||||
// openSize: 'small',
|
||||
},
|
||||
properties: {
|
||||
modal: {
|
||||
type: 'void',
|
||||
'x-decorator': 'FormV2',
|
||||
'x-decorator-props': {
|
||||
form,
|
||||
},
|
||||
'x-component': 'Action.Modal',
|
||||
title: `{{t("Test run", { ns: "workflow" })}}`,
|
||||
properties: {
|
||||
alert: {
|
||||
type: 'void',
|
||||
'x-component': 'Alert',
|
||||
'x-component-props': {
|
||||
message: `{{t("Test run will do the actual data manipulating or API calling, please use with caution.", { ns: "workflow" })}}`,
|
||||
type: 'warning',
|
||||
showIcon: true,
|
||||
className: css`
|
||||
margin-bottom: 1em;
|
||||
`,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
title: '{{t("Replace variables", { ns: "workflow" })}}',
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': TestFormFieldset,
|
||||
},
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
properties: {
|
||||
submit: {
|
||||
type: 'void',
|
||||
title: '{{t("Run")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
useAction: '{{ useRunAction }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
type: 'string',
|
||||
title: `{{t("Result", { ns: "workflow" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-component': 'Input.JSON',
|
||||
'x-component-props': {
|
||||
autoSize: {
|
||||
minRows: 5,
|
||||
maxRows: 20,
|
||||
},
|
||||
style: {
|
||||
whiteSpace: 'pre',
|
||||
cursor: 'text',
|
||||
},
|
||||
},
|
||||
'x-pattern': 'disabled',
|
||||
},
|
||||
footer: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Modal.Footer',
|
||||
properties: {
|
||||
cancel: {
|
||||
type: 'void',
|
||||
title: '{{t("Close")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: '{{ useCancelAction }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</VariableKeysContext.Provider>
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDefaultView(props) {
|
||||
const { data, children } = props;
|
||||
const compile = useCompile();
|
||||
@ -515,25 +706,45 @@ export function NodeDefaultView(props) {
|
||||
},
|
||||
properties: instruction.fieldset,
|
||||
},
|
||||
actions: workflow.executed
|
||||
footer: workflow.executed
|
||||
? null
|
||||
: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.Footer',
|
||||
properties: {
|
||||
cancel: {
|
||||
title: '{{t("Cancel")}}',
|
||||
'x-component': 'Action',
|
||||
actions: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
'x-component-props': {
|
||||
useAction: '{{ cm.useCancelAction }}',
|
||||
style: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
title: '{{t("Submit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
useAction: '{{ useUpdateAction }}',
|
||||
properties: {
|
||||
...(instruction.testable
|
||||
? {
|
||||
test: {
|
||||
type: 'void',
|
||||
'x-component': observer(TestButton),
|
||||
'x-align': 'left',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
cancel: {
|
||||
title: '{{t("Cancel")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
useAction: '{{ cm.useCancelAction }}',
|
||||
},
|
||||
},
|
||||
submit: {
|
||||
title: '{{t("Submit")}}',
|
||||
'x-component': 'Action',
|
||||
'x-component-props': {
|
||||
type: 'primary',
|
||||
useAction: '{{ useUpdateAction }}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -203,5 +203,8 @@
|
||||
"End process": "结束流程",
|
||||
"End the process immediately, with set status.": "以设置的状态立即结束流程。",
|
||||
"End status": "结束状态",
|
||||
"Succeeded": "成功"
|
||||
"Succeeded": "成功",
|
||||
"Test run": "测试执行",
|
||||
"Test run will do the actual data manipulating or API calling, please use with caution.": "测试执行会进行实际的数据操作或 API 调用,请谨慎使用。",
|
||||
"No variable": "无变量"
|
||||
}
|
||||
|
@ -251,6 +251,7 @@ export default class PluginWorkflowServer extends Plugin {
|
||||
'executions:destroy',
|
||||
'flow_nodes:update',
|
||||
'flow_nodes:destroy',
|
||||
'flow_nodes:test',
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -10,6 +10,7 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import Database from '@nocobase/database';
|
||||
import { getApp, sleep } from '@nocobase/plugin-workflow-test';
|
||||
import { JOB_STATUS } from '../../constants';
|
||||
|
||||
describe('workflow > actions > workflows', () => {
|
||||
let app: MockServer;
|
||||
@ -244,4 +245,32 @@ describe('workflow > actions > workflows', () => {
|
||||
expect(nodes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('test', () => {
|
||||
it('test method not implemented', async () => {
|
||||
const { status } = await agent.resource('flow_nodes').test({ values: { type: 'error' } });
|
||||
|
||||
expect(status).toBe(400);
|
||||
});
|
||||
|
||||
it('test method implemented', async () => {
|
||||
const {
|
||||
status,
|
||||
body: { data },
|
||||
} = await agent.resource('flow_nodes').test({ values: { type: 'echo' } });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.status).toBe(JOB_STATUS.RESOLVED);
|
||||
});
|
||||
|
||||
it('test with pending status', async () => {
|
||||
const {
|
||||
status,
|
||||
body: { data },
|
||||
} = await agent.resource('flow_nodes').test({ values: { type: 'pending' } });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.status).toBe(JOB_STATUS.PENDING);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -30,6 +30,7 @@ export default function ({ app }) {
|
||||
...make('flow_nodes', {
|
||||
update: nodes.update,
|
||||
destroy: nodes.destroy,
|
||||
test: nodes.test,
|
||||
}),
|
||||
...make('executions', executions),
|
||||
});
|
||||
|
@ -9,6 +9,7 @@
|
||||
|
||||
import { Context, utils } from '@nocobase/actions';
|
||||
import { MultipleRelationRepository, Op, Repository } from '@nocobase/database';
|
||||
import WorkflowPlugin from '..';
|
||||
import type { WorkflowModel } from '../types';
|
||||
|
||||
export async function create(context: Context, next) {
|
||||
@ -219,3 +220,23 @@ export async function update(context: Context, next) {
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
export async function test(context: Context, next) {
|
||||
const { values = {} } = context.action.params;
|
||||
const { type, config = {} } = values;
|
||||
const plugin = context.app.pm.get(WorkflowPlugin) as WorkflowPlugin;
|
||||
const instruction = plugin.instructions.get(type);
|
||||
if (!instruction) {
|
||||
context.throw(400, `instruction "${type}" not registered`);
|
||||
}
|
||||
if (typeof instruction.test !== 'function') {
|
||||
context.throw(400, `test method of instruction "${type}" not implemented`);
|
||||
}
|
||||
try {
|
||||
context.body = await instruction.test(config);
|
||||
} catch (error) {
|
||||
context.throw(500, error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export type InstructionInterface = {
|
||||
resume?: Runner;
|
||||
getScope?: (node: FlowNodeModel, data: any, processor: Processor) => any;
|
||||
duplicateConfig?: (node: FlowNodeModel, options: Transactionable) => object | Promise<object>;
|
||||
test?: (config: Record<string, any>) => IJob | Promise<IJob>;
|
||||
};
|
||||
|
||||
// what should a instruction do?
|
||||
|
Loading…
Reference in New Issue
Block a user