This commit is contained in:
Tw93
2019-09-30 17:29:09 +08:00
parent 6018f74384
commit 37bf78c8e6
93 changed files with 23771 additions and 1 deletions

6
.babelrc Executable file
View File

@@ -0,0 +1,6 @@
{
"presets": ["env", "react", "stage-0"],
"plugins": [
"transform-decorators-legacy"
]
}

17
.editorconfig Executable file
View 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
View File

@@ -0,0 +1,6 @@
node_modules/
docs/
demo/
examples/
webpack.config.js
test/

31
.eslintrc Executable file
View 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
View File

@@ -9,3 +9,4 @@ node_modules/
npm-debug.log
.idea/
.vscode/
/docs/demo

0
.nojekyll Normal file
View File

8
.npmignore Executable file
View File

@@ -0,0 +1,8 @@
**/.*
.DS_Store
Thumbs.db
.dist/
node_modules/
mochawesome-reports/
demo/
src/

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
*.bundle.js
*.min.js
dist
docs/dist
lib
node_modules
atom.css

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "es5"
}

4
CHANGELOG.md Normal file
View File

@@ -0,0 +1,4 @@
# Change Log
### 0.1.0
- [+]对外开源的第一个版本

41
CONTRIBUTING.md Normal file
View 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
View File

@@ -1 +1,162 @@
### In preparation
<img src="https://img.alicdn.com/tfs/TB17UtINiLaK1RjSZFxXXamPFXa-606-643.png" width="146px">
# FormRender
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)
> 通过 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 协议
* 请自由地享受和参与开源

3301
atom.css Normal file

File diff suppressed because it is too large Load Diff

33
demo/FrDemo.js Normal file
View 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
View 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
View 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 }));

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

13
docs/_sidebar.md Normal file
View 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
View File

@@ -0,0 +1,10 @@
# 样式覆盖
### 概述
- Form Render 其实是有一个默认的布局样式比如说表单之间的间距label 字号、颜色等场景
- 假如使用上有特殊定制,或者认为不符合要求的地方,可以通过常规 CSS 样式覆盖的方式,写到业务 global 样式代码中
### 如何使用
![](https://img.alicdn.com/tfs/TB1NEHdKpzqK1RjSZFoXXbfcXXa-2782-1488.png)

21
docs/depend.md Normal file
View 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
View 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] 可以匹配 az 范围内的任意小写字母字符。 |
| [^a-z] | 负值字符范围。匹配任何不在指定范围内的任意字符。 例如,’[^a-z] 可以匹配任何不在 az 范围内的任意字符。 |
| \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
View 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
View 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
View 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
View 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
View 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
View 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>

1
index.js Executable file
View File

@@ -0,0 +1 @@
module.exports = require('./lib/index.js');

15058
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
package.json Executable file
View 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
View 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
View 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">(&nbsp;{description}&nbsp;)</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
View 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 默认使用listarray?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
View 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
View 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
View 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;

View 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
View 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
View 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}`;
}
// 由于有人填写redrgba(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;
};

View 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;
// 获得titlevalue值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);
};

View 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
View 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
View 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;

View 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}
/>
);
}

View 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>
);
}

View 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
View 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}
/>
);
}

View 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}
/>
);
}

View 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;

View 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',
};

View 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
View 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
View File

@@ -0,0 +1,3 @@
import map from '../common/map';
export default map;

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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',
};

View 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
View 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}
/>
);
}
}

View File

@@ -0,0 +1,3 @@
import map from '../common/map';
export default map;

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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}
/>
);
}

View 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
View 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
View 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
View 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'],
},
],
},
};