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:
parent
332010a036
commit
51ce0afc06
@ -4,12 +4,12 @@ import { Input as AntdInput } from 'antd';
|
||||
import { InputProps, TextAreaProps } from 'antd/lib/input';
|
||||
import React from 'react';
|
||||
import { ReadPretty } from './ReadPretty';
|
||||
import { Json } from './Json';
|
||||
import { Json, JSONTextAreaProps } from './Json';
|
||||
|
||||
type ComposedInput = React.FC<InputProps> & {
|
||||
TextArea?: React.FC<TextAreaProps>;
|
||||
URL?: React.FC<InputProps>;
|
||||
JSON?: React.FC<TextAreaProps>;
|
||||
JSON?: React.FC<JSONTextAreaProps>;
|
||||
};
|
||||
|
||||
export const Input: ComposedInput = connect(
|
||||
|
@ -1,14 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { Ref } from 'react';
|
||||
import { Field } from '@formily/core';
|
||||
import { useField } from '@formily/react';
|
||||
import { Input } from 'antd';
|
||||
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>();
|
||||
return (
|
||||
<Input.TextArea
|
||||
{...props}
|
||||
ref={ref}
|
||||
defaultValue={value != null ? JSON.stringify(value, null, space) : ''}
|
||||
onChange={(ev) => {
|
||||
try {
|
||||
@ -25,4 +28,4 @@ export function Json({ value, onChange, space = 2, ...props }: TextAreaProps & {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -15,10 +15,8 @@
|
||||
"axios": "^0.27.2",
|
||||
"classnames": "^2.3.1",
|
||||
"cron-parser": "4.4.0",
|
||||
"ejs": "^3.1.8",
|
||||
"json-templates": "^4.2.0",
|
||||
"moment": "^2.29.2",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-js-cron": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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.",
|
||||
"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",
|
||||
"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.",
|
||||
"HTTP request": "HTTP request",
|
||||
"URL": "URL",
|
||||
"You can use the above available variables in URL.": "You can use the above available variables in URL.",
|
||||
"Request headers": "Request headers",
|
||||
"Name(e.g. Content-Type)": "Name(e.g. Content-Type)",
|
||||
"Value(e.g. Application/json)": "Value(e.g. Application/json)",
|
||||
"Add request header": "Add request header",
|
||||
"HTTP method": "HTTP method",
|
||||
"Request data": "Request data",
|
||||
"Input request data": "Input request data",
|
||||
"You can use the above available variables in request data.": "You can use the above available variables in request data.",
|
||||
"Copy success!": "Copy success!",
|
||||
"Copy variable output template statement": "Copy variable output template statement",
|
||||
"Default headers is Content-Type: application/json": "Default headers is Content-Type: application/json",
|
||||
"Ignore fail request and continue workflow": "Ignore fail request and continue workflow",
|
||||
"Syntax see": "Syntax see",
|
||||
"Show available variable tool": "Show available variable tool"
|
||||
'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.',
|
||||
|
||||
'HTTP request': 'HTTP request',
|
||||
'HTTP method': 'HTTP method',
|
||||
'URL': 'URL',
|
||||
'Headers': 'Headers',
|
||||
'Add request header': 'Add request header',
|
||||
'Parameters': 'Parameters',
|
||||
'Add parameter': 'Add parameter',
|
||||
'Body': 'Body',
|
||||
'Use variables': '使用变量',
|
||||
'Format': '格式化',
|
||||
'Insert': '插入',
|
||||
'Timeout config': '超时设置',
|
||||
'ms': '毫秒',
|
||||
'Input request data': 'Input request data',
|
||||
'Only support standard JSON data': 'Only support standard JSON data',
|
||||
'"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',
|
||||
};
|
||||
|
@ -9,10 +9,8 @@ export default {
|
||||
'Version': '版本',
|
||||
'Copy to new version': '复制到新版本',
|
||||
'Duplicate': '复制',
|
||||
|
||||
'Loading': '加载中',
|
||||
'Load failed': '加载失败',
|
||||
|
||||
'Trigger': '触发器',
|
||||
'Trigger variables': '触发器变量',
|
||||
'Trigger data': '触发数据',
|
||||
@ -27,7 +25,6 @@ export default {
|
||||
'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.': '只有被选中的某个字段发生变动时才会触发。如果不选择,则表示任何字段变动时都会触发。新增或删除数据时,任意字段都被认为发生变动。',
|
||||
'Only triggers when match conditions': '满足以下条件才触发',
|
||||
|
||||
'Schedule event': '定时任务',
|
||||
'Trigger mode': '触发模式',
|
||||
'Based on certain date': '自定义时间',
|
||||
@ -45,64 +42,48 @@ export default {
|
||||
'Days': '天',
|
||||
'Weeks': '周',
|
||||
'Months': '月',
|
||||
|
||||
'No repeat': '不重复',
|
||||
'Every': '每',
|
||||
|
||||
'By minute': '按分钟',
|
||||
'By hour': '按小时',
|
||||
'By day': '按天',
|
||||
'By week': '按周',
|
||||
'By month': '按月',
|
||||
|
||||
'By field': '数据表字段',
|
||||
'By custom date': '自定义时间',
|
||||
|
||||
'Advanced': '高级模式',
|
||||
|
||||
'End': '结束',
|
||||
|
||||
'Node result': '节点数据',
|
||||
'Constant': '常量',
|
||||
|
||||
'Null': '空值',
|
||||
'Boolean': '逻辑值',
|
||||
'String': '字符串',
|
||||
|
||||
'Calculator': '运算',
|
||||
'Arithmetic calculation': '算术运算',
|
||||
'String operation': '字符串',
|
||||
|
||||
'Executed at': '执行于',
|
||||
|
||||
'Queueing': '队列中',
|
||||
'On going': '进行中',
|
||||
'Succeeded': '成功',
|
||||
'Failed': '失败',
|
||||
'Pending': '等待处理',
|
||||
'Canceled': '已取消',
|
||||
|
||||
'This node contains branches, deleting will also be preformed to them, are you sure?': '节点包含分支,将同时删除其所有分支下的子节点,确定继续?',
|
||||
|
||||
'Control': '流程控制',
|
||||
'Collection operations': '数据表操作',
|
||||
'Extended types': '扩展类型',
|
||||
|
||||
'Node type': '节点类型',
|
||||
|
||||
'Calculation': '运算',
|
||||
'Configure calculation': '配置运算',
|
||||
'Calculation result': '运算结果',
|
||||
'True': '真',
|
||||
'False': '假',
|
||||
'concat': '连接',
|
||||
|
||||
'Condition': '条件判断',
|
||||
'Mode': '模式',
|
||||
'Continue when "Yes"': '“是”则继续',
|
||||
'Branch into "Yes" and "No"': '“是”和“否”分别继续',
|
||||
'Conditions': '条件配置',
|
||||
|
||||
'Parallel branch': '分支',
|
||||
'Add branch': '增加分支',
|
||||
'All succeeded': '全部成功',
|
||||
@ -111,14 +92,12 @@ export default {
|
||||
'Continue after all branches succeeded': '全部分支都成功后才能继续',
|
||||
'Continue after any branch succeeded': '任意分支成功后就继续',
|
||||
'Continue after any branch succeeded, or exit after any branch failed': '任意分支成功继续,或失败后退出',
|
||||
|
||||
'Delay': '延时',
|
||||
'Duration': '时长',
|
||||
'End Status': '到时状态',
|
||||
'Select status': '选择状态',
|
||||
'Succeed and continue': '通过并继续',
|
||||
'Fail and exit': '失败并退出',
|
||||
|
||||
'Create record': '新增数据',
|
||||
'Update 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}})引用,删除前请先移除引用。',
|
||||
|
||||
'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 方法',
|
||||
'Request data': '请求数据',
|
||||
'URL': '地址',
|
||||
'Headers': '请求头',
|
||||
'Add request header': '添加请求头',
|
||||
'Parameters': '参数',
|
||||
'Add parameter': '添加参数',
|
||||
'Body': '请求体',
|
||||
'Use variables': '使用变量',
|
||||
'Format': '格式化',
|
||||
'Insert': '插入',
|
||||
'Timeout config': '超时设置',
|
||||
'ms': '毫秒',
|
||||
'Input request data': '输入请求数据',
|
||||
'You can use the above available variables in request data.': '您可以在请求数据中使用上面的可用变量。',
|
||||
'Copy success!': '拷贝成功!',
|
||||
'Copy variable output template statement': '拷贝变量输出模版语句',
|
||||
'Default headers is Content-Type: application/json': '默认请求头是 Content-Type: application/json',
|
||||
'Ignore fail request and continue workflow': '忽略失败请求并继续工作流',
|
||||
'Syntax see': '语法参考',
|
||||
'Show available variable tool':'显示可用变量工具'
|
||||
'Only support standard JSON data': '仅支持标准 JSON 数据',
|
||||
'"Content-Type" only support "application/json", and no need to specify': '"Content-Type" 请求头仅支持 "application/json",无需填写',
|
||||
'Ignore fail request and continue workflow': '忽略失败的请求并继续工作流',
|
||||
};
|
||||
|
@ -1,93 +1,26 @@
|
||||
import { NAMESPACE } from '../locale';
|
||||
import { ArrayItems } from '@formily/antd';
|
||||
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 {
|
||||
title: `{{t("HTTP request", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'request',
|
||||
group: 'extended',
|
||||
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': {
|
||||
type: 'string',
|
||||
name: 'config.method',
|
||||
@ -108,22 +41,145 @@ export default {
|
||||
{ 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': {
|
||||
type: 'string',
|
||||
name: 'config.data',
|
||||
'x-hidden': false,
|
||||
title: `{{t("Request data", { ns: "${NAMESPACE}" })}}`,
|
||||
title: `{{t("Body", { ns: "${NAMESPACE}" })}}`,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {},
|
||||
'x-component': 'EjsTextArea',
|
||||
'x-component': 'VariableJSONInput',
|
||||
'x-component-props': {
|
||||
autoSize: {
|
||||
minRows: 5,
|
||||
minRows: 10,
|
||||
},
|
||||
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': {
|
||||
type: 'boolean',
|
||||
@ -137,6 +193,8 @@ export default {
|
||||
scope: {},
|
||||
components: {
|
||||
ArrayItems,
|
||||
EjsTextArea,
|
||||
Operand,
|
||||
VariableTypesContextProvider,
|
||||
VariableJSONInput
|
||||
},
|
||||
};
|
||||
|
@ -76,26 +76,6 @@ describe('workflow > instructions > request', () => {
|
||||
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 () => {
|
||||
await workflow.createNode({
|
||||
type: 'request',
|
||||
@ -195,7 +175,7 @@ describe('workflow > instructions > request', () => {
|
||||
config: {
|
||||
url: URL_DATA,
|
||||
method: 'POST',
|
||||
data: '{"title": "<%=ctx.data.title%>"}',
|
||||
data: { title: '{{$context.data.title}}' },
|
||||
} as RequestConfig,
|
||||
});
|
||||
|
||||
@ -208,5 +188,29 @@ describe('workflow > instructions > request', () => {
|
||||
expect(job.status).toEqual(JOB_STATUS.RESOLVED);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { render } from 'ejs';
|
||||
import axios, { AxiosRequestConfig } from 'axios';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Instruction } from './index';
|
||||
import { JOB_STATUS } from '../constants';
|
||||
import Processor from '../Processor';
|
||||
import FlowNodeModel from '../models/FlowNode';
|
||||
|
||||
export interface Header {
|
||||
name: 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>;
|
||||
ignoreFail: boolean;
|
||||
};
|
||||
@ -18,40 +18,28 @@ export type RequestConfig = Pick<AxiosRequestConfig, 'url' | 'method' | 'data' |
|
||||
export default class implements Instruction {
|
||||
constructor(public plugin) {}
|
||||
|
||||
request = async (node, job, processor) => {
|
||||
const templateVars = {
|
||||
node: processor.jobsMapByNodeId,
|
||||
ctx: processor.execution.context,
|
||||
$jobsMapByNodeId: processor.jobsMapByNodeId,
|
||||
$context: processor.execution.context,
|
||||
};
|
||||
const requestConfig = node.config as RequestConfig;
|
||||
request = async (node: FlowNodeModel, job, processor: Processor) => {
|
||||
const config = processor.getParsedValue(node.config) as RequestConfig;
|
||||
|
||||
// default headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const { headers: headerArr = [], method = 'POST', timeout = 5000 } = requestConfig;
|
||||
headerArr.forEach((header) => (headers[header.name] = header.value));
|
||||
const { url, method = 'POST', data, timeout = 5000 } = config;
|
||||
const headers = (config.headers ?? []).reduce((result, header) => {
|
||||
if (header.name.toLowerCase() === 'content-type') {
|
||||
return result;
|
||||
}
|
||||
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;
|
||||
try {
|
||||
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
|
||||
});
|
||||
}
|
||||
// TODO(feat): only support JSON type for now, should support others in future
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
try {
|
||||
const response = await axios.request({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
params,
|
||||
data,
|
||||
timeout,
|
||||
});
|
||||
@ -59,11 +47,10 @@ export default class implements Instruction {
|
||||
status: JOB_STATUS.RESOLVED,
|
||||
result: response.data
|
||||
});
|
||||
} catch (error1) {
|
||||
// console.error('axios error?', error1);
|
||||
} catch (error) {
|
||||
job.set({
|
||||
status: JOB_STATUS.REJECTED,
|
||||
result: error1.isAxiosError ? error1.toJSON() : error1.message
|
||||
result: error.isAxiosError ? error.toJSON() : error.message
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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() {}
|
||||
}
|
Loading…
Reference in New Issue
Block a user