diff --git a/packages/demo/public/schema.json b/packages/demo/public/schema.json index 616ff3bef..49f07baba 100644 --- a/packages/demo/public/schema.json +++ b/packages/demo/public/schema.json @@ -10,7 +10,21 @@ }, "fileName": "test", "dataSource": { - "list": [] + "list": [ + { + "type": "http", + "id": "http1", + "isInit": true, + "options": { + "uri": "https://www.taobao.com", + "params": { + "a": 1, + "b": true, + "c": "3" + } + } + } + ] }, "state": { "text": "outter", @@ -578,4 +592,4 @@ ] } ] - } \ No newline at end of file + } diff --git a/packages/demo/src/editor/components.ts b/packages/demo/src/editor/components.ts index bf00bd149..caa52a36f 100644 --- a/packages/demo/src/editor/components.ts +++ b/packages/demo/src/editor/components.ts @@ -3,6 +3,7 @@ import samplePreview from '@ali/lowcode-plugin-sample-preview'; import undoRedo from '@ali/lowcode-plugin-undo-redo'; import componentsPane from '@ali/lowcode-plugin-components-pane'; import outline, { OutlinePane } from '@ali/lowcode-plugin-outline-pane'; +import dataSourcePane from '@ali/lowcode-plugin-datasource-pane'; import zhEn from '@ali/lowcode-plugin-zh-en'; import eventBindDialog from '@ali/lowcode-plugin-event-bind-dialog'; import variableBindDialog from '@ali/lowcode-plugin-variable-bind-dialog'; @@ -23,4 +24,5 @@ export default { sourceEditor, codeout, saveload, + dataSourcePane, }; diff --git a/packages/demo/src/editor/config.js b/packages/demo/src/editor/config.js index 7223c6031..5965260f8 100644 --- a/packages/demo/src/editor/config.js +++ b/packages/demo/src/editor/config.js @@ -1,3 +1,5 @@ +import { DataSourceImportPluginCode } from '@ali/lowcode-plugin-datasource-pane'; + export default { plugins: { topArea: [ @@ -97,6 +99,60 @@ export default { }, }, }, + { + pluginKey: 'dataSourcePane', + pluginProps: { + importPlugins: [ + { + name: 'code2', + title: '源码2', + content: DataSourceImportPluginCode, + }, + ], + dataSourceTypes: [ + { + type: 'mopen', + schema: { + type: 'object', + properties: { + options: { + type: 'object', + properties: { + uri: { + title: 'api', + }, + v: { + title: 'v', + type: 'string', + }, + appKey: { + title: 'appKey', + type: 'string', + }, + }, + }, + }, + }, + }, + ], + }, + type: 'PanelIcon', + props: { + align: 'top', + icon: 'wenjian', + description: '数据源面板', + panelProps: { + floatable: true, + height: 300, + help: undefined, + hideTitleBar: false, + maxHeight: 800, + maxWidth: 1200, + title: '数据源面板', + width: 600, + }, + }, + }, { pluginKey: 'zhEn', type: 'Custom', diff --git a/packages/plugin-datasource-pane/README.md b/packages/plugin-datasource-pane/README.md new file mode 100644 index 000000000..ea5630f97 --- /dev/null +++ b/packages/plugin-datasource-pane/README.md @@ -0,0 +1,71 @@ +TODO +--- + +* 多语言 +* [later]表达式和其他类型的切换 +* 现有场景代码的兼容 +* class publich method bind issue +* ICON +* 支持变量 + +## 数据源面板 + +数据源管理 + +* 新建 +* 编辑 +* 导入 +* 指定数据源类型 +* 定制数据源导入插件 + +## 数据源类型定义 + +内置变量,http 和 mtop 类型,支持传入自定义类型。 + +``` +type DataSourceType = { + type: string; + optionsSchema: JSONSchema6 +}; +``` + +数据源类型需要在集团规范约束下扩展。 + +目前只允许在 options 下添加扩展字段。 + +比如 mtop 类型,需要添加 options.v (版本)字段。 + +## 导入插件 + +默认支持源码导入,可以传入自定义插件。 + +``` +interface DataSourcePaneImportPlugin { + name: string; + title: string; + component: React.ReactNode; + componentProps?: DataSourcePaneImportPluginCustomProps; +} + +interface DataSourcePaneImportPluginCustomProps { + [customPropName: string]: any; +} + +interface DataSourcePaneImportPluginComponentProps extends DataSourcePaneImportPluginCustomProps { + onChange: (dataSourceList: DataSourceConfig[]) => void; +} +``` + +## 问题 + +* 变量,上下文放数据源里管理是否合适 +* mockUrl 和 mockData +* 设计器的设计语言无法统一 + +## 插件开发 + + + +## node 版本 + +v14.4.0 diff --git a/packages/plugin-datasource-pane/build.json b/packages/plugin-datasource-pane/build.json new file mode 100644 index 000000000..49a393b6b --- /dev/null +++ b/packages/plugin-datasource-pane/build.json @@ -0,0 +1,7 @@ +{ + "plugins": [ + [ + "build-plugin-component" + ] + ] +} diff --git a/packages/plugin-datasource-pane/package.json b/packages/plugin-datasource-pane/package.json new file mode 100644 index 000000000..467a74399 --- /dev/null +++ b/packages/plugin-datasource-pane/package.json @@ -0,0 +1,44 @@ +{ + "name": "@ali/lowcode-plugin-datasource-pane", + "version": "1.0.7-0", + "description": "低代码引擎数据源面板", + "main": "lib/index.js", + "files": [ + "lib" + ], + "scripts": { + "build": "tsc", + "test": "ava", + "test:snapshot": "ava --update-snapshots" + }, + "ava": { + "compileEnhancements": false, + "snapshotDir": "test/fixtures/__snapshots__", + "extensions": [ + "ts" + ], + "require": [ + "ts-node/register" + ] + }, + "license": "MIT", + "devDependencies": { + "@types/json-schema": "^7.0.6", + "@types/react": "^16.9.49", + "@types/traverse": "^0.6.32", + "monaco-editor": "^0.20.0" + }, + "dependencies": { + "@ali/lowcode-editor-setters": "^1.0.7-0", + "@alifd/next": "^1.20.28", + "@formily/next": "^1.3.2", + "@formily/next-components": "^1.3.2", + "@formily/react-schema-renderer": "^1.3.2", + "@types/traverse": "^0.6.32", + "ajv": "^6.12.4", + "lodash": "^4.17.20", + "react-monaco-editor": "^0.40.0", + "styled-components": "^5.2.0", + "traverse": "^0.6.6" + } +} diff --git a/packages/plugin-datasource-pane/src/datasource-form.tsx b/packages/plugin-datasource-pane/src/datasource-form.tsx new file mode 100644 index 000000000..7fc5e9ab6 --- /dev/null +++ b/packages/plugin-datasource-pane/src/datasource-form.tsx @@ -0,0 +1,273 @@ +// @todo schema default +import React, { PureComponent, ReactElement, FC } from 'react'; +import { SchemaForm, FormButtonGroup, Submit } from '@formily/next'; +import { ArrayTable, Input, Switch, NumberPicker } from '@formily/next-components'; +import _isPlainObject from 'lodash/isPlainObject'; +import _isArray from 'lodash/isArray'; +import _isNumber from 'lodash/isNumber'; +import _isString from 'lodash/isString'; +import _isBoolean from 'lodash/isBoolean'; +import _cloneDeep from 'lodash/cloneDeep'; +import _mergeWith from 'lodash/mergeWith'; +import _get from 'lodash/get'; +import _tap from 'lodash/tap'; +import traverse from 'traverse'; +import { ParamValue, JSFunction } from './form-components'; +import { DataSourceType, DataSourceConfig } from './types'; + +// @todo $ref + +const SCHEMA = { + type: 'object', + properties: { + type: { + title: '类型', + type: 'string', + editable: false, + }, + id: { + type: 'string', + title: '数据源 ID', + required: true, + }, + isInit: { + title: '是否自动请求', + type: 'boolean', + default: true, + }, + dataHandler: { + type: 'string', + title: '单个数据结果处理函数', + required: true, + 'x-component': 'JSFunction', + default: 'function() {}', + }, + options: { + type: 'object', + title: '请求参数', + required: true, + properties: { + uri: { + type: 'string', + title: '请求地址', + required: true, + }, + params: { + title: '请求参数', + type: 'object', + default: {}, + }, + method: { + type: 'string', + title: '请求方法', + required: true, + enum: ['GET', 'POST', 'OPTIONS', 'PUT', 'DELETE'], + default: 'GET', + }, + isCors: { + type: 'boolean', + title: '是否支持跨域', + required: true, + default: true, + }, + timeout: { + type: 'number', + title: '超时时长(毫秒)', + default: 5000, + }, + headers: { + type: 'object', + title: '请求头信息', + default: {}, + }, + }, + }, + }, +}; + +export interface DataSourceFormProps { + dataSourceType: DataSourceType; + dataSource?: DataSourceConfig; + omComplete?: (dataSource: DataSourceConfig) => void; +} + +export interface DataSourceFormState { +} + +/** + * 通过是否存在 ID 来决定读写状态 + */ +export class DataSourceForm extends PureComponent { + state = {}; + + handleFormSubmit = (formData) => { + // @todo mutable? + if (_isArray(_get(formData, 'options.params'))) { + formData.options.params = formData.options.params.reduce((acc, cur) => { + if (!cur.name) return; + acc[cur.name] = cur.value; + return acc; + }, {}); + } + if (_isArray(_get(formData, 'options.headers'))) { + formData.options.headers = formData.options.headers.reduce((acc, cur) => { + if (!cur.name) return; + acc[cur.name] = cur.value; + return acc; + }, {}); + } + console.log('submit', formData); + this.props?.onComplete(formData); + }; + + deriveInitialData = (dataSource = {}) => { + const { dataSourceType } = this.props; + const result = _cloneDeep(dataSource); + + if (_isPlainObject(_get(result, 'options.params'))) { + result.options.params = Object.keys(result.options.params).reduce( + (acc, cur) => { + acc.push({ + name: cur, + value: result.options.params[cur] + }); + return acc; + }, + [] + ); + } + if (_isPlainObject(_get(result, 'options.headers'))) { + result.options.headers = Object.keys(result.options.headers).reduce( + (acc, cur) => { + acc.push({ + name: cur, + value: result.options.headers[cur] + }); + return acc; + }, + [] + ); + } + + result.type = dataSourceType.type; + + return result; + } + + deriveSchema = () => { + const { dataSourceType } = this.props; + + // @todo 减小覆盖的风险 + const formSchema = _mergeWith({}, SCHEMA, dataSourceType.schema, (objValue, srcValue) => { + if (_isArray(objValue)) { + return srcValue; + } + }); + + debugger; + + if (_get(formSchema, 'properties.options.properties.params')) { + formSchema.properties.options.properties.params = { + ...formSchema.properties.options.properties.params, + type: 'array', + 'x-component': 'ArrayTable', + 'x-component-props': { + operationsWidth: 100, + }, + items: { + type: 'object', + properties: { + name: { + title: '参数名', + type: 'string', + }, + value: { + title: '参数值', + type: 'string', + 'x-component': 'ParamValue', + 'x-component-props': { + types: [ + 'string', + 'boolean', + 'expression', + 'number' + ], + }, + }, + }, + } + }; + delete formSchema.properties.options.properties.params.properties; + } + if (_get(formSchema, 'properties.options.properties.headers')) { + formSchema.properties.options.properties.headers = { + ...formSchema.properties.options.properties.headers, + type: 'array', + 'x-component': 'ArrayTable', + 'x-component-props': { + operationsWidth: 100, + }, + items: { + type: 'object', + properties: { + name: { + title: '参数名', + type: 'string', + }, + value: { + title: '参数值', + type: 'string', + 'x-component': 'ParamValue', + 'x-component-props': { + types: [ + 'string' + ], + }, + }, + }, + }, + }; + delete formSchema.properties.options.properties.headers.properties; + } + + return traverse(formSchema).forEach(function(node) { + if (node?.type && !node['x-component']) { + if (node.type === 'string') { + node['x-component'] = 'Input'; + } else if (node.type === 'number') { + node['x-component'] = 'NumberPicker'; + } else if (node.type === 'boolean') { + node['x-component'] = 'Switch'; + } + } + }); + }; + + render() { + const { dataSource } = this.props; + + return ( +
+ + + 提交 + + +
+ ); + } +} diff --git a/packages/plugin-datasource-pane/src/form-components/expression.tsx b/packages/plugin-datasource-pane/src/form-components/expression.tsx new file mode 100644 index 000000000..59da91da6 --- /dev/null +++ b/packages/plugin-datasource-pane/src/form-components/expression.tsx @@ -0,0 +1,122 @@ +/** + * 表达式控件,在原类型基础上切换成表达式模式 + */ +/* import React, { PureComponent, ReactElement, FC } from 'react'; +import { Button, Input, Radio, NumberPicker, Switch, Icon } from '@alifd/next'; +import { ArrayTable } from '@formily/next-components'; +import { connect } from '@formily/react-schema-renderer'; +import _isPlainObject from 'lodash/isPlainObject'; +import _isArray from 'lodash/isArray'; +import _isNumber from 'lodash/isNumber'; +import _isString from 'lodash/isString'; +import _isBoolean from 'lodash/isBoolean'; +import _get from 'lodash/get'; +import _tap from 'lodash/tap'; +import { ExpressionSetter } from '@ali/lowcode-editor-setters'; + +const { Group: RadioGroup } = Radio; + +export interface ExpressionProps { + className: string; + value: any; + onChange?: () => void; + type: 'string' | 'number' | 'boolan' | 'array'; +} + +export interface ExpressionState { + useExpression: false; +} + +class ExpressionComp extends PureComponent { + static isFieldComponent = true; + + state = { + useExpression: '', + }; + + constructor(props) { + super(props); + this.state.useExpression = this.isUseExpression(this.props.value); + this.handleChange = this.handleChange; + } + + isUseExpression = (value: any) => { + if (_isPlainObject(value) && value.type === 'JSFunction') { + return true; + } + return false; + }; + + // @todo 需要再 bind 一次? + handleChange = (value) => { + this.props?.onChange(value); + } + + componentDidUpdate(prevProps) { + if (this.props.value !== prevProps.value) { + this.setState({ + value: this.props.value, + useExpression: this.isUseExpression(this.props.value), + }); + } + } + + handleUseExpressionChange = (useExpression) => { + this.setState(({ value }) => { + let nextValue = value || ''; + if (useExpression) { + nextValue = { + type: 'JSFunction', + value: '' + }; + } else { + nextVaule = null; + } + return { + value: nextValue, + useExpression, + }; + }); + }; + + renderOriginal = () => { + const { value, type } = this.props; + + if (type === 'string') { + return ; + } else if (type === 'boolean') { + return ; + } else if (type === 'number') { + return ; + } else if (type === 'array') { + return ; + } + return null; + }; + + renderExpression = () => { + const { value, type } = this.props; + + // @todo 传入上下文才有智能提示 + return ( + + ); + }; + + render() { + const { useExpression } = this.state; + return ( +
+ {!useExpression && this.renderOriginal()} + {useExpression && this.renderExpression()} + +
+ ); + } +} + +export const Expression = connect({ + getProps: (componentProps, fieldProps) => { + debugger; + } +})(ExpressionComp); */ diff --git a/packages/plugin-datasource-pane/src/form-components/index.ts b/packages/plugin-datasource-pane/src/form-components/index.ts new file mode 100644 index 000000000..9560f79da --- /dev/null +++ b/packages/plugin-datasource-pane/src/form-components/index.ts @@ -0,0 +1,2 @@ +export * from './param-value'; +export * from './jsfunction'; diff --git a/packages/plugin-datasource-pane/src/form-components/jsfunction.tsx b/packages/plugin-datasource-pane/src/form-components/jsfunction.tsx new file mode 100644 index 000000000..03dbb5cfe --- /dev/null +++ b/packages/plugin-datasource-pane/src/form-components/jsfunction.tsx @@ -0,0 +1,66 @@ +import React, { PureComponent, createRef } from 'react'; +import { Button, Input, Radio, NumberPicker, Switch } from '@alifd/next'; +import { connect } from '@formily/react-schema-renderer'; +import _isPlainObject from 'lodash/isPlainObject'; +import _isArray from 'lodash/isArray'; +import _isNumber from 'lodash/isNumber'; +import _isString from 'lodash/isString'; +import _isBoolean from 'lodash/isBoolean'; +import _get from 'lodash/get'; +import _tap from 'lodash/tap'; +import MonacoEditor, { EditorWillMount } from 'react-monaco-editor'; + +const { Group: RadioGroup } = Radio; + +export interface JSFunctionProps { + className: string; + value: any; + onChange?: () => void; +} + +export interface JSFunctionState { +} + +class JSFunctionComp extends PureComponent { + static isFieldComponent = true; + + private monacoRef = createRef(); + + constructor(props) { + super(props); + this.handleEditorChange = this.handleEditorChange.bind(this); + } + + handleEditorChange = () => { + if (this.monacoRef.current) { + if ( + !(this.monacoRef.current as editor.IStandaloneCodeEditor) + .getModelMarkers() + .find((marker: editor.IMarker) => marker.owner === 'json') + ) { + this.props?.onChange(this.monacoRef.current?.getModels()?.[0]?.getValue()); + } + } + }; + + handleEditorWillMount: EditorWillMount = (editor) => { + (this.monacoRef as MutableRefObject).current = editor?.editor; + }; + + render() { + const { value, onChange } = this.props; + return ( + + ); + } +} + +export const JSFunction = connect()(JSFunctionComp); diff --git a/packages/plugin-datasource-pane/src/form-components/param-value.tsx b/packages/plugin-datasource-pane/src/form-components/param-value.tsx new file mode 100644 index 000000000..2cb723b37 --- /dev/null +++ b/packages/plugin-datasource-pane/src/form-components/param-value.tsx @@ -0,0 +1,103 @@ +import React, { PureComponent, ReactElement, FC } from 'react'; +import { Button, Input, Radio, NumberPicker, Switch } from '@alifd/next'; +import { connect } from '@formily/react-schema-renderer'; +import _isPlainObject from 'lodash/isPlainObject'; +import _isArray from 'lodash/isArray'; +import _isNumber from 'lodash/isNumber'; +import _isString from 'lodash/isString'; +import _isBoolean from 'lodash/isBoolean'; +import _get from 'lodash/get'; +import _tap from 'lodash/tap'; +import { ExpressionSetter } from '@ali/lowcode-editor-setters'; + +const { Group: RadioGroup } = Radio; + +export interface ParamValueProps { + className: string; + value: any; + onChange?: () => void; +} + +export interface ParamValueState { + type: 'string' | 'number' | 'boolean' | ''; +} + +class ParamValueComp extends PureComponent { + static isFieldComponent = true; + + state = { + type: '', + }; + + constructor(props) { + super(props); + this.state.type = this.getTypeFromValue(this.props.value); + } + + getTypeFromValue = (value) => { + if (_isBoolean(value)) { + return 'boolean'; + } else if (_isNumber(value)) { + return 'number'; + } else if (_isPlainObject(value) && value.type === 'JSFunction') { + return 'expression'; + } + return 'string'; + }; + + // @todo 需要再 bind 一次? + handleChange = (value) => { + this.props?.onChange(value); + } + + componentDidUpdate(prevProps) { + if (this.props.value !== prevProps.value) { + this.setState({ + value: this.props.value, + type: this.getTypeFromValue(this.props.value), + }); + } + } + + handleTypeChange = (type) => { + this.setState(({ value }) => { + let nextValue = value || ''; + if (type === 'string') { + nextValue = nextValue.toString(); + } else if (type === 'number') { + nextValue = nextValue * 1; + } else if (type === 'boolean') { + nextValue = nextValue === 'true' || nextValue; + } else if (type === 'expression') { + nextValue = ''; + } + return { + value: nextValue, + type, + }; + }); + }; + + render() { + const { type } = this.state; + const { value } = this.props; + return ( +
+ { + + 字符串 + 布尔 + 数字 + 表达式 + + } + {type === 'string' && } + {type === 'boolean' && } + {type === 'number' && } + {type === 'expression' && } +
+ ); + } +} + +export const ParamValue = connect()(ParamValueComp); diff --git a/packages/plugin-datasource-pane/src/import-plugins/code.tsx b/packages/plugin-datasource-pane/src/import-plugins/code.tsx new file mode 100644 index 000000000..a8f1d10ff --- /dev/null +++ b/packages/plugin-datasource-pane/src/import-plugins/code.tsx @@ -0,0 +1,138 @@ +/** + * 源码导入插件 + * @todo editor 关联 types,并提供详细的出错信息 + */ +import React, { PureComponent, createRef, MutableRefObject } from 'react'; +import { Button } from '@alifd/next'; +import _noop from 'lodash/noop'; +import _isArray from 'lodash/isArray'; +import _last from 'lodash/last'; +import _isPlainObject from 'lodash/isPlainObject'; +import MonacoEditor, { EditorWillMount } from 'react-monaco-editor'; +import { editor } from 'monaco-editor'; +import { DataSourceConfig } from '@ali/lowcode-types'; +import Ajv from 'ajv'; +import { DataSourcePaneImportPluginComponentProps } from '../types'; + +export interface DataSourceImportPluginCodeProps extends DataSourcePaneImportPluginComponentProps { + defaultValue?: DataSourceConfig[]; +} + +export interface DataSourceImportPluginCodeState { + code: string; + isCodeValid: boolean; +} + +export class DataSourceImportPluginCode extends PureComponent< + DataSourceImportPluginCodeProps, + DataSourceImportPluginCodeState +> { + static defaultProps = { + defaultValue: [ + { + type: 'http', + id: 'test', + }, + ] + }; + + state = { + code: '', + isCodeValid: true, + }; + + private monacoRef = createRef(); + + constructor(props: DataSourceImportPluginCodeProps) { + super(props); + this.state.code = JSON.stringify(this.deriveValue(this.props.defaultValue)); + this.handleEditorWillMount = this.handleEditorWillMount.bind(this); + this.handleEditorChange = this.handleEditorChange.bind(this); + this.handleComplete = this.handleComplete.bind(this); + } + + deriveValue = (value: any) => { + const { dataSourceTypes } = this.props; + + if (!_isArray(dataSourceTypes) || dataSourceTypes.length === 0) return []; + + let result = value; + if (_isPlainObject(result)) { + // 如果是对象则转化成数组 + result = [result]; + } else if (!_isArray(result)) { + return []; + } + + const ajv = new Ajv(); + + return (result as DataSourceConfig[]).filter((dataSource) => { + if (!dataSource.type) return false; + const dataSourceType = dataSourceTypes.find((type) => type.type === dataSource.type); + if (!dataSourceType) return false; + return ajv.validate(dataSourceType.schema, dataSource); + }); + }; + + handleComplete = () => { + if (this.monacoRef.current) { + if ( + !(this.monacoRef.current as editor.IStandaloneCodeEditor) + .getModelMarkers() + .find((marker: editor.IMarker) => marker.owner === 'json') + ) { + this.setState({ isCodeValid: true }); + this.props?.onImport(this.deriveValue(JSON.parse(_last(this.monacoRef.current.getModels()).getValue()))); + return; + } + } + this.setState({ isCodeValid: false }); + }; + + handleEditorChange = () => { + if (this.monacoRef.current) { + if ( + !(this.monacoRef.current as editor.IStandaloneCodeEditor) + .getModelMarkers() + .find((marker: editor.IMarker) => marker.owner === 'json') + ) { + this.setState({ isCodeValid: true }); + } + } + }; + + handleEditorWillMount: EditorWillMount = (editor) => { + (this.monacoRef as MutableRefObject).current = editor?.editor; + // @todo 格式化一次 + }; + + handleCodeChagne = (code) => { + this.setState({ code }); + } + + render() { + const { onCancel = _noop } = this.props; + const { code, isCodeValid } = this.state; + + return ( +
+ + {!isCodeValid &&

格式有误

} +

+ + +

+
+ ); + } +} diff --git a/packages/plugin-datasource-pane/src/import-plugins/index.ts b/packages/plugin-datasource-pane/src/import-plugins/index.ts new file mode 100644 index 000000000..d18a4e09a --- /dev/null +++ b/packages/plugin-datasource-pane/src/import-plugins/index.ts @@ -0,0 +1 @@ +export * from './code'; diff --git a/packages/plugin-datasource-pane/src/index.scss b/packages/plugin-datasource-pane/src/index.scss new file mode 100644 index 000000000..67d1208f6 --- /dev/null +++ b/packages/plugin-datasource-pane/src/index.scss @@ -0,0 +1,18 @@ +.lowcode-plugin-datasource-pane { + margin: 0 8px; + >.next-tabs { + >.next-tabs-bar { + .next-tabs-nav-extra { + .next-btn { + margin-left: 4px; + } + } + } + } +} + +.lowcode-plugin-datasource-pane-list { + .next-virtual-list-wrapper { + min-height: 400px; + } +} diff --git a/packages/plugin-datasource-pane/src/index.tsx b/packages/plugin-datasource-pane/src/index.tsx new file mode 100644 index 000000000..ccde8d705 --- /dev/null +++ b/packages/plugin-datasource-pane/src/index.tsx @@ -0,0 +1,114 @@ +import React, { PureComponent } from 'react'; +import { PluginProps } from '@ali/lowcode-types'; +import { DataSourcePane } from './pane'; +import { DataSourcePaneImportPlugin, DataSourceType } from './types'; +import { DataSourceImportPluginCode } from './import-plugins'; + +export { DataSourceImportPluginCode }; + +const PLUGIN_NAME = 'dataSourcePane'; + +export interface DataSourcePaneProps extends PluginProps { + importPlugins: DataSourcePaneImportPlugin[]; + dataSourceTypes?: DataSourceType[]; +} + +export interface DataSourcePaneState { + active: boolean; +} + +const BUILTIN_DATASOURCE_TYPES = [ + { + type: 'http', + schema: { + type: 'object', + properties: { + options: { + type: 'object', + properties: {}, + }, + }, + }, + }, + { + type: 'mtop', + schema: { + type: 'object', + properties: { + options: { + type: 'object', + properties: { + uri: { + title: 'api', + }, + v: { + title: 'v', + type: 'string', + }, + appKey: { + title: 'appKey', + type: 'string', + }, + }, + }, + }, + }, + }, +]; + +const BUILTIN_IMPORT_PLUGINS = [ + { + name: 'default', + title: '源码', + component: DataSourceImportPluginCode, + }, +]; + +export default class DataSourcePanePlugin extends PureComponent { + static displayName = 'DataSourcePanePlugin'; + + static defaultProps = { + dataSourceTypes: [], + importPlugins: [], + }; + + state = { + active: false, + }; + + constructor(props: DataSourcePaneProps) { + super(props); + this.state.active = true; + + const { editor } = this.props; + // @todo pluginName, to unsubscribe + // 第一次 active 事件不会触发监听器 + editor.on('skeleton.panel-dock.active', (pluginName, dock) => { + if (pluginName === PLUGIN_NAME) { + this.setState({ active: true }); + } + }); + editor.on('skeleton.panel-dock.unactive', (pluginName, dock) => { + if (pluginName === PLUGIN_NAME) { + this.setState({ active: false }); + } + }); + } + + render() { + const { importPlugins, dataSourceTypes, editor } = this.props; + const { active } = this.state; + + if (!active) return null; + + const defaultSchema = editor.get('designer').project?.currentDocument?.schema?.dataSource ?? {}; + + return ( + + ); + } +} diff --git a/packages/plugin-datasource-pane/src/list.tsx b/packages/plugin-datasource-pane/src/list.tsx new file mode 100644 index 000000000..4f0ace919 --- /dev/null +++ b/packages/plugin-datasource-pane/src/list.tsx @@ -0,0 +1,143 @@ +import React, { PureComponent, ReactElement, FC } from 'react'; +import { Button, Search, VirtualList, Tag, Balloon, Table } from '@alifd/next'; +import { DataSourceConfig } from '@ali/lowcode-types'; +import _isPlainObject from 'lodash/isPlainObject'; +import _isNumber from 'lodash/isNumber'; +import _isBoolean from 'lodash/isBoolean'; +import _isNil from 'lodash/isNil'; +import _tap from 'lodash/tap'; +import { DataSourceType } from './types'; + +const { Column: TableCol } = Table; + +export interface DataSourceListProps { + dataSourceTypes: DataSourceType[]; + dataSource: DataSourceConfig[]; + onEditDataSource?: (dataSourceId: string) => void; + onDuplicateDataSource?: (dataSourceId: string) => void; + onRemoveDataSource?: (dataSourceId: string) => void; +} + +export interface DataSourceListState { + filteredType: string; + keyword: string; +} + +type TableRow = { + label: string; + value: any; +}; + +export default class DataSourceList extends PureComponent { + state = { + filteredType: '', + keyword: '', + }; + + handleSearchFilterChange = (filteredType: any) => { + this.setState({ filteredType }); + }; + + handleSearch = (keyword: any) => { + this.setState({ keyword }); + }; + + handleEditDataSource = (id: any) => { + this.props.onEditDataSource?.(id); + }; + + handleDuplicateDataSource = (id: any) => { + this.props.onDuplicateDataSource?.(id); + }; + + handleRemoveDataSource = (id: any) => { + this.props.onRemoveDataSource?.(id); + }; + + deriveListDataSource = () => { + const { filteredType, keyword } = this.state; + const { dataSource } = this.props; + + return ( + dataSource + ?.filter((item) => (filteredType ? item.type === filteredType : true)) + ?.filter((item) => (keyword ? item.id.indexOf(keyword) !== -1 : true)) + ?.map((item) => ( +
  • + + {item.type} + {item.isInit ? '自动' : '手动'} + {item.id} + + + + + } + align="r" + alignEdge + triggerType="hover" + style={{ width: 300 }} + > + ((acc, cur) => { + // @todo 这里的 ts 处理得不好 + if (_isPlainObject(item.options[cur])) { + Object.keys(item?.options?.[cur] || {}).forEach((curInOption) => { + acc.push({ + label: `${cur}.${curInOption}`, + value: (item?.options?.[cur] as any)?.[curInOption], + }); + }); + } else if (!_isNil(item.options[cur])) { + // @todo 排除 null + acc.push({ + label: cur, + value: item.options[cur], + }); + } + return acc; + }, []), console.log)} + > + + ( +
    + + {_isBoolean(val) ? 'bool' : _isNumber(val) ? 'number' : _isPlainObject(val) ? 'obj' : 'string'} + + {val.toString()} +
    + )} + /> +
    +
    +
  • + )) || [] + ); + }; + + render() { + const { dataSourceTypes } = this.props; + + return ( +
    + ({ + label: type?.type, + value: type?.type, + }))} + onFilterChange={this.handleSearchFilterChange} + /> + {this.deriveListDataSource()} +
    + ); + } +} diff --git a/packages/plugin-datasource-pane/src/locale/en-US.json b/packages/plugin-datasource-pane/src/locale/en-US.json new file mode 100644 index 000000000..5b7636ab3 --- /dev/null +++ b/packages/plugin-datasource-pane/src/locale/en-US.json @@ -0,0 +1,3 @@ +{ + "DataSourcePane": "DataSource Pane" +} diff --git a/packages/plugin-datasource-pane/src/locale/index.ts b/packages/plugin-datasource-pane/src/locale/index.ts new file mode 100644 index 000000000..26507f0ef --- /dev/null +++ b/packages/plugin-datasource-pane/src/locale/index.ts @@ -0,0 +1,10 @@ +import { createIntl } from '@ali/lowcode-editor-core'; +import en_US from './en-US.json'; +import zh_CN from './zh-CN.json'; + +const { intl, intlNode, getLocale, setLocale } = createIntl({ + 'en-US': en_US, + 'zh-CN': zh_CN, +}); + +export { intl, intlNode, getLocale, setLocale }; diff --git a/packages/plugin-datasource-pane/src/locale/zh-CN.json b/packages/plugin-datasource-pane/src/locale/zh-CN.json new file mode 100644 index 000000000..dba113fb6 --- /dev/null +++ b/packages/plugin-datasource-pane/src/locale/zh-CN.json @@ -0,0 +1,3 @@ +{ + "DataSourcePane": "数据源面板" +} diff --git a/packages/plugin-datasource-pane/src/pane.tsx b/packages/plugin-datasource-pane/src/pane.tsx new file mode 100644 index 000000000..de1b41725 --- /dev/null +++ b/packages/plugin-datasource-pane/src/pane.tsx @@ -0,0 +1,344 @@ +/** + * 面板,先通过 Dialog 呈现 + */ +import React, { PureComponent } from 'react'; +import { DataSource, DataSourceConfig } from '@ali/lowcode-types'; +import { Tab, Button, MenuButton, Message } from '@alifd/next'; +import _cloneDeep from 'lodash/cloneDeep'; +import _uniqueId from 'lodash/uniqueId'; +import _startsWith from 'lodash/startsWith'; +import _isArray from 'lodash/isArray'; +import _get from 'lodash/get'; +import List from './list'; +// import { DataSourceImportButton, DataSourceImportPluginCode } from './import'; +import { DataSourceForm } from './datasource-form'; +import { DataSourcePaneImportPlugin, DataSourceType } from './types'; + +const { Item: TabItem } = Tab; +const { Item: MenuButtonItem } = MenuButton; + +const TAB_ITEM_LIST = 'list'; +const TAB_ITEM_IMPORT = 'import'; +const TAB_ITEM_CREATE = 'create'; +const TAB_ITEM_EDIT = 'edit'; + +export interface DataSourcePaneProps { + dataSourceTypes?: DataSourceType[]; + importPlugins?: DataSourcePaneImportPlugin[]; + defaultSchema?: DataSource; + onSchemaChange?: (schema: DataSource) => void; +} + +export interface TabItemProps { + key: string; + title: string; + closeable: boolean; + data: any; +} + +export interface TabItem { + tabItemProps: TabItemProps; +} + +export interface DataSourcePaneState { + dataSourceList: DataSourceConfig[]; + tabItems: TabItem[]; + activeTabKey: string; +} + +export class DataSourcePane extends PureComponent { + state = { + dataSourceList: [...(this.props.defaultSchema?.list || [])], + tabItems: [ + { + tabItemProps: { + key: TAB_ITEM_LIST, + title: '数据源列表', + closeable: false, + }, + }, + ], + activeTabKey: TAB_ITEM_LIST, + }; + + constructor(props) { + super(props); + this.handleDataSourceListChange = this.handleDataSourceListChange.bind(this); + this.handleImportDataSourceList = this.handleImportDataSourceList.bind(this); + this.handleCreateDataSource = this.handleCreateDataSource.bind(this); + this.handleUpdateDataSource = this.handleUpdateDataSource.bind(this); + this.handleRemoveDataSource = this.handleRemoveDataSource.bind(this); + this.handleDuplicateDataSource = this.handleDuplicateDataSource.bind(this); + this.handleEditDataSource = this.handleEditDataSource.bind(this); + this.handleTabChange = this.handleTabChange.bind(this); + } + + handleDataSourceListChange = (dataSourceList?: DataSourceConfig[]) => { + if (dataSourceList) { + this.setState({ dataSourceList }); + } + this.props.onSchemaChange?.({ + list: this.state.dataSourceList, + }); + }; + + handleImportDataSourceList = (toImport: DataSourceConfig[]) => { + this.closeTab(TAB_ITEM_IMPORT); + this.setState( + ({ dataSourceList }) => ({ + dataSourceList: dataSourceList.concat(toImport), + }), + () => { + this.handleDataSourceListChange(); + }, + ); + }; + + handleCreateDataSource = (toCreate: DataSourceConfig) => { + this.closeTab(TAB_ITEM_CREATE); + this.setState( + ({ dataSourceList }) => ({ + dataSourceList: dataSourceList.concat([ + { + ...toCreate, + }, + ]), + }), + () => { + this.handleDataSourceListChange(); + }, + ); + }; + + handleUpdateDataSource = (toUpdate: DataSourceConfig) => { + this.closeTab(TAB_ITEM_EDIT); + this.setState( + ({ dataSourceList }) => { + const nextDataSourceList = [...dataSourceList]; + const dataSourceUpdate = nextDataSourceList.find((dataSource) => dataSource.id === toUpdate.id); + if (dataSourceUpdate) { + Object.assign(dataSourceUpdate, toUpdate); + } + return { + dataSourceList: nextDataSourceList, + }; + }, + () => { + this.handleDataSourceListChange(); + }, + ); + }; + + handleRemoveDataSource = (dataSourceId: string) => { + this.setState( + ({ dataSourceList }) => ({ + dataSourceList: dataSourceList.filter((item) => item.id !== dataSourceId), + }), + () => { + this.handleDataSourceListChange(); + }, + ); + }; + + handleDuplicateDataSource = (dataSourceId: string) => { + const target = this.state.dataSourceList.find((item) => item.id === dataSourceId); + const cloned = _cloneDeep(target); + + this.openCreateDataSourceTab({ + ...cloned, + id: _uniqueId(`${cloned.id}_`), + }); + }; + + handleEditDataSource = (dataSourceId: string) => { + const target = this.state.dataSourceList.find((item) => item.id === dataSourceId); + const cloned = _cloneDeep(target); + + this.openEditDataSourceTab({ + ...cloned, + }); + }; + + // @todo 没有识别出类型 + handleTabChange = (activeTabKey: any) => { + this.setState({ activeTabKey }); + }; + + openCreateDataSourceTab = (dataSourceTypeName: string) => { + const { tabItems } = this.state; + const { dataSourceTypes } = this.props; + + if (!tabItems.find((item) => item.tabItemProps.key === TAB_ITEM_CREATE)) { + this.setState(({ tabItems }) => ({ + tabItems: tabItems.concat({ + tabItemProps: { + key: TAB_ITEM_CREATE, + title: `添加数据源`, + closeable: true, + data: { + dataSourceType: dataSourceTypes?.find( + type => type.type === dataSourceTypeName + ), + }, + }, + }), + })); + this.setState({ activeTabKey: TAB_ITEM_CREATE }); + } else { + Message.notice('当前已有一个新建数据源的 TAB 被大家'); + } + }; + + openEditDataSourceTab = (dataSource: DataSourceConfig) => { + const { tabItems } = this.state; + const { dataSourceTypes } = this.props; + + if (!tabItems.find((item) => item.tabItemProps.key === TAB_ITEM_EDIT)) { + this.setState(({ tabItems }) => ({ + tabItems: tabItems.concat({ + tabItemProps: { + key: TAB_ITEM_EDIT, + title: '修改数据源', + closeable: true, + data: { + dataSource, + dataSourceType: dataSourceTypes?.find( + type => type.type === dataSource.type + ), + }, + }, + }), + })); + } + this.setState({ activeTabKey: TAB_ITEM_EDIT }); + }; + + openImportDataSourceTab = (selectedImportPluginName) => { + const { tabItems } = this.state; + const { importPlugins } = this.props; + + if (!tabItems.find((item) => item.tabItemProps.key === `${TAB_ITEM_IMPORT}_${selectedImportPluginName}`)) { + this.setState(({ tabItems }) => ({ + tabItems: tabItems.concat({ + tabItemProps: { + key: TAB_ITEM_IMPORT, + title: `导入数据源-${selectedImportPluginName}`, + closeable: true, + content: _get( + importPlugins?.find((plugin) => selectedImportPluginName === plugin.name), + 'component', + ), + }, + }), + })); + this.setState({ activeTabKey: TAB_ITEM_IMPORT }); + } else { + Message.notice('当前已有一个导入数据源的 TAB 被大家'); + } + }; + + closeTab = (tabKey: any) => { + this.setState( + ({ tabItems }) => ({ + tabItems: tabItems.filter((item) => item.tabItemProps.key !== tabKey), + }), + () => { + this.setState(({ tabItems }) => ({ + activeTabKey: _get(tabItems, '[0].tabItemProps.key') + })); + }, + ); + }; + + renderTabExtraContent = () => { + const { importPlugins, dataSourceTypes } = this.props; + + // @todo onSelect 不行? + return [ + _isArray(dataSourceTypes) && dataSourceTypes.length > 1 ? ( + + {dataSourceTypes.map((type) => ( + {type.type} + ))} + + ) : _isArray(dataSourceTypes) && dataSourceTypes.length > 1 ? ( + + ) : null, + _isArray(importPlugins) && importPlugins.length > 1 ? ( + + {importPlugins.map((plugin) => ( + {plugin.name} + ))} + + ) : _isArray(importPlugins) && importPlugins.length > 1 ? ( + + ) : null, + ]; + }; + + // 更通用的处理 + renderTabItemContentByKey = (tabItemKey: any, data: any) => { + const { dataSourceList, tabItems } = this.state; + const { dataSourceTypes } = this.props; + + if (tabItemKey === TAB_ITEM_LIST) { + return ( + + ); + } else if (tabItemKey === TAB_ITEM_EDIT) { + const dataSourceType = dataSourceTypes.find((type) => type.type === data?.dataSource.type); + return ( + + ); + } else if (tabItemKey === TAB_ITEM_CREATE) { + const tabItemData = tabItems.find((tabItem) => tabItem.tabItemProps.key === tabItemKey); + return ( + + ); + } else if (tabItemKey === TAB_ITEM_IMPORT) { + const tabItemData = tabItems.find((tabItem) => tabItem.tabItemProps.key === tabItemKey); + if (tabItemData) { + const Content = tabItemData.tabItemProps.content; + return ; + } + } + return null; + }; + + render() { + const { dataSourceList, activeTabKey, tabItems } = this.state; + const { importPlugins, dataSourceTypes } = this.props; + + return ( +
    + + {tabItems.map((item) => ( + + {this.renderTabItemContentByKey(item.tabItemProps.key, item.tabItemProps.data)} + + ))} + +
    + ); + } +} diff --git a/packages/plugin-datasource-pane/src/types/datasource-type.ts b/packages/plugin-datasource-pane/src/types/datasource-type.ts new file mode 100644 index 000000000..88533593f --- /dev/null +++ b/packages/plugin-datasource-pane/src/types/datasource-type.ts @@ -0,0 +1,6 @@ +import { JSONSchema4 } from '@types/json-schema'; + +export type DataSourceType = { + type: string; + schema: JSONSchema4 +}; diff --git a/packages/plugin-datasource-pane/src/types/import-plugin.ts b/packages/plugin-datasource-pane/src/types/import-plugin.ts new file mode 100644 index 000000000..f3a999241 --- /dev/null +++ b/packages/plugin-datasource-pane/src/types/import-plugin.ts @@ -0,0 +1,20 @@ +import { DataSourceConfig } from '@ali/lowcode-types'; +import { DataSourceType } from './datasource-type'; + +// 导入插件 +export interface DataSourcePaneImportPlugin { + name: string; + title: string; + component: React.ReactNode; + componentProps?: DataSourcePaneImportPluginCustomProps; +} + +export interface DataSourcePaneImportPluginCustomProps { + [customPropName: string]: any; +} + +export interface DataSourcePaneImportPluginComponentProps extends DataSourcePaneImportPluginCustomProps { + dataSourceTypes: DataSourceType[]; + onImport?: (dataSourceList: DataSourceConfig[]) => void; + onCancel?: () => void; +} diff --git a/packages/plugin-datasource-pane/src/types/index.ts b/packages/plugin-datasource-pane/src/types/index.ts new file mode 100644 index 000000000..f3ea68ac2 --- /dev/null +++ b/packages/plugin-datasource-pane/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './datasource-type'; +export * from './import-plugin'; diff --git a/packages/plugin-datasource-pane/test/foobar.ts b/packages/plugin-datasource-pane/test/foobar.ts new file mode 100644 index 000000000..7a14c4b2d --- /dev/null +++ b/packages/plugin-datasource-pane/test/foobar.ts @@ -0,0 +1,5 @@ +import test from 'ava'; + +test('foobar', t => { + t.pass(); +}); diff --git a/packages/plugin-datasource-pane/tsconfig.json b/packages/plugin-datasource-pane/tsconfig.json new file mode 100644 index 000000000..020e1e82f --- /dev/null +++ b/packages/plugin-datasource-pane/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "lib": ["es6", "dom"], + "compilerOptions": { + "outDir": "lib" + }, + "include": [ + "./src/" + ] +}