mirror of
https://github.com/zhigang1992/form-render.git
synced 2026-01-12 17:22:41 +08:00
coming
This commit is contained in:
6
.babelrc
Executable file
6
.babelrc
Executable file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"presets": ["env", "react", "stage-0"],
|
||||
"plugins": [
|
||||
"transform-decorators-legacy"
|
||||
]
|
||||
}
|
||||
17
.editorconfig
Executable file
17
.editorconfig
Executable file
@@ -0,0 +1,17 @@
|
||||
# EditorConfig helps developers define and maintain consistent
|
||||
# coding styles between different editors and IDEs
|
||||
# editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
# Apply for all files
|
||||
[*]
|
||||
|
||||
charset = utf-8
|
||||
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
6
.eslintignore
Executable file
6
.eslintignore
Executable file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
docs/
|
||||
demo/
|
||||
examples/
|
||||
webpack.config.js
|
||||
test/
|
||||
31
.eslintrc
Executable file
31
.eslintrc
Executable file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": ["plugin:prettier/recommended"],
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
"experimentalObjectRestSpread": true
|
||||
}
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"rules": {
|
||||
"comma-dangle": 0,
|
||||
"no-plusplus": 0,
|
||||
"react/no-array-index-key": 0,
|
||||
"no-template-curly-in-string": 1,
|
||||
"import/extensions": [
|
||||
".js",
|
||||
".jsx"
|
||||
],
|
||||
"import/no-extraneous-dependencies": 0,
|
||||
"react/prop-types": 0,
|
||||
"react/forbid-prop-types": 0,
|
||||
"jsx-a11y/label-has-for": 0
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ node_modules/
|
||||
npm-debug.log
|
||||
.idea/
|
||||
.vscode/
|
||||
/docs/demo
|
||||
|
||||
8
.npmignore
Executable file
8
.npmignore
Executable file
@@ -0,0 +1,8 @@
|
||||
**/.*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.dist/
|
||||
node_modules/
|
||||
mochawesome-reports/
|
||||
demo/
|
||||
src/
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
||||
*.bundle.js
|
||||
*.min.js
|
||||
dist
|
||||
docs/dist
|
||||
lib
|
||||
node_modules
|
||||
atom.css
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
4
CHANGELOG.md
Normal file
4
CHANGELOG.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Change Log
|
||||
|
||||
### 0.1.0
|
||||
- [+]对外开源的第一个版本
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 如何贡献代码
|
||||
|
||||
欢迎给 Form Render 提优化建议,或者修复已有 Bug,共促其发展
|
||||
|
||||
## Branch 管理
|
||||
|
||||
```
|
||||
master
|
||||
↑
|
||||
dev <--- Develop/PR
|
||||
```
|
||||
|
||||
- `dev` 分支
|
||||
- 所有的开发均在 dev 分支进行
|
||||
- 提 PR 时候请提交到 dev 分支
|
||||
- `master` 分支
|
||||
- `master` 是稳定不改的分支,不会在上面进行代码开发
|
||||
- 在 dev 分支 publish 后会 merge 到 master,同时打对应 tag
|
||||
|
||||
## Commit 格式
|
||||
|
||||
```
|
||||
[{action}] {description}
|
||||
```
|
||||
|
||||
- `{action}`
|
||||
- `+` 新增功能
|
||||
- `!` 更新或者修复 bug
|
||||
- `-` 移除功能
|
||||
- `{description}`
|
||||
- 尽可能详细的描述就好
|
||||
|
||||
for example:
|
||||
|
||||
- [+] 列表选项新增拖拽功能
|
||||
- [!] 修复输入框长按闪烁的问题
|
||||
|
||||
## 更多
|
||||
|
||||
- 很推荐在提交 PR 前,先在钉钉群里进行讨论,已防止此功能已经有同学在开发了
|
||||
- 但是如何是想修复文档和明显代码错误,直接提交 PR 就好
|
||||
163
Readme.md
Normal file → Executable file
163
Readme.md
Normal file → Executable file
@@ -1 +1,162 @@
|
||||
### In preparation
|
||||
<img src="https://img.alicdn.com/tfs/TB17UtINiLaK1RjSZFxXXamPFXa-606-643.png" width="146px">
|
||||
|
||||
# FormRender
|
||||
|
||||

|
||||
|
||||
|
||||
> 通过 JSON Schema 生成标准 Form,常用于自定义搭建配置界面生成
|
||||
|
||||
## Topics
|
||||
|
||||
- <a href="xxx" target="_blank">文档官网</a>
|
||||
- <a href="xxx" target="_blank">Form Render Demo</a>
|
||||
- <a href="xxx" target="_blank">升级日志</a>
|
||||
|
||||
<a href="xxx" target="_blank"><img src="https://gw.alipayobjects.com/mdn/feizhu_pla/afts/img/A*EpFjSo5XJqkAAAAAAAAAAABjARQnAQ" width="700"/></a>
|
||||
|
||||
## Features
|
||||
|
||||
- 支持 Ant Design 和 Fusion Design 主流的视觉主题
|
||||
- 使用 JSON Schema 标准协议描述表单配置,并搭配丰富类型且可扩展的组件
|
||||
- 支持 1 排 N、横纵排、支持对象无限嵌套、自定义正则校验、自定义样式组件、列表拖拽等特性
|
||||
- 已在内部不少场景使用,可简单使用同时支持复杂场景使用
|
||||
- 使用上有详细文档,维护上有专人支持
|
||||
|
||||
## 思路
|
||||
|
||||
Form Render 底层引擎用原生 JS 来实现,通过解析 JSON Schema 配置,并支持渲染原生的 HTML 表单,通过 UiSchema 来配置 Widget 组件,在此基础上处理好上层 Antd 或 Fusion 组件 Widget 和 Input Format 的对应关系,最后还可以做到无缝接入其他组件体系的好处
|
||||
|
||||
<img src="https://img.alicdn.com/tfs/TB1AoJUKNTpK1RjSZR0XXbEwXXa-1466-858.png" width="500"/>
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
tnpm i @ali/form-render -S
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
详细使用可见 [开始开始](xxx)
|
||||
|
||||
```react
|
||||
import React from 'react';
|
||||
|
||||
// antd 是这样使用(使用3.x版本)
|
||||
import 'antd/dist/antd.css';
|
||||
import FormRender from '@ali/form-render/lib/antd.js';
|
||||
|
||||
// fusion 这样使用(使用开源版本)
|
||||
//import '@alifd/next/dist/next.min.css';
|
||||
//import FormRender from '@ali/form-render/lib/fusion';
|
||||
|
||||
// propsSchema 是配置 Form Render 的必备参数,使用标准的 JSON Schema 来描述表单结构
|
||||
const propSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stringDemo: {
|
||||
title: '字符串',
|
||||
description: '英文或数字组合',
|
||||
type: 'string',
|
||||
pattern: '^[A-Za-z0-9]+$'
|
||||
},
|
||||
dateDemo: {
|
||||
title: '时间',
|
||||
format: 'dateTime',
|
||||
type: 'string'
|
||||
},
|
||||
},
|
||||
required: [
|
||||
'stringDemo'
|
||||
]
|
||||
};
|
||||
|
||||
//通过uiSchema可以增强 Form Render 展示的丰富性,比如说日历视图
|
||||
const uiSchema = {
|
||||
dateDemo: {
|
||||
'ui:widget': 'date'
|
||||
}
|
||||
};
|
||||
|
||||
class Playground extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
formData: {}
|
||||
};
|
||||
}
|
||||
|
||||
// 数据变化回调
|
||||
onChange = value => {
|
||||
this.setState({
|
||||
formData: value
|
||||
});
|
||||
}
|
||||
|
||||
// 数据格式校验回调
|
||||
onValidate = list => {
|
||||
console.log(list);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formData } = this.state;
|
||||
return (
|
||||
<FormRender
|
||||
name="表单配置"
|
||||
propsSchema={propSchema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
onChange={this.onChange}
|
||||
onValidate={this.onValidate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
ReactDOM.render(<Playground />, mountNode);
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ----------------- | :--------: | :------: | :------: | :-----------------------------------------: |
|
||||
| **`name`** | `String` | `Y` | `$form` | 表单的名称 |
|
||||
| **`propsSchema`** | `Object` | `Y` | `{}` | 表单属性配置 json |
|
||||
| **`uiSchema`** | `Object` | `N` | `{}` | 表单 UI 配置 json |
|
||||
| **`formData`** | `Object` | `N` | `{}` | 配置数据 |
|
||||
| **`onChange`** | `Function` | `Y` | `()=>{}` | 数据更改回调函数 |
|
||||
| **`onValidate`** | `Function` | `N` | `()=>{}` | 表单输入校验回调 |
|
||||
| **`displayType`** | `String` | `N` | `column` | 设置表单横向排列或者纵向排序`column`/ `row` |
|
||||
|
||||
\*设置表单 `displayType` 为 row 时候,请设置 `showDescIcon` 为 `true`,隐藏说明,效果会更好
|
||||
|
||||
### Rare API
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ------------------ | :---------: | :------: | :------: | :-----------------------------------------------: |
|
||||
| **`column`** | `Number` | `1` | `N` | 整体布局 1 排 N,局部的 1 排 N 一般使用`ui:width` |
|
||||
| **`showValidate`** | `Boolean` | `N` | `true` | 是否展示校验信息 |
|
||||
| **`showDescIcon`** | `Boolean` | `N` | `false` | 是否将文字形式说明显示成描述 tooltip 形式 |
|
||||
| **`widgets`** | `Object` | `N` | `{}` | 自定义组件 |
|
||||
| **`mapping`** | `Object` | `N` | `{}` | 用于修改默认组件映射表 |
|
||||
| **`FieldUI`** | `Component` | `N` | 内置组件 | 用于自定义整个元素的样式(标签、结构等) |
|
||||
|
||||
注:样式覆盖基本能满足简单的样式修改需求,普通用户慎用`FieldUI`
|
||||
|
||||
`FieldUI` 的 `props`:
|
||||
|
||||
| Prop | Type | Required | Default | Description |
|
||||
| ------------------ | :-------: | :------: | :------: | :-----------------------------------------: |
|
||||
| **`className`** | `String` | `N` | `N` | 使用`ui:options`里设置的`className` |
|
||||
| **`displayType`** | `String` | `N` | `column` | 设置表单横向排列或者纵向排序`column`/ `row` |
|
||||
| **`isComplex`** | `Boolean` | `N` | `N` | 是否是复杂结构:对象和对象数组 |
|
||||
| **`isRequired`** | `Boolean` | `N` | `N` | 是否是必填项 |
|
||||
| **`schema`** | `Object` | `Y` | `N` | 组件对应的子 schema |
|
||||
| **`showLabel`** | `Boolean` | `N` | `true` | 是否展示 label |
|
||||
| **`showDescIcon`** | `Boolean` | `N` | `false` | 是否将文字形式说明显示成描述 tooltip 形式 |
|
||||
| **`showValidate`** | `Boolean` | `N` | `true` | 是否展示校验 |
|
||||
| **`validateText`** | `String` | `N` | `N` | 校验文字 |
|
||||
|
||||
## 协议
|
||||
|
||||
* 遵循 MIT 协议
|
||||
* 请自由地享受和参与开源
|
||||
|
||||
33
demo/FrDemo.js
Normal file
33
demo/FrDemo.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import FormRender from '@ali/form-render/lib/antd';
|
||||
import SCHEMA from './schema.json';
|
||||
import 'antd/dist/antd.css';
|
||||
|
||||
class Demo extends React.Component {
|
||||
state = { formData: SCHEMA.formData || {} };
|
||||
|
||||
onChange = formData => {
|
||||
this.setState({ formData });
|
||||
};
|
||||
|
||||
onValidate = valid => {
|
||||
console.log('没有通过的校验:', valid);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { formData } = this.state;
|
||||
const { propsSchema, uiSchema } = SCHEMA;
|
||||
return (
|
||||
<FormRender
|
||||
propsSchema={propsSchema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
onChange={this.onChange}
|
||||
onValidate={this.onValidate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(<Demo />, document.getElementById('root'));
|
||||
73
demo/Monaco/index.js
Normal file
73
demo/Monaco/index.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.main';
|
||||
import { suggestions } from './snippets';
|
||||
import theme from 'monaco-themes/themes/Solarized-light.json';
|
||||
export default class MonacoEditor extends React.Component {
|
||||
componentDidMount() {
|
||||
const { value, onValueChange = () => {}, ...options } = this.props;
|
||||
// const model = monaco.editor.createModel(value, language, path);
|
||||
self.MonacoEnvironment = {
|
||||
getWorkerUrl: function(moduleId, label) {
|
||||
if (label === 'json') {
|
||||
return './json.worker.bundle.js';
|
||||
}
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return './ts.worker.bundle.js';
|
||||
}
|
||||
return './editor.worker.bundle.js';
|
||||
},
|
||||
};
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
provideCompletionItems: (model, position, context, token) => {
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('solarized-light', theme);
|
||||
// monaco.editor.setTheme('solarized-light');
|
||||
this._editor = monaco.editor.create(this._node, {
|
||||
value,
|
||||
language: 'json',
|
||||
fontSize: '14px',
|
||||
theme: 'solarized-light',
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
const model = this._editor.getModel();
|
||||
model.updateOptions({ tabSize: 2 });
|
||||
this._subscription = model.onDidChangeContent(() => {
|
||||
onValueChange(model.getValue());
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { value } = this.props;
|
||||
// this._editor.updateOptions(options);
|
||||
const model = this._editor.getModel();
|
||||
if (value !== model.getValue()) {
|
||||
// model.setValue(value);
|
||||
// better than setValue
|
||||
model.pushEditOperations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: model.getFullModelRange(),
|
||||
text: value,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
console.log('dunmont');
|
||||
this._editor && this._editor.dispose();
|
||||
this._subscription && this._subscription.dispose();
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div style={{ height: 600 }} ref={c => (this._node = c)} />;
|
||||
}
|
||||
}
|
||||
223
demo/Monaco/snippets.js
Normal file
223
demo/Monaco/snippets.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.main';
|
||||
|
||||
const Snippet = monaco.languages.CompletionItemKind.Snippet;
|
||||
const insertTextRules =
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet;
|
||||
|
||||
export const initialValue = `{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"uiSchema": {
|
||||
|
||||
},
|
||||
"formData": {
|
||||
|
||||
}
|
||||
}`;
|
||||
|
||||
export const suggestions = [
|
||||
{
|
||||
label: 'fr-init',
|
||||
insertText: `{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
$0
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"uiSchema": {
|
||||
|
||||
},
|
||||
"formData": {
|
||||
|
||||
}
|
||||
}`,
|
||||
documentation: '基本schema结构',
|
||||
detail: '基本schema结构',
|
||||
},
|
||||
{
|
||||
label: 'fr-object',
|
||||
insertText: `"\${1:objectName}": {
|
||||
"title": "\${2:对象}",
|
||||
"description": "这是一个对象类型",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
$0
|
||||
}
|
||||
}`,
|
||||
documentation: '对象',
|
||||
detail: '对象',
|
||||
},
|
||||
{
|
||||
label: 'fr-list',
|
||||
insertText: `"\${1:listName}": {
|
||||
"title": "\${2:对象数组}",
|
||||
"description": "对象数组嵌套功能",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
$0
|
||||
}
|
||||
}
|
||||
}`,
|
||||
documentation: '对象列表',
|
||||
detail: '对象列表',
|
||||
},
|
||||
{
|
||||
label: 'fr-input',
|
||||
insertText: `"\${1:inputName}": {
|
||||
"title": "\${2:字符串}",
|
||||
"type": "string"
|
||||
},`,
|
||||
documentation: '简单input',
|
||||
detail: '简单input',
|
||||
},
|
||||
{
|
||||
label: 'fr-input-complex',
|
||||
insertText: `"\${1:inputName}": {
|
||||
"title": "\${2:字符串}",
|
||||
"description": "英文或数字组合",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入正确格式"
|
||||
}
|
||||
},$0`,
|
||||
documentation: '复杂校验input',
|
||||
detail: '复杂校验input',
|
||||
},
|
||||
{
|
||||
label: 'fr-number',
|
||||
insertText: `"\${1:numberName}": {
|
||||
"title": "\${2:数字}",
|
||||
"description": "数字输入框",
|
||||
"type": "number"
|
||||
},$0`,
|
||||
documentation: '数字输入框',
|
||||
detail: '数字输入框',
|
||||
},
|
||||
{
|
||||
label: 'fr-select',
|
||||
insertText: `"\${1:selectName}": {
|
||||
"title": "\${2:单选}",
|
||||
"type": "string",
|
||||
"enum": ["a", "b","c"],
|
||||
"enumNames": ["早","中","晚"]
|
||||
},$0`,
|
||||
documentation: '下拉单选框',
|
||||
detail: '下拉单选框',
|
||||
},
|
||||
{
|
||||
label: 'fr-select-radio',
|
||||
insertText: `"\${1:radiosName}": {
|
||||
"title": "\${2:单选}",
|
||||
"type": "string",
|
||||
"enum": ["a", "b","c"],
|
||||
"enumNames": ["早","中","晚"],
|
||||
"ui:widget": "radio"
|
||||
},$0`,
|
||||
documentation: 'radio单选框',
|
||||
detail: 'radio单选框',
|
||||
},
|
||||
{
|
||||
label: 'fr-mselect',
|
||||
insertText: `"\${1:multiSelect}": {
|
||||
"title": "\${2:多选}",
|
||||
"description": "下拉多选",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["杭州", "武汉", "湖州", "贵阳"],
|
||||
"ui:widget": "multiSelect"
|
||||
},$0`,
|
||||
documentation: '下拉多选',
|
||||
detail: '下拉多选',
|
||||
},
|
||||
{
|
||||
label: 'fr-mselect-box',
|
||||
insertText: `"\${1:selectBoxes}": {
|
||||
"title": "\${2:多选}",
|
||||
"description": "点击多选",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["杭州", "武汉", "湖州", "贵阳"],
|
||||
},$0`,
|
||||
documentation: 'box类多选',
|
||||
detail: 'box类多选',
|
||||
},
|
||||
{
|
||||
label: 'fr-disabled',
|
||||
insertText: `"\${1:disabledName}": {
|
||||
"title": "\${2:不可用}",
|
||||
"type": "string",
|
||||
"default": "\${3:我是一个被 disabled 的值}",
|
||||
"ui:disabled": true
|
||||
},$0`,
|
||||
documentation: '只读的信息',
|
||||
detail: '只读的信息',
|
||||
},
|
||||
{
|
||||
label: 'fr-date',
|
||||
insertText: `"\${1:dateName}": {
|
||||
"title": "\${2:时间}",
|
||||
"format": "\${3|date,dateTime,time|}",
|
||||
"type": "string",
|
||||
"ui:widget": "date"
|
||||
},$0`,
|
||||
detail: '日期选择',
|
||||
documentation: '日期选择',
|
||||
},
|
||||
{
|
||||
label: 'fr-dateRange',
|
||||
insertText: `"\${1:rangeName}": {
|
||||
"title": "\${2:日期范围}",
|
||||
"type": "range",
|
||||
"format": "\${3|date,dateTime,time|}",
|
||||
"ui:options": {
|
||||
"placeholder": ["开始日期", "结束日期"]
|
||||
}
|
||||
}`,
|
||||
detail: '日期范围',
|
||||
documentation: '从x月y日到a月b日',
|
||||
},
|
||||
{
|
||||
label: 'ui-width',
|
||||
insertText: `"ui:width": "\${1:50%}",$0`,
|
||||
detail: '组件宽度',
|
||||
documentation: '组件宽度',
|
||||
},
|
||||
{
|
||||
label: 'ui-widget',
|
||||
insertText: `"ui:widget": "\${1|date,radio,multiSelect,slider|}",$0`,
|
||||
detail: '组件类型',
|
||||
documentation: '组件类型',
|
||||
},
|
||||
{
|
||||
label: 'ui-options',
|
||||
insertText: `"ui:options": {
|
||||
$0
|
||||
}`,
|
||||
detail: '特定UI设置',
|
||||
documentation: '特定UI设置',
|
||||
},
|
||||
{
|
||||
label: 'format',
|
||||
insertText: `"format": "\${1|date,dateTime,time,textarea,image,color|}",$0`,
|
||||
detail: '组件格式',
|
||||
documentation: '组件格式',
|
||||
},
|
||||
].map(item => ({ ...item, kind: Snippet, insertTextRules }));
|
||||
83
demo/customized/AsyncSelect.js
Normal file
83
demo/customized/AsyncSelect.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Select } from 'antd';
|
||||
import jsonp from 'fetch-jsonp';
|
||||
import querystring from 'querystring';
|
||||
const { Option } = Select;
|
||||
|
||||
let timeout;
|
||||
let currentValue;
|
||||
|
||||
function fetch(value, callback) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
currentValue = value;
|
||||
|
||||
function fake() {
|
||||
const str = querystring.encode({
|
||||
code: 'utf-8',
|
||||
q: value,
|
||||
});
|
||||
jsonp(`https://suggest.taobao.com/sug?${str}`)
|
||||
.then(response => response.json())
|
||||
.then(d => {
|
||||
if (currentValue === value) {
|
||||
const { result } = d;
|
||||
const data = [];
|
||||
result.forEach(r => {
|
||||
data.push({
|
||||
value: r[0],
|
||||
text: r[0],
|
||||
});
|
||||
});
|
||||
callback(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
timeout = setTimeout(fake, 300);
|
||||
}
|
||||
|
||||
class SearchInput extends React.Component {
|
||||
state = {
|
||||
data: [],
|
||||
};
|
||||
|
||||
handleSearch = value => {
|
||||
if (value) {
|
||||
fetch(value, data => this.setState({ data }));
|
||||
} else {
|
||||
this.setState({ data: [] });
|
||||
}
|
||||
};
|
||||
|
||||
handleChange = value => {
|
||||
const { onChange, name } = this.props;
|
||||
onChange(name, value);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { value, options: uiOptions } = this.props;
|
||||
const options = this.state.data.map(d => (
|
||||
<Option key={d.value}>{d.text}</Option>
|
||||
));
|
||||
return (
|
||||
<Select
|
||||
{...uiOptions}
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
value={value || undefined}
|
||||
defaultActiveFirstOption={false}
|
||||
showArrow={false}
|
||||
filterOption={false}
|
||||
onSearch={this.handleSearch}
|
||||
onChange={this.handleChange}
|
||||
notFoundContent={null}
|
||||
>
|
||||
{options}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchInput;
|
||||
12
demo/index.html
Executable file
12
demo/index.html
Executable file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FormRender Demo</title><% (htmlWebpackPlugin.options.resources.css || []).forEach(function(path) { %>
|
||||
<link rel="stylesheet" href="//unpkg.alipay.com/<%=path%>.css" /><% }) %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="__render_content_"></div><% (htmlWebpackPlugin.options.resources.js || []).forEach(function(path) { %>
|
||||
<script src="//unpkg.alipay.com/<%=path%>.js"></script><% }) %>
|
||||
</body>
|
||||
</html>
|
||||
120
demo/index.js
Normal file
120
demo/index.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import GithubCorner from 'react-github-corner';
|
||||
import Demo from './main';
|
||||
import { Radio, Select, Switch, Collapse } from 'antd';
|
||||
const Option = Select.Option;
|
||||
const RadioGroup = Radio.Group;
|
||||
const { Panel } = Collapse;
|
||||
// constant
|
||||
const themeList = [
|
||||
{ label: 'antd主题', value: 'antd' },
|
||||
{ label: 'fusion主题', value: 'fusion' },
|
||||
];
|
||||
const FRadio = props => <Radio {...props} style={{ marginBottom: 12 }} />;
|
||||
class Root extends Component {
|
||||
state = {
|
||||
schemaName: 'default',
|
||||
theme: 'antd',
|
||||
column: 1,
|
||||
displayType: 'column',
|
||||
showDescIcon: false,
|
||||
};
|
||||
|
||||
onThemeChange = e => {
|
||||
this.setState({
|
||||
theme: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onColumnNumberChange = value => {
|
||||
this.setState({
|
||||
column: value,
|
||||
});
|
||||
};
|
||||
|
||||
onDisplayChange = value => {
|
||||
this.setState({
|
||||
displayType: value,
|
||||
showDescIcon: value === 'row',
|
||||
});
|
||||
};
|
||||
|
||||
onShowDescChange = value => {
|
||||
this.setState({
|
||||
showDescIcon: value,
|
||||
});
|
||||
};
|
||||
|
||||
onSchemaChange = e => {
|
||||
this.setState({ schemaName: e.target.value });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { showDescIcon } = this.state;
|
||||
return (
|
||||
<div className="vh-100 overflow-auto flex flex-column">
|
||||
<GithubCorner
|
||||
href="https://alibaba.github.io/form-render/"
|
||||
bannerColor="#F6C14F"
|
||||
className="absolute top-0 right-0 z-999"
|
||||
/>
|
||||
<Collapse defaultActiveKey={['1']} onChange={() => {}}>
|
||||
<Panel header={<div className="b f3">FormRender</div>} key="1">
|
||||
<div className="w-100 flex">
|
||||
<div className="w-50">
|
||||
<Radio.Group
|
||||
name="schemaName"
|
||||
defaultValue="basic"
|
||||
className="flex flex-wrap"
|
||||
onChange={this.onSchemaChange}
|
||||
>
|
||||
<FRadio value="basic">基础控件</FRadio>
|
||||
<FRadio value="input">个性输入框</FRadio>
|
||||
<FRadio value="select">个性选择框</FRadio>
|
||||
<FRadio value="date">日期format</FRadio>
|
||||
<FRadio value="new-feature">新功能</FRadio>
|
||||
<FRadio value="demo">例子</FRadio>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
<div className="w-50 flex items-center justify-center">
|
||||
<RadioGroup
|
||||
options={themeList}
|
||||
value={this.state.theme}
|
||||
onChange={this.onThemeChange}
|
||||
/>
|
||||
<Select
|
||||
style={{ marginRight: 12 }}
|
||||
onChange={this.onColumnNumberChange}
|
||||
defaultValue="1"
|
||||
>
|
||||
<Option value="1">一行一列</Option>
|
||||
<Option value="2">一行二列</Option>
|
||||
<Option value="3">一行三列</Option>
|
||||
</Select>
|
||||
<Select
|
||||
style={{ marginRight: 12 }}
|
||||
onChange={this.onDisplayChange}
|
||||
defaultValue="column"
|
||||
>
|
||||
<Option value="column">上下排列</Option>
|
||||
<Option value="row">左右排列</Option>
|
||||
</Select>
|
||||
<Switch
|
||||
className="mr2"
|
||||
checkedChildren="关描述"
|
||||
onChange={this.onShowDescChange}
|
||||
unCheckedChildren="开描述"
|
||||
checked={showDescIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
<Demo {...this.state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(<Root />, document.getElementById('__render_content_'));
|
||||
134
demo/json/basic.json
Normal file
134
demo/json/basic.json
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"AllString": {
|
||||
"title": "string类",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"title": "简单输入框",
|
||||
"type": "string",
|
||||
"ui:options": {
|
||||
"placeholder": "昵称"
|
||||
}
|
||||
},
|
||||
"textarea": {
|
||||
"title": "简单文本编辑框",
|
||||
"type": "string",
|
||||
"format": "textarea"
|
||||
},
|
||||
"color": {
|
||||
"title": "颜色选择",
|
||||
"type": "string",
|
||||
"format": "color"
|
||||
},
|
||||
"date": {
|
||||
"title": "日期选择",
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"image": {
|
||||
"title": "图片展示",
|
||||
"type": "string",
|
||||
"format": "image"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allNumber": {
|
||||
"title": "number类",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number1": {
|
||||
"title": "数字输入框",
|
||||
"description": "5 - 20",
|
||||
"type": "number",
|
||||
"min": 5,
|
||||
"max": 20
|
||||
},
|
||||
"number2": {
|
||||
"title": "带滑动条",
|
||||
"type": "number",
|
||||
"ui:widget": "slider"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allBoolean": {
|
||||
"title": "boolean类",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"radio": {
|
||||
"title": "表单类使用radio",
|
||||
"type": "boolean"
|
||||
},
|
||||
"switch": {
|
||||
"title": "控件类使用switch",
|
||||
"type": "boolean",
|
||||
"ui:widget": "switch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"allRange": {
|
||||
"title": "range类",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dateRange": {
|
||||
"title": "日期范围",
|
||||
"type": "range",
|
||||
"format": "dateTime",
|
||||
"ui:options": {
|
||||
"placeholder": ["开始时间", "结束时间"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allEnum": {
|
||||
"title": "选择类",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"title": "单选",
|
||||
"type": "string",
|
||||
"enum": ["a", "b", "c"],
|
||||
"enumNames": ["早", "中", "晚"]
|
||||
},
|
||||
"radio": {
|
||||
"title": "单选",
|
||||
"type": "string",
|
||||
"enum": ["a", "b", "c"],
|
||||
"enumNames": ["早", "中", "晚"],
|
||||
"ui:widget": "radio"
|
||||
},
|
||||
"multiSelect": {
|
||||
"title": "多选",
|
||||
"description": "下拉多选",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["杭州", "武汉", "湖州", "贵阳"],
|
||||
"ui:widget": "multiSelect"
|
||||
},
|
||||
"boxes": {
|
||||
"title": "多选",
|
||||
"description": "checkbox",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["杭州", "武汉", "湖州", "贵阳"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"uiSchema": {},
|
||||
"formData": {
|
||||
"AllString": {
|
||||
"image": "http://placekitten.com/200/300"
|
||||
}
|
||||
}
|
||||
}
|
||||
28
demo/json/date.json
Normal file
28
demo/json/date.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date1": {
|
||||
"title": "时间选择",
|
||||
"type": "string",
|
||||
"format": "time"
|
||||
},
|
||||
"date2": {
|
||||
"title": "日期选择",
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"date3": {
|
||||
"title": "日期选择",
|
||||
"description": "精确到秒",
|
||||
"type": "string",
|
||||
"format": "dateTime"
|
||||
},
|
||||
"data4": {
|
||||
"title": "日期范围",
|
||||
"type": "range",
|
||||
"format": "date"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
221
demo/json/demo.json
Normal file
221
demo/json/demo.json
Normal file
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"name": "FpDemo",
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputDemo": {
|
||||
"title": "长度",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入正确格式"
|
||||
}
|
||||
},
|
||||
"numberDemo": {
|
||||
"title": "数字",
|
||||
"description": "数字输入框",
|
||||
"type": "number",
|
||||
"min": 10,
|
||||
"max": 100,
|
||||
"step": 10
|
||||
},
|
||||
"textareaDemo": {
|
||||
"title": "输入框",
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
"imgDemo": {
|
||||
"title": "图片",
|
||||
"type": "string",
|
||||
"format": "image",
|
||||
"default": "https://img.alicdn.com/tfs/TB1P8p2uQyWBuNjy0FpXXassXXa-750-1334.png"
|
||||
},
|
||||
"uploadDemo": {
|
||||
"title": "文件上传",
|
||||
"type": "string",
|
||||
"default": "https://img.alicdn.com/tfs/TB1P8p2uQyWBuNjy0FpXXassXXa-750-1334.png"
|
||||
},
|
||||
"disabledDemo": {
|
||||
"title": "不可用",
|
||||
"type": "string",
|
||||
"default": "我是一个被 disabled 的值"
|
||||
},
|
||||
"enumDemo": {
|
||||
"title": "枚举",
|
||||
"type": "string",
|
||||
"enum": ["A", "B"],
|
||||
"enumNames": [
|
||||
"养成",
|
||||
"<span style='background-color: black;display: inline-block;vertical-align: text-top;width: 48px;height: 24px;margin-top:-2px;color:white; border: 1px solid #ddd;'>试试</span>"
|
||||
]
|
||||
},
|
||||
"dateDemo": {
|
||||
"title": "时间",
|
||||
"format": "dateTime",
|
||||
"type": "string"
|
||||
},
|
||||
"objDemo": {
|
||||
"title": "单个对象",
|
||||
"description": "这是一个对象类型",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"isLike": {
|
||||
"title": "是否显示颜色选择",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"title": "颜色选择",
|
||||
"description": "特殊面板",
|
||||
"format": "color",
|
||||
"type": "string"
|
||||
},
|
||||
"wayToTravel": {
|
||||
"title": "旅行方式",
|
||||
"type": "string",
|
||||
"enum": ["self", "group"],
|
||||
"enumNames": ["自驾", "跟团"]
|
||||
},
|
||||
"canDrive": {
|
||||
"title": "是否拥有驾照",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["background"]
|
||||
},
|
||||
"multiSelectDemo": {
|
||||
"title": "多选组件",
|
||||
"description": "多选功能",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["杭州", "武汉", "湖州", "贵阳"]
|
||||
},
|
||||
"custom": {
|
||||
"properties": {
|
||||
"payType": {
|
||||
"title": "支付方式",
|
||||
"description": "",
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"enum": ["1", "5", "6"],
|
||||
"enumNames": ["预付", "面付", "信用住"]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": ["payType"],
|
||||
"title": "酒店行业限制",
|
||||
"name": "custom"
|
||||
},
|
||||
"arrDemo": {
|
||||
"title": "对象数组",
|
||||
"description": "对象数组嵌套功能",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "字符名称",
|
||||
"description": "string类型",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$"
|
||||
},
|
||||
"num": {
|
||||
"title": "数字参数",
|
||||
"description": "number类型",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["textareaDemo", "dateDemo", "multiSelectDemo"]
|
||||
},
|
||||
"uiSchema": {
|
||||
"inputDemo": {
|
||||
"ui:className": "input-with-px",
|
||||
"ui:options": {
|
||||
"addonAfter": "px"
|
||||
}
|
||||
},
|
||||
"textareaDemo": {
|
||||
"ui:widget": "textarea",
|
||||
"ui:button": {
|
||||
"text": "测试",
|
||||
"icon": "",
|
||||
"callback": "clg"
|
||||
}
|
||||
},
|
||||
"disabledDemo": {
|
||||
"ui:disabled": true
|
||||
},
|
||||
"enumDemo": {
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"dateDemo": {
|
||||
"ui:widget": "date",
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"uploadDemo": {
|
||||
"ui:widget": "upload",
|
||||
"ui:action": "https://www.mocky.io/v2/5cc8019d300000980a055e76"
|
||||
},
|
||||
"arrDemo": {
|
||||
"ui:options": {
|
||||
"foldable": true
|
||||
},
|
||||
"items": {
|
||||
"name": {
|
||||
"ui:hidden": "num==3"
|
||||
}
|
||||
},
|
||||
"ui:extraButtons": [
|
||||
{
|
||||
"text": "复制",
|
||||
"icon": "copy",
|
||||
"callback": "copyLast"
|
||||
}
|
||||
]
|
||||
},
|
||||
"objDemo": {
|
||||
"background": {
|
||||
"ui:dependShow": "'{{objDemo.isLike}}' == 'true'",
|
||||
"ui:widget": "color"
|
||||
},
|
||||
"wayToTravel": {
|
||||
"ui:widget": "radio"
|
||||
},
|
||||
"canDrive": {
|
||||
"ui:hidden": "wayToTravel!='self'"
|
||||
}
|
||||
},
|
||||
"multiSelectDemo": {
|
||||
"ui:widget": "multiSelect"
|
||||
}
|
||||
},
|
||||
"formData": {
|
||||
"inputDemo": "750",
|
||||
"textareaDemo": "FormRender\nHello World!",
|
||||
"imgDemo": "https://img.alicdn.com/tfs/TB1P8p2uQyWBuNjy0FpXXassXXa-750-1334.png",
|
||||
"enumDemo": "A",
|
||||
"dateDemo": "2018-11-22",
|
||||
"isLike": true,
|
||||
"objDemo": {
|
||||
"isLike": true,
|
||||
"background": "#ffff00"
|
||||
},
|
||||
"multiSelectDemo": ["C", "D"],
|
||||
"arrDemo": [
|
||||
{
|
||||
"name": "表单渲染",
|
||||
"num": 10
|
||||
},
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
92
demo/json/input.json
Normal file
92
demo/json/input.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string": {
|
||||
"title": "字符串",
|
||||
"description": "带清空x按钮",
|
||||
"type": "string",
|
||||
"default": "hello world",
|
||||
"ui:options": {
|
||||
"allowClear": true
|
||||
},
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string2": {
|
||||
"title": "复杂校验",
|
||||
"description": "pattern和message的用法",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入数字或英文字母"
|
||||
},
|
||||
"ui:options": {
|
||||
"placeholder": "请输入数字或英文"
|
||||
},
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string3": {
|
||||
"title": "长度控制",
|
||||
"description": "长度在5-15个字之间",
|
||||
"type": "string",
|
||||
"minLength": 5,
|
||||
"maxLength": 15,
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string4": {
|
||||
"title": "前置/后置标签",
|
||||
"type": "string",
|
||||
"ui:options": {
|
||||
"addonBefore": "长度",
|
||||
"addonAfter": "px"
|
||||
},
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string5": {
|
||||
"title": "前后缀",
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入数字"
|
||||
},
|
||||
"ui:options": {
|
||||
"prefix": "¥",
|
||||
"suffix": "RMB"
|
||||
},
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string6": {
|
||||
"title": "置灰的输入框",
|
||||
"type": "string",
|
||||
"ui:disabled": true,
|
||||
"default": "hello world",
|
||||
"ui:width": "50%"
|
||||
},
|
||||
"string7": {
|
||||
"title": "文本框",
|
||||
"description": "固定高度",
|
||||
"type": "string",
|
||||
"format": "textarea",
|
||||
"ui:options": {
|
||||
"row": 4
|
||||
}
|
||||
},
|
||||
"string8": {
|
||||
"title": "文本框",
|
||||
"description": "自动高度",
|
||||
"type": "string",
|
||||
"format": "textarea",
|
||||
"ui:options": {
|
||||
"autosize": {
|
||||
"minRows": 3,
|
||||
"maxRows": 5
|
||||
},
|
||||
"placeholder": "根据内容缩放"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["string4", "string5"]
|
||||
},
|
||||
"uiSchema": {},
|
||||
"formData": {}
|
||||
}
|
||||
117
demo/json/new-feature.json
Normal file
117
demo/json/new-feature.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inputDemo": {
|
||||
"title": "前后缀",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入正确格式"
|
||||
}
|
||||
},
|
||||
"numberDemo": {
|
||||
"title": "数字",
|
||||
"description": "数字输入框",
|
||||
"type": "number",
|
||||
"min": 10,
|
||||
"max": 100,
|
||||
"step": 10
|
||||
},
|
||||
"dateRange": {
|
||||
"title": "日期范围",
|
||||
"type": "range",
|
||||
"format": "dateTime",
|
||||
"ui:options": {
|
||||
"placeholder": ["开始", "结束"]
|
||||
}
|
||||
},
|
||||
"customizedWidgets": {
|
||||
"title": "自定义组件",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"asyncSelect": {
|
||||
"title": "异步加载的下拉框",
|
||||
"type": "string",
|
||||
"ui:widget": "asyncSelect",
|
||||
"ui:options": {
|
||||
"placeholder": "搜索淘宝商品"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"complexArray": {
|
||||
"title": "复杂结构数组",
|
||||
"description": "数组item中含有数组等",
|
||||
"type": "array",
|
||||
"ui:options": {
|
||||
"foldable": true
|
||||
},
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"age": {
|
||||
"title": "填写年龄",
|
||||
"type": "string",
|
||||
"ui:hidden": "movieType=='a'||movieType=='c'"
|
||||
},
|
||||
"movieType": {
|
||||
"title": "短片类型",
|
||||
"type": "string",
|
||||
"enum": ["a", "b", "c"],
|
||||
"enumNames": ["教育", "恐怖", "故事"]
|
||||
},
|
||||
"movieList": {
|
||||
"title": "电影列表",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"movieName": {
|
||||
"title": "电影名",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"when": {
|
||||
"title": "何时观看",
|
||||
"description": "多选",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["A", "B", "C", "D"],
|
||||
"enumNames": ["早上", "中午", "下午", "晚上"]
|
||||
}
|
||||
},
|
||||
"required": ["age"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiSchema": {
|
||||
"inputDemo": {
|
||||
"ui:width": "50%",
|
||||
"ui:options": {
|
||||
"addonBefore": "画布长度",
|
||||
"addonAfter": "px"
|
||||
}
|
||||
},
|
||||
"numberDemo": {
|
||||
"ui:width": "50%",
|
||||
"ui:widget": "slider"
|
||||
}
|
||||
},
|
||||
"formData": {
|
||||
"inputDemo": "750",
|
||||
"textareaDemo": "FormRender\nHello World!",
|
||||
"complexArray": [
|
||||
{
|
||||
"age": "",
|
||||
"movieType": "b",
|
||||
"movieList": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
demo/json/select.json
Normal file
29
demo/json/select.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"select": {
|
||||
"title": "带搜索的单选框",
|
||||
"type": "string",
|
||||
"enum": ["a", "b", "c"],
|
||||
"enumNames": ["jack", "steve", "david"],
|
||||
"ui:options": {
|
||||
"filterOption": true,
|
||||
"showSearch": true,
|
||||
"optionFilterProp": "children"
|
||||
}
|
||||
},
|
||||
"multiSelect": {
|
||||
"title": "标签模式",
|
||||
"description": "除了可选的标签,还可输入自定义的标签",
|
||||
"type": "array",
|
||||
"enum": ["旅行达人", "工作狂", "老司机", "小资"],
|
||||
"ui:widget": "select",
|
||||
"ui:options": {
|
||||
"mode": "tags"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
130
demo/main.js
Normal file
130
demo/main.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import deepEqual from 'deep-equal';
|
||||
import parseJson from 'json-parse-better-errors';
|
||||
import AntdComp from '../src/antd';
|
||||
import FusionComp from '../src/fusion';
|
||||
import 'antd/dist/antd.css';
|
||||
import '../atom.css';
|
||||
import CODE from '!!raw-loader!./FrDemo';
|
||||
import DefaultSchema from './json/basic.json';
|
||||
import { Tabs } from 'antd';
|
||||
import AsyncSelect from './customized/AsyncSelect';
|
||||
import CodeBlock from './Monaco';
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
// help functions
|
||||
const schema2str = obj => JSON.stringify(obj, null, 2) || '';
|
||||
|
||||
class Demo extends React.Component {
|
||||
state = {
|
||||
schemaStr: schema2str(DefaultSchema),
|
||||
error: '',
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { schemaName } = this.props;
|
||||
if (prevProps.schemaName !== schemaName) {
|
||||
const schema = require(`./json/${schemaName}.json`);
|
||||
this.setState({ schemaStr: schema2str(schema) });
|
||||
}
|
||||
}
|
||||
|
||||
getSchemaString = () => {
|
||||
const { schemaName } = this.props;
|
||||
const schema = require(`./json/${schemaName}.json`);
|
||||
return schema2str(schema);
|
||||
};
|
||||
|
||||
tryParse = schemaStr => {
|
||||
let schema = {};
|
||||
try {
|
||||
schema = parseJson(schemaStr);
|
||||
if (typeof schema !== 'object') {
|
||||
this.setState({ error: 'schema非正确json' });
|
||||
return;
|
||||
}
|
||||
this.setState({ error: '' });
|
||||
return schema;
|
||||
} catch (error) {
|
||||
this.setState({ error: String(error) });
|
||||
}
|
||||
};
|
||||
|
||||
handleCodeChange = schemaStr => {
|
||||
this.setState({ schemaStr });
|
||||
this.tryParse(schemaStr);
|
||||
};
|
||||
|
||||
handleDataChange = data => {
|
||||
const { schemaStr } = this.state;
|
||||
let schema = this.tryParse(schemaStr);
|
||||
if (schema && typeof data === 'object') {
|
||||
if (!deepEqual(schema.formData, data)) {
|
||||
schema = { ...schema, formData: data };
|
||||
this.setState({ schemaStr: schema2str(schema) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleValidate = valid => {
|
||||
console.log('没有通过的校验:', valid);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { theme, column, displayType, showDescIcon } = this.props;
|
||||
const { schemaStr } = this.state;
|
||||
const formProps = { column, displayType, showDescIcon };
|
||||
const FormRender = theme === 'antd' ? AntdComp : FusionComp;
|
||||
let schema = {};
|
||||
try {
|
||||
schema = parseJson(schemaStr);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
const { propsSchema = {}, uiSchema = {}, formData = {} } = schema;
|
||||
return (
|
||||
<div className="flex-auto flex">
|
||||
<div className="w-50 h-100 flex flex-column">
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
onChange={() => {}}
|
||||
className="flex flex-column"
|
||||
style={{ overflow: 'auto' }}
|
||||
>
|
||||
<TabPane tab="Schema" key="1">
|
||||
<CodeBlock
|
||||
value={schemaStr}
|
||||
onValueChange={this.handleCodeChange}
|
||||
/>
|
||||
</TabPane>
|
||||
<TabPane tab="Data" key="2">
|
||||
<CodeBlock value={schema2str(formData)} readOnly />
|
||||
</TabPane>
|
||||
<TabPane tab="Code" key="3">
|
||||
<CodeBlock value={CODE} readOnly language="javascript" />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="w-50 h-100 flex flex-column pa2">
|
||||
<div className="h-100 overflow-auto pa3 pt4 flex-auto">
|
||||
{this.state.error ? (
|
||||
<div>{this.state.error}</div>
|
||||
) : (
|
||||
<FormRender
|
||||
{...formProps}
|
||||
propsSchema={propsSchema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
onChange={this.handleDataChange}
|
||||
onValidate={this.handleValidate}
|
||||
widgets={{ asyncSelect: AsyncSelect }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Demo;
|
||||
7
docs/_coverpage.md
Normal file
7
docs/_coverpage.md
Normal file
@@ -0,0 +1,7 @@
|
||||
<img src="https://img.alicdn.com/tfs/TB17UtINiLaK1RjSZFxXXamPFXa-606-643.png" width="146px">
|
||||
|
||||
# <span style="font-weight:400;">Form Render</span>
|
||||
|
||||
> <span style="line-height:1.8rem;font-weight:400;font-size:1.3rem">通过 JSON Schema 生成标准 Form,常用于自定义搭建配置界面生成<span>
|
||||
|
||||
[快速开始](README)
|
||||
0
docs/_navbar.md
Normal file
0
docs/_navbar.md
Normal file
13
docs/_sidebar.md
Normal file
13
docs/_sidebar.md
Normal file
@@ -0,0 +1,13 @@
|
||||
- **基础使用**
|
||||
- [快速开始](README)
|
||||
- [propSchema 配置](docs/prop-schema)
|
||||
- [uiSchema 配置](docs/ui-schema)
|
||||
- [pattern 自定义正则校验](docs/pattern)
|
||||
- **高级配置**
|
||||
- [样式覆盖](docs/css)
|
||||
- [自定义组件](docs/widget)
|
||||
- [表达式依赖决定显示表单](docs/depend)
|
||||
- **也许需要**
|
||||
- [常见问题](docs/question)
|
||||
- [**欢迎贡献代码**](CONTRIBUTING)
|
||||
- [**升级日志**](CHANGELOG)
|
||||
10
docs/css.md
Normal file
10
docs/css.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 样式覆盖
|
||||
|
||||
### 概述
|
||||
- Form Render 其实是有一个默认的布局样式,比如说表单之间的间距,label 字号、颜色等场景
|
||||
- 假如使用上有特殊定制,或者认为不符合要求的地方,可以通过常规 CSS 样式覆盖的方式,写到业务 global 样式代码中
|
||||
|
||||
### 如何使用
|
||||
|
||||

|
||||
|
||||
21
docs/depend.md
Normal file
21
docs/depend.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 通过表达式的方式来控制某个表单是否显示
|
||||
|
||||
### 概述
|
||||
|
||||
- 有一些场景中存在当选项为某个值的时候才显示某一项目的场景,如下所示
|
||||
<img src="https://gw.alipayobjects.com/mdn/feizhu_pla/afts/img/A*xQT3Ro38-c4AAAAAAAAAAABjARQnAQ" width="600"/>
|
||||
|
||||
### 如何使用
|
||||
|
||||
- 比如说,如上例子,当勾选字段 isLike 为 true 的时候,才显示下面的颜色选择,直接通过在需要控制的 uiWidget 上面加入`ui:dependShow`属性,写上他对应的表达式即可
|
||||
- 此处需要主要,由于表达式最终会解析成 String 去执行,请确保所有的都通过字符串来表示,否则执行`true==='true'`或者`A=='A'`这一类型会报错(如 A 变量找不到)
|
||||
|
||||
```json
|
||||
"uiSchema": {
|
||||
"objDemo": {
|
||||
"background": {
|
||||
"ui:dependShow": "'{{objDemo.isLike}}' == 'true'"
|
||||
}
|
||||
}
|
||||
},
|
||||
```
|
||||
42
docs/pattern.md
Normal file
42
docs/pattern.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# pattern 自定义正则校验
|
||||
|
||||
### 概述
|
||||
- 简单的长度校验推荐使用 <a href="http://groups.alidemo.cn/hive/form-render/#/docs/prop-schema?type=html" target="_blank">propSchema</a> 中的属性
|
||||
- pattern 是存在于 propSchema 中属性的一个字段,长用在当长度限制还不满足要求的场景,可通过正则的方式来对用户输入项进行校验
|
||||
- pattern 和 required、minLength、maxLength 等这一类显示为`&&`的关系,当有一个不满足时候,onValidate 回调中将会返回不符合的数组项
|
||||
|
||||
### 格式
|
||||
|
||||
- 格式和 JS 正则的格式保持一致,实际为此正则类中的 pattern 字段 `new RegExp(pattern).test(value)`
|
||||
|
||||
### 常见 pattern 规则:
|
||||
|
||||
| 字符 | 含意 |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| \ | 将下一个字符标记为一个特殊字符、或一个原义字符、或一个后向引用、或一个八进制转义符。 例如,’n’ 匹配字符 “n”。’\n’ 匹配一个换行符。序列 ‘' 匹配 “” 而 “\(” 则匹配 “(“。 |
|
||||
| ^ | 匹配输入字符串的开始位置。如果设置了 RegExp 对象的 Multiline 属性,^ 也匹配 ‘\n’ 或 ‘\r’ 之后的位置。 |
|
||||
| \$ | 匹配输入字符串的结束位置。如果设置了 RegExp 对象的 Multiline 属性,\$ 也匹配 ‘\n’ 或 ‘\r’ 之前的位置。 |
|
||||
| \* | 匹配前面的子表达式零次或多次。例如,zo* 能匹配 “z” 以及 “zoo”。 * 等价于{0,}。 |
|
||||
| + | 匹配前面的子表达式一次或多次。例如,’zo+’ 能匹配 “zo” 以及 “zoo”,但不能匹配 “z”。+ 等价于 {1,}。 |
|
||||
| ? | 匹配前面的子表达式零次或一次。例如,”do(es)?” 可以匹配 “do” 或 “does” 中的”do” 。? 等价于 {0,1}。 |
|
||||
| {n} | n 是一个非负整数。匹配确定的 n 次。例如,’o{2}’ 不能匹配 “Bob” 中的 ‘o’,但是能匹配 “food” 中的两个 o。 |
|
||||
| {n,} | n 是一个非负整数。至少匹配 n 次。 例如: ‘o{2,}’ 不能匹配 “Bob” 中的 ‘o’,但能匹配 “foooood” 中的所有 o。 ‘o{1,}’ 等价于 ‘o+’。’o{0,}’ 则等价于 ‘o\*’。 |
|
||||
| {n,m} | m 和 n 均为非负整数,其中 n <= m。最少匹配 n 次且最多匹配 m 次。例如: “o{1,3}” 将匹配 “fooooood” 中的前三个 o。’o{0,1}’ 等价于 ‘o?’。请注意在逗号和两个数之间不能有空格。 |
|
||||
| ? | 当该字符紧跟在任何一个其他限制符 (\*, +, ?, {n}, {n,}, {n,m}) 后面时,匹配模式是非贪婪的。 非贪婪模式尽可能少的匹配所搜索的字符串,而默认的贪婪模式则尽可能多的匹配所搜索的字符串。 例如,对于字符串 “oooo”,’o+?’ 将匹配单个 “o”,而 ‘o+’ 将匹配所有 ‘o’。 |
|
||||
| . | 匹配除 “\n” 之外的任何单个字符。要匹配包括 ‘\n’ 在内的任何字符,请使用象 ‘[.\n]’ 的模式。 |
|
||||
| (pattern) | 匹配 pattern 并获取这一匹配。 所获取的匹配可以从产生的 Matches 集合得到,要匹配圆括号字符,请使用 ‘‘或‘‘或‘’。 |
|
||||
| (?:pattern) | 匹配 pattern 但不获取匹配结果,也就是说这是一个非获取匹配,不进行存储供以后使用。 这在使用 “或” 字符 (\|) 来组合一个模式的各个部分是很有用。 例如, ‘industr(?:y\|ies) 就是一个比 ‘industry\|industries’ 更简略的表达式。 |
|
||||
| (?=pattern) | **肯定** 顺序环视 **:子表达式能够匹配右侧的文本**正向预查,在任何匹配 pattern 的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如, ‘Windows (?=95\|98\|NT\|2000)’ 能匹配 “Windows 2000” 中的 “Windows” , 但不能匹配 “Windows 3.1” 中的 “Windows”。_预查不消耗字符,也就是说,在一个匹配发生后,在最后一次匹配之后立即开始下一次匹配的搜索, 而不是从包含预查的字符之后开始。_ |
|
||||
| (?!pattern) | **否定顺序环视:子表达式不能匹配右侧的文本**负向预查,在任何不匹配的字符串开始处匹配查找字符串。这是一个非获取匹配,也就是说,该匹配不需要获取供以后使用。例如’Windows (?!95\|98\|NT\|2000)’ 能匹配 “Windows 3.1” 中的 “Windows”, 但不能匹配 “Windows 2000” 中的 “Windows”。_预查不消耗字符,也就是说,在一个匹配发生后,在最后一次匹配之后立即开始下一次匹配的搜索, 而不是从包含预查的字符之后开始_ |
|
||||
| (?<=pattern))))) | **肯定逆序环视:子表达式能够匹配左侧的文本** |
|
||||
| (?<!pattern))))) | **否定逆序环视:子表达式不能匹配左侧的文本** |
|
||||
| x\|y | 匹配 x 或 y。例如,’z\|food’ 能匹配 “z” 或 “food”。’(z\|f)ood’ 则匹配 “zood” 或 “food”。 |
|
||||
| [xyz] | 字符集合。匹配所包含的任意一个字符。例如, ‘[abc]’ 可以匹配 “plain” 中的 ‘a’。 |
|
||||
| [^xyz] | 负值字符集合。匹配未包含的任意字符。例如, ‘[^abc]’ 可以匹配 “plain” 中的’p’。 |
|
||||
| [a-z] | 字符范围。匹配指定范围内的任意字符。例如,’[a-z]’ 可以匹配 ‘a’ 到 ‘z’ 范围内的任意小写字母字符。 |
|
||||
| [^a-z] | 负值字符范围。匹配任何不在指定范围内的任意字符。 例如,’[^a-z]’ 可以匹配任何不在 ‘a’ 到 ‘z’ 范围内的任意字符。 |
|
||||
| \b | 匹配一个单词边界,也就是指单词和空格间的位置。 例如, ‘er\b’ 可以匹配”never” 中的 ‘er’,但不能匹配 “verb” 中的 ‘er’。 |
|
||||
| \B | 匹配非单词边界。’er\B’ 能匹配 “verb” 中的 ‘er’,但不能匹配 “never” 中的 ‘er’。 |
|
||||
| \cx | 匹配由 x 指明的控制字符。 例如, \cM 匹配一个 Control-M 或回车符。 x 的值必须为 A-Z 或 a-z 之一。否则将 c 视为一个原义的’c’字符。 |
|
||||
| \d | 匹配一个数字字符。等价于 [0-9]。 |
|
||||
| \D | 匹配一个非数字字符。等价于 [^0-9]。 |
|
||||
237
docs/prop-schema.md
Normal file
237
docs/prop-schema.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# propsSchema 配置
|
||||
|
||||
### 概述
|
||||
|
||||
- `propsSchema` 是 Form Render 的必填 props,用于描述表单的基本信息、结构和校验。
|
||||
- `propsSchema`遵从和使用了`JSON Schema`的约定规范。已经接入`JSON Schema`标准的团队可以几乎无缝接入`form-render`。`JSON Schema`是一个约定了可用的结构和字段的特殊 json,作为国际标准,主要应用于校验 JSON 数据
|
||||
- 在少数写法抉择上`propsSchema`并未完全遵守`JSON Schema`,目前主要两个区别:
|
||||
|
||||
- 引入了新类型`range`
|
||||
- 使用字段 `enumNames`,用于描述下拉单选的选项文案(enumNames 曾经是 JSON Schema 的 draft 提案,但最后被否绝了)
|
||||
- 这是权衡各类用户使用便利性的结果。毕竟`JSON Schema`是为了校验数据而生的,与表单的场景的侧重点是不尽相同的。当然`propsSchema`规范坚守的原则是对于使用`JSON Schema`标准的用户做到 schema 不改一字快速接入
|
||||
|
||||
- 通过 `JSON Schema` 里的字段可以描述表单的标题、描述、类型、必须项、自定义正则校验等信息。想深入了解的同学,<a href="https://json-schema.org/understanding-json-schema/" target="_blank">Understanding JSON Schema</a>是笔者认为最好的学习文档,同时也可去 <a href="xxxx" target="_blank">Form Render Demo</a> 折腾
|
||||
- 虽然这里我们只以 json 格式为例,但 javascript object 作为入参完全可以
|
||||
|
||||
一个基础的 propsSchema 如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"jobNumber": {
|
||||
"title": "数字",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
描述了一个 object 结构,其第一个属性为数字类型。最外层约定为 object 结构,所有 propsSchema 都需要如是写。
|
||||
|
||||
### 通用参数
|
||||
|
||||
对于每一个表单控件,我们都会使用如下的 schema 描述
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "数字",
|
||||
"type": "number"
|
||||
}
|
||||
```
|
||||
|
||||
- `title`:表单的标题信息,作为 label 展示,注意 title 为""时占位,title 不写时不占位
|
||||
- `description`:表单的描述信息,常将填写注意点放入此参数
|
||||
- `type`:表单的类型,支持 string、number、boolean、array、object、range
|
||||
- `format`:用来描述输入框的格式,支持 image、dateTime、date、time
|
||||
- `pattern`:自定义正则校验,用于校验 string 或 number 数据是否合格,详细使用可见 <a href="http://groups.alidemo.cn/hive/form-render/#/docs/pattern?type=html" target="_blank">pattern 自定义正则校验</a>
|
||||
- `message` 校验提示自定义文案,与 pattern 共同使用
|
||||
|
||||
#### String
|
||||
|
||||
string 类对应的控件非常多:
|
||||
|
||||
- 输入框:input、textarea
|
||||
|
||||
- `minLength`:字符串最小长度
|
||||
- `maxLength`:字符串最大长度
|
||||
|
||||
- 单选(类型也可能是 number)
|
||||
|
||||
- `enum` 选项值
|
||||
- `enumNames` 选项的文案
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "单选",
|
||||
"type": "string",
|
||||
"enum": ["hz", "wh", "gy"],
|
||||
"enumNames": ["杭州", "武汉", "贵阳"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Number
|
||||
|
||||
- `minimum`:数字最小值
|
||||
- `maximum`:数字最大值
|
||||
|
||||
#### Object
|
||||
|
||||
- `properties`:描述 object 的结构,必要属性
|
||||
- `required`:描述对象下哪些项必填,非必要属性。使用方法传入需要限制的 name 即可
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "用户信息",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tickets": {
|
||||
"title": "门票数",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["tickets"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Array
|
||||
|
||||
`Array`的数据结构可能是:列表 & 多选框
|
||||
|
||||
- `items`:用于描述 Array 中每个 item 的结构、类型
|
||||
|
||||
- 列表:
|
||||
- `minItems`:最少数组项为几项
|
||||
- `maxItems`:最多数组项为几项
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "对象数组",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tickets": {
|
||||
"title": "门票数",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 多选框
|
||||
|
||||
- `enum`:参考单选
|
||||
- `enumNames`:参考单选
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "多选",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": ["hz", "wh", "gy"],
|
||||
"enumNames": ["杭州", "武汉", "贵阳"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Range
|
||||
|
||||
长度为 2 的 array,目前支持的组件为时间范围
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "日期范围",
|
||||
"type": "range",
|
||||
"format": "dateTime",
|
||||
"ui:options": {
|
||||
"placeholder": ["开始", "结束"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 一个很全的结构
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stringDemo": {
|
||||
"title": "字符串",
|
||||
"description": "英文或数字组合",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$",
|
||||
"message": {
|
||||
"pattern": "请输入正确格式"
|
||||
}
|
||||
},
|
||||
"imgDemo": {
|
||||
"title": "图片",
|
||||
"type": "string",
|
||||
"format": "image",
|
||||
"default": "'https://img.alicdn.com/tfs/TB1P8p2uQyWBuNjy0FpXXassXXa-750-1334.png'"
|
||||
},
|
||||
"disabledDemo": {
|
||||
"title": "不可用",
|
||||
"type": "string",
|
||||
"default": "我是一个被 disabled 的值"
|
||||
},
|
||||
"enumDemo": {
|
||||
"title": "枚举",
|
||||
"enum": ["A", "B"],
|
||||
"enumNames": ["养成", "动作"]
|
||||
},
|
||||
"dateDemo": {
|
||||
"title": "时间",
|
||||
"format": "dateTime",
|
||||
"type": "string"
|
||||
},
|
||||
"objDemo": {
|
||||
"title": "单个对象",
|
||||
"description": "这是一个对象类型",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"isLike": {
|
||||
"title": "单选项",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"background": {
|
||||
"title": "颜色选择",
|
||||
"description": "特殊面板",
|
||||
"format": "color",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"arrDemo": {
|
||||
"title": "对象数组",
|
||||
"description": "对象数组嵌套功能",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "字符名称",
|
||||
"description": "string类型",
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9]+$"
|
||||
},
|
||||
"num": {
|
||||
"title": "数字参数",
|
||||
"description": "number类型",
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["stringDemo", "dateDemo"]
|
||||
}
|
||||
```
|
||||
18
docs/question.md
Normal file
18
docs/question.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 常见问题
|
||||
|
||||
### 1. 我引入了组件,但发现无论如何都显示不出来?
|
||||
|
||||
- 首先去 <a href="xxxx" target="_blank">Demo 探索</a> 中去将对于的参数填入进去,看是否可以正常出现
|
||||
- 假如可以,检查一下 onChange 回调里面是否对 formData 进行了 setState,没有的话,可能由于初始化问题没有显示,加上即可
|
||||
|
||||
### 2. 我的 FormRender 可以正常渲染,但是貌似 antd 的样式都没有生效?
|
||||
|
||||
- 在原有的 jsx 文件中,检测是否有默认 antd 的样式,或者是否引入了两个 antd 版本
|
||||
- 若无样式,则加入 `import 'antd/dist/antd.css';` 样式即可
|
||||
- 若两个版本,直接 `tnpm update` 即可
|
||||
|
||||
### 3.tooltip 如果在有其他层级元素内部,就会被遮挡,我试了调 z-index 也没用?
|
||||
|
||||
- 应该是父容器设置了 overflow 为 hidden,修改成 auto 应该就好了
|
||||
|
||||
如果有新问题,欢迎提交 PR 来补充,遇到不能解决的问题,可以添加钉钉群,寻找远程协助。
|
||||
141
docs/ui-schema.md
Normal file
141
docs/ui-schema.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# uiSchema 配置
|
||||
|
||||
### 概述
|
||||
|
||||
- uiSchema 虽然不是必备参数,但是通过它可以增强 Form Render 展示的丰富性
|
||||
- 通过 uiSchema 可以覆盖 propSchema 中 type 对应的默认 widget 组件
|
||||
- 通过 `ui:disabled`、`ui:readonly`、`ui:hidden` 属性来控制表单项的 UI 展示
|
||||
- 通过 `ui:options` 属性能够实现大量特定的 ui 展示选项
|
||||
|
||||
### 书写规范
|
||||
|
||||
- uiSchema 里所有的字段都以 `ui:` 开始,如 `ui:widget`。
|
||||
- 为了满足各用户的使用偏好,uiSchema 可以单独书写,也可以完全归并到 propsSchema,例如:
|
||||
|
||||
```json
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string": {
|
||||
"title": "字符串",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiSchema": {
|
||||
"string": {
|
||||
"ui:width": "50%"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以合并为
|
||||
|
||||
```json
|
||||
{
|
||||
"propsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string": {
|
||||
"title": "字符串",
|
||||
"type": "string",
|
||||
"ui:width": "50%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
事实上 form-render 内部处理 schema 时第一件事就是将 propsSchema 和 uiSchema 合并,所以上述两种 schema 的渲染效果是一致的。前者适用于遵循 json schema 的团队无缝接入,后者在书写上更为高效。
|
||||
|
||||
### 覆盖 propSchema 中表单项对应的默认 UI Widget
|
||||
|
||||
| 类型 | type | 默认 widget | 其他支持 widget(备注) |
|
||||
| ------ | :------------------------: | :---------: | :-------------------: |
|
||||
| 布尔值 | boolean | checkbox | switch、radio |
|
||||
| 字符串 | string | input | color、date、textarea |
|
||||
| 数字 | number | number | slider |
|
||||
| 单选项 | string/number(带属性 enum) | select | radio |
|
||||
| 多选 | array(带属性 enum) | checkboxes | multiSelect |
|
||||
|
||||
### 控制表单项的 UI 展示(共通配置)
|
||||
|
||||
- `ui:disabled`: 可控制 input、number、date、checkbox、radio、select、switch 对于组件的 disabled 属性(变灰不可点击)
|
||||
- `ui:readonly`:可控制 input、number 组件中的 readonly 属性(不可编辑,但不变灰)
|
||||
- `ui:hidden`:可控制所有基础组件是否显示,可使用 true/false 或表达式,例如:
|
||||
- `ui:className`:添加组件 root 元素的 className(和 fr-field 这个 className 在同级),用于自定义单独组件的样式
|
||||
|
||||
```json
|
||||
"age": {
|
||||
"ui:hidden": false
|
||||
}
|
||||
// 或
|
||||
"age": {
|
||||
"ui:hidden": "agree==false && personType!=2"
|
||||
}
|
||||
```
|
||||
|
||||
如果使用如上表达式, age 元素将在 agree 元素的值为 false 且 personType 元素不等于 2 时隐藏。
|
||||
注:目前`ui:hidden`支持`==`,`!=`,`>`和`<`四种运算符和`&&`(且),`||`(或)两种关系符,使用的判断字段(表达式左侧)必须是组件的同级字段。
|
||||
|
||||
- `ui:width`:单个基础组件的长度,建议使用百分比例如`"ui:width":"50%"`。
|
||||
|
||||
### Options
|
||||
|
||||
- `ui:options` 里存放特定元素的特定配置。例如`textarea`的`rows`
|
||||
|
||||
```json
|
||||
"textareaDemo": {
|
||||
"ui:widget": "textarea",
|
||||
"ui:options": {
|
||||
"rows": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
基本上所有`antd`文档中组件的 props 都可以使用 `ui:options` 的方式来直接使用。除此之外我们还提供了一些特别 options:
|
||||
|
||||
| option | 类型 | 可用组件 | 说明 |
|
||||
| -------- | :-----: | :-----------: | :----------------------------------------: |
|
||||
| foldable | boolean | 列表(array) | `{ foldable: true }`用于长列表的收起和展开 |
|
||||
|
||||
### 如何编写 uiSchema 设置
|
||||
|
||||
```json
|
||||
{
|
||||
"disabledDemo": {
|
||||
"ui:disabled": true
|
||||
},
|
||||
"dateDemo": {
|
||||
"ui:widget": "date"
|
||||
},
|
||||
"objDemo": {
|
||||
// 覆盖object里面的元素对应的组件
|
||||
"background": {
|
||||
"ui:widget": "color"
|
||||
}
|
||||
},
|
||||
"arrDemo": {
|
||||
// 数组列表除了默认的新增按钮外,自定义的按钮组(每个按钮点击时会执行对应的callback,使用前请先咨询@侑夕)
|
||||
"ui:extraButtons": [
|
||||
{
|
||||
"text": "Excel导入",
|
||||
"icon": "copy",
|
||||
"callback": "onCallback1"
|
||||
},
|
||||
{
|
||||
"text": "删除全部",
|
||||
"icon": "delete",
|
||||
"callback": "clearAll"
|
||||
},
|
||||
{
|
||||
"text": "复制上个",
|
||||
"icon": "copy",
|
||||
"callback": "copyLast"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
86
docs/widget.md
Normal file
86
docs/widget.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 自定义组件
|
||||
|
||||
当 form-render 提供的组件无法 100%满足用户需求时,可以考虑自己写一个。自定义组件功能使 form-render 佣有无限的扩展性。可能的应用场景如下:
|
||||
|
||||
1. 我需要写一个异步加载的搜索输入框(普适性不高/难以用 schema 描述的组件)
|
||||
2. 我们团队使用 xxx ui,与 antd/fusion 不搭,希望能适配一套 xxx ui 组件的 form-render(欢迎 Pull Request)
|
||||
3. 我需要在表单内部写一个 excel 上传按钮(完全定制化的需求)
|
||||
|
||||
注:如果是新增一个常用组件,建议给 Form Render 维护的同学来提 Pull Request,这样可以更好扩展其生态
|
||||
|
||||
## 如何使用
|
||||
|
||||
### 写自定义组件
|
||||
|
||||
1. 可以 clone 下对应 git 代码,然后参考 `widget/antd` 或者 `widget/fusion`里面对应的组件写法。
|
||||
|
||||
- 组件接收到的 props:
|
||||
|
||||
- **default**: 对应 `default` 字段
|
||||
- **description**:对应 `description`
|
||||
- **displayType**:值为 row 和 column,用于处理在横竖模式下组件显示不同的情况
|
||||
- **disabled**:对应 `ui:disable`
|
||||
- **hidden**:对应 `ui:hidden`
|
||||
- **name**:对应 `title` 字段
|
||||
- **onChange**:函数,接收 name/value 两个入参,调用可修改 formData 里对应名字字段的值(见下例)
|
||||
- **options**:对应 `ui:options` 字段,所有的其他字段没有 cover 到的参数都建议放在 options 里
|
||||
- **readonly**:对应 `ui:readonly` 字段
|
||||
- **required**:组件是否是必填的
|
||||
- **rootValue**:组件对应键值对所在的 data 树从父级开始的所有值
|
||||
- **schema**:组件对应的子 schema
|
||||
- **value**:组件的值
|
||||
|
||||
简单的说,通过`title`、`description`、`default`、`ui:hidden`、`ui:disable` 和 `ui:readonly`等 schema 字段定义的值在`this.props`中可直接获得,剩下的字段也可以在`schema`(注意这个是组件对应的子 schema)中获得,比如`schema.type`。更多细节的入参,建议不要新添特殊字段,而统一放在`ui:options`这个对象中传递。
|
||||
|
||||
例如一个简单的 checkbox 自定义组件
|
||||
|
||||
```js
|
||||
// MyCheckbox.js
|
||||
import React from 'react';
|
||||
import Checkbox from '../Checkbox';
|
||||
|
||||
export default function Checkbox({ name, value, onChange, options }) {
|
||||
return (
|
||||
<Checkbox
|
||||
{...options}
|
||||
onChange={e => onChange(name, e.target.checked)}
|
||||
checked={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 使用自定义组件
|
||||
|
||||
自定义组件的使用方式非常简单。只需在顶层通过`widgets` props 注入即可:
|
||||
|
||||
```js
|
||||
import MyCheckbox from './path/to/MyCheckbox'
|
||||
...
|
||||
<FormRender
|
||||
propsSchema={propsSchema}
|
||||
uiSchema={uiSchema}
|
||||
formData={formData}
|
||||
onChange={this.onChange}
|
||||
onValidate={this.onValidate}
|
||||
// 注入
|
||||
widgets={{ myBox: MyCheckbox, mySecondUI: SomethingElse }}
|
||||
/>
|
||||
```
|
||||
|
||||
然后即可在 schema 中通过`ui:widget`参数使用:
|
||||
|
||||
```json
|
||||
// propsSchema
|
||||
"myWidget": {
|
||||
"title": "自定义checkbox",
|
||||
"type": "boolean"
|
||||
},
|
||||
// uiSchema
|
||||
"myWidget": {
|
||||
"ui:widget": "myBox",
|
||||
"ui:options": {
|
||||
"defaultChecked": true
|
||||
}
|
||||
},
|
||||
```
|
||||
235
index.css
Normal file
235
index.css
Normal file
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
用于原有样式的覆盖
|
||||
*/
|
||||
|
||||
.fr-set {
|
||||
padding: 18px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fr-field {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.fr-label {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
min-height: 22px; /* ""的标签页占位 */
|
||||
}
|
||||
|
||||
.fr-label-required {
|
||||
margin-right: 4px;
|
||||
color: #f5222d;
|
||||
font-size: 14px;
|
||||
font-family: SimSun, sans-serif;
|
||||
}
|
||||
|
||||
.fr-desc {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.fr-validate {
|
||||
margin: 3px 0 0 12px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
/* Row */
|
||||
|
||||
.fr-label-row {
|
||||
width: 104px;
|
||||
}
|
||||
|
||||
.fr-field-row .fr-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fr-field-row .fr-tooltip-icon {
|
||||
margin: 3px 2px 0 0;
|
||||
}
|
||||
|
||||
/* 自定义类 */
|
||||
.hover-b--black-20:hover {
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.pt44 {
|
||||
padding-top: 46px;
|
||||
}
|
||||
|
||||
.pv12 {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.mover:hover {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
/* 组件内部样式*/
|
||||
|
||||
.fr-color-picker {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.fr-color-picker .rc-color-picker-trigger {
|
||||
margin-right: 12px;
|
||||
height: 30px;
|
||||
width: 60px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.fr-color-picker > p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.fr-color-picker .rc-color-picker-wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.next-input,
|
||||
.next-number-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
.fr-preview-image {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.fr-preview {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fr-upload-mod,
|
||||
.fr-upload-file {
|
||||
display: flex;
|
||||
}
|
||||
.fr-upload-mod {
|
||||
align-items: center;
|
||||
}
|
||||
.fr-upload-mod .fr-upload-preview {
|
||||
margin: 0 12px;
|
||||
}
|
||||
.fr-upload-file .ant-upload-list-item {
|
||||
margin: 5px 0 0 8px;
|
||||
}
|
||||
.fr-upload-file .ant-upload-list-item-name {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.fr-upload-file .ant-upload-list-item-info {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fr-upload-file .next-upload-list-text .next-upload-list-item-done,
|
||||
.fr-upload-file .next-upload-list-text .next-upload-list-item .next-icon {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.fr-upload-file .next-upload-list-item-name-wrap {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.sort-help-class {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 其他样式 */
|
||||
|
||||
.fold-icon.fold-icon-active {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.fold-icon {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.24s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fr-tooltip-toggle {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fr-tooltip-toggle:hover .fr-tooltip-container {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.fr-tooltip-icon {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAEnUlEQVR42u1bS0iUURT+zd4Y9KDosSihNhYUaBE9YKpFDKKOOmOLahtJ2SoxA2fUdUoQJK5qU0ib6LlxUYJpkY0LE3tQZkU7y7A0e1jnA4Of8pz//5373/+BF44Oc+/c853v3nOf5xpup0QiMZ9kD8mxeDzeTHKXPveSvKXP3+j/GP1/h++QhzIoW1ZWthu/NYKYysvLN5EhKZJ7ZMQEye8ZyjjJfZIk6vS10YWFhcvI4FoC2wfwLkkf6aiBLt8YHovFNhCoFnRlgNQkY9BJbrLeS9uzqFueYAzXJV9ITgKLVsvBPCnuAAifSIe23kBd7zApHHUI8D3JBfptFUlialbYSJIDweep7+Iog7L4jUMdn4HNzeksmxRcdgColyRZWlq6NQOd21AH6rKrFxiBVanxxcXFS6jSdpsAHtLYUODC1FqAum0S0Q7Mqlp+ESlO2zD8DUmJBhcsgS4bJDwBdhUKb1ko+kll6qLR6AJDU4IuuAZ0W2C7kWnrJy1a/QONvjsNjxLp32VjsEzO1OcOkoJJoeJuGuBWGB4nYCCcD4RGmiSJOm35PIup7kokEplr+CQBCzAJeEeJqC22V3jE2IBQWVd+fv48w2cJmAhbp4C7H7bZaf1KoZJBbET8vCEDRgF/pdXGZim1/ifGl344WtTIJJ//B+ggts+K6t4BrAwBH2GjNKo2CuxVqzJeGLDqFemoFnQ0sq0vDHyP4D+KwI1ILqZqpyqsGmFjznStX8tNIzQlbjYUJasVnCo9wAzsjE1npyPgFQPqJvKDRYC8ioWt/200BFD7FRMwIu0nFBNwgNMFm80Fz0mAFIOqVz4IyoS/ZvQ1mQv1MYBqkO8GCSDXTDRjvAoCznAHrH/X0qu4FqE99Voj2AkErOPsg+3w/wqmwDMjHAkkPGdsPITMBqb7t4SFANjCjTkgoI0ZJY+Hwnp5f9MGdtIMOxF35n1ZXDrG38fYmDa48zWcs4eFANxesUtv+jPMZC4PCwE4NWL0DRvcehkHjyEhACdGCxl9vwBwggEyxw0wXhCAOANG34TsAi4k37kA/RliMnND1ANyGX1DGAMe89Og+uSjaRDSAwKucgeIYSEAcQzc8T4ISDGZrSFygVaml6dAQILJfBEiAl4yy/0KZK6WtsMBJwD+v0bcDk/tlp4yveB0cAmQj8hhs7lQE1NoIAQEPGdsazZ3k+3SoahOAvQfisoDBeR2QAmArjvcsbi2i5FMQ+Ay0Jvn6GIE10VWV2MBIiALmKWrMfFylJsRgkIAsAp1NojX47hCZn74HdfjficAAzp3PY6rf9g4GyBhw3/6Axoi0yW48IDtcQwBRVIUOHaQfguSAiYpvB42Od1Cxiz8s5tkpdfGAwOwSFhhy0xXUnV+DpSEbmCwwFiXKcPXbYTKpnSHykIndFsYf01JsDRJTxCDpUGU7nB5SKcfwuWLiooWK38wQXLJwcOFtIoHE/Bh5v6SE2DMdvNN4BE3nszQ93szfTJDclTbU7lAP5pSGIxY5eWzOdL/lXrkKW6Fp7M3XPTi4SR0zz6dnX087fOEq2k8hTc/nydJm57Pj3v5fP4PSqRR6oYkTaUAAAAASUVORK5CYII=');
|
||||
background-size: cover;
|
||||
display: block;
|
||||
margin: 2px 0 0 6px;
|
||||
}
|
||||
|
||||
.fr-tooltip-container {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
left: 50%;
|
||||
white-space: initial !important;
|
||||
top: -34px;
|
||||
text-align: center;
|
||||
background: #2b222a;
|
||||
padding: 4px;
|
||||
margin-left: -77px;
|
||||
border-radius: 4px;
|
||||
color: #efefef;
|
||||
font-size: 13px;
|
||||
cursor: auto;
|
||||
z-index: 99999;
|
||||
transition: all 0.5s ease;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.fr-tooltip-triangle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid #2b222a;
|
||||
transition: all 0.5s ease;
|
||||
content: ' ';
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
margin-left: -5px;
|
||||
width: 0;
|
||||
bottom: -5px;
|
||||
}
|
||||
|
||||
.fr-tooltip-toggle::before,
|
||||
.fr-tooltip-toggle::after {
|
||||
color: #efefef;
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fr-tooltip-toggle:focus::before,
|
||||
.fr-tooltip-toggle:focus::after,
|
||||
.fr-tooltip-toggle:hover::before,
|
||||
.fr-tooltip-toggle:hover::after {
|
||||
opacity: 1;
|
||||
transition: all 0.75s ease;
|
||||
}
|
||||
71
index.html
Normal file
71
index.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Form Render</title>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="通过标准 JSON Schema 生成可视化 From 表单,常用于搭建表单配置,支持 Antd 和 Fusion 体系、一排N、无限嵌套、自定义正则校验、自定义样式组件等能力"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
|
||||
/>
|
||||
<link rel="icon" href="https://img.alicdn.com/tfs/TB17UtINiLaK1RjSZFxXXamPFXa-606-643.png" type="image/png" />
|
||||
<link rel="stylesheet" href="//h5.m.taobao.com/trip/fp-form-render/vue.css" />
|
||||
<style>
|
||||
.community-img {
|
||||
height: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 0 0, 0 6px 12px rgba(0, 0, 0, 0.5);
|
||||
margin: 0 auto;
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
right: 16px;
|
||||
z-index: 101;
|
||||
transition: 0.4s all ease-in-out;
|
||||
transform: scale(0) perspective(600px) rotateX(45deg);
|
||||
}
|
||||
.community-img.active {
|
||||
right: 10px;
|
||||
height: 340px;
|
||||
top: 76px;
|
||||
transform: none;
|
||||
}
|
||||
.show_community {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.$docsify = {
|
||||
name: 'Form Render',
|
||||
repo: 'http://github.com/alibaba/form-render',
|
||||
themeColor: '#F6C14F',
|
||||
icon: 'https://img.alicdn.com/tfs/TB1el3MJMDqK1RjSZSyXXaxEVXa-400-400.png',
|
||||
search: 'auto',
|
||||
auto2top: true,
|
||||
coverpage: 'docs/_coverpage.md',
|
||||
loadSidebar: 'docs/_sidebar.md',
|
||||
loadNavbar: 'docs/_navbar.md',
|
||||
nameLink: {
|
||||
'/': '#/'
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
if (typeof navigator.serviceWorker !== 'undefined') {
|
||||
navigator.serviceWorker.register('./docs/dist/sw.js');
|
||||
}
|
||||
if (window.location.host === 'groups.alidemo.cn') {
|
||||
window.location.replace('https://alibaba.github.io/form-render');
|
||||
}
|
||||
</script>
|
||||
<script src="//h5.m.taobao.com/trip/fp-form-render/docsify.min.js
|
||||
"></script>
|
||||
</body>
|
||||
</html>
|
||||
15058
package-lock.json
generated
Normal file
15058
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
package.json
Executable file
99
package.json
Executable file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "form-render",
|
||||
"version": "0.1.0",
|
||||
"description": "通过标准 JSON Schema 生成可视化 From 表单,常用于搭建表单配置,支持 Antd 和 Fusion 体系、一排N、横纵排、无限嵌套、自定义正则校验、自定义样式组件等能力",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:alibaba/form-render.git"
|
||||
},
|
||||
"keywords": [
|
||||
"FormRender",
|
||||
"Render",
|
||||
"React",
|
||||
"Json Schema",
|
||||
"Ant Design",
|
||||
"Fusion"
|
||||
],
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Tw93",
|
||||
"email": "tw93@qq.com"
|
||||
},
|
||||
{
|
||||
"name": "mankaiviky",
|
||||
"email": "mankaiviky@163.com"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"start": "npm run watch & webpack-dev-server --open",
|
||||
"watch": "webpack --watch",
|
||||
"prepare": "npm run build & npm run lint",
|
||||
"build": "babel src -d lib && webpack --config webpack.config.prod.js",
|
||||
"dist": "webpack --mode=production",
|
||||
"lint": "eslint 'src/**/*.@(js|jsx)'",
|
||||
"test": "mocha --compilers js:babel-core/register --recursive test/",
|
||||
"prettier": "prettier --write \"{demo,src}/**/*.{js,jsx,css,md}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@alifd/next": "^1.13.4",
|
||||
"antd": "^3.22.2",
|
||||
"json-parse-better-errors": "^1.0.2",
|
||||
"moment": "^2.22.2",
|
||||
"monaco-editor": "^0.17.1",
|
||||
"pope": "^2.0.2",
|
||||
"prop-types": "^15.x",
|
||||
"rc-color-picker": "^1.2.6",
|
||||
"react": "^16.x",
|
||||
"react-dom": "^16.x",
|
||||
"react-sortable-hoc": "^1.6.1",
|
||||
"string-template": "^1.0.0",
|
||||
"validator": "^10.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.0 || ^16.0.0",
|
||||
"react-dom": "^15.0.0 || ^16.0.0",
|
||||
"babel-polyfill": "^6.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-cli": "^6.26.0",
|
||||
"babel-core": "^6.26.3",
|
||||
"babel-eslint": "^8.2.3",
|
||||
"babel-loader": "^7.1.4",
|
||||
"babel-plugin-import": "^1.11.0",
|
||||
"babel-plugin-transform-decorators-legacy": "^1.3.5",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"babel-preset-react": "^6.24.1",
|
||||
"babel-preset-stage-0": "^6.24.1",
|
||||
"css-loader": "^0.28.11",
|
||||
"deep-equal": "^1.1.0",
|
||||
"eslint": "^4.19.1",
|
||||
"eslint-config-airbnb": "^16.1.0",
|
||||
"eslint-config-prettier": "^6.0.0",
|
||||
"eslint-loader": "^2.0.0",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-react": "^7.9.1",
|
||||
"expect.js": "^0.3.1",
|
||||
"fetch-jsonp": "^1.1.3",
|
||||
"html-loader": "^0.5.5",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"mocha": "^5.2.0",
|
||||
"monaco-editor-webpack-plugin": "^1.7.0",
|
||||
"monaco-themes": "^0.3.3",
|
||||
"node-sass": "^4.9.0",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-prefix-selector": "^1.7.1",
|
||||
"prettier": "^1.18.2",
|
||||
"prismjs": "^1.17.1",
|
||||
"raw-loader": "^3.1.0",
|
||||
"react-github-corner": "^2.3.0",
|
||||
"react-simple-code-editor": "^0.9.14",
|
||||
"sass-loader": "^7.0.3",
|
||||
"style-loader": "^0.21.0",
|
||||
"webpack": "^4.12.0",
|
||||
"webpack-cli": "^3.0.8",
|
||||
"webpack-dev-server": "^3.1.4"
|
||||
}
|
||||
}
|
||||
45
src/antd.js
Normal file
45
src/antd.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Created by Tw93 on 2018-08-28.
|
||||
* antd 主题入口文件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormRender from './index';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/es/locale-provider/zh_CN';
|
||||
import { mapping, widgets } from './widgets/antd';
|
||||
|
||||
export default class AntdForm extends React.PureComponent {
|
||||
static propTypes = {
|
||||
mapping: PropTypes.object,
|
||||
widgets: PropTypes.object,
|
||||
};
|
||||
static defaultProps = {
|
||||
mapping: {},
|
||||
widgets: {},
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
mapping: customizedMapping,
|
||||
widgets: customizedWidgets,
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<FormRender
|
||||
{...props}
|
||||
mapping={{
|
||||
...mapping,
|
||||
...customizedMapping,
|
||||
}}
|
||||
widgets={{
|
||||
...widgets,
|
||||
...customizedWidgets,
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
191
src/base/as-field.jsx
Normal file
191
src/base/as-field.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getValidateText } from './validate';
|
||||
import { isHidden, isDependShow } from './isHidden';
|
||||
|
||||
// asField拆分成逻辑组件和展示组件,从而可替换展示组件的方式完全插拔fr的样式
|
||||
export const asField = ({ FieldUI, Widget }) => {
|
||||
const FieldContainer = ({
|
||||
className,
|
||||
column,
|
||||
showValidate,
|
||||
isRoot,
|
||||
hidden,
|
||||
props,
|
||||
showDescIcon,
|
||||
width,
|
||||
...others
|
||||
}) => {
|
||||
const { schema, displayType, rootValue } = others;
|
||||
// 对于需要隐藏的场景
|
||||
// "ui:hidden": true 的情况,隐藏
|
||||
if (isHidden({ hidden, rootValue })) {
|
||||
return null;
|
||||
}
|
||||
// 不建议使用ui:dependShow, 一般一律使用ui:hidden。ui:dependShow可以做复杂、跨结构的校验
|
||||
if (isDependShow(others)) {
|
||||
return null;
|
||||
}
|
||||
const isComplex =
|
||||
schema.type === 'object' ||
|
||||
(schema.type === 'array' && schema.enum === undefined);
|
||||
|
||||
const validateText = getValidateText(others);
|
||||
|
||||
// 必填*,label,描述,竖排时的校验语,只要存在一个,label就不为空
|
||||
const showLabel =
|
||||
schema.title ||
|
||||
others.description ||
|
||||
others.required ||
|
||||
(displayType !== 'row' && showValidate && validateText);
|
||||
|
||||
let columnStyle = {};
|
||||
if (!isComplex && width) {
|
||||
columnStyle = {
|
||||
width,
|
||||
paddingRight: '24px',
|
||||
};
|
||||
} else if (!isComplex && column > 1) {
|
||||
columnStyle = {
|
||||
width: `calc(100% /${column})`,
|
||||
paddingRight: '24px',
|
||||
};
|
||||
}
|
||||
|
||||
const fieldProps = {
|
||||
className,
|
||||
columnStyle,
|
||||
displayType,
|
||||
isComplex,
|
||||
isRequired: others.required,
|
||||
isRoot,
|
||||
schema,
|
||||
showDescIcon,
|
||||
showLabel,
|
||||
showValidate,
|
||||
validateText,
|
||||
};
|
||||
// console.log(schema, others);
|
||||
return (
|
||||
<FieldUI {...fieldProps}>
|
||||
<Widget {...others} />
|
||||
</FieldUI>
|
||||
);
|
||||
};
|
||||
FieldContainer.propTypes = {
|
||||
showValidate: PropTypes.bool,
|
||||
column: PropTypes.number,
|
||||
isRoot: PropTypes.bool,
|
||||
props: PropTypes.object,
|
||||
showDescIcon: PropTypes.bool,
|
||||
displayType: PropTypes.string,
|
||||
};
|
||||
|
||||
FieldContainer.defaultProps = {
|
||||
showValidate: true,
|
||||
column: 1,
|
||||
isRoot: false,
|
||||
props: {},
|
||||
showDescIcon: false,
|
||||
displayType: 'column',
|
||||
};
|
||||
|
||||
return FieldContainer;
|
||||
};
|
||||
|
||||
export const DefaultFieldUI = ({
|
||||
children,
|
||||
className,
|
||||
columnStyle, // 处理组件宽度,外部一般不需修改
|
||||
displayType, // 展示方式:row 横 column 竖
|
||||
isComplex, // 是否是复杂结构:对象和对象数组
|
||||
isRequired, // 是否是必填项
|
||||
isRoot,
|
||||
schema,
|
||||
showDescIcon,
|
||||
showLabel, // 是否展示label
|
||||
showValidate, // 是否展示校验
|
||||
validateText, // 校验文字
|
||||
}) => {
|
||||
const { title } = schema;
|
||||
// field 整体 label 标签 content 内容
|
||||
const { type, enum: _enum, description = '' } = schema;
|
||||
let fieldClass = `fr-field w-100 ${isComplex ? 'fr-field-complex' : ''}`;
|
||||
let labelClass = 'fr-label flex mb2';
|
||||
let contentClass = 'fr-content';
|
||||
|
||||
switch (type) {
|
||||
case 'object':
|
||||
if (title) {
|
||||
labelClass += ' fr-label-object bb b--black-20 pb2 mt2 mb3'; // fr-label-object 无默认style,只是占位用于使用者样式覆盖
|
||||
}
|
||||
if (!isRoot) {
|
||||
fieldClass += ' fr-field-object mb0'; // object的margin bottom由内部元素撑起
|
||||
if (title) {
|
||||
contentClass += ' ml3'; // 缩进
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'array':
|
||||
if (title && !_enum) {
|
||||
labelClass += ' mt2 mb3';
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (title) {
|
||||
labelClass += ' ml2';
|
||||
if (displayType === 'row') {
|
||||
labelClass = labelClass.replace('mb2', 'mb0');
|
||||
}
|
||||
}
|
||||
fieldClass += ' flex flex-row-reverse justify-end';
|
||||
break;
|
||||
default:
|
||||
if (displayType === 'row') {
|
||||
labelClass = labelClass.replace('mb2', 'mb0');
|
||||
}
|
||||
}
|
||||
// 横排时
|
||||
if (displayType === 'row' && !isComplex && type !== 'boolean') {
|
||||
fieldClass += ' flex items-center';
|
||||
labelClass += ' flex-shrink-0 fr-label-row mr2';
|
||||
labelClass = labelClass.replace('mb2', 'mb0');
|
||||
contentClass += ' flex-grow-1 relative';
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={className ? `${className} ${fieldClass}` : fieldClass}
|
||||
style={columnStyle}
|
||||
>
|
||||
{showLabel && (
|
||||
<label className={labelClass} title={title}>
|
||||
{isRequired && <span className="fr-label-required"> *</span>}
|
||||
<span className={isComplex ? 'b' : ''}>{title}</span>
|
||||
{description &&
|
||||
(showDescIcon ? (
|
||||
<span className="fr-tooltip-toggle" aria-label={description}>
|
||||
<i className="fr-tooltip-icon" />
|
||||
<div className="fr-tooltip-container">
|
||||
<i className="fr-tooltip-triangle" />
|
||||
{description}
|
||||
</div>
|
||||
</span>
|
||||
) : (
|
||||
<span className="fr-desc ml2">( {description} )</span>
|
||||
))}
|
||||
{displayType !== 'row' && showValidate && validateText && (
|
||||
<span className="fr-validate">{validateText}</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className={contentClass}>
|
||||
<div className={`flex ${isComplex ? 'flex-column' : 'items-center'}`}>
|
||||
{children}
|
||||
</div>
|
||||
{displayType === 'row' && showValidate && validateText && (
|
||||
<span className="fr-validate absolute">{validateText}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
47
src/base/get-field.js
Normal file
47
src/base/get-field.js
Normal file
@@ -0,0 +1,47 @@
|
||||
function getWidgetName(schema, map) {
|
||||
const { type, format, enum: enums, readonly } = schema;
|
||||
const list = [];
|
||||
if (readonly) {
|
||||
list.push(`${type}?readonly`);
|
||||
list.push('*?readonly');
|
||||
}
|
||||
if (enums) {
|
||||
list.push(`${type}?enum`);
|
||||
// array 默认使用list,array?enum 默认使用checkboxes,*?enum 默认使用select
|
||||
list.push('*?enum');
|
||||
}
|
||||
if (format) {
|
||||
list.push(`${type}:${format}`);
|
||||
}
|
||||
list.push(type); // 放在最后兜底,其他都不match时使用type默认的组件
|
||||
let found = '';
|
||||
list.some(item => {
|
||||
found = map[item];
|
||||
return !!found;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
export default function getField(
|
||||
schema = {},
|
||||
{ customized, generated, mapping }
|
||||
) {
|
||||
const { 'ui:widget': widget, 'ui:field': field } = schema;
|
||||
// Field能否被重定义
|
||||
let fieldCanRedefine = false;
|
||||
let Field;
|
||||
if (field && !Field) {
|
||||
Field = typeof field === 'string' ? customized[field] : field;
|
||||
}
|
||||
if (!Field && widget) {
|
||||
Field = typeof widget === 'string' ? generated[widget] : widget;
|
||||
}
|
||||
if (!Field && !widget) {
|
||||
Field = generated[getWidgetName(schema, mapping)];
|
||||
fieldCanRedefine = !!Field;
|
||||
}
|
||||
return {
|
||||
fieldCanRedefine,
|
||||
Field: Field || null,
|
||||
};
|
||||
}
|
||||
72
src/base/isHidden.js
Normal file
72
src/base/isHidden.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Created by Tw93 on 2019-04-28.
|
||||
* 表达式判断
|
||||
*/
|
||||
|
||||
import { pope } from 'pope';
|
||||
|
||||
export function isDependShow({ formData, dependShow } = {}) {
|
||||
if (formData && dependShow) {
|
||||
try {
|
||||
return !parseString(pope(dependShow, formData)); //eslint-disable-line
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isHidden({ hidden, rootValue } = {}) {
|
||||
// hidden 为表达式:
|
||||
if (typeof hidden === 'string') {
|
||||
// 支持 && 和 ||
|
||||
const hasAnd = string => string.indexOf('&&') > -1;
|
||||
const hasOr = string => string.indexOf('||') > -1;
|
||||
let hiddenList = [];
|
||||
if (!hasOr(hidden)) {
|
||||
if (!hasAnd(hidden)) {
|
||||
return calcHidden(hidden, rootValue);
|
||||
} else {
|
||||
hiddenList = hidden.split('&&').map(item => item.trim());
|
||||
return hiddenList.every(item => calcHidden(item, rootValue));
|
||||
}
|
||||
} else {
|
||||
hiddenList = hidden.split('||').map(item => item.trim());
|
||||
if (!hasAnd(hidden)) {
|
||||
return hiddenList.some(item => calcHidden(item, rootValue));
|
||||
} else {
|
||||
return hiddenList.some(item => {
|
||||
if (hasAnd(item)) {
|
||||
const list = item.split('&&').map(item => item.trim());
|
||||
return list.every(x => calcHidden(x, rootValue));
|
||||
} else {
|
||||
return calcHidden(item, rootValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
// 代替eval的函数
|
||||
const parseString = string =>
|
||||
Function('"use strict";return (' + string + ')')();
|
||||
|
||||
// 计算单个表达式的hidden值
|
||||
const calcHidden = (hiddenString, rootValue) => {
|
||||
if (!rootValue || typeof rootValue !== 'object') {
|
||||
return false;
|
||||
}
|
||||
// 支持四种基本运算符
|
||||
const operators = ['==', '!=', '>', '<'];
|
||||
try {
|
||||
const op = operators.find(op => hiddenString.indexOf(op) > -1);
|
||||
const [key, value] = hiddenString.split(op).map(item => item.trim());
|
||||
const left = rootValue[key];
|
||||
const right = parseString(value);
|
||||
return parseString(`"${String(left)}"${op}"${String(right)}"`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
161
src/base/parser.js
Normal file
161
src/base/parser.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import getField from './get-field';
|
||||
import resolve from './resolve';
|
||||
import subFieldGenerator from './sub-field-generator';
|
||||
|
||||
// 对于数组或对象类型,获取其子集schema
|
||||
function getSubSchemas(schema = {}) {
|
||||
const {
|
||||
// object subset
|
||||
properties,
|
||||
// array subset
|
||||
items,
|
||||
column,
|
||||
// as subset's parent
|
||||
...$parent
|
||||
} = schema;
|
||||
const { type } = $parent;
|
||||
// no subset
|
||||
if (!properties && !items) {
|
||||
return [];
|
||||
}
|
||||
let children = {};
|
||||
if (type === 'object') {
|
||||
children = properties;
|
||||
}
|
||||
if (type === 'array') {
|
||||
children = [].concat(items);
|
||||
}
|
||||
return Object.keys(children).map(name => ({
|
||||
schema: children[name],
|
||||
name,
|
||||
column,
|
||||
// parent propsSchema
|
||||
$parent,
|
||||
}));
|
||||
}
|
||||
|
||||
function getBasicProps(settings, materials) {
|
||||
const {
|
||||
schema,
|
||||
name = '',
|
||||
$parent = {},
|
||||
column,
|
||||
displayType,
|
||||
showDescIcon,
|
||||
showValidate,
|
||||
formData,
|
||||
} = settings;
|
||||
// 目前做了处理的`uiSchema`参数
|
||||
const {
|
||||
'ui:className': className,
|
||||
'ui:options': options = {},
|
||||
'ui:hidden': hidden,
|
||||
'ui:disabled': disabled,
|
||||
'ui:width': width,
|
||||
'ui:readonly': readonly,
|
||||
'ui:extraButtons': extraButtons = [],
|
||||
'ui:dependShow': dependShow,
|
||||
'ui:action': action,
|
||||
} = schema;
|
||||
const { required = [] } = $parent;
|
||||
const { generated: widgets, customized: fields } = materials;
|
||||
// 标准化属性模型
|
||||
// 除了value和onChange为动态值这里不处理
|
||||
let basicProps = {
|
||||
name,
|
||||
schema,
|
||||
column,
|
||||
displayType,
|
||||
showDescIcon,
|
||||
showValidate,
|
||||
options, // 所有特定组件规则,addable等规则TODO
|
||||
hidden,
|
||||
required: required.indexOf(name) !== -1,
|
||||
disabled: !!disabled,
|
||||
readonly: !!readonly,
|
||||
width,
|
||||
widgets,
|
||||
fields,
|
||||
};
|
||||
// 假如有表达式来决定显示的场景,才传入dependShow,formData
|
||||
if (dependShow) {
|
||||
basicProps = { ...basicProps, dependShow, formData };
|
||||
}
|
||||
if (className) {
|
||||
basicProps = { ...basicProps, className };
|
||||
}
|
||||
if (action) {
|
||||
basicProps = { ...basicProps, action };
|
||||
}
|
||||
// 子集的属性
|
||||
const subItems = {};
|
||||
const subSchemas = getSubSchemas(schema);
|
||||
subSchemas.forEach(subSchema => {
|
||||
const { name: _name, schema: _schema = {} } = subSchema;
|
||||
subItems[_name] = {
|
||||
field: getField(_schema, materials),
|
||||
props: getBasicProps(
|
||||
{
|
||||
...subSchema,
|
||||
column,
|
||||
showDescIcon,
|
||||
displayType,
|
||||
formData,
|
||||
},
|
||||
materials
|
||||
),
|
||||
};
|
||||
});
|
||||
if (['array', 'object'].indexOf(schema.type) >= 0) {
|
||||
// 传入name和Field(如果重定义Field的话)及其配置信息(如onChange等)
|
||||
basicProps.getSubField = o => {
|
||||
// getSchemaData(schema)
|
||||
const { field, props, column: c } = subItems[o.name] || subItems[0] || {};
|
||||
return subFieldGenerator({
|
||||
...field,
|
||||
column: c,
|
||||
props: {
|
||||
...props,
|
||||
name: o.name,
|
||||
rootValue: o.rootValue,
|
||||
},
|
||||
})(o);
|
||||
};
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
// 将数组uiSchema配置里面的抽离出来使用
|
||||
basicProps.extraButtons = extraButtons;
|
||||
// 数组新增的默认值
|
||||
if (subSchemas && subSchemas[0]) {
|
||||
basicProps.newItem = resolve(subSchemas[0].schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
return basicProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* schema + materials --> parse --> Field + props
|
||||
* schema {
|
||||
* propsSchema,
|
||||
* uiSchema,
|
||||
* data,
|
||||
* name,
|
||||
* }
|
||||
* materials {
|
||||
* // 根据 Widget 生成的 Field
|
||||
* generated,
|
||||
* // 自定义的 Field
|
||||
* customized,
|
||||
* // 字段 type 与 widgetName 的映射关系
|
||||
* mapping,
|
||||
* }
|
||||
*/
|
||||
const parse = (settings = {}, materials) => {
|
||||
const { schema = {} } = settings;
|
||||
return {
|
||||
Field: getField(schema, materials).Field,
|
||||
props: getBasicProps(settings, materials),
|
||||
};
|
||||
};
|
||||
|
||||
export default parse;
|
||||
79
src/base/resolve.js
Normal file
79
src/base/resolve.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// 克隆对象
|
||||
function clone(data) {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(data));
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前字段默认值
|
||||
function getDefaultValue({ default: def, enum: enums = [], type }) {
|
||||
// 如果设置默认值,优先从默认值中获取
|
||||
if (typeof def !== 'undefined') {
|
||||
return def;
|
||||
}
|
||||
// array且enum的情况,为多选框,默认值[]
|
||||
if (type === 'array' && enums.length > 0) {
|
||||
return [];
|
||||
}
|
||||
// 如果设置枚举值,其次从枚举值中获取
|
||||
if (typeof enums[0] !== 'undefined') {
|
||||
return enums[0];
|
||||
}
|
||||
// 最后使用对应基础类型的默认值
|
||||
return {
|
||||
array: [],
|
||||
boolean: false,
|
||||
integer: '',
|
||||
null: null,
|
||||
number: '',
|
||||
object: {},
|
||||
string: '',
|
||||
range: null,
|
||||
}[type];
|
||||
}
|
||||
|
||||
function resolve(schema, data, options = {}) {
|
||||
const {
|
||||
// 类型
|
||||
type,
|
||||
// 对象子集
|
||||
properties,
|
||||
// 数组子集
|
||||
items,
|
||||
// 必选值,对象的子集
|
||||
required = [],
|
||||
} = schema;
|
||||
const {
|
||||
// 按照required规则做数据补全
|
||||
checkRequired = false,
|
||||
} = options;
|
||||
// 当前值
|
||||
const value =
|
||||
typeof data === 'undefined' ? getDefaultValue(schema) : clone(data);
|
||||
if (type === 'object') {
|
||||
const subs = properties || {};
|
||||
const ret = {};
|
||||
Object.keys(subs).forEach(name => {
|
||||
const checkAndPass =
|
||||
checkRequired && [].concat(required).indexOf(name) !== -1;
|
||||
if (!checkRequired || checkAndPass) {
|
||||
ret[name] = resolve(subs[name], value[name], options);
|
||||
}
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
if (type === 'array') {
|
||||
const subs = [].concat(items || []);
|
||||
const ret = [];
|
||||
value.forEach &&
|
||||
value.forEach((item, idx) => {
|
||||
ret[idx] = resolve(subs[idx] || subs[0], item, options);
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export default resolve;
|
||||
16
src/base/sub-field-generator.jsx
Normal file
16
src/base/sub-field-generator.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const subFieldGenerator = ({
|
||||
fieldCanRedefine: can,
|
||||
Field: SourceField = null,
|
||||
props = {},
|
||||
}) => args => {
|
||||
const { name, Field: RedefineField = null, ...others } = args;
|
||||
const Field = (can && RedefineField) || SourceField;
|
||||
if (Field) {
|
||||
return <Field {...props} name={name} {...others} key={name} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default subFieldGenerator;
|
||||
109
src/base/utils.js
Normal file
109
src/base/utils.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Created by Tw93 on 2018-09-26.
|
||||
* 深度对比
|
||||
*/
|
||||
|
||||
export function isDeepEqual(param1, param2) {
|
||||
if (param1 === undefined && param2 === undefined) return true;
|
||||
else if (param1 === undefined || param2 === undefined) return false;
|
||||
else if (param1.constructor !== param2.constructor) return false;
|
||||
|
||||
if (param1.constructor === Array) {
|
||||
if (param1.length !== param2.length) return false;
|
||||
for (let i = 0; i < param1.length; i++) {
|
||||
if (param1[i].constructor === Array || param1[i].constructor === Object) {
|
||||
if (!isDeepEqual(param1[i], param2[i])) return false;
|
||||
} else if (param1[i] !== param2[i]) return false;
|
||||
}
|
||||
} else if (param1.constructor === Object) {
|
||||
if (Object.keys(param1).length !== Object.keys(param2).length) return false;
|
||||
for (let i = 0; i < Object.keys(param1).length; i++) {
|
||||
const key = Object.keys(param1)[i];
|
||||
if (
|
||||
param1[key] &&
|
||||
typeof param1[key] !== 'number' &&
|
||||
(param1[key].constructor === Array ||
|
||||
param1[key].constructor === Object)
|
||||
) {
|
||||
if (!isDeepEqual(param1[key], param2[key])) return false;
|
||||
} else if (param1[key] !== param2[key]) return false;
|
||||
}
|
||||
} else if (param1.constructor === String || param1.constructor === Number) {
|
||||
return param1 === param2;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 合并propsSchema和UISchema。由于两者的逻辑相关性,合并为一个大schema能简化内部处理
|
||||
export function combineSchema(propsSchema, uiSchema) {
|
||||
const propList = getChildren(propsSchema);
|
||||
const newList = propList.map(p => {
|
||||
const { name } = p;
|
||||
const { type, enum: options, properties, items } = p.schema;
|
||||
const isObj = type === 'object' && properties;
|
||||
const isArr = type === 'array' && items && !options; // enum + array 代表的多选框,没有sub
|
||||
const ui = name && uiSchema[p.name];
|
||||
if (!ui) {
|
||||
return p;
|
||||
}
|
||||
// 如果是list,递归合并items
|
||||
if (isArr) {
|
||||
const newItems = combineSchema(items, ui.items || {});
|
||||
return { ...p, schema: { ...p.schema, ...ui, items: newItems } };
|
||||
}
|
||||
// object递归合并整个schema
|
||||
if (isObj) {
|
||||
const newSchema = combineSchema(p.schema, ui);
|
||||
return { ...p, schema: newSchema };
|
||||
}
|
||||
return { ...p, schema: { ...p.schema, ...ui } };
|
||||
});
|
||||
|
||||
const newObj = {};
|
||||
newList.forEach(s => {
|
||||
newObj[s.name] = s.schema;
|
||||
});
|
||||
|
||||
const topLevelUi = {};
|
||||
Object.keys(uiSchema).forEach(key => {
|
||||
if (typeof key === 'string' && key.substring(0, 3) === 'ui:') {
|
||||
topLevelUi[key] = uiSchema[key];
|
||||
}
|
||||
});
|
||||
if (isEmpty(newObj)) {
|
||||
return { ...propsSchema, ...topLevelUi };
|
||||
}
|
||||
return { ...propsSchema, ...topLevelUi, properties: newObj };
|
||||
}
|
||||
|
||||
function isEmpty(obj) {
|
||||
return Object.keys(obj).length === 0;
|
||||
}
|
||||
|
||||
// 获得propsSchema的children
|
||||
function getChildren(schema) {
|
||||
const {
|
||||
// object
|
||||
properties,
|
||||
// array
|
||||
items,
|
||||
type,
|
||||
} = schema;
|
||||
if (!properties && !items) {
|
||||
return [];
|
||||
}
|
||||
let schemaSubs = {};
|
||||
if (type === 'object') {
|
||||
schemaSubs = properties;
|
||||
}
|
||||
if (type === 'array') {
|
||||
schemaSubs = items;
|
||||
}
|
||||
return Object.keys(schemaSubs).map(name => ({
|
||||
schema: schemaSubs[name],
|
||||
name,
|
||||
}));
|
||||
}
|
||||
|
||||
// 合并多个schema
|
||||
export function combine() {}
|
||||
171
src/base/validate.js
Normal file
171
src/base/validate.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Created by Tw93 on 2018-09-04.
|
||||
* 校验表单格式
|
||||
*/
|
||||
|
||||
import isLength from 'validator/lib/isLength';
|
||||
import { isHidden } from './isHidden';
|
||||
|
||||
const isEmptyObject = obj =>
|
||||
Object.keys(obj).length === 0 && obj.constructor === Object;
|
||||
|
||||
// 值是是否为空
|
||||
const isEmptyValue = (value, schema) => {
|
||||
// 多选组件的值为 [] 时,也判断为空值
|
||||
if (schema.type === 'array' && schema.enum) {
|
||||
return !value || value.length === 0;
|
||||
}
|
||||
if (value === 0) {
|
||||
return false;
|
||||
}
|
||||
return !value;
|
||||
};
|
||||
|
||||
export const getValidateText = (obj = {}) => {
|
||||
const { value, defaultValue, required, schema = {} } = obj;
|
||||
|
||||
const {
|
||||
type,
|
||||
minLength,
|
||||
maxLength,
|
||||
minimum,
|
||||
maximum,
|
||||
minItems,
|
||||
maxItems,
|
||||
pattern,
|
||||
message,
|
||||
format,
|
||||
'ui:widget': widget,
|
||||
} = schema;
|
||||
let finalValue = value || defaultValue;
|
||||
// fix: number = 0 返回空字符串
|
||||
if (type === 'number' && value === 0) {
|
||||
finalValue = 0;
|
||||
}
|
||||
const needPattern = pattern && ['string', 'number'].indexOf(type) > -1;
|
||||
// schema 里面没有内容的,直接退出
|
||||
if (isEmptyObject(schema)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 校验是否为required
|
||||
if (required && isEmptyValue(finalValue, schema)) {
|
||||
return (message && message.required) || '不能为空';
|
||||
}
|
||||
|
||||
// 字符串相关校验
|
||||
if (type === 'string' && finalValue) {
|
||||
if (maxLength && !isLength(finalValue, 0, parseInt(maxLength, 10))) {
|
||||
return (message && message.maxLength) || `长度不能大于 ${maxLength}`;
|
||||
}
|
||||
|
||||
if (
|
||||
(minLength || minLength === 0) &&
|
||||
!isLength(finalValue, parseInt(minLength, 10), undefined)
|
||||
) {
|
||||
return (message && message.minLength) || `长度不能小于 ${minLength}`;
|
||||
}
|
||||
// 由于有人填写red,rgba(0,123,65,0.3)等值,所以暂时不做验证
|
||||
// if (format === 'color' || widget === 'color') {
|
||||
// const isHex = finalValue.match(/^(#{0,1})([0-9A-F]{6})$/i);
|
||||
// if (!isHex) {
|
||||
// return '请填写正确hex值';
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
// 数字相关校验
|
||||
if (type === 'number') {
|
||||
if (typeof finalValue !== 'number') {
|
||||
return '请填写数字';
|
||||
}
|
||||
if (maximum && parseInt(finalValue, 10) > maximum) {
|
||||
return (message && message.maximum) || `数值不能大于 ${maximum}`;
|
||||
}
|
||||
if ((minimum || minimum === 0) && parseInt(finalValue, 10) < minimum) {
|
||||
return (message && message.minimum) || `数值不能小于 ${minimum}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 正则只对数字和字符串有效果
|
||||
if (finalValue && needPattern && !new RegExp(pattern).test(finalValue)) {
|
||||
return (message && message.pattern) || '格式不匹配';
|
||||
}
|
||||
|
||||
// 数组项目相关校验
|
||||
if (type === 'array') {
|
||||
if (maxItems && finalValue && finalValue.length > maxItems) {
|
||||
return (message && message.maxItems) || `组数不能大于 ${maxItems}`;
|
||||
}
|
||||
|
||||
if (
|
||||
(minItems || minItems === 0) &&
|
||||
finalValue &&
|
||||
finalValue.length < minItems
|
||||
) {
|
||||
return (message && message.minItems) || `组数不能小于 ${minItems}`;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const dealTypeValidate = (key, value, schema = {}) => {
|
||||
const checkList = [];
|
||||
const { type, items } = schema;
|
||||
const obj = {
|
||||
value,
|
||||
schema,
|
||||
};
|
||||
if (type === 'object') {
|
||||
const list = getValidateList(value, schema); // eslint-disable-line
|
||||
checkList.push(...list);
|
||||
} else if (type === 'array') {
|
||||
value.forEach(v => {
|
||||
const list = dealTypeValidate(key, v, items);
|
||||
checkList.push(...list);
|
||||
});
|
||||
}
|
||||
if (getValidateText(obj)) {
|
||||
checkList.push(key);
|
||||
}
|
||||
return checkList;
|
||||
};
|
||||
|
||||
const keyHidden = (schema, val) => {
|
||||
let hidden = schema && schema['ui:hidden'];
|
||||
if (typeof hidden === 'string') {
|
||||
hidden = isHidden({ hidden, rootValue: val });
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
|
||||
export const getValidateList = (val = {}, prop = {}) => {
|
||||
const checkList = [];
|
||||
const { properties, required } = prop;
|
||||
// 校验必填(required 属性只在 type:object 下存在)
|
||||
if (required && required.length > 0) {
|
||||
required.forEach(key => {
|
||||
const schema = (properties && properties[key]) || {};
|
||||
const hidden = keyHidden(schema, val);
|
||||
const itemValue = val && val[key];
|
||||
if (isEmptyValue(itemValue, schema) && !hidden) {
|
||||
checkList.push(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (properties && val && Object.keys(val) && Object.keys(val).length > 0) {
|
||||
Object.keys(val).forEach(key => {
|
||||
const value = val[key];
|
||||
const schema = properties[key] || {};
|
||||
const hidden = keyHidden(schema, val);
|
||||
if (!hidden) {
|
||||
const list = dealTypeValidate(key, value, schema);
|
||||
checkList.push(...list);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return checkList;
|
||||
};
|
||||
75
src/components/DescriptionList.js
Normal file
75
src/components/DescriptionList.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { isHidden } from '../base/isHidden';
|
||||
|
||||
const getEnumValue = (value, enums, enumNames) => {
|
||||
if (Array.isArray(enums) && Array.isArray(enumNames)) {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const count = enums.indexOf(value);
|
||||
if (count > -1) {
|
||||
return enumNames[count];
|
||||
}
|
||||
return value;
|
||||
} else if (Array.isArray(value)) {
|
||||
const result = value.map(v => getEnumValue(value, enums, enumNames));
|
||||
return String(result);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const DescriptionList = ({ schema = {}, value = [], index }) => {
|
||||
const list = getDescription({ schema, value, index })
|
||||
.filter(item => item.title)
|
||||
.slice(0, 3);
|
||||
return (
|
||||
<ul className="flex overflow-hidden" style={{ paddingRight: 45 }}>
|
||||
{list.map((item, i) => {
|
||||
return item.title ? (
|
||||
<li className="flex-auto mr2 overflow-hidden" key={i}>
|
||||
<span className="fw5">{item.title}: </span>
|
||||
<span>{item.text}</span>
|
||||
</li>
|
||||
) : null;
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionList;
|
||||
|
||||
// 获得title,value值list
|
||||
export const getDescription = ({ schema = {}, value = [], index }) => {
|
||||
const { items = {} } = schema;
|
||||
// 只有当items为object时才做收起(fold)处理
|
||||
if (items.type !== 'object') {
|
||||
return [];
|
||||
}
|
||||
let titles = (items && items.properties) || {};
|
||||
titles = Object.values(titles);
|
||||
let description = (value && value.length && value[index]) || {};
|
||||
const valueList = Object.values(description);
|
||||
const descList = titles.map((t, idx) => {
|
||||
let hidden = t && t['ui:hidden'];
|
||||
// ui:hidden为判断式时解析
|
||||
if (typeof hidden === 'string') {
|
||||
hidden = isHidden({ hidden, rootValue: description });
|
||||
}
|
||||
if (hidden) return;
|
||||
const title = t.title;
|
||||
let text = valueList[idx];
|
||||
if (text === null && text === undefined) {
|
||||
text = '';
|
||||
} else if (typeof text !== 'string' && typeof text !== 'number' && text) {
|
||||
text = '{复杂结构}';
|
||||
} else if (t.enum && t.enumNames) {
|
||||
text = getEnumValue(text, t.enum, t.enumNames);
|
||||
}
|
||||
return {
|
||||
title,
|
||||
text,
|
||||
};
|
||||
});
|
||||
// 去空
|
||||
return descList.filter(d => !!d);
|
||||
};
|
||||
26
src/components/FoldIcon/index.jsx
Normal file
26
src/components/FoldIcon/index.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
const FoldIcon = ({
|
||||
fold = false,
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
style = {},
|
||||
...rest
|
||||
}) => (
|
||||
<svg
|
||||
style={style}
|
||||
className={fold ? 'fold-icon' : 'fold-icon fold-icon-active'}
|
||||
viewBox="0 0 1024 1024"
|
||||
width={width || 24}
|
||||
height={height || 24}
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
d="M942.048 306.176c-12.288-12.288-31.328-13.024-43.008-2.016L529.056 674.112c-15.072 15.872-19.008 15.808-34.816 0L124.288 304.16c-11.68-11.04-30.72-10.272-43.008 2.016-12.512 12.512-13.216 32.032-1.6 43.68L490.624 760.8c5.056 5.056 11.648 7.328 18.464 7.744h5.152c6.816-.448 13.408-2.72 18.464-7.744l410.944-410.944c11.584-11.648 10.88-31.2-1.6-43.68z"
|
||||
fill={fill || '#3c3c3c'}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default FoldIcon;
|
||||
41
src/fusion.js
Normal file
41
src/fusion.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Created by Tw93 on 2018-08-28.
|
||||
* fusion 主题入口文件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import FormRender from './index';
|
||||
import { mapping, widgets } from './widgets/fusion';
|
||||
|
||||
export default class FusionForm extends React.PureComponent {
|
||||
static propTypes = {
|
||||
mapping: PropTypes.object,
|
||||
widgets: PropTypes.object,
|
||||
};
|
||||
static defaultProps = {
|
||||
mapping: {},
|
||||
widgets: {},
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
mapping: customizedMapping,
|
||||
widgets: customizedWidgets,
|
||||
...props
|
||||
} = this.props;
|
||||
return (
|
||||
<FormRender
|
||||
{...props}
|
||||
mapping={{
|
||||
...mapping,
|
||||
...customizedMapping,
|
||||
}}
|
||||
widgets={{
|
||||
...widgets,
|
||||
...customizedWidgets,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/index.js
Executable file
165
src/index.js
Executable file
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isDeepEqual, combineSchema } from './base/utils';
|
||||
import { asField, DefaultFieldUI } from './base/as-field';
|
||||
import parse from './base/parser';
|
||||
import resolve from './base/resolve';
|
||||
import { getValidateList } from './base/validate';
|
||||
import '../atom.css';
|
||||
import '../index.css';
|
||||
|
||||
function renderField(schema, fields, events) {
|
||||
const { Field, props } = parse(schema, fields);
|
||||
if (!Field) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Field
|
||||
isRoot
|
||||
{...props}
|
||||
value={schema.data}
|
||||
{...events}
|
||||
formData={schema.formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 在顶层将 propsSchema 和 uiSchema 合并,便于后续处理。 也可直接传入合并的 schema
|
||||
const Wrapper = ({ schema, propsSchema = {}, uiSchema = {}, ...rest }) => {
|
||||
const _schema = schema ? schema : combineSchema(propsSchema, uiSchema);
|
||||
return <FormRender {...rest} schema={_schema} />;
|
||||
};
|
||||
|
||||
class FormRender extends React.Component {
|
||||
static propTypes = {
|
||||
name: PropTypes.string,
|
||||
column: PropTypes.number,
|
||||
schema: PropTypes.object,
|
||||
formData: PropTypes.object,
|
||||
widgets: PropTypes.objectOf(PropTypes.func),
|
||||
FieldUI: PropTypes.elementType,
|
||||
fields: PropTypes.objectOf(PropTypes.element),
|
||||
mapping: PropTypes.object,
|
||||
showDescIcon: PropTypes.bool,
|
||||
showValidate: PropTypes.bool,
|
||||
displayType: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
onValidate: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
name: '$form',
|
||||
column: 1,
|
||||
schema: {},
|
||||
formData: {},
|
||||
widgets: {},
|
||||
FieldUI: DefaultFieldUI,
|
||||
fields: {},
|
||||
mapping: {},
|
||||
showDescIcon: false,
|
||||
showValidate: true,
|
||||
displayType: 'column',
|
||||
onChange: () => {},
|
||||
onValidate: () => {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.originWidgets = null;
|
||||
this.generatedFields = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.needUpdateForm();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { schema, formData } = this.props;
|
||||
// 遇到有更新 schema 情况,假如项数增多了,发现增多项不会更新
|
||||
if (
|
||||
!isDeepEqual(nextProps.schema, schema) ||
|
||||
!isDeepEqual(Object.keys(nextProps.formData), Object.keys(formData))
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.needUpdateForm();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
needUpdateForm = () => {
|
||||
const { schema, formData, onChange, onValidate } = this.props;
|
||||
const data = resolve(schema, formData);
|
||||
onChange(data);
|
||||
onValidate(getValidateList(data, schema));
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
name,
|
||||
column,
|
||||
className,
|
||||
schema,
|
||||
formData,
|
||||
widgets,
|
||||
FieldUI,
|
||||
fields: customized,
|
||||
mapping,
|
||||
showDescIcon,
|
||||
showValidate,
|
||||
displayType,
|
||||
onChange,
|
||||
onValidate,
|
||||
} = this.props;
|
||||
|
||||
const generated = {};
|
||||
const list = widgets;
|
||||
if (!this.originWidgets) {
|
||||
this.originWidgets = list;
|
||||
}
|
||||
Object.keys(list).forEach(key => {
|
||||
const oWidget = this.originWidgets[key];
|
||||
const nWidget = list[key];
|
||||
let gField = this.generatedFields[key];
|
||||
if (!gField || oWidget !== nWidget) {
|
||||
if (oWidget !== nWidget) {
|
||||
this.originWidgets[key] = nWidget;
|
||||
}
|
||||
gField = asField({ FieldUI, Widget: nWidget });
|
||||
this.generatedFields[key] = gField;
|
||||
}
|
||||
generated[key] = gField;
|
||||
});
|
||||
return (
|
||||
<div className={className}>
|
||||
{renderField(
|
||||
{
|
||||
schema,
|
||||
data: formData,
|
||||
name,
|
||||
column,
|
||||
showDescIcon,
|
||||
showValidate,
|
||||
displayType,
|
||||
formData,
|
||||
},
|
||||
{
|
||||
// 根据 Widget 生成的 Field
|
||||
generated,
|
||||
// 自定义的 Field
|
||||
customized,
|
||||
// 字段 type 与 widgetName 的映射关系
|
||||
mapping,
|
||||
},
|
||||
{
|
||||
onChange(key, val) {
|
||||
onChange(val);
|
||||
onValidate(getValidateList(val, schema));
|
||||
},
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Wrapper;
|
||||
12
src/widgets/antd/checkbox.jsx
Normal file
12
src/widgets/antd/checkbox.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from 'antd';
|
||||
|
||||
export default function radio(p) {
|
||||
return (
|
||||
<Checkbox
|
||||
disabled={p.disabled}
|
||||
onChange={e => p.onChange(p.name, e.target.checked)}
|
||||
checked={p.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/widgets/antd/checkboxes.jsx
Normal file
23
src/widgets/antd/checkboxes.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from 'antd';
|
||||
|
||||
export default function checkboxes(p) {
|
||||
return (
|
||||
<Checkbox.Group
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={values => p.onChange(p.name, values)}
|
||||
>
|
||||
{(p.schema.enum || [true, false]).map((val, index) => (
|
||||
<Checkbox value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
27
src/widgets/antd/color.jsx
Normal file
27
src/widgets/antd/color.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import ColorPicker from 'rc-color-picker';
|
||||
import { Input } from 'antd';
|
||||
import 'rc-color-picker/assets/index.css';
|
||||
|
||||
export default function color(p) {
|
||||
const { format } = p.schema;
|
||||
const onPickerChange = e => p.onChange(p.name, e.color);
|
||||
const onInputChange = e => {
|
||||
// const isHex = value.match(/^(#{0,1})([0-9A-F]{6})$/i);
|
||||
p.onChange(p.name, e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fr-color-picker">
|
||||
{
|
||||
<ColorPicker
|
||||
type={format}
|
||||
animation="slide-up"
|
||||
color={p.value ? p.value : '#ffffff'}
|
||||
onChange={onPickerChange}
|
||||
/>
|
||||
}
|
||||
<Input placeholder="#ffffff" value={p.value} onChange={onInputChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/widgets/antd/date.jsx
Normal file
51
src/widgets/antd/date.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { DatePicker, TimePicker } from 'antd';
|
||||
import moment from 'moment';
|
||||
|
||||
export default function date(p) {
|
||||
const { format = 'dateTime' } = p.schema;
|
||||
let dateFormat;
|
||||
switch (format) {
|
||||
case 'date':
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
case 'time':
|
||||
dateFormat = 'HH:mm:ss';
|
||||
break;
|
||||
default:
|
||||
// dateTime
|
||||
dateFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
}
|
||||
let defaultObj = {};
|
||||
if (p.value) {
|
||||
defaultObj = {
|
||||
defaultValue: moment(p.value, dateFormat),
|
||||
};
|
||||
}
|
||||
|
||||
const placeholderObj = p.description ? { placeholder: p.description } : {};
|
||||
|
||||
const onTimeChange = (time, timeString) => p.onChange(p.name, timeString);
|
||||
const onDateChange = (value, dateString) => p.onChange(p.name, dateString);
|
||||
|
||||
return format === 'time' ? (
|
||||
<TimePicker
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
style={{ width: '100%' }}
|
||||
{...defaultObj}
|
||||
onChange={onTimeChange}
|
||||
/>
|
||||
) : (
|
||||
<DatePicker
|
||||
{...placeholderObj}
|
||||
{...p.options}
|
||||
showTime={format === 'dateTime'}
|
||||
style={{ width: '100%' }}
|
||||
{...defaultObj}
|
||||
format={dateFormat}
|
||||
disabled={p.disabled}
|
||||
onChange={onDateChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/widgets/antd/dateRange.jsx
Normal file
42
src/widgets/antd/dateRange.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { DatePicker } from 'antd';
|
||||
import moment from 'moment';
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export default function date(p) {
|
||||
const { format = 'dateTime' } = p.schema;
|
||||
let dateFormat;
|
||||
switch (format) {
|
||||
case 'date':
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
default:
|
||||
// dateTime
|
||||
dateFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
}
|
||||
let defaultObj = {};
|
||||
if (p.value && Array.isArray(p.value) && p.value[0] && p.value[1]) {
|
||||
defaultObj = {
|
||||
defaultValue: [
|
||||
moment(p.value[0], dateFormat),
|
||||
moment(p.value[1], dateFormat),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const handleChange = (value, dateString) => {
|
||||
p.onChange(p.name, dateString);
|
||||
};
|
||||
|
||||
return (
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
showTime={format === 'dateTime'}
|
||||
{...defaultObj}
|
||||
format={dateFormat}
|
||||
disabled={p.disabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
src/widgets/antd/image.jsx
Normal file
79
src/widgets/antd/image.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Upload, Icon, Modal } from 'antd';
|
||||
|
||||
class Image extends React.Component {
|
||||
static propTypes = {
|
||||
data: PropTypes.object,
|
||||
type: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
data: {},
|
||||
type: 'single',
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
state = {
|
||||
previewVisible: false,
|
||||
previewImage: '',
|
||||
fileList: [
|
||||
{
|
||||
uid: -1,
|
||||
name: 'xxx.png',
|
||||
status: 'done',
|
||||
url: this.props.data.value,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
handleCancel = () => this.setState({ previewVisible: false });
|
||||
|
||||
handlePreview = file => {
|
||||
this.setState({
|
||||
previewImage: file.url || file.thumbUrl,
|
||||
previewVisible: true,
|
||||
});
|
||||
};
|
||||
|
||||
handleChange = ({ fileList, file }) => {
|
||||
const { type } = this.props;
|
||||
const isSingle = type === 'single';
|
||||
const flies = isSingle ? [file] : fileList;
|
||||
// todo 多图处理,还有图片上传服务
|
||||
this.props.onChange(flies[0].thumbUrl);
|
||||
this.setState({ fileList: flies });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { previewVisible, previewImage, fileList } = this.state;
|
||||
|
||||
return (
|
||||
<div className="clearfix">
|
||||
<Upload
|
||||
action="//jsonplaceholder.typicode.com/posts/"
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
onPreview={this.handlePreview}
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
<div>
|
||||
<Icon type="plus" />
|
||||
<div className="ant-upload-text">Upload</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<Modal
|
||||
visible={previewVisible}
|
||||
footer={null}
|
||||
onCancel={this.handleCancel}
|
||||
>
|
||||
<img alt="example" style={{ width: '100%' }} src={previewImage} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Image;
|
||||
57
src/widgets/antd/index.jsx
Normal file
57
src/widgets/antd/index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import checkbox from './checkbox';
|
||||
import checkboxes from './checkboxes';
|
||||
import color from './color';
|
||||
import date from './date';
|
||||
import dateRange from './dateRange';
|
||||
import input from './input';
|
||||
import list from './list';
|
||||
import map from './map';
|
||||
import multiSelect from './multiSelect';
|
||||
import number from './number';
|
||||
import radio from './radio';
|
||||
import select from './select';
|
||||
import slider from './slider';
|
||||
import switch1 from './switch';
|
||||
import textarea from './textarea';
|
||||
import upload from './upload';
|
||||
|
||||
export const widgets = {
|
||||
checkbox,
|
||||
checkboxes, // checkbox多选
|
||||
color,
|
||||
date,
|
||||
dateRange,
|
||||
input,
|
||||
list,
|
||||
map,
|
||||
multiSelect, // 下拉多选
|
||||
number,
|
||||
radio,
|
||||
select,
|
||||
slider, // 带滚条的number
|
||||
switch: switch1,
|
||||
textarea,
|
||||
upload,
|
||||
};
|
||||
|
||||
export const mapping = {
|
||||
default: 'input',
|
||||
string: 'input',
|
||||
array: 'list',
|
||||
boolean: 'checkbox',
|
||||
integer: 'number',
|
||||
number: 'number',
|
||||
object: 'map',
|
||||
'string:upload': 'upload',
|
||||
'string:date': 'date',
|
||||
'string:dateTime': 'date',
|
||||
'string:time': 'date',
|
||||
'string:textarea': 'textarea',
|
||||
'string:color': 'color',
|
||||
'string:image': 'input',
|
||||
'range:date': 'dateRange',
|
||||
'range:dateTime': 'dateRange',
|
||||
'*?enum': 'select',
|
||||
'array?enum': 'checkboxes',
|
||||
'*?readonly': 'text',
|
||||
};
|
||||
41
src/widgets/antd/input.jsx
Normal file
41
src/widgets/antd/input.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Input, Icon, Popover } from 'antd';
|
||||
|
||||
const defaultImg =
|
||||
'https://img.alicdn.com/tfs/TB14tSiKhTpK1RjSZFKXXa2wXXa-354-330.png';
|
||||
const previewNode = (format, value) => {
|
||||
if (format !== 'image') {
|
||||
return null;
|
||||
}
|
||||
const content = (
|
||||
<img
|
||||
src={value || defaultImg}
|
||||
alt="图片地址错误"
|
||||
className="fr-preview-image"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} className="fr-preview" placement="bottom">
|
||||
<Icon type="eye" />
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
export default function input(p) {
|
||||
const { options = {} } = p;
|
||||
const { format = 'text' } = p.schema;
|
||||
const type = format === 'image' ? 'text' : format;
|
||||
const handleChange = e => p.onChange(p.name, e.target.value);
|
||||
return (
|
||||
<Input
|
||||
{...options}
|
||||
value={p.value}
|
||||
type={type}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
addonAfter={
|
||||
options.addonAfter ? options.addonAfter : previewNode(format, p.value)
|
||||
}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
214
src/widgets/antd/list.jsx
Normal file
214
src/widgets/antd/list.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
SortableContainer,
|
||||
sortableHandle,
|
||||
SortableElement,
|
||||
arrayMove,
|
||||
} from 'react-sortable-hoc';
|
||||
import { Button } from 'antd';
|
||||
import FoldIcon from '../../components/FoldIcon';
|
||||
import DescriptionList, {
|
||||
getDescription,
|
||||
} from '../../components/DescriptionList';
|
||||
|
||||
const DragHandle = sortableHandle(() => (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
paddingTop: 2,
|
||||
paddingRight: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className="mover"
|
||||
>
|
||||
:::
|
||||
</span>
|
||||
));
|
||||
|
||||
class ListItem extends React.Component {
|
||||
componentDidMount() {
|
||||
const { p = {}, name, fold } = this.props;
|
||||
const description = getDescription({
|
||||
schema: p.schema,
|
||||
value: p.value,
|
||||
index: name,
|
||||
});
|
||||
// 如果第一个值不为空,则收起
|
||||
// 新增的值为0,不折叠
|
||||
const hasValue = description && description[0] && description[0].text;
|
||||
if (hasValue && fold !== 0) {
|
||||
this.props.toggleFoldItem(name);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFold = () => {
|
||||
this.props.toggleFoldItem(this.props.name);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, p = {}, name, fold } = this.props;
|
||||
const descProps = { ...p, index: name };
|
||||
const canFold = p.options && p.options.foldable;
|
||||
// 只有当items为object时才做收起(fold)处理
|
||||
const isObj = p.schema.items && p.schema.items.type == 'object';
|
||||
let setClass =
|
||||
'fr-set ba b--black-10 hover-b--black-20 relative flex flex-column';
|
||||
if (canFold && fold) {
|
||||
setClass += ' pv12';
|
||||
} else if (p.displayType === 'row') {
|
||||
setClass += ' pt44';
|
||||
}
|
||||
return (
|
||||
<li className={setClass}>
|
||||
{canFold && fold && isObj ? <DescriptionList {...descProps} /> : item}
|
||||
{canFold && (
|
||||
<FoldIcon
|
||||
fold={fold}
|
||||
onClick={this.toggleFold}
|
||||
style={{ position: 'absolute', top: 12, right: 36 }}
|
||||
/>
|
||||
)}
|
||||
<DragHandle />
|
||||
{!(canFold && fold) && (
|
||||
<Button
|
||||
className="self-end"
|
||||
type="dashed"
|
||||
icon="delete"
|
||||
onClick={() => {
|
||||
const value = [...p.value];
|
||||
value.splice(name, 1);
|
||||
p.onChange(p.name, value);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SortableItem = SortableElement(ListItem);
|
||||
|
||||
const FieldList = ({ p, foldList = [], toggleFoldItem, addUnfoldItem }) => {
|
||||
const handleAddClick = () => {
|
||||
const value = [...p.value];
|
||||
value.push(p.newItem);
|
||||
p.onChange(p.name, value);
|
||||
addUnfoldItem();
|
||||
};
|
||||
|
||||
const baseList = p.value || [];
|
||||
// 如果为空数组,至少显示一个item
|
||||
if (baseList.length === 0) {
|
||||
baseList.push(p.newItem);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pl0 ma0">
|
||||
{baseList.map((_, name) => (
|
||||
<SortableItem
|
||||
key={`item-${name}`}
|
||||
index={name}
|
||||
name={name}
|
||||
p={p}
|
||||
fold={foldList[name]}
|
||||
toggleFoldItem={toggleFoldItem}
|
||||
item={p.getSubField({
|
||||
name,
|
||||
value: p.value[name],
|
||||
onChange(key, val) {
|
||||
const value = [...p.value];
|
||||
value[key] = val;
|
||||
p.onChange(p.name, value);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<div className="tr">
|
||||
<Button className="" icon="file-add" onClick={handleAddClick}>
|
||||
新增
|
||||
</Button>
|
||||
{p.extraButtons &&
|
||||
p.extraButtons.length > 0 &&
|
||||
p.extraButtons.map(item => (
|
||||
<Button
|
||||
className="ml2"
|
||||
icon={item.icon}
|
||||
onClick={() => {
|
||||
if (item.callback === 'clearAll') {
|
||||
p.onChange(p.name, []);
|
||||
return;
|
||||
}
|
||||
if (item.callback === 'copyLast') {
|
||||
const value = [...p.value];
|
||||
const lastIndex = value.length - 1;
|
||||
value.push(lastIndex > -1 ? value[lastIndex] : p.newItem);
|
||||
p.onChange(p.name, value);
|
||||
return;
|
||||
}
|
||||
if (typeof window[item.callback] === 'function') {
|
||||
window[item.callback].call(); // eslint-disable-line
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableList = SortableContainer(FieldList);
|
||||
|
||||
export default class extends React.Component {
|
||||
static defaultProps = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const len = this.props.value.length || 0;
|
||||
this.state = { foldList: new Array(len).fill(false) || [] };
|
||||
}
|
||||
|
||||
addUnfoldItem = () =>
|
||||
this.setState({ foldList: [...this.state.foldList, 0] });
|
||||
|
||||
toggleFoldItem = index => {
|
||||
const { foldList = [] } = this.state;
|
||||
foldList[index] = !foldList[index]; // TODO: need better solution for the weird behavior caused by setState being async
|
||||
this.setState({ foldList });
|
||||
};
|
||||
|
||||
handleSort = ({ oldIndex, newIndex }) => {
|
||||
const { onChange, name, value } = this.props;
|
||||
onChange(name, arrayMove(value, oldIndex, newIndex));
|
||||
this.setState({
|
||||
foldList: arrayMove(this.state.foldList, oldIndex, newIndex),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { foldList } = this.state;
|
||||
return (
|
||||
<SortableList
|
||||
p={this.props}
|
||||
foldList={foldList}
|
||||
toggleFoldItem={this.toggleFoldItem}
|
||||
addUnfoldItem={this.addUnfoldItem}
|
||||
distance={6}
|
||||
useDragHandle
|
||||
helperClass="sort-help-class"
|
||||
shouldCancelStart={e =>
|
||||
e.toElement && e.toElement.className === 'fr-tooltip-container'
|
||||
}
|
||||
onSortEnd={this.handleSort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/widgets/antd/map.jsx
Normal file
3
src/widgets/antd/map.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import map from '../common/map';
|
||||
|
||||
export default map;
|
||||
29
src/widgets/antd/multiSelect.jsx
Normal file
29
src/widgets/antd/multiSelect.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function multiSelect(p) {
|
||||
const onChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
mode="multiple"
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={onChange}
|
||||
>
|
||||
{(p.schema.enum || []).map((val, index) => (
|
||||
<Option value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
34
src/widgets/antd/number.jsx
Normal file
34
src/widgets/antd/number.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
export default function number(p) {
|
||||
const { max, min, step } = p.schema;
|
||||
let obj = {};
|
||||
if (max || max === 0) {
|
||||
obj = { max };
|
||||
}
|
||||
|
||||
if (min || min === 0) {
|
||||
obj = { ...obj, min };
|
||||
}
|
||||
|
||||
if (step) {
|
||||
obj = { ...obj, step };
|
||||
}
|
||||
|
||||
const handleChange = value => {
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
{...p.options}
|
||||
style={{ width: '100%' }}
|
||||
value={p.value}
|
||||
{...obj}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
src/widgets/antd/radio.jsx
Normal file
25
src/widgets/antd/radio.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Radio } from 'antd';
|
||||
|
||||
const RadioGroup = Radio.Group;
|
||||
|
||||
export default function radio(p) {
|
||||
return (
|
||||
<RadioGroup
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={e => p.onChange(p.name, e.target.value)}
|
||||
>
|
||||
{(p.schema.enum || [true, false]).map((val, index) => (
|
||||
<Radio value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
30
src/widgets/antd/select.jsx
Normal file
30
src/widgets/antd/select.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function select(p) {
|
||||
const onChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={onChange}
|
||||
>
|
||||
{(p.schema.enum || []).map((val, index) => {
|
||||
let option = p.schema.enumNames ? p.schema.enumNames[index] : val;
|
||||
const isHtml = typeof option === 'string' && option[0] === '<';
|
||||
if (isHtml) {
|
||||
option = <span dangerouslySetInnerHTML={{ __html: option }} />;
|
||||
}
|
||||
return (
|
||||
<Option value={val} key={index}>
|
||||
{option}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
42
src/widgets/antd/slider.jsx
Normal file
42
src/widgets/antd/slider.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { InputNumber, Slider } from 'antd';
|
||||
|
||||
export default function number(p) {
|
||||
const { max, min, step } = p.schema;
|
||||
let setting = {};
|
||||
if (max || max === 0) {
|
||||
setting = { max };
|
||||
}
|
||||
|
||||
if (min || min === 0) {
|
||||
setting = { ...setting, min };
|
||||
}
|
||||
|
||||
if (step) {
|
||||
setting = { ...setting, step };
|
||||
}
|
||||
|
||||
const handleChange = value => {
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-100 items-center">
|
||||
<Slider
|
||||
style={{ flexGrow: 1, marginRight: 12 }}
|
||||
{...setting}
|
||||
onChange={handleChange}
|
||||
value={typeof p.value === 'number' ? p.value : min || 0}
|
||||
/>
|
||||
<InputNumber
|
||||
style={{ width: '35%' }}
|
||||
{...p.options}
|
||||
value={p.value}
|
||||
{...setting}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/widgets/antd/switch.jsx
Normal file
12
src/widgets/antd/switch.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Switch } from 'antd';
|
||||
|
||||
export default function sw(p) {
|
||||
return (
|
||||
<Switch
|
||||
disabled={p.disabled}
|
||||
onChange={checked => p.onChange(p.name, checked)}
|
||||
defaultChecked={p.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/widgets/antd/textarea.jsx
Normal file
21
src/widgets/antd/textarea.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Input } from 'antd';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function ta(p) {
|
||||
const { options } = p;
|
||||
const defaultUi = { rows: 3 };
|
||||
const ui = { ...defaultUi, ...options };
|
||||
const onChange = e => p.onChange(p.name, e.target.value);
|
||||
return (
|
||||
<TextArea
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
value={p.value}
|
||||
{...ui}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
src/widgets/antd/upload.jsx
Normal file
43
src/widgets/antd/upload.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Icon, Upload, message, Button } from 'antd';
|
||||
|
||||
export default function input(p) {
|
||||
const props = {
|
||||
name: 'file',
|
||||
action: p.action,
|
||||
enctype: 'multipart/form-data',
|
||||
withCredentials: true,
|
||||
type: 'file',
|
||||
onChange(info) {
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 上传成功`);
|
||||
p.onChange(p.name, info.file.response.url);
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(`${info.file.name} 上传失败`);
|
||||
}
|
||||
},
|
||||
onRemove() {
|
||||
p.onChange(p.name, '');
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fr-upload-mod">
|
||||
<Upload {...props} className="fr-upload-file">
|
||||
<Button>
|
||||
<Icon type="upload" /> 上传
|
||||
</Button>
|
||||
</Upload>
|
||||
{p.value && (
|
||||
<a
|
||||
href={p.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fr-upload-preview"
|
||||
>
|
||||
已上传地址
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/widgets/common/map.jsx
Normal file
26
src/widgets/common/map.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function map(p) {
|
||||
return (
|
||||
<div className="flex flex-wrap fr-map">
|
||||
{Object.keys(p.value).map(name =>
|
||||
p.getSubField({
|
||||
name,
|
||||
value: p.value[name],
|
||||
onChange(key, val, objValue) {
|
||||
let value = {
|
||||
...p.value,
|
||||
[key]: val,
|
||||
};
|
||||
// 第三个参数,允许object里的一个子控件改动整个object的值
|
||||
if (objValue) {
|
||||
value = objValue;
|
||||
}
|
||||
p.onChange(p.name, value);
|
||||
},
|
||||
rootValue: p.value,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/widgets/fusion/checkbox.jsx
Normal file
12
src/widgets/fusion/checkbox.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '@alifd/next';
|
||||
|
||||
export default function radio(p) {
|
||||
return (
|
||||
<Checkbox
|
||||
disabled={p.disabled}
|
||||
onChange={checked => p.onChange(p.name, checked)}
|
||||
checked={p.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/widgets/fusion/checkboxes.jsx
Normal file
23
src/widgets/fusion/checkboxes.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Checkbox } from '@alifd/next';
|
||||
|
||||
export default function checkboxes(p) {
|
||||
return (
|
||||
<Checkbox.Group
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={values => p.onChange(p.name, values)}
|
||||
>
|
||||
{(p.schema.enum || [true, false]).map((val, index) => (
|
||||
<Checkbox value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
27
src/widgets/fusion/color.jsx
Normal file
27
src/widgets/fusion/color.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import ColorPicker from 'rc-color-picker';
|
||||
import { Input } from '@alifd/next';
|
||||
import 'rc-color-picker/assets/index.css';
|
||||
|
||||
export default function color(p) {
|
||||
const { format } = p.schema;
|
||||
const onPickerChange = e => p.onChange(p.name, e.color);
|
||||
const onInputChange = value => {
|
||||
// const isHex = value.match(/^(#{0,1})([0-9A-F]{6})$/i);
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fr-color-picker">
|
||||
{
|
||||
<ColorPicker
|
||||
type={format}
|
||||
animation="slide-up"
|
||||
color={p.value ? p.value : '#ffffff'}
|
||||
onChange={onPickerChange}
|
||||
/>
|
||||
}
|
||||
<Input placeholder="#ffffff" value={p.value} onChange={onInputChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/widgets/fusion/date.jsx
Normal file
54
src/widgets/fusion/date.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { DatePicker, TimePicker } from '@alifd/next';
|
||||
import moment from 'moment';
|
||||
|
||||
export default function date(p) {
|
||||
const { format = 'dateTime' } = p.schema;
|
||||
let dateFormat;
|
||||
switch (format) {
|
||||
case 'date':
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
case 'time':
|
||||
dateFormat = 'HH:mm:ss';
|
||||
break;
|
||||
default:
|
||||
// dateTime
|
||||
dateFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
}
|
||||
let defaultObj = {};
|
||||
if (p.value) {
|
||||
defaultObj = {
|
||||
defaultValue: moment(p.value, dateFormat),
|
||||
};
|
||||
}
|
||||
|
||||
const placeholderObj = p.description ? { placeholder: p.description } : {};
|
||||
|
||||
const onChange = value => {
|
||||
p.onChange(p.name, moment(value, dateFormat).format(dateFormat));
|
||||
};
|
||||
|
||||
if (format === 'time') {
|
||||
return (
|
||||
<TimePicker
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
{...defaultObj}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
{...placeholderObj}
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
showTime={format === 'dateTime'}
|
||||
{...defaultObj}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
48
src/widgets/fusion/dateRange.jsx
Normal file
48
src/widgets/fusion/dateRange.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { DatePicker } from '@alifd/next';
|
||||
import moment from 'moment';
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export default function date(p) {
|
||||
const { format = 'dateTime' } = p.schema;
|
||||
let dateFormat;
|
||||
switch (format) {
|
||||
case 'date':
|
||||
dateFormat = 'YYYY-MM-DD';
|
||||
break;
|
||||
default:
|
||||
// dateTime
|
||||
dateFormat = 'YYYY-MM-DD HH:mm:ss';
|
||||
}
|
||||
let defaultObj = {};
|
||||
if (p.value && Array.isArray(p.value) && p.value[0] && p.value[1]) {
|
||||
defaultObj = {
|
||||
defaultValue: [
|
||||
moment(p.value[0], dateFormat),
|
||||
moment(p.value[1], dateFormat),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const handleChange = value => {
|
||||
if (Array.isArray(value) && value[0] && value[1]) {
|
||||
const result = [
|
||||
moment(value[0]).format(dateFormat),
|
||||
moment(value[1]).format(dateFormat),
|
||||
];
|
||||
p.onChange(p.name, result);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
showTime={format === 'dateTime'}
|
||||
{...defaultObj}
|
||||
format={dateFormat}
|
||||
disabled={p.disabled}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/widgets/fusion/index.jsx
Normal file
58
src/widgets/fusion/index.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import checkbox from './checkbox';
|
||||
import checkboxes from './checkboxes';
|
||||
import color from './color';
|
||||
import date from './date';
|
||||
import dateRange from './dateRange';
|
||||
import input from './input';
|
||||
import list from './list';
|
||||
import map from './map';
|
||||
import multiSelect from './multiSelect';
|
||||
import number from './number';
|
||||
import radio from './radio';
|
||||
import select from './select';
|
||||
import slider from './slider';
|
||||
import switch1 from './switch';
|
||||
import textarea from './textarea';
|
||||
import upload from './upload';
|
||||
|
||||
export const widgets = {
|
||||
checkbox,
|
||||
checkboxes, // checkbox多选
|
||||
color,
|
||||
date,
|
||||
dateRange,
|
||||
input,
|
||||
list,
|
||||
map,
|
||||
multiSelect, // 下拉多选
|
||||
number,
|
||||
radio,
|
||||
select,
|
||||
slider, // 带滚条的number
|
||||
switch: switch1,
|
||||
textarea,
|
||||
upload,
|
||||
};
|
||||
|
||||
// 默认映射关系
|
||||
export const mapping = {
|
||||
default: 'input',
|
||||
string: 'input',
|
||||
array: 'list',
|
||||
boolean: 'checkbox',
|
||||
integer: 'number',
|
||||
number: 'number',
|
||||
object: 'map',
|
||||
'string:upload': 'upload',
|
||||
'string:date': 'date',
|
||||
'string:dateTime': 'date',
|
||||
'string:time': 'date',
|
||||
'string:textarea': 'textarea',
|
||||
'string:color': 'color',
|
||||
'string:image': 'input',
|
||||
'range:date': 'dateRange',
|
||||
'range:dateTime': 'dateRange',
|
||||
'*?enum': 'select',
|
||||
'array?enum': 'checkboxes',
|
||||
'*?readonly': 'text',
|
||||
};
|
||||
45
src/widgets/fusion/input.jsx
Normal file
45
src/widgets/fusion/input.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@alifd/next';
|
||||
// import { Input, Icon, Balloon } from '@alifd/next';
|
||||
|
||||
// TODO: fusion图片预览效果不佳,暂不支持
|
||||
// const defaultImg =
|
||||
// 'https://img.alicdn.com/tfs/TB14tSiKhTpK1RjSZFKXXa2wXXa-354-330.png';
|
||||
// const previewNode = (format, value) => {
|
||||
// if (format !== 'image') {
|
||||
// return null;
|
||||
// }
|
||||
// const content = (
|
||||
// <img
|
||||
// src={value || defaultImg}
|
||||
// alt="图片地址错误"
|
||||
// className="fr-preview-image"
|
||||
// />
|
||||
// );
|
||||
// return (
|
||||
// <Balloon
|
||||
// trigger={<Icon type="picture" />}
|
||||
// className="fr-preview"
|
||||
// align="tl"
|
||||
// >
|
||||
// {content}
|
||||
// </Balloon>
|
||||
// );
|
||||
// };
|
||||
export default function input(p) {
|
||||
const { options = {} } = p;
|
||||
const { addonBefore, addonAfter, ...rest } = options;
|
||||
// const { format = 'text' } = p.schema;
|
||||
const handleChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<Input
|
||||
{...rest}
|
||||
value={p.value}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
addonTextBefore={addonBefore ? addonBefore : ''}
|
||||
addonTextAfter={addonAfter ? addonAfter : ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
205
src/widgets/fusion/list.jsx
Normal file
205
src/widgets/fusion/list.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
SortableContainer,
|
||||
sortableHandle,
|
||||
SortableElement,
|
||||
arrayMove,
|
||||
} from 'react-sortable-hoc';
|
||||
import { Button, Icon } from '@alifd/next';
|
||||
import FoldIcon from '../../components/FoldIcon';
|
||||
import DescriptionList, {
|
||||
getDescription,
|
||||
} from '../../components/DescriptionList';
|
||||
|
||||
const DragHandle = sortableHandle(() => (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 10,
|
||||
fontSize: 24,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
:::
|
||||
</span>
|
||||
));
|
||||
|
||||
class ListItem extends React.Component {
|
||||
componentDidMount() {
|
||||
const { p = {}, name, fold } = this.props;
|
||||
const description = getDescription({
|
||||
schema: p.schema,
|
||||
value: p.value,
|
||||
index: name,
|
||||
});
|
||||
// 如果第一个值不为空,则收起
|
||||
// 新增的值为0,不折叠
|
||||
const hasValue = description && description[0] && description[0].text;
|
||||
if (hasValue && fold !== 0) {
|
||||
this.props.toggleFoldItem(name);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFold = () => {
|
||||
this.props.toggleFoldItem(this.props.name);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, p = {}, name, fold } = this.props;
|
||||
const descProps = { ...p, index: name };
|
||||
const canFold = p.options && p.options.foldable;
|
||||
// 只有当items为object时才做收起(fold)处理
|
||||
const isObj = p.schema.items && p.schema.items.type == 'object';
|
||||
let setClass =
|
||||
'fr-set ba b--black-10 hover-b--black-20 relative flex flex-column';
|
||||
if (canFold && fold) {
|
||||
setClass += ' pv12';
|
||||
} else if (p.displayType === 'row') {
|
||||
setClass += ' pt44';
|
||||
}
|
||||
return (
|
||||
<li className={setClass}>
|
||||
{canFold && fold && isObj ? <DescriptionList {...descProps} /> : item}
|
||||
{canFold && (
|
||||
<FoldIcon
|
||||
fold={fold}
|
||||
onClick={this.toggleFold}
|
||||
style={{ position: 'absolute', top: 12, right: 36 }}
|
||||
/>
|
||||
)}
|
||||
<DragHandle />
|
||||
{!(canFold && fold) && (
|
||||
<Button
|
||||
className="self-end"
|
||||
onClick={() => {
|
||||
const value = [...p.value];
|
||||
value.splice(name, 1);
|
||||
p.onChange(p.name, value);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SortableItem = SortableElement(ListItem);
|
||||
|
||||
const FieldList = ({ p, foldList = [], toggleFoldItem }) => {
|
||||
const handleAddClick = () => {
|
||||
const value = [...p.value];
|
||||
value.push(p.newItem);
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
const baseList = p.value || [];
|
||||
// 如果为空数组,至少显示一个item
|
||||
if (baseList.length === 0) {
|
||||
baseList.push(p.newItem);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="pl0 ma0">
|
||||
{baseList.map((_, name) => (
|
||||
<SortableItem
|
||||
key={`item-${name}`}
|
||||
index={name}
|
||||
name={name}
|
||||
p={p}
|
||||
fold={foldList[name]}
|
||||
toggleFoldItem={toggleFoldItem}
|
||||
item={p.getSubField({
|
||||
name,
|
||||
value: p.value[name],
|
||||
onChange(key, val) {
|
||||
const value = [...p.value];
|
||||
value[key] = val;
|
||||
p.onChange(p.name, value);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<div className="tr">
|
||||
<Button className="" onClick={handleAddClick}>
|
||||
<Icon type="add" />
|
||||
新增
|
||||
</Button>
|
||||
{p.extraButtons &&
|
||||
p.extraButtons.length > 0 &&
|
||||
p.extraButtons.map(item => (
|
||||
<Button
|
||||
className="ml2"
|
||||
onClick={() => {
|
||||
if (item.callback === 'clearAll') {
|
||||
p.onChange(p.name, []);
|
||||
return;
|
||||
}
|
||||
if (item.callback === 'copyLast') {
|
||||
const value = [...p.value];
|
||||
const lastIndex = value.length - 1;
|
||||
value.push(lastIndex > -1 ? value[lastIndex] : p.newItem);
|
||||
p.onChange(p.name, value);
|
||||
return;
|
||||
}
|
||||
if (typeof window[item.callback] === 'function') {
|
||||
window[item.callback].call(); // eslint-disable-line
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon type={item.icon} />
|
||||
{item.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableList = SortableContainer(FieldList);
|
||||
|
||||
export default class extends React.Component {
|
||||
static defaultProps = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const len = this.props.value.length || 0;
|
||||
this.state = { foldList: new Array(len).fill(false) || [] };
|
||||
}
|
||||
|
||||
toggleFoldItem = index => {
|
||||
const { foldList = [] } = this.state;
|
||||
foldList[index] = !foldList[index];
|
||||
this.setState({ foldList });
|
||||
};
|
||||
|
||||
handleSort = ({ oldIndex, newIndex }) => {
|
||||
const { onChange, name, value } = this.props;
|
||||
onChange(name, arrayMove(value, oldIndex, newIndex));
|
||||
this.setState({
|
||||
foldList: arrayMove(this.state.foldList, oldIndex, newIndex),
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { foldList } = this.state;
|
||||
return (
|
||||
<SortableList
|
||||
p={this.props}
|
||||
foldList={foldList}
|
||||
toggleFoldItem={this.toggleFoldItem}
|
||||
distance={6}
|
||||
useDragHandle
|
||||
helperClass="sort-help-class"
|
||||
shouldCancelStart={e =>
|
||||
e.toElement && e.toElement.className === 'fr-tooltip-container'
|
||||
}
|
||||
onSortEnd={this.handleSort}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/widgets/fusion/map.jsx
Normal file
3
src/widgets/fusion/map.jsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import map from '../common/map';
|
||||
|
||||
export default map;
|
||||
29
src/widgets/fusion/multiSelect.jsx
Normal file
29
src/widgets/fusion/multiSelect.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Select } from '@alifd/next';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function select(p) {
|
||||
const onChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
mode="multiple"
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={onChange}
|
||||
>
|
||||
{(p.schema.enum || []).map((val, index) => (
|
||||
<Option value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
33
src/widgets/fusion/number.jsx
Normal file
33
src/widgets/fusion/number.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { NumberPicker } from '@alifd/next';
|
||||
|
||||
export default function number(p) {
|
||||
const { max, min, step } = p.schema;
|
||||
let obj = {};
|
||||
if (max || max === 0) {
|
||||
obj = { max };
|
||||
}
|
||||
|
||||
if (min || min === 0) {
|
||||
obj = { ...obj, min };
|
||||
}
|
||||
|
||||
if (step) {
|
||||
obj = { ...obj, step };
|
||||
}
|
||||
|
||||
const handleChange = value => {
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<NumberPicker
|
||||
{...p.options}
|
||||
{...obj}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
value={p.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
src/widgets/fusion/radio.jsx
Normal file
25
src/widgets/fusion/radio.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Radio } from '@alifd/next';
|
||||
|
||||
const RadioGroup = Radio.Group;
|
||||
|
||||
export default function radio(p) {
|
||||
return (
|
||||
<RadioGroup
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={v => p.onChange(p.name, v)}
|
||||
>
|
||||
{(p.schema.enum || [true, false]).map((val, index) => (
|
||||
<Radio value={val} key={index}>
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: p.schema.enumNames ? p.schema.enumNames[index] : val,
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
30
src/widgets/fusion/select.jsx
Normal file
30
src/widgets/fusion/select.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Select } from '@alifd/next';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function select(p) {
|
||||
const onChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
onChange={onChange}
|
||||
>
|
||||
{(p.schema.enum || []).map((val, index) => {
|
||||
let option = p.schema.enumNames ? p.schema.enumNames[index] : val;
|
||||
const isHtml = typeof option === 'string' && option[0] === '<';
|
||||
if (isHtml) {
|
||||
option = <span dangerouslySetInnerHTML={{ __html: option }} />;
|
||||
}
|
||||
return (
|
||||
<Option value={val} key={index}>
|
||||
{option}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
43
src/widgets/fusion/slider.jsx
Normal file
43
src/widgets/fusion/slider.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { NumberPicker, Range } from '@alifd/next';
|
||||
|
||||
export default function number(p) {
|
||||
const { max, min, step } = p.schema;
|
||||
let setting = {};
|
||||
if (max || max === 0) {
|
||||
setting = { max };
|
||||
}
|
||||
|
||||
if (min || min === 0) {
|
||||
setting = { ...setting, min };
|
||||
}
|
||||
|
||||
if (step) {
|
||||
setting = { ...setting, step };
|
||||
}
|
||||
|
||||
const handleChange = value => {
|
||||
p.onChange(p.name, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-100 items-center justify-between">
|
||||
<Range
|
||||
style={{ flexGrow: 1, marginRight: 12 }}
|
||||
{...setting}
|
||||
defaultValue={min || 20}
|
||||
onChange={handleChange}
|
||||
value={typeof p.value === 'number' ? p.value : min || 0}
|
||||
/>
|
||||
<NumberPicker
|
||||
style={{ width: '35%' }}
|
||||
{...p.options}
|
||||
{...setting}
|
||||
disabled={p.disabled}
|
||||
readOnly={p.readonly}
|
||||
value={p.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/widgets/fusion/switch.jsx
Normal file
12
src/widgets/fusion/switch.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '@alifd/next';
|
||||
|
||||
export default function sw(p) {
|
||||
return (
|
||||
<Switch
|
||||
disabled={p.disabled}
|
||||
onChange={checked => p.onChange(p.name, checked)}
|
||||
defaultChecked={p.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
src/widgets/fusion/textarea.jsx
Normal file
19
src/widgets/fusion/textarea.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Input } from '@alifd/next';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function ta(p) {
|
||||
const { options } = p;
|
||||
const onChange = value => p.onChange(p.name, value);
|
||||
return (
|
||||
<TextArea
|
||||
{...p.options}
|
||||
disabled={p.disabled}
|
||||
value={p.value}
|
||||
readOnly={p.readonly}
|
||||
{...options}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/widgets/fusion/upload.jsx
Normal file
45
src/widgets/fusion/upload.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Icon, Upload, Message, Button } from '@alifd/next';
|
||||
|
||||
export default function input(p) {
|
||||
const props = {
|
||||
name: 'file',
|
||||
listType: 'text',
|
||||
action: p.action,
|
||||
enctype: 'multipart/form-data',
|
||||
withCredentials: true,
|
||||
type: 'file',
|
||||
onChange(res) {
|
||||
const info = res[0];
|
||||
if (info && info.response && info.response.status === 'done') {
|
||||
Message.success(`${info.name} 上传成功`);
|
||||
p.onChange(p.name, info.response.url);
|
||||
} else if (info && info.response && info.response.status === 'error') {
|
||||
Message.error(`${info.file.name} 上传失败`);
|
||||
}
|
||||
},
|
||||
onRemove() {
|
||||
p.onChange(p.name, '');
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fr-upload-mod">
|
||||
<Upload {...props} className="fr-upload-file">
|
||||
<Button>
|
||||
<Icon type="upload" /> 上传
|
||||
</Button>
|
||||
</Upload>
|
||||
{p.value && (
|
||||
<a
|
||||
href={p.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fr-upload-preview"
|
||||
>
|
||||
地址查看
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
test/index-spec.js
Executable file
8
test/index-spec.js
Executable file
@@ -0,0 +1,8 @@
|
||||
import expect from 'expect.js';
|
||||
import Component from '../index';
|
||||
|
||||
describe('index.js', () => {
|
||||
it('should export.', () => {
|
||||
expect(Component).to.not.eql(undefined);
|
||||
});
|
||||
});
|
||||
75
webpack.config.js
Executable file
75
webpack.config.js
Executable file
@@ -0,0 +1,75 @@
|
||||
const { resolve } = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
// const prefixer = require('postcss-prefix-selector');
|
||||
// const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
context: resolve(__dirname, 'demo'),
|
||||
entry: {
|
||||
main: './index.js',
|
||||
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
|
||||
'json.worker': 'monaco-editor/esm/vs/language/json/json.worker',
|
||||
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker',
|
||||
},
|
||||
output: {
|
||||
globalObject: 'self',
|
||||
filename: '[name].bundle.js',
|
||||
path: resolve(__dirname, 'docs/demo'),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
use: ['babel-loader'],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader'
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
devtool: 'inline-source-map',
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
resources: {
|
||||
js: [
|
||||
'react@16.x/umd/react.production.min',
|
||||
'react-dom@16.x/umd/react-dom.production.min',
|
||||
'prop-types@15.x/prop-types',
|
||||
'moment@2.24.0/moment',
|
||||
'@alifd/next@1.x/dist/next',
|
||||
],
|
||||
css: ['@alifd/next@1.x/dist/next'],
|
||||
},
|
||||
template: resolve(__dirname, 'demo/index.html'),
|
||||
}),
|
||||
],
|
||||
externals: {
|
||||
react: 'var window.React',
|
||||
'react-dom': 'var window.ReactDOM',
|
||||
'prop-types': 'var (window.PropTypes || window.React.PropTypes)',
|
||||
'@alifd/next': 'var window.Next',
|
||||
},
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
},
|
||||
devServer: {
|
||||
contentBase: resolve(__dirname, 'docs/demo'),
|
||||
publicPath: '/',
|
||||
port: 9000,
|
||||
host: '127.0.0.1',
|
||||
watchContentBase: true,
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
};
|
||||
46
webpack.config.prod.js
Executable file
46
webpack.config.prod.js
Executable file
@@ -0,0 +1,46 @@
|
||||
const webpack = require('webpack');
|
||||
const { resolve } = require('path');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
context: resolve(__dirname, 'src'),
|
||||
entry: {
|
||||
index: './index.js',
|
||||
antd: './antd.js',
|
||||
fusion: './fusion.js',
|
||||
},
|
||||
devtool: 'inline-source-map',
|
||||
output: {
|
||||
filename: '[name].js',
|
||||
path: resolve(__dirname, 'dist'),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn/),
|
||||
],
|
||||
externals: {
|
||||
react: 'var window.React',
|
||||
'react-dom': 'var window.ReactDOM',
|
||||
'prop-types': 'var (window.PropTypes || window.React.PropTypes)',
|
||||
'@alifd/next': 'var window.Next',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
use: ['babel-loader'],
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: ['style-loader', 'css-loader', 'sass-loader'],
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user