Fix(plugin-workflow): request node (#1367)

* fix(plugin-workflow): fix json data

* refactor(plugin-workflow): change ejs to json-templates in request

* feat(plugin-workflow): add variable insert button to json input

* fix(plugin-workflow): fix main locales

* fix(plugin-workflow): fix params in request

* fix(plugin-workflow): fix types

* feat(plugin-workflow): add ejs migration
This commit is contained in:
Junyi 2023-01-15 12:56:04 +08:00 committed by GitHub
parent 332010a036
commit 51ce0afc06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 393 additions and 326 deletions

View File

@ -4,12 +4,12 @@ import { Input as AntdInput } from 'antd';
import { InputProps, TextAreaProps } from 'antd/lib/input'; import { InputProps, TextAreaProps } from 'antd/lib/input';
import React from 'react'; import React from 'react';
import { ReadPretty } from './ReadPretty'; import { ReadPretty } from './ReadPretty';
import { Json } from './Json'; import { Json, JSONTextAreaProps } from './Json';
type ComposedInput = React.FC<InputProps> & { type ComposedInput = React.FC<InputProps> & {
TextArea?: React.FC<TextAreaProps>; TextArea?: React.FC<TextAreaProps>;
URL?: React.FC<InputProps>; URL?: React.FC<InputProps>;
JSON?: React.FC<TextAreaProps>; JSON?: React.FC<JSONTextAreaProps>;
}; };
export const Input: ComposedInput = connect( export const Input: ComposedInput = connect(

View File

@ -1,14 +1,17 @@
import React, { useState } from 'react'; import React, { Ref } from 'react';
import { Field } from '@formily/core'; import { Field } from '@formily/core';
import { useField } from '@formily/react'; import { useField } from '@formily/react';
import { Input } from 'antd'; import { Input } from 'antd';
import { TextAreaProps } from 'antd/lib/input'; import { TextAreaProps } from 'antd/lib/input';
export function Json({ value, onChange, space = 2, ...props }: TextAreaProps & { value: any, space: number }) { export type JSONTextAreaProps = TextAreaProps & { ref: Ref<any>, value: any, space: number };
export const Json = React.forwardRef<Ref<any>>(({ value, onChange, space = 2, ...props }: JSONTextAreaProps, ref: Ref<any>) => {
const field = useField<Field>(); const field = useField<Field>();
return ( return (
<Input.TextArea <Input.TextArea
{...props} {...props}
ref={ref}
defaultValue={value != null ? JSON.stringify(value, null, space) : ''} defaultValue={value != null ? JSON.stringify(value, null, space) : ''}
onChange={(ev) => { onChange={(ev) => {
try { try {
@ -25,4 +28,4 @@ export function Json({ value, onChange, space = 2, ...props }: TextAreaProps & {
}} }}
/> />
); );
} });

View File

@ -15,10 +15,8 @@
"axios": "^0.27.2", "axios": "^0.27.2",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"cron-parser": "4.4.0", "cron-parser": "4.4.0",
"ejs": "^3.1.8",
"json-templates": "^4.2.0", "json-templates": "^4.2.0",
"moment": "^2.29.2", "moment": "^2.29.2",
"react-copy-to-clipboard": "^5.1.0",
"react-js-cron": "^1.4.0" "react-js-cron": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,125 +0,0 @@
import React, { useState } from 'react';
import { Row, Col } from 'antd';
import { CopyOutlined, ToolOutlined } from '@ant-design/icons';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import {Operand, VariableTypes, VariableTypesContext} from '../calculators';
import { Button, Input, message, Tooltip } from 'antd';
import { useFlowContext } from '../FlowContext';
import { useCollectionManager, useCompile } from '@nocobase/client';
import {useWorkflowTranslation} from '../locale';
function getVal(operand) {
const { t } = useWorkflowTranslation();
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
const { nodes } = useFlowContext();
if (typeof operand === 'string') {
let pathArr = operand.replace('{{', '').replace('}}', '').split('.');
if (pathArr.length > 0) {
const type = pathArr[0];
if (type == '$context') {
return `<%=${pathArr.map((value, index) => {
if (index === 0) {
return 'ctx';
}
return value;
}).join('.')}%>`;
}
if (pathArr.length>1 && type == '$jobsMapByNodeId') {
const nodeId = pathArr[1]
const node = nodes.find((n) => n.id == nodeId);
if (node) {
if (node.type === 'calculation') {
return `<%= node[${nodeId}] // ${t('Calculation result')} %>`;
}
if (pathArr.length>2 && node?.config?.collection) {
const fieldName = pathArr[2]
const fields = getCollectionFields(node?.config?.collection);
const field = fields.find((f) => f.name == fieldName);
if (field) {
return `<%= node[${nodeId}].${fieldName} // ${compile(
field.uiSchema?.title || field.name,
)} %>`;
}
return `<%= node[${nodeId}].${fieldName}%>`;
}
}
}
}
}
return '';
}
const AvailableVariableTool = (props) => {
const { t } = useWorkflowTranslation();
const [operand, setOperand] = useState('');
return (
<Row style={{ width: '100%' }}>
<Col span={10}>
<Operand value={operand} onChange={(v) => setOperand(v)} />
</Col>
<Col span={10}>
<Input value={getVal(operand)} disabled />
</Col>
<Col span={4}>
<CopyToClipboard
text={getVal(operand)}
onCopy={() => {
message.success({
content: t('Copy success!'),
duration: 1,
style: {
marginTop: '20vh',
},
});
}}
>
<Tooltip title={t('Copy variable output template statement')}>
<Button icon={<CopyOutlined />} />
</Tooltip>
</CopyToClipboard>
</Col>
</Row>
);
};
export default function (props) {
const [showTool, setShowTool] = useState(false);
const { t } = useWorkflowTranslation();
const requestVariableTypes = {
$context: VariableTypes.$context,
$jobsMapByNodeId: VariableTypes.$jobsMapByNodeId,
};
return (
<VariableTypesContext.Provider value={requestVariableTypes}>
<Row style={{ width: '100%' }}>
<Col span={24}>
<Tooltip title={t('Show available variable tool')}>
<Button type={'text'} icon={<ToolOutlined />} onClick={() => setShowTool(!showTool)} />
</Tooltip>
</Col>
</Row>
{showTool && <AvailableVariableTool></AvailableVariableTool>}
<Row style={{ width: '100%' }}>
<Col span={24}>
<Input.TextArea {...props} />
</Col>
</Row>
{showTool && (
<Row>
<Col span={24}>
<div className="ant-formily-item-extra">
{props.description} {t('Syntax see')}{' '}
<a target={'_blank'} href={'https://ejs.co'}>
ejs
</a>{' '}
</div>
</Col>
</Row>
)}
</VariableTypesContext.Provider>
);
}

View File

@ -0,0 +1,95 @@
import React, { useState, useRef } from 'react';
import { Popover, Button } from 'antd';
import { css } from "@emotion/css";
import { Input } from "@nocobase/client";
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
import { lang } from '../locale';
// NOTE: https://stackoverflow.com/questions/23892547/what-is-the-best-way-to-trigger-onchange-event-in-react-js/46012210#46012210
function setNativeInputValue(input, value) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(input.constructor.prototype, 'value').set;
nativeInputValueSetter.call(input, value);
input.dispatchEvent(new Event('input', {
bubbles: true,
}));
}
export function VariableJSONInput(props) {
const [variable, setVariable] = useState<string>('');
const inputRef = useRef(null);
const { value, space = 2 } = props;
function onFormat() {
if (!inputRef.current) {
return;
}
if (!value) {
return;
}
const { textArea } = inputRef.current.resizableTextArea;
const nextValue = JSON.stringify(value, null, space);
setNativeInputValue(textArea, nextValue);
textArea.setSelectionRange(nextValue.length, nextValue.length);
textArea.focus();
}
function onInsert() {
if (!inputRef.current) {
return;
}
if (!variable) {
return;
}
const { textArea } = inputRef.current.resizableTextArea;
const nextValue = textArea.value.slice(0, textArea.selectionStart) + `"${variable}"` + textArea.value.slice(textArea.selectionEnd);
const nextPos = [textArea.selectionStart, textArea.selectionStart + variable.length + 2];
setNativeInputValue(textArea, nextValue);
textArea.setSelectionRange(...nextPos);
textArea.focus();
}
return (
<div className={css`
position: relative;
`}>
<Input.JSON {...props} ref={inputRef} />
<Button.Group
className={css`
position: absolute;
right: 2px;
top: 2px;
.ant-btn-sm{
font-size: 85%;
}
`}
>
<Button size="small" onClick={onFormat}>{lang('Format')}</Button>
<Popover
trigger="click"
placement="topRight"
content={
<div className={css`
display: flex;
gap: .5em;
`}>
<VariableTypesContext.Provider value={{
$jobsMapByNodeId: VariableTypes.$jobsMapByNodeId,
$context: VariableTypes.$context,
}}>
<Operand value={variable} onChange={setVariable} />
<Button onClick={onInsert} disabled={!variable}>{lang('Insert')}</Button>
</VariableTypesContext.Provider>
</div>
}
>
<Button size="small">{lang('Use variables')}</Button>
</Popover>
</Button.Group>
</div>
);
}

View File

@ -107,23 +107,24 @@ export default {
"Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.": "Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.", "Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.": "Fields that are not assigned a value will be set to the default value, and those that do not have a default value are set to null.",
"Trigger in executed workflow cannot be modified": "Trigger in executed workflow cannot be modified", "Trigger in executed workflow cannot be modified": "Trigger in executed workflow cannot be modified",
"Node in executed workflow cannot be modified": "Node in executed workflow cannot be modified", "Node in executed workflow cannot be modified": "Node in executed workflow cannot be modified",
"Can not delete": "Can not delete", 'Can not delete': 'Can not delete',
"The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.": "The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.", 'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.': 'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.',
"HTTP request": "HTTP request",
"URL": "URL", 'HTTP request': 'HTTP request',
"You can use the above available variables in URL.": "You can use the above available variables in URL.", 'HTTP method': 'HTTP method',
"Request headers": "Request headers", 'URL': 'URL',
"Name(e.g. Content-Type)": "Name(e.g. Content-Type)", 'Headers': 'Headers',
"Value(e.g. Application/json)": "Value(e.g. Application/json)", 'Add request header': 'Add request header',
"Add request header": "Add request header", 'Parameters': 'Parameters',
"HTTP method": "HTTP method", 'Add parameter': 'Add parameter',
"Request data": "Request data", 'Body': 'Body',
"Input request data": "Input request data", 'Use variables': '使用变量',
"You can use the above available variables in request data.": "You can use the above available variables in request data.", 'Format': '格式化',
"Copy success!": "Copy success!", 'Insert': '插入',
"Copy variable output template statement": "Copy variable output template statement", 'Timeout config': '超时设置',
"Default headers is Content-Type: application/json": "Default headers is Content-Type: application/json", 'ms': '毫秒',
"Ignore fail request and continue workflow": "Ignore fail request and continue workflow", 'Input request data': 'Input request data',
"Syntax see": "Syntax see", 'Only support standard JSON data': 'Only support standard JSON data',
"Show available variable tool": "Show available variable tool" '"Content-Type" only support "application/json", and no need to specify': '"Content-Type" only support "application/json", and no need to specify',
'Ignore fail request and continue workflow': 'Ignore fail request and continue workflow',
}; };

View File

@ -9,10 +9,8 @@ export default {
'Version': '版本', 'Version': '版本',
'Copy to new version': '复制到新版本', 'Copy to new version': '复制到新版本',
'Duplicate': '复制', 'Duplicate': '复制',
'Loading': '加载中', 'Loading': '加载中',
'Load failed': '加载失败', 'Load failed': '加载失败',
'Trigger': '触发器', 'Trigger': '触发器',
'Trigger variables': '触发器变量', 'Trigger variables': '触发器变量',
'Trigger data': '触发数据', 'Trigger data': '触发数据',
@ -27,7 +25,6 @@ export default {
'Changed fields': '发生变动的字段', 'Changed fields': '发生变动的字段',
'Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。', 'Triggered only if one of the selected fields changes. If unselected, it means that it will be triggered when any field changes. When record is added or deleted, any field is considered to have been changed.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
'Only triggers when match conditions': '满足以下条件才触发', 'Only triggers when match conditions': '满足以下条件才触发',
'Schedule event': '定时任务', 'Schedule event': '定时任务',
'Trigger mode': '触发模式', 'Trigger mode': '触发模式',
'Based on certain date': '自定义时间', 'Based on certain date': '自定义时间',
@ -45,64 +42,48 @@ export default {
'Days': '天', 'Days': '天',
'Weeks': '周', 'Weeks': '周',
'Months': '月', 'Months': '月',
'No repeat': '不重复', 'No repeat': '不重复',
'Every': '每', 'Every': '每',
'By minute': '按分钟', 'By minute': '按分钟',
'By hour': '按小时', 'By hour': '按小时',
'By day': '按天', 'By day': '按天',
'By week': '按周', 'By week': '按周',
'By month': '按月', 'By month': '按月',
'By field': '数据表字段', 'By field': '数据表字段',
'By custom date': '自定义时间', 'By custom date': '自定义时间',
'Advanced': '高级模式', 'Advanced': '高级模式',
'End': '结束', 'End': '结束',
'Node result': '节点数据', 'Node result': '节点数据',
'Constant': '常量', 'Constant': '常量',
'Null': '空值', 'Null': '空值',
'Boolean': '逻辑值', 'Boolean': '逻辑值',
'String': '字符串', 'String': '字符串',
'Calculator': '运算', 'Calculator': '运算',
'Arithmetic calculation': '算术运算', 'Arithmetic calculation': '算术运算',
'String operation': '字符串', 'String operation': '字符串',
'Executed at': '执行于', 'Executed at': '执行于',
'Queueing': '队列中', 'Queueing': '队列中',
'On going': '进行中', 'On going': '进行中',
'Succeeded': '成功', 'Succeeded': '成功',
'Failed': '失败', 'Failed': '失败',
'Pending': '等待处理', 'Pending': '等待处理',
'Canceled': '已取消', 'Canceled': '已取消',
'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?', 'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
'Control': '流程控制', 'Control': '流程控制',
'Collection operations': '数据表操作', 'Collection operations': '数据表操作',
'Extended types': '扩展类型', 'Extended types': '扩展类型',
'Node type': '节点类型', 'Node type': '节点类型',
'Calculation': '运算', 'Calculation': '运算',
'Configure calculation': '配置运算', 'Configure calculation': '配置运算',
'Calculation result': '运算结果', 'Calculation result': '运算结果',
'True': '真', 'True': '真',
'False': '假', 'False': '假',
'concat': '连接', 'concat': '连接',
'Condition': '条件判断', 'Condition': '条件判断',
'Mode': '模式', 'Mode': '模式',
'Continue when "Yes"': '“是”则继续', 'Continue when "Yes"': '“是”则继续',
'Branch into "Yes" and "No"': '“是”和“否”分别继续', 'Branch into "Yes" and "No"': '“是”和“否”分别继续',
'Conditions': '条件配置', 'Conditions': '条件配置',
'Parallel branch': '分支', 'Parallel branch': '分支',
'Add branch': '增加分支', 'Add branch': '增加分支',
'All succeeded': '全部成功', 'All succeeded': '全部成功',
@ -111,14 +92,12 @@ export default {
'Continue after all branches succeeded': '全部分支都成功后才能继续', 'Continue after all branches succeeded': '全部分支都成功后才能继续',
'Continue after any branch succeeded': '任意分支成功后就继续', 'Continue after any branch succeeded': '任意分支成功后就继续',
'Continue after any branch succeeded, or exit after any branch failed': '任意分支成功继续,或失败后退出', 'Continue after any branch succeeded, or exit after any branch failed': '任意分支成功继续,或失败后退出',
'Delay': '延时', 'Delay': '延时',
'Duration': '时长', 'Duration': '时长',
'End Status': '到时状态', 'End Status': '到时状态',
'Select status': '选择状态', 'Select status': '选择状态',
'Succeed and continue': '通过并继续', 'Succeed and continue': '通过并继续',
'Fail and exit': '失败并退出', 'Fail and exit': '失败并退出',
'Create record': '新增数据', 'Create record': '新增数据',
'Update record': '更新数据', 'Update record': '更新数据',
'Query record': '查询数据', 'Query record': '查询数据',
@ -132,20 +111,20 @@ export default {
'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.': '该节点的执行结果已被其他节点({{nodes}})引用,删除前请先移除引用。', 'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.': '该节点的执行结果已被其他节点({{nodes}})引用,删除前请先移除引用。',
'HTTP request': 'HTTP 请求', 'HTTP request': 'HTTP 请求',
'URL': '地址',
'You can use the above available variables in URL.': '您可以在地址中使用使用上面的可用变量。',
'Request headers': '请求头',
'Name(e.g. Content-Type)': '名称(例如 Content-Type)',
'Value(e.g. Application/json)': '值(例如 Application/json)',
'Add request header': '添加请求头',
'HTTP method': 'HTTP 方法', 'HTTP method': 'HTTP 方法',
'Request data': '请求数据', 'URL': '地址',
'Headers': '请求头',
'Add request header': '添加请求头',
'Parameters': '参数',
'Add parameter': '添加参数',
'Body': '请求体',
'Use variables': '使用变量',
'Format': '格式化',
'Insert': '插入',
'Timeout config': '超时设置',
'ms': '毫秒',
'Input request data': '输入请求数据', 'Input request data': '输入请求数据',
'You can use the above available variables in request data.': '您可以在请求数据中使用上面的可用变量。', 'Only support standard JSON data': '仅支持标准 JSON 数据',
'Copy success!': '拷贝成功!', '"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
'Copy variable output template statement': '拷贝变量输出模版语句', 'Ignore fail request and continue workflow': '忽略失败的请求并继续工作流',
'Default headers is Content-Type: application/json': '默认请求头是 Content-Type: application/json',
'Ignore fail request and continue workflow': '忽略失败请求并继续工作流',
'Syntax see': '语法参考',
'Show available variable tool':'显示可用变量工具'
}; };

View File

@ -1,93 +1,26 @@
import { NAMESPACE } from '../locale';
import { ArrayItems } from '@formily/antd';
import React from 'react'; import React from 'react';
import EjsTextArea from '../components/EjsTextArea'; import { ArrayItems } from '@formily/antd';
import { css } from '@emotion/css';
import { NAMESPACE } from '../locale';
import { Operand, VariableTypes, VariableTypesContext } from '../calculators';
import { VariableJSONInput } from '../components/VariableJSONInput';
function VariableTypesContextProvider(props) {
return (
<VariableTypesContext.Provider value={VariableTypes}>
{props.children}
</VariableTypesContext.Provider>
)
}
export default { export default {
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`, title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
type: 'request', type: 'request',
group: 'extended', group: 'extended',
fieldset: { fieldset: {
'config.url': {
type: 'string',
name: 'config.url',
required: true,
title: `{{t("URL", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'EjsTextArea',
'x-component-props': {
autoSize: {
minRows: 1,
},
placeholder: 'https://xxxxxx',
description: `{{t("You can use the above available variables in URL.", { ns: "${NAMESPACE}" })}}`,
},
},
'config.timeout': {
type: 'number',
name: 'config.timeout',
required: true,
title: `{{t("Timeout config", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'InputNumber',
'x-component-props': {
addonAfter: `{{t("ms", { ns: "${NAMESPACE}" })}}`,
min: 1,
step: 1000,
defaultValue: 5000,
},
},
'config.headers': {
type: 'array',
name: 'config.headers',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Request headers", { ns: "${NAMESPACE}" })}}`,
description: `{{t("Default headers is Content-Type: application/json", { ns: "${NAMESPACE}" })}}`,
items: {
type: 'object',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Name(e.g. Content-Type)", { ns: "${NAMESPACE}" })}}`,
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Value(e.g. Application/json)", { ns: "${NAMESPACE}" })}}`,
},
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: `{{t("Add request header", { ns: "${NAMESPACE}" })}}`,
'x-component': 'ArrayItems.Addition',
},
},
},
'config.method': { 'config.method': {
type: 'string', type: 'string',
name: 'config.method', name: 'config.method',
@ -108,22 +41,145 @@ export default {
{ label: 'DELETE', value: 'DELETE' }, { label: 'DELETE', value: 'DELETE' },
], ],
}, },
'config.url': {
type: 'string',
name: 'config.url',
required: true,
title: `{{t("URL", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {
className: css`
.ant-formily-item-control-content-component .ant-input-affix-wrapper{
width: 100%;
}
`
},
'x-component': 'Input',
'x-component-props': {
placeholder: 'https://www.nocobase.com',
},
},
'config.headers': {
type: 'array',
name: 'config.headers',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Headers", { ns: "${NAMESPACE}" })}}`,
description: `{{t('"Content-Type" only support "application/json", and no need to specify', { ns: "${NAMESPACE}" })}}`,
items: {
type: 'object',
'x-decorator': 'VariableTypesContextProvider',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Name")}}`,
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Operand',
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: `{{t("Add request header", { ns: "${NAMESPACE}" })}}`,
'x-component': 'ArrayItems.Addition',
},
},
},
'config.params': {
type: 'array',
name: 'config.params',
'x-component': 'ArrayItems',
'x-decorator': 'FormItem',
title: `{{t("Parameters", { ns: "${NAMESPACE}" })}}`,
items: {
type: 'object',
'x-decorator': 'VariableTypesContextProvider',
properties: {
space: {
type: 'void',
'x-component': 'Space',
properties: {
name: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: `{{t("Name")}}`,
},
},
value: {
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Operand',
},
remove: {
type: 'void',
'x-decorator': 'FormItem',
'x-component': 'ArrayItems.Remove',
},
},
},
},
},
properties: {
add: {
type: 'void',
title: `{{t("Add parameter", { ns: "${NAMESPACE}" })}}`,
'x-component': 'ArrayItems.Addition',
},
},
},
'config.data': { 'config.data': {
type: 'string', type: 'string',
name: 'config.data', name: 'config.data',
'x-hidden': false, title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
title: `{{t("Request data", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-decorator-props': {}, 'x-decorator-props': {},
'x-component': 'EjsTextArea', 'x-component': 'VariableJSONInput',
'x-component-props': { 'x-component-props': {
autoSize: { autoSize: {
minRows: 5, minRows: 10,
}, },
placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`, placeholder: `{{t("Input request data", { ns: "${NAMESPACE}" })}}`,
description: `{{t("You can use the above available variables in request data.", { ns: "${NAMESPACE}" })}}`, className: css`
font-size: 85%;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
`
},
description: `{{t("Only support standard JSON data", { ns: "${NAMESPACE}" })}}`,
},
'config.timeout': {
type: 'number',
name: 'config.timeout',
title: `{{t("Timeout config", { ns: "${NAMESPACE}" })}}`,
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'InputNumber',
'x-component-props': {
addonAfter: `{{t("ms", { ns: "${NAMESPACE}" })}}`,
min: 1,
step: 1000,
defaultValue: 5000,
}, },
}, },
'config.ignoreFail': { 'config.ignoreFail': {
type: 'boolean', type: 'boolean',
@ -137,6 +193,8 @@ export default {
scope: {}, scope: {},
components: { components: {
ArrayItems, ArrayItems,
EjsTextArea, Operand,
VariableTypesContextProvider,
VariableJSONInput
}, },
}; };

View File

@ -76,26 +76,6 @@ describe('workflow > instructions > request', () => {
expect(job.status).toEqual(JOB_STATUS.RESOLVED); expect(job.status).toEqual(JOB_STATUS.RESOLVED);
}); });
it('request - url with variable template', async () => {
await workflow.createNode({
type: 'request',
config: {
url: `${URL_DATA}?title=<%= ctx.data.title %>`,
method: 'GET',
} as RequestConfig,
});
await PostRepo.create({ values: { title: 't1' } });
await sleep(500);
let [execution] = await workflow.getExecutions();
let [job] = await execution.getJobs();
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
console.log(job.result);
expect(job.result.meta.title).toEqual('t1');
});
it('request - timeout', async () => { it('request - timeout', async () => {
await workflow.createNode({ await workflow.createNode({
type: 'request', type: 'request',
@ -195,7 +175,7 @@ describe('workflow > instructions > request', () => {
config: { config: {
url: URL_DATA, url: URL_DATA,
method: 'POST', method: 'POST',
data: '{"title": "<%=ctx.data.title%>"}', data: { title: '{{$context.data.title}}' },
} as RequestConfig, } as RequestConfig,
}); });
@ -208,5 +188,29 @@ describe('workflow > instructions > request', () => {
expect(job.status).toEqual(JOB_STATUS.RESOLVED); expect(job.status).toEqual(JOB_STATUS.RESOLVED);
expect(job.result.data).toEqual({ title: 't1' }); expect(job.result.data).toEqual({ title: 't1' });
}); });
// TODO(bug): should not use ejs
it('request json data with multiple lines', async () => {
const n1 = await workflow.createNode({
type: 'request',
config: {
url: URL_DATA,
method: 'POST',
data: { title: '{{$context.data.title}}' },
} as RequestConfig,
});
const title = 't1\n\nline 2';
await PostRepo.create({
values: { title }
});
await sleep(500);
let [execution] = await workflow.getExecutions();
let [job] = await execution.getJobs();
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
expect(job.result.data).toEqual({ title });
});
}); });
}); });

View File

@ -1,16 +1,16 @@
import { render } from 'ejs';
import axios, { AxiosRequestConfig } from 'axios'; import axios, { AxiosRequestConfig } from 'axios';
import _ from 'lodash';
import { Instruction } from './index'; import { Instruction } from './index';
import { JOB_STATUS } from '../constants'; import { JOB_STATUS } from '../constants';
import Processor from '../Processor';
import FlowNodeModel from '../models/FlowNode';
export interface Header { export interface Header {
name: string; name: string;
value: string; value: string;
} }
export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'data' | 'timeout'> & { export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'params' | 'data' | 'timeout'> & {
headers: Array<Header>; headers: Array<Header>;
ignoreFail: boolean; ignoreFail: boolean;
}; };
@ -18,40 +18,28 @@ export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'data' |
export default class implements Instruction { export default class implements Instruction {
constructor(public plugin) {} constructor(public plugin) {}
request = async (node, job, processor) => { request = async (node: FlowNodeModel, job, processor: Processor) => {
const templateVars = { const config = processor.getParsedValue(node.config) as RequestConfig;
node: processor.jobsMapByNodeId,
ctx: processor.execution.context,
$jobsMapByNodeId: processor.jobsMapByNodeId,
$context: processor.execution.context,
};
const requestConfig = node.config as RequestConfig;
// default headers // default headers
const headers = { const { url, method = 'POST', data, timeout = 5000 } = config;
'Content-Type': 'application/json', const headers = (config.headers ?? []).reduce((result, header) => {
}; if (header.name.toLowerCase() === 'content-type') {
const { headers: headerArr = [], method = 'POST', timeout = 5000 } = requestConfig; return result;
headerArr.forEach((header) => (headers[header.name] = header.value)); }
return Object.assign(result, { [header.name]: header.value });
}, {});
const params = (config.params ?? []).reduce((result, param) => Object.assign(result, { [param.name]: param.value }), {});
let url, data; // TODO(feat): only support JSON type for now, should support others in future
try { headers['Content-Type'] = 'application/json';
url = await render(requestConfig.url!.trim(), templateVars, { async: true });
data = requestConfig.data ? await render(requestConfig.data.trim(), templateVars, { async: true }) : undefined;
} catch (error2) {
// console.error(error2);
job.set({
status: JOB_STATUS.REJECTED,
result: error2.message
});
}
try { try {
const response = await axios.request({ const response = await axios.request({
url, url,
method, method,
headers, headers,
params,
data, data,
timeout, timeout,
}); });
@ -59,11 +47,10 @@ export default class implements Instruction {
status: JOB_STATUS.RESOLVED, status: JOB_STATUS.RESOLVED,
result: response.data result: response.data
}); });
} catch (error1) { } catch (error) {
// console.error('axios error?', error1);
job.set({ job.set({
status: JOB_STATUS.REJECTED, status: JOB_STATUS.REJECTED,
result: error1.isAxiosError ? error1.toJSON() : error1.message result: error.isAxiosError ? error.toJSON() : error.message
}); });
} }

View File

@ -0,0 +1,67 @@
import { Migration } from '@nocobase/server';
const EJS_RE = /"?<%=\s*(ctx|node)([\w\.\[\]-]+)\s*.*%>"?/;
function migrateData(input) {
if (typeof input !== 'string') {
return input;
}
if (!input) {
return null;
}
const typeMap = {
ctx: '$context',
node: '$jobsMapByNodeId'
};
return input.replace(EJS_RE, (_, type, path) => {
if (type === 'ctx') {
return `"{{$context${path}}}"`;
}
if (type === 'node') {
return `"{{$jobsMapByNodeId${path.replace('[', '.').replace(']', '.').replace(/\.$/, '')}}}"`;
}
return _;
});
}
export default class extends Migration {
async up() {
const match = await this.app.version.satisfies('<=0.9.0-alpha.2');
if (!match) {
return;
}
const NodeRepo = this.context.db.getRepository('flow_nodes');
await this.context.db.sequelize.transaction(async (transaction) => {
const nodes = await NodeRepo.find({
filter: {
type: 'request'
},
transaction
});
console.log('%d nodes need to be migrated.', nodes.length);
await nodes.reduce((promise, node) => promise.then(async () => {
if (typeof node.config.data !== 'string') {
return;
}
let data = migrateData(node.config.data);
try {
data = JSON.parse(node.config.data);
return node.update({
config: {
...node.config,
data
}
}, {
transaction
});
} catch (error) {
console.error(`flow_node #${node.id} config migrating failed! you should migrate its format from ejs to json-templates manually in your db.`);
}
}), Promise.resolve());
});
}
async down() {}
}