feat: client v0.6 (#150)

* v0.6

* update...

* feat: improve code

* improve code

* action & form

* update...

* improve code

* improve code

* improve code

* designable

* update

* update...

* api client

* RecordProvider

* collection manager

* update...

* update api client

* update use request

* update

* update doc

* test cases for compose

* docs: improve documentation
This commit is contained in:
chenos 2022-01-10 19:22:21 +08:00 committed by GitHub
parent 72a968b29d
commit a87a089acf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
146 changed files with 5799 additions and 51 deletions

View File

@ -6,5 +6,9 @@ export default defineConfig({
logo: 'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png',
outputPath: 'docs-dist',
mode: 'site',
resolve: {
includes: ['docs', 'packages/client'],
},
hash: true,
// more config: https://d.umijs.org/config
});

View File

@ -1,21 +0,0 @@
---
hero:
title: umilib
desc: umilib site example
actions:
- text: Getting Started
link: /components
features:
- icon: https://gw.alipayobjects.com/zos/bmw-prod/881dc458-f20b-407b-947a-95104b5ec82b/k79dm8ih_w144_h144.png
title: Feature 1
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d60657df-0822-4631-9d7c-e7a869c2f21c/k79dmz3q_w126_h126.png
title: Feature 2
desc: Balabala
- icon: https://gw.alipayobjects.com/zos/bmw-prod/d1ee0c6f-5aed-4a45-a507-339a4bfe076c/k7bjsocq_w144_h144.png
title: Feature 3
desc: Balabala
footer: Open-source MIT Licensed | Copyright © 2020<br />Powered by [dumi](https://d.umijs.org)
---
## Hello umilib!

View File

@ -9,6 +9,7 @@
"bootstrap": "lerna bootstrap",
"clean": "rimraf -rf packages/*/{lib,esm,dist} && lerna clean",
"build": "lerna run build",
"build-docs": "dumi build",
"test": "jest",
"lint": "eslint ."
},
@ -17,6 +18,7 @@
"@types/react-dom": "^17.0.0"
},
"devDependencies": {
"@testing-library/react": "^12.1.2",
"@types/jest": "^26.0.0",
"@types/koa": "^2.13.4",
"@types/koa-bodyparser": "^4.3.4",
@ -26,6 +28,7 @@
"@types/react-dom": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^4.9.1",
"@typescript-eslint/parser": "^4.8.2",
"antd": "^4.16.11",
"cross-env": "^5.2.0",
"dotenv": "^10.0.0",
"dumi": "^1.1.33",

View File

@ -0,0 +1,61 @@
---
toc: menu
---
# 参与贡献
客户端的模块都在 `src` 目录下,各自模块独立。
## 客户端模块
最小单元模块化,一个完整的模块包含:
```bash
|- /__tests__/ # 测试文件目录
|- /demos/ # demo 目录
|- index.ts # 最好不要直接在 index.ts 文件里写代码index 文件只负责 export
|- index.md # 文档,默认为英文
|- index.zh-CN.md # 中文文档,可缺失,可以先都写到 index.md 里
```
## 测试
详细文档见 [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/)
```tsx | pure
import React from 'react';
import { render } from '@testing-library/react';
import { compose } from '../';
describe('compose', () => {
it('case 1', () => {
const A: React.FC = (props) => (
<div>
<h1>A</h1>
{props.children}
</div>
);
const App = compose(A)();
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
});
```
## Demo
组件 Demo 见 [dumi](https://d.umijs.org/guide/basic#write-component-demo),可以直接写在文档里,如:
<pre lang="markdown">
```jsx
import React from 'react';
export default () => <h1>Hello NocoBase!</h1>;
```
</pre>
也可以引用 demo 文件
```markdown
<code src="./demos/dmeo1.tsx"/>
```

854
packages/client/intro.md Normal file
View File

@ -0,0 +1,854 @@
---
order: 1
---
# 客户端内核
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/5be7ebc2f47effef85be7a0c75cf76f9.png" style="max-width: 800px;" />
示例:
```tsx | pure
const app = new Application();
app.use([MemoryRouter, { initialEntries: ['/'] }]);
app.use(({ children }) => {
const location = useLocation();
if (location.pathname === '/hello') {
return <div>Hello NocoBase!</div>;
}
return children;
});
export default app.compose();
```
## RouteSwitch
稍微复杂的应用都会用到路由来管理前端的页面,如下:
```jsx
/**
* defaultShowCode: true
* title: Router
*/
import React from 'react';
import { Route, Switch, Link, MemoryRouter as Router } from 'react-router-dom';
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const App = () => (
<Router initialEntries={['/']}>
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link>
<Switch>
<Route exact path="/">
<Home />
</Route>
<Route path="/about">
<About />
</Route>
</Switch>
</Router>
);
export default App;
```
上述例子,组件经由路由转发,`/` 转发给 `Home``/about` 转发给 `About`。这种 JSX 的写法,对于熟悉 JSX 的开发来说,十分便捷,但需要开发来编写和维护,不符合 NocoBase 低代码、无代码的设计理念。所以将 Route 做了封装和配置化改造,如下:
```tsx
/**
* defaultShowCode: true
* title: RouteSwitch
*/
import React from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom';
import { RouteRedirectProps, RouteSwitchProvider, RouteSwitch } from '@nocobase/client';
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const routes: RouteRedirectProps[] = [
{
type: 'route',
path: '/',
exact: true,
component: 'Home',
},
{
type: 'route',
path: '/about',
component: 'About',
},
];
export default () => {
return (
<RouteSwitchProvider components={{ Home, About }}>
<Router initialEntries={['/']}>
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link>
<RouteSwitch routes={routes} />
</Router>
</RouteSwitchProvider>
);
};
```
- 由 RouteSwitchProvider 配置 components由开发编写以 Layout 或 Template 的方式提供给 RouteSwitch 使用。
- 由 RouteSwitch 配置 routesJSON 的方式,可以由后端获取,方便后续的动态化、无代码的支持。
## SchemaComponent
路由可以通过 JSON 的方式配置,可以注册诸多可供路由使用的组件模板,以方便各种场景支持,但是这些组件还是需要开发编写和维护,所以进一步将组件抽象,转换成配置化的方式。如:
```tsx
/**
* defaultShowCode: true
* title: Schema Component
*/
import React from 'react';
import { ISchema } from '@formily/react';
import { SchemaComponentProvider, SchemaComponent } from '@nocobase/client';
const schema: ISchema = {
name: 'hello',
'x-component': 'Hello',
'x-component-props': {
name: 'World',
},
};
const Hello = ({ name }) => <h1>Hello {name}!</h1>;
export default function App() {
return (
<SchemaComponentProvider components={{ Hello }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
)
};
```
可以通过 schema 方式配置的组件,称之为 schema 组件。在 SchemaComponentProvider 里注册各种 JSX 组件,编写相应的 schema再通过 SchemaComponent 渲染。SchemaComponent 的 schema 就是 Formily 的 [Schema](https://react.formilyjs.org/zh-CN/api/shared/schema)。实际上 SchemaComponent 就是 Formily 的 [SchemaField](https://react.formilyjs.org/zh-CN/api/components/schema-field),之所以叫 SchemaComponent是因为 SchemaComponent 可用于构建页面的各个部分,不局限于表单场景。
**思维转换:**
虽然 Formily 的核心是致力于解决表单的复杂问题但是随着不断的演变已经不局限在表单层面了。Formily 核心提供了 Form 和 Field 两个非常重要的模型Form 提供了路径系统和联动模型也同样适用于页面视图Field 实际上也可以理解为组件,分为有值组件和无值组件两类,有值组件例如 Input、Select 等,无值组件例如 Drawer、Button 等。有值组件的数值又可以分不同类型String、Number、Boolean、Object、Array 等等,无值组件没有数值,所以用 Void 表示,和 Formily 的 Field、ArrayField、ObjectField、VoidField 都对应上了。
为了适应动态的配置化表单解决方案Formily 又提炼了 Schema 协议DSL这个协议完全适用于描述组件模型用类 JSON Schema 的语法描述组件结构和对应的数值类型,这个 Schema 也是 SchemaComponent 的重要组成部分。
- Schema 是一个树结构,多个节点以树形结构连接起来,其中的一个 property 表示的就是其中的一个 Schema 节点。
- 单 Schema 节点(不包括 properties由核心 `x-component`、包装器 `x-decorator`、设计器 `x-designable` 三个组件构成。
- `x-component` 核心组件
- `x-decorator` 包装器,不同场景中,同一个核心组件,可能使用不同的包装器,如 FormItem、CardItem、BlockItem、Editable 等
- `x-designable` 节点设计器NocoBase 的扩展参数),一般为当前 schema 节点的配置表单。与 Formily 提供的 Designable 解决方案不同,`x-designable` 直接作用于当前 schema 节点,使用和配置不分离。
理论上,很多现有组件都可以直接转为 schema 组件,但是并不一定好用。以 Drawer 为例,常规 JSX 的写法一般是这样的:
```tsx
/**
* defaultShowCode: true
* title: JSX Drawer
*/
import React, { useState } from 'react';
import { Drawer, Button } from 'antd';
const App: React.FC = () => {
const [visible, setVisible] = useState(false);
const showDrawer = () => {
setVisible(true);
};
const onClose = () => {
setVisible(false);
};
return (
<>
<Button type="primary" onClick={showDrawer}>
Open
</Button>
<Drawer
title="Basic Drawer"
placement="right"
onClose={onClose}
visible={visible}
footer={
<Button onClick={onClose}>关闭</Button>
}
>
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</Drawer>
</>
);
};
export default App;
```
将组件转换成 schema如果 1:1 转换是这样的:
```tsx
/**
* defaultShowCode: true
* title: Drawer Schema
*/
import React, { useMemo } from 'react';
import { SchemaComponentProvider, SchemaComponent } from '@nocobase/client';
import { Drawer as AntdDrawer, Button } from 'antd';
import { createForm } from '@formily/core';
import { RecursionField } from '@formily/react';
const Drawer = (props) => {
const { footerSchema, ...others } = props;
return (
<AntdDrawer
footer={
footerSchema && <RecursionField schema={footerSchema} onlyRenderProperties />
}
{...others}
/>
);
};
const schema = {
type: 'object',
properties: {
b1: {
type: 'void',
'x-component': 'Button',
'x-component-props': {
children: 'Open',
type: 'primary',
onClick: '{{showDrawer}}',
},
},
d1: {
type: 'void',
'x-component': 'Drawer',
'x-component-props': {
title: 'Basic Drawer',
onClose: '{{onClose}}',
footerSchema: {
type: 'object',
properties: {
fb1: {
type: 'void',
'x-component': 'Button',
'x-component-props': {
children: 'Close',
onClick: '{{onClose}}',
},
},
},
},
},
},
},
};
export default function App() {
const form = useMemo(() => createForm(), []);
const showDrawer = () => {
form.query('d1').take((field) => {
field.componentProps.visible = true;
});
};
const onClose = () => {
form.query('d1').take((field) => {
field.componentProps.visible = false;
});
};
return (
<SchemaComponentProvider
form={form}
components={{ Drawer, Button }}
>
<SchemaComponent schema={schema} scope={{ showDrawer, onClose }} />
</SchemaComponentProvider>
);
}
```
这个例子讲述了怎么将组件转换为可 Schema 配置,虽然达成了某种效果,但并不是一个很好的示例。
- 一个 property 就是一个 Schema 节点Drawer 的 Schema 由平行的两个 schema 节点组成,不利于管理;
- 需要额外的自定义 scope 支持 drawer 组件 visible 的状态管理,而且这里自定义的 scope 复用性差;
- footer 需要特殊处理。在 x-component-props 里加了个 footerSchema 参数。但这个 footerSchema 并不是一个常规的 schema 节点,因为不是在 properties 里,不利于后端 schema 存储的统一规划;
- 删除 drawer需要删除两个 schema 节点;
- 后端如何输出 drawer 这部分的 schema 也非常不方便,因为 drawer 由平行的两个节点组成。
为了解决上述问题,从结构上做了一些改良:
```ts
{
type: 'object',
properties: {
a1: {
'x-component': 'Action',
title: 'Open',
properties: {
d1: {
'x-component': 'Action.Drawer',
title: 'Drawer Title',
properties: {
c1: {
'x-content': 'Hello',
},
f1: {
'x-component': 'Action.Drawer.Footer',
properties: {
a1: {
'x-component': 'Action',
title: 'Close',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
},
},
},
},
},
},
},
}
```
以上示例,自定义了一个 Action 组件,用于配置按钮操作,又扩展了 Action.Drawer 和 Action.Drawer.Footer 两个特殊节点,分别用于配置抽屉弹框和抽屉的 footer。以上 schema 是个标准的组件树结构层次十分分明。组件树的各个节点层次分明schema 的增删改查就完全是标准流程了。
- 查询 Drawer 的 schema只需要把 a1 节点的 json 全部输出就可以。
- 修改各个节点和子节点都可以单节点独立处理,逻辑一致。
- 删除时,直接删除不需要的节点就可以了。
- 有利于扩展,比如继续增加 Action.Modal、Action.Modal.Footer 两个节点用于配置对话框。
Action.Drawer 完整的例子如下:
```tsx
/**
* title: Action.Drawer
*/
import React, { createContext, useContext, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Drawer } from 'antd';
import { SchemaComponentProvider, SchemaComponent } from '@nocobase/client';
import { observer, RecursionField, useField, useFieldSchema, ISchema } from '@formily/react';
const VisibleContext = createContext(null);
const useA = () => {
return {
async run() {},
};
};
function useCloseAction() {
const [, setVisible] = useContext(VisibleContext);
return {
async run() {
setVisible(false);
},
};
}
const Action: any = observer((props: any) => {
const { useAction = useA, onClick, ...others } = props;
const [visible, setVisible] = useState(false);
const schema = useFieldSchema();
const field = useField();
const { run } = useAction();
return (
<VisibleContext.Provider value={[visible, setVisible]}>
<Button
{...others}
onClick={() => {
onClick && onClick();
setVisible(true);
run();
}}
>
{schema.title}
</Button>
<RecursionField basePath={field.address} schema={schema} onlyRenderProperties />
</VisibleContext.Provider>
);
});
Action.Drawer = observer((props: any) => {
const [visible, setVisible] = useContext(VisibleContext);
const schema = useFieldSchema();
const field = useField();
return (
<>
{createPortal(
<Drawer
title={schema.title}
visible={visible}
onClose={() => setVisible(false)}
footer={
<RecursionField
basePath={field.address}
schema={schema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] === 'Action.Drawer.Footer';
}}
/>
}
>
<RecursionField
basePath={field.address}
schema={schema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] !== 'Action.Drawer.Footer';
}}
/>
</Drawer>,
document.body,
)}
</>
);
});
Action.Drawer.Footer = observer((props: any) => {
const field = useField();
const schema = useFieldSchema();
return <RecursionField basePath={field.address} schema={schema} onlyRenderProperties />;
});
const schema: ISchema = {
type: 'object',
properties: {
a1: {
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
title: 'Open',
properties: {
d1: {
'x-component': 'Action.Drawer',
title: 'Drawer Title',
properties: {
c1: {
'x-content': 'Hello',
},
f1: {
'x-component': 'Action.Drawer.Footer',
properties: {
a1: {
'x-component': 'Action',
title: 'Close',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
},
},
},
},
},
},
},
};
export default function App() {
return (
<SchemaComponentProvider components={{ Action }} scope={{ useCloseAction }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
}
```
## RouteSwitch + SchemaComponent
当路由和组件都可以配置之后,可以进一步将二者结合,例子如下:
```tsx
/**
* defaultShowCode: true
* title: RouteSwitch + SchemaComponent
*/
import React, { useMemo } from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom';
import {
RouteRedirectProps,
RouteSwitchProvider,
RouteSwitch,
useRoute,
SchemaComponentProvider,
SchemaComponent,
useDesignable,
} from '@nocobase/client';
import { Spin, Button } from 'antd';
import { observer, Schema } from '@formily/react';
const Hello = observer(({ name }) => {
const { patch, remove } = useDesignable();
return (
<div>
<h1>Hello {name}!</h1>
<Button
onClick={() => {
patch('x-component-props.name', Math.random());
}}
>更新</Button>
</div>
)
});
const RouteSchemaComponent = (props) => {
const route = useRoute();
return <SchemaComponent schema={route.schema}/>
}
const routes: RouteRedirectProps[] = [
{
type: 'route',
path: '/',
exact: true,
component: 'RouteSchemaComponent',
schema: {
name: 'home',
'x-component': 'Hello',
'x-component-props': {
name: 'Home',
},
},
},
{
type: 'route',
path: '/about',
component: 'RouteSchemaComponent',
schema: {
name: 'home',
'x-component': 'Hello',
'x-component-props': {
name: 'About',
},
},
},
];
export default () => {
return (
<SchemaComponentProvider components={{ Hello }}>
<RouteSwitchProvider components={{ RouteSchemaComponent }}>
<Router initialEntries={['/']}>
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link>
<RouteSwitch routes={routes} />
</Router>
</RouteSwitchProvider>
</SchemaComponentProvider>
);
};
```
以上例子实现了路由和组件层面的配置化,在开发层面配置了两个组件:
- `<RouteSchemaComponent/>` 简易的可以在路由里配置 schema 的方案
- `<Hello/>` 自定义的 Schema 组件
为了让大家更加能感受到 Schema 组件的不一样之处,例子添加了一个简易的随机更新 `x-component-props.name` 值的按钮,当路由切换后,更新后的 name 并不会被重置。这也是 Schema 组件的 Designable 的能力,可以任意的动态更新 schema 配置,实时更新,实时渲染。
## Designable
SchemaComponent 基于 Formily 的 SchemaFieldFormily 提供了 [Designable](https://github.com/alibaba/designable) 来解决 Schema 的配置问题,但是这套方案:
- 需要维护两套代码,以 antd 为例,需要同时维护 @formily/antd@designable/formily-antd 两套代码
- 使用和设计分离,在设计器界面表单无法正常工作
另辟蹊径NocoBase 构想了一种更为便捷的配置方案,使用和配置也可以兼顾,只需要维护一套代码。为此,提炼了一个简易的 `useDesignable()` Hook可用于任意 Schema 组件中,动态配置 Schema实时更新实时渲染。
Hook API
```ts
const {
designable, // 是否可以配置
patch, // 更新当前节点配置
remove, // 移除当前节点
insertAdjacent, // 在当前节点的相邻位置插入四个位置beforeBegin、afterBegin、beforeEnd、afterEnd
insertBeforeBegin, // 在当前节点的前面插入
insertAfterBegin, // 在当前节点的第一个子节点前面插入
insertBeforeEnd, // 在当前节点的最后一个子节点后面
insertAfterEnd, // 在当前节点的后面
} = useDesignable();
const schema = {
'x-component': 'Hello',
};
// 在当前节点的前面插入
insertBeforeBegin(schema);
// 等同于
insertAdjacent('beforeBegin', schema);
// 在当前节点的第一个子节点前面插入
insertAfterBegin(schema);
// 等同于
insertAdjacent('afterBegin', schema);
// 在当前节点的最后一个子节点后面
insertBeforeEnd(schema);
// 等同于
insertAdjacent('beforeEnd', schema);
// 在当前节点的后面
insertAfterEnd(schema);
// 等同于
insertAdjacent('afterEnd', schema);
```
insertAdjacent 的几个插入的位置:
```ts
{
properties: {
// beforeBegin 在当前节点的前面插入
node1: {
properties: {
// afterBegin 在当前节点的第一个子节点前面插入
// ...
// beforeEnd 在当前节点的最后一个子节点后面
},
},
// afterEnd 在当前节点的后面
},
}
```
并不是所有场景都能使用 hook所以提供了 `createDesignable()` 方法(实际上 `useDesignable()` 也是基于它来实现):
```ts
const dn = createDesignable({
current: schema,
});
dn.on('afterInsertAdjacent', (position, schema) => {
});
dn.insertAfterEnd(schema);
```
相关例子如下:
<code src="./src/schema-component/demos/demo1.tsx" />
insertAdjacent 操作不仅可以用于新增节点,也可以用于现有节点的位置移动,如以下拖拽排序的例子:
```tsx
/**
* title: 拖拽排序
*/
import React from 'react';
import { uid } from '@formily/shared';
import { observer, useField, useFieldSchema } from '@formily/react';
import { DndContext, DragEndEvent, useDraggable, useDroppable } from '@dnd-kit/core';
import { SchemaComponent, SchemaComponentProvider, createDesignable, useDesignable } from '@nocobase/client';
const useDragEnd = () => {
const { refresh } = useDesignable();
return ({ active, over }: DragEndEvent) => {
const activeSchema = active?.data?.current?.schema;
const overSchema = over?.data?.current?.schema;
if (!activeSchema || !overSchema) {
return;
}
const dn = createDesignable({
current: overSchema,
});
dn.on('afterInsertAdjacent', refresh);
dn.insertBeforeBeginOrAfterEnd(activeSchema);
};
};
const Page = observer((props) => {
return <DndContext onDragEnd={useDragEnd()}>{props.children}</DndContext>;
});
function Draggable(props) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: props.id,
data: props.data,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
{props.children}
</button>
);
}
function Droppable(props) {
const { isOver, setNodeRef } = useDroppable({
id: props.id,
data: props.data,
});
const style = {
color: isOver ? 'green' : undefined,
};
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
);
}
const Block = observer((props) => {
const field = useField();
const fieldSchema = useFieldSchema();
return (
<Droppable id={field.address.toString()} data={{ schema: fieldSchema }}>
<div style={{ marginBottom: 20, padding: '20px', background: '#f1f1f1' }}>
Block {fieldSchema.name}{' '}
<Draggable id={field.address.toString()} data={{ schema: fieldSchema }}>
Drag
</Draggable>
</div>
</Droppable>
);
});
export default function App() {
return (
<SchemaComponentProvider components={{ Page, Block }}>
<SchemaComponent
schema={{
type: 'void',
name: 'page',
'x-component': 'Page',
properties: {
block1: {
'x-component': 'Block',
},
block2: {
'x-component': 'Block',
},
block3: {
'x-component': 'Block',
},
},
}}
/>
</SchemaComponentProvider>
);
}
```
## APIClient
在 WEB 应用里,客户端请求无处不在。为了便于客户端请求,提供的 API 有:
- APIClient客户端 SDK
- APIClientProvider提供 APIClient 实例的 Context全局共享
- useRequest():需要结合 APIClientProvider 来使用
- useApiClient():获取到当前配置的 apiClient 实例
```tsx | pure
const api = new APIClient({
request, // 将 request 抛出去,方便各种自定义适配
});
api.request(options);
api.resource(name);
<APIClientProvider apiClient={api}>
{/* children */}
</APIClientProvider>
```
useRequest() 需要结合 APIClientProvider 一起使用,是对 ahooks 的 useRequest 的封装,支持 resource 请求。
```ts
const { data, loading } = useRequest();
```
## Providers
客户端的扩展以 Providers 的形式存在,提供各种可供组件使用的 Context可全局也可以局部使用。上文我们已经介绍了核心的三个 Providers
- RouteSwitchProvider提供配置路由所需的 Layout 和 Template 组件
- SchemaComponentProvider提供配置 Schema 所需的各种组件
- ApiClientProvider提供客户端 SDK
除此之外,还有:
- Router实际也是 Provider提供 History 的 Context对应的有 BrowserRouterHashRouter、MemoryRouter、NativeRouter、StaticRouter 几种可选方案
- AntdConfigProvider为 antd 组件提供统一的全局化配置
- I18nextProvider提供国际化解决方案
- ACLProvider提供权限配置plugin-acl 的前端模块
- CollectionManagerProvider提供全局的数据表配置plugin-collection-manager 的前端模块
- SystemSettingsProvider提供系统设置plugin-system-settings 的前端模块
- 其他扩展
多个 Providers 需要嵌套使用:
```tsx | pure
<ApiClientProvider>
<SchemaComponentProvider>
<RouteSwitchProvider>
{...}
</RouteSwitchProvider>
</SchemaComponentProvider>
</ApiClientProvider>
```
但是这样的方式不利于 Providers 的管理和扩展,为此提炼了 `compose()` 函数用于配置多个 providers如下
<code defaultShowCode="true" titile="compose" src="./src/application/demos/demo1.tsx"/>
## Application
上文例子的 Providers 还是差点意思,再进一步封装改造:
```tsx | pure
const app = new Application({});
app.use(ApiClientProvider);
app.use([SchemaComponentProvider, { components: { Hello } }]);
app.use((props) => {
return (
<div>
<Link to={'/'}>Home</Link>,<Link to={'/about'}>About</Link>
<RouteSwitch routes={routes} />
</div>
);
});
app.mount('#root');
// 等于
ReactDOM.render(<App/>, document.getElementById('root'));
```
对比 NocoBase Server Application 中间件的核心实现:
```ts
app.use((ctx, next) => {});
const ctx = this.createContext(req, res)
await compose(app.middleware)(ctx);
await respond(ctx);
```
通过 app.use() 方法注册各种中间件插件,最后由 compose 来处理中间件,如果有需要也可以往 app.context 里添加各种东西待用。前端在处理 Provider 时也是类似的机制,这也是为什么客户端的扩展是以 Provider 的形式存在的原因。
从例子来看,以 Provider 的形式扩展是个不错的方案,但是还有两个问题没有解决:
- Provider 的顺序怎么处理
- 如何动态的加载前端模块
未完待续...

View File

@ -16,18 +16,33 @@
"engines": {
"npm": ">=3.0.0"
},
"dependencies": {
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/sortable": "^5.1.0",
"@emotion/css": "^11.7.1",
"@formily/antd": "^2.0.7",
"@formily/core": "^2.0.7",
"@formily/react": "^2.0.7",
"ahooks": "^3.0.5",
"axios": "^0.24.0",
"i18next": "^21.6.0",
"react-i18next": "^11.15.1"
},
"peerDependencies": {
"@types/react": ">=16.8.0 || >=17.0.0",
"@types/react-dom": ">=16.8.0 || >=17.0.0",
"antd": "^4.0.0",
"react": ">=16.8.0 || >=17.0.0",
"react-dom": ">=16.8.0",
"react-is": ">=16.8.0 || >=17.0.0"
"react-is": ">=16.8.0 || >=17.0.0",
"react-router-dom": "^5.2.0"
},
"scripts": {
"build": "rimraf -rf lib esm dist && npm run build:cjs && npm run build:esm",
"build:cjs": "tsc --project tsconfig.build.json",
"build:esm": "tsc --project tsconfig.build.json --module es2015 --outDir esm"
},
"dependencies": {}
"devDependencies": {
"axios-mock-adapter": "^1.20.0"
}
}

View File

@ -0,0 +1,13 @@
import React, { createContext, useContext } from 'react';
export const ACLContext = createContext(null);
export function ACLProvider() {
return (
<ACLContext.Provider value={{}}>
</ACLContext.Provider>
)
}
export default ACLProvider;

View File

@ -0,0 +1 @@
export function RoleManager() {}

View File

@ -0,0 +1,12 @@
---
nav:
title: Client
path: /client
group:
title: Client
path: /client
---
# ACL <Badge>待定</Badge>
访问控制列表plugin-acl 的前端模块

View File

@ -0,0 +1,2 @@
export * from './ACLProvider';
export * from './RolePermissionManager';

View File

@ -0,0 +1,53 @@
import React, { useContext } from 'react';
import { createPortal } from 'react-dom';
import { Drawer } from 'antd';
import { VisibleContext } from './context';
import { ComposedActionDrawer } from './types';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
export const ActionDrawer: ComposedActionDrawer = observer((props) => {
const [visible, setVisible] = useContext(VisibleContext);
const schema = useFieldSchema();
const field = useField();
return (
<>
{createPortal(
<Drawer
title={schema.title}
{...props}
destroyOnClose
visible={visible}
onClose={() => setVisible(false)}
footer={
<RecursionField
basePath={field.address}
schema={schema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] === 'Action.Drawer.Footer';
}}
/>
}
>
<RecursionField
basePath={field.address}
schema={schema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] !== 'Action.Drawer.Footer';
}}
/>
</Drawer>,
document.body,
)}
</>
);
});
ActionDrawer.Footer = observer(() => {
const field = useField();
const schema = useFieldSchema();
return <RecursionField basePath={field.address} schema={schema} onlyRenderProperties />;
});
export default ActionDrawer;

View File

@ -0,0 +1,34 @@
import React, { useState } from 'react';
import { Button, ButtonProps } from 'antd';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { useA } from './hooks';
import { VisibleContext } from './context';
import { ComposedAction } from './types';
import { ActionDrawer } from './Action.Drawer';
export const Action: ComposedAction = observer((props) => {
const { useAction = useA, onClick, ...others } = props;
const [visible, setVisible] = useState(false);
const schema = useFieldSchema();
const field = useField();
const { run } = useAction();
return (
<VisibleContext.Provider value={[visible, setVisible]}>
<Button
{...others}
onClick={(e) => {
onClick && onClick(e);
setVisible(true);
run();
}}
>
{schema.title}
</Button>
<RecursionField basePath={field.address} schema={schema} onlyRenderProperties />
</VisibleContext.Provider>
);
});
Action.Drawer = ActionDrawer;
export default Action;

View File

@ -0,0 +1,3 @@
import { createContext } from 'react';
export const VisibleContext = createContext(null);

View File

@ -0,0 +1,66 @@
import React from 'react';
import { observer, ISchema, useForm } from '@formily/react';
import { SchemaComponent, SchemaComponentProvider, Form, Action, useActionVisible } from '@nocobase/client';
import { FormItem, Input } from '@formily/antd';
import 'antd/dist/antd.css';
const useCloseAction = () => {
const { setVisible } = useActionVisible();
const form = useForm();
return {
async run() {
setVisible(false);
form.submit((values) => {
console.log(values);
});
},
};
};
const schema: ISchema = {
type: 'object',
properties: {
action1: {
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
type: 'void',
title: 'Open',
properties: {
drawer1: {
'x-component': 'Action.Drawer',
type: 'void',
title: 'Drawer Title',
properties: {
hello1: {
'x-content': 'Hello',
title: 'T1',
},
footer1: {
'x-component': 'Action.Drawer.Footer',
type: 'void',
properties: {
close1: {
title: 'Close',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
},
},
},
},
},
},
},
};
export default observer(() => {
return (
<SchemaComponentProvider scope={{ useCloseAction }} components={{ Form, Action, Input, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
});

View File

@ -0,0 +1,27 @@
import { useForm } from '@formily/react';
import { useContext } from 'react';
import { VisibleContext } from './context';
export const useA = () => {
return {
async run() {},
};
};
export const useActionVisible = () => {
const [visible, setVisible] = useContext(VisibleContext);
return { visible, setVisible };
};
export const useCloseAction = () => {
const [, setVisible] = useContext(VisibleContext);
const form = useForm();
return {
async run() {
setVisible(false);
form.submit((values) => {
console.log(values);
});
},
};
};

View File

@ -0,0 +1,33 @@
---
nav:
path: /client
group:
title: Schema Components
path: /schema-components
---
# Action <Badge>待定</Badge>
## Nodes
已确定的节点:
- Action
- Action.URL
- Action.Link
- Action.Drawer
- Action.Drawer.Footer
- Action.Modal
- Action.Modal.Footer
- Action.Popover
不确定的节点:
- Action.Group
- Action.Dropdown
- Action.Window
- ActionBar
## Examples
<code src="./demos/demo1.tsx"/>

View File

@ -0,0 +1,3 @@
export * from './Action';
export * from './context';
export * from './hooks';

View File

@ -0,0 +1,16 @@
import { ButtonProps, DrawerProps } from 'antd';
export type ActionProps = ButtonProps & {
useAction?: () => {
run(): Promise<void>;
};
};
export type ComposedAction = React.FC<ActionProps> & {
Drawer?: ComposedActionDrawer;
[key: string]: any;
};
export type ComposedActionDrawer = React.FC<DrawerProps> & {
Footer?: React.FC;
};

View File

@ -0,0 +1,27 @@
---
nav:
path: /client
group:
title: Route Components
path: /route-components
---
# AdminLayout <Badge>待定</Badge>
结构
- Layout
- Layout.Header
- SiteTitle
- MenuSchemaComponent
- PluginActionBar
- Designable.Action引用
- CollectionManager.Action引用
- ACL.Action引用
- SystemSettings.Action引用
- CurrentUser.Dropdown引用
- Layout.Sider
- SideMenu随 Menu 联动的)
- Layout.Content
- PageTitle
- SchemaComponent

View File

@ -0,0 +1,12 @@
import React from 'react';
export function AdminLayout(props: any) {
return (
<div>
</div>
);
}
export default AdminLayout;

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /client
---
# AntdConfigProvider
为 antd 组件提供统一的全局化配置

View File

@ -0,0 +1,10 @@
import React, { createContext } from 'react';
import { I18nextProvider, useTranslation } from 'react-i18next';
import { ConfigProvider, Spin } from 'antd';
import enUS from 'antd/lib/locale/en_US';
import zhCN from 'antd/lib/locale/zh_CN';
export function AntdConfigProvider(props) {
const { i18n } = useTranslation();
return <ConfigProvider locale={i18n.language === 'zh-CN' ? zhCN : enUS}>{props.children}</ConfigProvider>;
}

View File

@ -0,0 +1,62 @@
import axios, { Axios, AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios';
export interface ActionParams {
[key: string]: any;
}
type ResourceActionOptions<P = any> = {
resource?: string;
resourceOf?: any;
action?: string;
params?: P;
};
export interface IResource {
list?: (params?: ActionParams) => Promise<any>;
get?: (params?: ActionParams) => Promise<any>;
create?: (params?: ActionParams) => Promise<any>;
update?: (params?: ActionParams) => Promise<any>;
destroy?: (params?: ActionParams) => Promise<any>;
}
export class APIClient {
axios: AxiosInstance;
constructor(instance?: AxiosInstance | AxiosRequestConfig) {
if (typeof instance === 'function') {
this.axios = instance;
} else {
this.axios = axios.create(instance);
}
}
request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D> | ResourceActionOptions): Promise<R> {
const { resource, resourceOf, action, params } = config as any;
if (resource) {
return this.resource(resource, resourceOf)[action](params);
}
return this.axios.request<T, R, D>(config);
}
resource(name: string, of?: any): IResource {
const target = {};
const handler = {
get: (_: any, actionName: string) => {
let url = name.split('.').join(`/${of || '_'}/`);
url += `:${actionName}`;
const config: AxiosRequestConfig = { url };
if (['get', 'list'].includes(actionName)) {
config['method'] = 'get';
} else {
config['method'] = 'post';
}
return (params?: ActionParams) => {
config[config.method === 'get' ? 'params' : 'data'] = { __params__: params };
console.log({ config });
return this.request(config);
};
},
};
return new Proxy(target, handler);
}
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import { APIClient } from './APIClient';
import { APIClientContext } from './context';
export interface APIClientProviderProps {
apiClient: APIClient;
}
export const APIClientProvider: React.FC<APIClientProviderProps> = (props) => {
const { apiClient, children } = props;
return <APIClientContext.Provider value={apiClient}>{children}</APIClientContext.Provider>;
};

View File

@ -0,0 +1,26 @@
import axios from 'axios';
import { APIClient } from '../APIClient';
describe('APIClient', () => {
describe('axios', () => {
it('case 1', () => {
const apiClient = new APIClient();
expect(apiClient.axios).toBeDefined();
expect(typeof apiClient.axios).toBe('function');
expect(typeof apiClient.axios.request).toBe('function');
});
it('case 2', () => {
const apiClient = new APIClient({
baseURL: 'http://localhost/api/',
});
expect(apiClient.axios.defaults.baseURL).toBe('http://localhost/api/');
});
it('case 3', () => {
const instance = axios.create();
const apiClient = new APIClient(instance);
expect(apiClient.axios).toBe(instance);
});
})
});

View File

@ -0,0 +1,4 @@
import { createContext } from 'react';
import { APIClient } from './APIClient';
export const APIClientContext = createContext<APIClient>(new APIClient());

View File

@ -0,0 +1,24 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { APIClient, APIClientProvider, useRequest, compose } from '@nocobase/client';
const apiClient = new APIClient();
const mock = new MockAdapter(apiClient.axios);
mock.onGet('/users:get').reply(200, {
data: { id: 1, name: 'John Smith' },
});
const providers = [
[APIClientProvider, { apiClient }]
];
export default compose(...providers)(() => {
const { data } = useRequest({
resource: 'users',
action: 'get',
params: {},
});
return <div>{data?.name}</div>;
});

View File

@ -0,0 +1,3 @@
export * from './useAPIClient';
export * from './useRequest';
export * from './useResource';

View File

@ -0,0 +1,6 @@
import { useContext } from 'react';
import { APIClientContext } from '../context';
export function useAPIClient() {
return useContext(APIClientContext);
}

View File

@ -0,0 +1,36 @@
import { useContext } from 'react';
import { AxiosRequestConfig } from 'axios';
import { Options } from 'ahooks/lib/useRequest/src/types';
import { default as useReq } from 'ahooks/lib/useRequest';
import { APIClientContext } from '../context';
type FunctionService = (...args: any[]) => Promise<any>;
type ResourceActionOptions<P = any> = {
resource?: string;
resourceOf?: any;
action?: string;
params?: P;
};
export function useRequest<P>(
service: AxiosRequestConfig<P> | ResourceActionOptions<P> | FunctionService,
options?: Options<any, any>,
) {
const api = useContext(APIClientContext);
if (typeof service === 'function') {
return useReq(service, options);
}
return useReq(async () => {
const { url, resource, resourceOf, action, params } = service as any;
if (url) {
const response = await api.request(service);
return response?.data;
}
if (resource) {
const response = await api.resource(resource, resourceOf)[action](params);
return response?.data?.data;
}
return;
}, options);
}

View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { APIClientContext } from '../context';
export function useResource(name: string, of?: string | number) {
const apiClient = useContext(APIClientContext);
return apiClient.resource(name, of);
}

View File

@ -0,0 +1,139 @@
---
nav:
path: /client
group:
path: /client
---
# APIClient
## APIClient
```ts
class APIClient {
// axios 实例
axios: AxiosInstance;
// 构造器
constructor(instance?: AxiosInstance | AxiosRequestConfig);
// 客户端请求,支持 AxiosRequestConfig 和 ResourceActionOptions
request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D> | ResourceActionOptions): Promise<R>;
// 获取资源
resource<R = IResource>(name: string, of?: any): R;
}
```
示例
```ts
import axios from 'axios';
// 不传参时,内部直接创建 axios 实例
const apiClient = new APIClient();
// 提供 AxiosRequestConfig 配置参数
const apiClient = new APIClient({
baseURL: '',
});
// 提供 AxiosInstance
const instance = axios.create({
baseURL: '',
});
const apiClient = new APIClient(instance);
```
## APIClientProvider
提供 APIClient 实例的上下文。
```tsx | pure
const apiClient = new APIClient();
<APIClientProvider apiClient={apiClient}></APIClientProvider>
```
## useAPIClient
获取当前上下文的 APIClient 实例。
```ts
const apiClient = useAPIClient();
```
## useRequest
```ts
function useRequest<P>(
service: AxiosRequestConfig<P> | ResourceActionOptions<P> | FunctionService,
options?: Options<any, any>,
);
```
支持 `axios.request(config)`config 详情查看 [axios](https://github.com/axios/axios#request-config)
```ts
const { data, loading, refresh } = useRequest({ url: '/users' });
```
或者是 NocoBase 的 resource & action 请求:
```ts
const { data } = useRequest({
resource: 'users',
action: 'get',
params: {},
});
```
例子如下:
<code src="./demos/demo1.tsx" />
也可以是自定义的异步函数:
```ts
const { data, loading, refresh } = useRequest(() => Promise.resolve({}));
```
更多用法查看 ahooks 的 [useRequest()](https://ahooks.js.org/hooks/use-request/index)
## useResource
```ts
function useResource(name: string, of?: string | number): IResource;
```
资源是 NocoBase 的核心概念,包括:
- 独立资源,如 `posts`
- 关系资源,如 `posts.tags` `posts.user` `posts.comments`
资源 URI
```bash
# 独立资源,文章
/api/posts
# 关系资源,文章 ID=1 的评论
/api/posts/1/comments
```
通过 APIClient 获取资源
```ts
const api = new APIClient();
api.resource('posts');
api.resource('posts.comments', 1);
```
useResource 用法:
```ts
const resource = useResource('posts');
const resource = useResource('posts.comments', 1);
```
resource 的实际场景用例参见:
- [useCollection()](collection-manager#usecollection)
- [useCollectionField()](collection-manager#usecollectionfield)

View File

@ -0,0 +1,3 @@
export * from './hooks';
export * from './APIClient';
export * from './APIClientProvider';

View File

@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`compose case 1 1`] = `
<div>
<div>
<h1>
A
</h1>
</div>
</div>
`;
exports[`compose case 2 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
</h1>
</div>
</div>
</div>
`;
exports[`compose case 3 1`] = `
<div>
<div>
<h1>
A
</h1>
</div>
</div>
`;
exports[`compose case 4 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
</h1>
<div>
<h1>
C
</h1>
</div>
</div>
</div>
</div>
`;
exports[`compose case 5 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
1
</h1>
<div>
<h1>
C
</h1>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,95 @@
import React from 'react';
import { render } from '@testing-library/react';
import { compose } from '../';
describe('compose', () => {
it('case 1', () => {
const A: React.FC = (props) => (
<div>
<h1>A</h1>
{props.children}
</div>
);
const App = compose(A)();
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
it('case 2', () => {
const A: React.FC = (props) => (
<div>
<h1>A</h1>
{props.children}
</div>
);
const B: React.FC = (props) => (
<div>
<h1>B</h1>
{props.children}
</div>
);
const App = compose(A)(B);
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
it('case 3', () => {
const A: React.FC = (props) => (
<div>
<h1>A</h1>
{props.children}
</div>
);
const App = compose([A])();
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
it('case 4', () => {
const A: React.FC = (props) => (
<div>
<h1>A</h1>
{props.children}
</div>
);
const B: React.FC = (props) => (
<div>
<h1>B</h1>
{props.children}
</div>
);
const C: React.FC = (props) => (
<div>
<h1>C</h1>
{props.children}
</div>
);
const App = compose(A, B)(C);
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
it('case 5', () => {
const A: React.FC<any> = (props) => (
<div>
<h1>A {props.name}</h1>
{props.children}
</div>
);
const B: React.FC<any> = (props) => (
<div>
<h1>B {props.name}</h1>
{props.children}
</div>
);
const C: React.FC<any> = (props) => (
<div>
<h1>C</h1>
{props.children}
</div>
);
const App = compose(A, [B, { name: '1' }])(C);
const { container } = render(<App />);
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,18 @@
import React from 'react';
const Blank = ({ children }) => children || null;
export const compose = (...components: any[]) => {
const Root = [...components, Blank].reduce((parent, child) => {
const [Parent, parentProps] = Array.isArray(parent) ? parent : [parent];
const [Child, childProps] = Array.isArray(child) ? child : [child];
return ({ children }) => (
<Parent {...parentProps}>
<Child {...childProps}>{children}</Child>
</Parent>
);
});
return (LastChild?: any) => (props?: any) => {
return <Root>{LastChild && <LastChild {...props} />}</Root>;
};
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import { useDesignable } from '@nocobase/client';
import { Button } from 'antd';
import { observer } from '@formily/react';
export const Hello: React.FC<any> = observer(({ name }) => {
const { patch, remove } = useDesignable();
return (
<div>
<h1>Hello {name}!</h1>
<Button
onClick={() => {
patch('x-component-props.name', Math.random());
}}
>
</Button>
</div>
);
});

View File

@ -0,0 +1,7 @@
import React from 'react';
import { SchemaComponent, useRoute } from '@nocobase/client';
export const RouteSchemaComponent = () => {
const route = useRoute();
return <SchemaComponent schema={route.schema} />;
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import { Link, MemoryRouter } from 'react-router-dom';
import { RouteSwitchProvider, RouteSwitch, SchemaComponentProvider, compose } from '@nocobase/client';
import { Hello } from './Hello';
import { RouteSchemaComponent } from './RouteSchemaComponent';
import routes from './routes';
const providers = [
[MemoryRouter, { initialEntries: ['/'] }],
[SchemaComponentProvider, { components: { Hello } }],
[RouteSwitchProvider, { components: { RouteSchemaComponent } }],
];
const App = compose(...providers)(() => {
return (
<div>
<Link to={'/'}>Home</Link>,<Link to={'/about'}>About</Link>
<RouteSwitch routes={routes} />
</div>
);
});
export default App;

View File

@ -0,0 +1,29 @@
import { RouteRedirectProps } from '@nocobase/client';
export default [
{
type: 'route',
path: '/',
exact: true,
component: 'RouteSchemaComponent',
schema: {
name: 'home',
'x-component': 'Hello',
'x-component-props': {
name: 'Home',
},
},
},
{
type: 'route',
path: '/about',
component: 'RouteSchemaComponent',
schema: {
name: 'home',
'x-component': 'Hello',
'x-component-props': {
name: 'About',
},
},
},
] as Array<RouteRedirectProps>;

View File

@ -0,0 +1,14 @@
---
nav:
path: /client
group:
path: /client
---
# Application <Badge>待定</Badge>
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/5be7ebc2f47effef85be7a0c75cf76f9.png" style="max-width: 800px;" />
## compose
<code src="./demos/demo1.tsx"/>

View File

@ -0,0 +1 @@
export * from './compose';

View File

@ -0,0 +1,9 @@
---
nav:
path: /client
group:
path: /route-components
---
# AuthLayout

View File

@ -0,0 +1,10 @@
import React from 'react';
export function AuthLayout(props: any) {
return (
<div style={{ maxWidth: 320, margin: '0 auto', paddingTop: '20vh' }}>
<h1>NocoBase</h1>
{props.children}
</div>
);
}

View File

@ -0,0 +1,86 @@
import React from 'react';
import { uid } from '@formily/shared';
import { observer, useField, useFieldSchema } from '@formily/react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { SchemaComponent, SchemaComponentProvider, BlockItem, DndContext } from '@nocobase/client';
function Draggable(props) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: props.id,
data: props.data,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
{props.children}
</button>
);
}
function Droppable(props) {
const { isOver, setNodeRef } = useDroppable({
id: props.id,
data: props.data,
});
const style = {
color: isOver ? 'green' : undefined,
};
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
);
}
const Block = observer((props) => {
const fieldSchema = useFieldSchema();
const field = useField();
return (
<Droppable id={field.address.toString()} data={{ schema: fieldSchema }}>
<div style={{ marginBottom: 20, padding: '0 20px', height: 50, lineHeight: '50px', background: '#f1f1f1' }}>
Block {fieldSchema.name}
<Draggable id={field.address.toString()} data={{ schema: fieldSchema }}>
Drag
</Draggable>
</div>
</Droppable>
);
});
export default function App() {
return (
<SchemaComponentProvider components={{ DndContext, BlockItem, Block }}>
<SchemaComponent
schema={{
type: 'void',
name: 'page',
'x-component': 'DndContext',
'x-uid': uid(),
properties: {
block1: {
'x-decorator': 'BlockItem',
'x-component': 'Block',
'x-uid': uid(),
},
block2: {
'x-decorator': 'BlockItem',
'x-component': 'Block',
'x-uid': uid(),
},
block3: {
'x-decorator': 'BlockItem',
'x-component': 'Block',
'x-uid': uid(),
},
},
}}
/>
</SchemaComponentProvider>
);
}

View File

@ -0,0 +1,12 @@
---
nav:
path: /client
group:
path: /schema-components
---
# BlockItem <Badge>待定</Badge>
普通的装饰器Decorator组件无特殊 UI 效果,一般用在 x-decorator 中。用于提供区块的管理,如拖拽功能、当前节点的 SettingsForm。CardItem 和 FormItem 组件都是基于 BlockItem 实现,也具备以上相同功能。
<code src="./demos/demo1.tsx" />

View File

@ -0,0 +1,12 @@
import React from 'react';
import { useDesignable } from '../schema-component';
export const BlockItem: React.FC<any> = (props) => {
const { DesignableBar } = useDesignable();
return (
<div className="nb-block-item">
{props.children}
<DesignableBar />
</div>
);
};

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Calendar <Badge>待定</Badge>

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /schema-components
---
# CardItem
卡片装饰器。除此之外,也继承了 BlockItem 的功能。

View File

@ -0,0 +1,13 @@
import React from 'react';
import { Card } from 'antd';
import { BlockItem } from '../block-item';
export const CardItem: React.FC = (props) => {
return (
<BlockItem className={'noco-card-item'}>
<Card bordered={false} {...props}>
{props.children}
</Card>
</BlockItem>
);
};

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Cascader

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Chart <Badge>待定</Badge>

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Checkbox

View File

@ -0,0 +1,42 @@
import React, { useContext, useEffect } from 'react';
import { connect, SchemaOptionsContext, useField, useFieldSchema, useForm } from '@formily/react';
import { useCollectionField } from './hooks';
import { CollectionFieldProvider } from './CollectionFieldProvider';
import { Field, FormPath } from '@formily/core';
// TODO: 初步适配
const InternalField: React.FC = (props) => {
const field = useField<Field>();
const fieldSchema = useFieldSchema();
const { uiSchema } = useCollectionField();
const options = useContext(SchemaOptionsContext);
const component = FormPath.getIn(options?.components, uiSchema['x-component']);
const setFieldProps = (key, value) => {
field[key] = typeof field[key] === 'undefined' ? value : field[key];
};
const setRequired = () => {
if (typeof fieldSchema['required'] === 'undefined') {
field.required = !!uiSchema['required'];
}
};
// TODO: 初步适配
useEffect(() => {
setFieldProps('title', uiSchema.title);
setFieldProps('description', uiSchema.description);
setFieldProps('initialValue', uiSchema.default);
setRequired();
field.component = [component, uiSchema['x-component-props']];
}, [uiSchema.title, uiSchema.description, uiSchema.required]);
return React.createElement(component, props);
};
export const CollectionField = connect((props) => {
const fieldSchema = useFieldSchema();
return (
<CollectionFieldProvider name={fieldSchema.name}>
<InternalField {...props} />
</CollectionFieldProvider>
);
});
export default CollectionField;

View File

@ -0,0 +1,16 @@
import React from 'react';
import { merge } from '@formily/shared';
import { useCollection } from './hooks';
import { CollectionFieldOptions } from './types';
import { CollectionFieldContext } from './context';
import { SchemaKey } from '@formily/react';
export const CollectionFieldProvider: React.FC<{ name?: SchemaKey; field?: CollectionFieldOptions }> = (props) => {
const { name, field, children } = props;
const { getField } = useCollection();
return (
<CollectionFieldContext.Provider value={field || getField(field?.name || name)}>
{children}
</CollectionFieldContext.Provider>
);
};

View File

@ -0,0 +1,12 @@
import React from 'react';
import { CollectionManagerOptions } from './types';
import { CollectionManagerContext } from './context';
export const CollectionManagerProvider: React.FC<CollectionManagerOptions> = (props) => {
const { interfaces, collections } = props;
return (
<CollectionManagerContext.Provider value={{ interfaces, collections }}>
{props.children}
</CollectionManagerContext.Provider>
);
};

View File

@ -0,0 +1,14 @@
import React from 'react';
import { useCollectionManager } from './hooks';
import { CollectionOptions } from './types';
import { CollectionContext } from './context';
export const CollectionProvider: React.FC<{ name?: string; collection: CollectionOptions }> = (props) => {
const { name, collection, children } = props;
const { get } = useCollectionManager();
return (
<CollectionContext.Provider value={collection || get(collection?.name || name)}>
{children}
</CollectionContext.Provider>
);
};

View File

@ -0,0 +1,11 @@
import { createContext } from 'react';
import { CollectionFieldOptions, CollectionManagerOptions, CollectionOptions } from './types';
export const CollectionManagerContext = createContext<CollectionManagerOptions>({
collections: [],
interfaces: {},
});
export const CollectionContext = createContext<CollectionOptions>({});
export const CollectionFieldContext = createContext<CollectionFieldOptions>({});

View File

@ -0,0 +1,124 @@
import React from 'react';
import { observer, ISchema, useForm } from '@formily/react';
import {
SchemaComponent,
SchemaComponentProvider,
Form,
Action,
CollectionProvider,
CollectionField,
} from '@nocobase/client';
import 'antd/dist/antd.css';
import { FormItem, Input } from '@formily/antd';
export default observer(() => {
const collection = {
name: 'tests',
fields: [
{
type: 'string',
name: 'title1',
interface: 'input',
uiSchema: {
title: 'Title1',
type: 'string',
'x-component': 'Input',
required: true,
description: 'description1',
} as ISchema,
},
{
type: 'string',
name: 'title2',
interface: 'input',
uiSchema: {
title: 'Title2',
type: 'string',
'x-component': 'Input',
description: 'description',
default: 'ttt',
},
},
{
type: 'string',
name: 'title3',
},
],
};
const schema: ISchema = {
type: 'object',
properties: {
form1: {
type: 'void',
'x-component': 'Form',
properties: {
// 字段 title1 直接使用全局提供的 uiSchema
title1: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
default: '111',
},
// 等同于
// title1: {
// type: 'string',
// title: 'Title',
// required: true,
// 'x-component': 'Input',
// 'x-decorator': 'FormItem',
// },
title2: {
'x-component': 'CollectionField',
'x-decorator': 'FormItem',
title: 'Title4', // 覆盖全局已定义的 Title2
required: true, // 扩展的配置参数
description: 'description4',
},
// 等同于
// title2: {
// type: 'string',
// title: 'Title22',
// required: true,
// 'x-component': 'Input',
// 'x-decorator': 'FormItem',
// },
// 字段 title3 没有提供 uiSchema自行处理
title3: {
'x-component': 'Input',
'x-decorator': 'FormItem',
title: 'Title3',
required: true,
},
action1: {
// type: 'void',
'x-component': 'Action',
title: 'Submit',
'x-component-props': {
type: 'primary',
useAction: '{{ useSubmit }}',
},
},
},
},
},
};
const useSubmit = () => {
const form = useForm();
return {
async run() {
form.submit(() => {
console.log(form.values);
});
},
};
};
return (
<SchemaComponentProvider scope={{ useSubmit }} components={{ Action, Form, CollectionField, Input, FormItem }}>
<CollectionProvider collection={collection}>
<SchemaComponent schema={schema} />
</CollectionProvider>
</SchemaComponentProvider>
);
});

View File

@ -0,0 +1,67 @@
import React, { useEffect, useState } from 'react';
import {
APIClient,
APIClientProvider,
CollectionFieldProvider,
CollectionProvider,
compose,
RecordProvider,
useCollectionField,
} from '@nocobase/client';
import MockAdapter from 'axios-mock-adapter';
const apiClient = new APIClient();
const mock = new MockAdapter(apiClient.axios);
mock.onGet('/posts/1/tags:list').reply(200, {
data: [
{ id: 1, name: 'Tag 1' },
{ id: 2, name: 'Tag 2' },
],
});
const collection = {
name: 'posts',
fields: [
{
type: 'belongsToMany',
name: 'tags',
targetKey: 'id',
sourceKey: 'id',
foreignKey: 'postId',
otherKey: 'tagId',
},
],
};
const record = { id: 1 };
const providers = [
// API 客户端
[APIClientProvider, { apiClient }],
// 提供当前数据表行记录的上下文
[RecordProvider, { record }],
// 提供数据表配置的上下文
[CollectionProvider, { collection }],
// 提供字段配置上的下文
[CollectionFieldProvider, { name: 'tags' }],
];
export default compose(...providers)(() => {
const { resource } = useCollectionField();
const [items, setItems] = useState([]);
useEffect(() => {
// 请求地址为 /posts/1/tags:list
resource.list().then((response) => {
setItems(response?.data?.data);
});
}, []);
return (
<div>
{items?.map((item, key) => (
<div key={key}>{item.name}</div>
))}
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from './useCollection';
export * from './useCollectionField';
export * from './useCollectionManager';

View File

@ -0,0 +1,18 @@
import { useContext } from 'react';
import { SchemaKey } from '@formily/react';
import { CollectionFieldOptions } from '../types';
import { CollectionContext } from '../context';
import { useAPIClient } from '../../api-client';
export const useCollection = () => {
const collection = useContext(CollectionContext);
const api = useAPIClient();
const resource = api?.resource(collection.name);
return {
...collection,
resource,
getField(name: SchemaKey): CollectionFieldOptions {
return collection?.fields?.find((field) => field.name === name);
},
};
};

View File

@ -0,0 +1,17 @@
import { useContext } from 'react';
import { CollectionFieldContext } from '../context';
import { useRecord } from '../../record-provider';
import { useCollection } from './useCollection';
import { useAPIClient } from '../../api-client';
export const useCollectionField = () => {
const collection = useCollection();
const record = useRecord();
const api = useAPIClient();
const ctx = useContext(CollectionFieldContext);
const resource = api?.resource(`${collection?.name || ctx?.collectinName}.${ctx.name}`, record[ctx.sourceKey]);
return {
...ctx,
resource,
};
};

View File

@ -0,0 +1,11 @@
import { useContext } from 'react';
import { CollectionManagerContext } from '../context';
export const useCollectionManager = () => {
const { collections } = useContext(CollectionManagerContext);
return {
get(name: string) {
return collections?.find((collection) => collection.name === name);
},
};
};

View File

@ -0,0 +1,144 @@
---
nav:
path: /client
group:
path: /client
---
# CollectionManager
## Components
### CollectionManagerProvider
```jsx | pure
<CollectionManagerProvider interfaces={{}} collections={[]}></CollectionManagerProvider>
```
### CollectionProvider
```jsx | pure
const collection = {
name: 'tests',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
'x-component': 'Input'
},
},
],
};
<CollectionProvider collection={collection}></CollectionProvider>
```
如果没有传 collection 参数,从 CollectionManagerProvider 里取对应 name 的 collection。
```jsx | pure
const collections = [
{
name: 'tests',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
'x-component': 'Input'
},
},
],
}
];
<CollectionManagerProvider collections={collections}>
<CollectionProvider name={'tests'}></CollectionProvider>
</CollectionManagerProvider>
```
### CollectionFieldProvider
```jsx | pure
const field = {
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
'x-component': 'Input'
},
};
<CollectionFieldProvider field={field}></CollectionFieldProvider>
```
如果没有传 field 参数,从 CollectionProvider 里取对应 name 的 field。
```jsx | pure
const collection = {
name: 'tests',
fields: [
{
type: 'string',
name: 'title',
interface: 'input',
uiSchema: {
type: 'string',
'x-component': 'Input'
},
},
],
};
<CollectionProvider collection={collection}>
<CollectionFieldProvider name={'title'}></CollectionFieldProvider>
</CollectionProvider>
```
### CollectionField
万能字段组件,需要与 `<CollectionProvider/>` 搭配使用,仅限于在 Schema 场景使用。从 CollectionProvider 里取对应 name 的 field schema。可通过 CollectionField 所在的 schema 扩展配置。
```ts
{
name: 'title',
'x-decorator': 'FormItem',
'x-decorator-props': {},
'x-component': 'CollectionField',
'x-component-props': {},
properties: {},
}
```
<code src="./demos/demo2.tsx"/>
## Hooks
### useCollectionManager()
`<CollectionManagerProvider/>` 搭配使用
```jsx | pure
const { collections, get } = useCollectionManager();
```
### useCollection()
`<CollectionProvider/>` 搭配使用
```jsx | pure
const { name, fields, getField, findField, resource } = useCollection();
```
### useCollectionField()
`<CollectionFieldProvider/>` 搭配使用
```jsx | pure
const { name, uiSchema, resource } = useCollectionField();
```
resource 需要与 `<RecordProvider/>` 搭配使用,用于提供当前数据表行记录的上下文。如:
<code src="./demos/demo3.tsx"/>

View File

@ -0,0 +1,7 @@
export * from './CollectionFieldProvider';
export * from './CollectionManagerProvider';
export * from './CollectionProvider';
export * from './context';
export * from './hooks';
export * from './types';
export * from './CollectionField';

View File

@ -0,0 +1,23 @@
import { ISchema } from '@formily/react';
export interface CollectionManagerOptions {
interfaces?: any;
collections?: any[];
}
export interface CollectionOptions {
name?: string;
fields?: any[];
}
export interface ICollectionProviderProps {
name?: string;
fields?: any;
}
export interface CollectionFieldOptions {
name?: any;
collectinName?: string;
sourceKey?: string; // association field
uiSchema?: ISchema;
}

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# ColorSelect

View File

@ -0,0 +1,9 @@
---
nav:
path: /client
group:
title: Client
path: /client
---
# CurrentUser <Badge>待定</Badge>

View File

@ -0,0 +1,3 @@
export function CurrentUser() {
}

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# DatePicker

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# DndContext <Badge>待定</Badge>

View File

@ -0,0 +1,29 @@
import { DndContext as DndKitContext, DragEndEvent } from '@dnd-kit/core';
import { observer } from '@formily/react';
import React from 'react';
import { createDesignable, useDesignable } from '../schema-component';
const useDragEnd = () => {
const { refresh } = useDesignable();
return ({ active, over }: DragEndEvent) => {
console.log({ active, over });
const activeSchema = active?.data?.current?.schema;
const overSchema = over?.data?.current?.schema;
if (!activeSchema || !overSchema) {
return;
}
const dn = createDesignable({
current: overSchema,
});
dn.on('afterInsertAdjacent', refresh);
dn.insertBeforeBeginOrAfterEnd(activeSchema);
};
};
export const DndContext = observer((props) => {
return <DndKitContext onDragEnd={useDragEnd()}>{props.children}</DndKitContext>;
});

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /client
---
# DocumentTitle <Badge>待定</Badge>

View File

@ -0,0 +1,3 @@
export function DocumentTitleProvider() {
}

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Filter

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /schema-components
---
# FormItem
表单字段装饰器。除此之外,也继承了 BlockItem 的功能。

View File

@ -0,0 +1,11 @@
import React from 'react';
import { BlockItem } from '../block-item';
import { FormItem as FormilyFormItem } from '@formily/antd';
export const FormItem: React.FC = (props) => {
return (
<BlockItem className={'nb-form-item'}>
<FormilyFormItem {...props} />
</BlockItem>
);
};

View File

@ -0,0 +1,58 @@
import React, { useContext, useMemo } from 'react';
import { toJS } from '@formily/reactive';
import { createForm, FormPath } from '@formily/core';
import {
FieldContext,
FormContext,
FormProvider,
observer,
SchemaContext,
SchemaOptionsContext,
useField,
useFieldSchema,
} from '@formily/react';
import { SchemaComponent, useAttach } from '../schema-component';
type ComposedForm = React.FC<any> & {
__NOCOBASE_FORM?: boolean;
};
const useFormDecorator = () => {
const field = useField();
const options = useContext(SchemaOptionsContext);
const decorator = field.decoratorType ? FormPath.getIn(options?.components, field.decoratorType) : null;
return decorator?.__NOCOBASE_FORM ? decorator : null;
};
const FormDecorator: React.FC<any> = (props) => {
const { form } = props;
const options = useContext(SchemaOptionsContext);
const field = useField();
const fieldSchema = useFieldSchema();
const f = useAttach(form.createVoidField({ ...field.props, basePath: '' }));
const finalComponent = FormPath.getIn(options?.components, field.componentType) ?? field.componentType;
return (
<FormContext.Provider value={form}>
<SchemaContext.Provider value={fieldSchema}>
<FieldContext.Provider value={f}>
{React.createElement(finalComponent, toJS(field.componentProps))}
</FieldContext.Provider>
</SchemaContext.Provider>
</FormContext.Provider>
);
};
export const Form: ComposedForm = observer((props) => {
const form = useMemo(() => createForm(), []);
const fieldSchema = useFieldSchema();
const decorator = useFormDecorator();
return decorator ? (
<FormDecorator form={form} />
) : (
<FormProvider form={form}>
<SchemaComponent schema={fieldSchema} />
</FormProvider>
);
});
Form.__NOCOBASE_FORM = true;

View File

@ -0,0 +1,54 @@
import React from 'react';
import { observer, ISchema } from '@formily/react';
import { SchemaComponent, SchemaComponentProvider, Form, Action, useCloseAction } from '@nocobase/client';
import { FormItem, Input } from '@formily/antd';
import 'antd/dist/antd.css';
export default observer(() => {
const schema: ISchema = {
type: 'object',
properties: {
a1: {
type: 'void',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
},
title: 'Open',
properties: {
d1: {
type: 'void',
'x-component': 'Action.Drawer',
'x-decorator': 'Form',
title: 'Drawer Title',
properties: {
field1: {
'x-component': 'Input',
'x-decorator': 'FormItem',
title: 'T1',
},
f1: {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
a1: {
'x-component': 'Action',
title: 'Close',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
},
},
},
},
},
},
},
};
return (
<SchemaComponentProvider scope={{ useCloseAction }} components={{ Form, Action, Input, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
});

View File

@ -0,0 +1,47 @@
import React from 'react';
import { observer, ISchema, useForm } from '@formily/react';
import { SchemaComponent, SchemaComponentProvider, Form, Action } from '@nocobase/client';
import 'antd/dist/antd.css';
import { FormItem, Input } from '@formily/antd';
export default observer(() => {
const schema: ISchema = {
type: 'object',
properties: {
form1: {
type: 'void',
'x-component': 'Form',
properties: {
field1: {
'x-component': 'Input',
'x-decorator': 'FormItem',
title: 'T1',
},
action1: {
// type: 'void',
'x-component': 'Action',
title: 'Submit',
'x-component-props': {
useAction: '{{ useSubmit }}',
},
},
},
},
},
};
const useSubmit = () => {
const form = useForm();
return {
async run() {
console.log(form.values);
},
};
};
return (
<SchemaComponentProvider scope={{ useSubmit }} components={{ Action, Form, Input, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
});

View File

@ -0,0 +1,14 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Form
<code src="./demos/demo2.tsx"/>
Form 也可以作 decorator 存在,和 Action.Drawer 结合就是 DrawerForm 了。
<code src="./demos/demo1.tsx"/>

View File

@ -0,0 +1 @@
export * from './Form';

View File

@ -0,0 +1,124 @@
import React from 'react';
import { uid } from '@formily/shared';
import { observer, useFieldSchema } from '@formily/react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { SchemaComponent, SchemaComponentProvider, Grid } from '@nocobase/client';
function Draggable(props) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: props.id,
data: props.data,
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
{props.children}
</button>
);
}
function Droppable(props) {
const { isOver, setNodeRef } = useDroppable({
id: props.id,
data: props.data,
});
const style = {
color: isOver ? 'green' : undefined,
};
return (
<div ref={setNodeRef} style={style}>
{props.children}
</div>
);
}
const Block = observer((props) => {
const fieldSchema = useFieldSchema();
return (
<Droppable id={fieldSchema.name} data={{ schema: fieldSchema }}>
<div style={{ marginBottom: 20, padding: '0 20px', height: 50, lineHeight: '50px', background: '#f1f1f1' }}>
Block {fieldSchema.name}
<Draggable id={fieldSchema.name} data={{ schema: fieldSchema }}>
Drag
</Draggable>
</div>
</Droppable>
);
});
export default function App() {
return (
<SchemaComponentProvider components={{ Grid, Block }}>
<SchemaComponent
schema={{
type: 'void',
name: 'page',
'x-component': 'Grid',
'x-uid': uid(),
properties: {
[uid()]: {
type: 'void',
'x-component': 'Grid.Row',
'x-uid': uid(),
properties: {
[uid()]: {
type: 'void',
'x-component': 'Grid.Col',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Block',
},
},
},
[uid()]: {
type: 'void',
'x-component': 'Grid.Col',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Block',
},
},
},
},
},
[uid()]: {
type: 'void',
'x-component': 'Grid.Row',
'x-uid': uid(),
properties: {
[uid()]: {
type: 'void',
'x-component': 'Grid.Col',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Block',
},
},
},
[uid()]: {
type: 'void',
'x-component': 'Grid.Col',
properties: {
[uid()]: {
type: 'void',
'x-component': 'Block',
},
},
},
},
},
},
}}
/>
</SchemaComponentProvider>
);
}

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Grid <Badge>待定</Badge>
<code src="./demos/demo1.tsx" />

View File

@ -0,0 +1,19 @@
import React from 'react';
import { observer } from '@formily/react';
import { DndContext } from '../dnd-context';
export const Grid: any = observer((props) => {
return (
<div>
<DndContext>{props.children}</DndContext>
</div>
);
});
Grid.Row = observer((props) => {
return <div>{props.children}</div>;
});
Grid.Col = observer((props) => {
return <div>{props.children}</div>;
});

View File

@ -0,0 +1,10 @@
---
nav:
path: /client
group:
path: /client
---
# I18n
提供国际化解决方案。

View File

@ -0,0 +1,54 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import moment from 'moment';
const zhCN = require('../locale/zh_CN');
const enUS = require('../locale/en_US');
const log = require('debug')('i18next');
export const i18n = i18next.createInstance();
i18n.use(initReactI18next).init({
lng: localStorage.getItem('locale') || 'en-US',
debug: false,
defaultNS: 'client',
// parseMissingKeyHandler: (key) => {
// console.log('parseMissingKeyHandler', `'${key}': '${key}',`);
// return key;
// },
// ns: ['client'],
resources: {
'en-US': {
client: {
...enUS,
},
},
'zh-CN': {
client: {
...zhCN,
},
},
},
});
const momentLngs = {
'en-US': 'en',
'zh-CN': 'zh-cn',
};
function setMomentLng(language) {
const lng = momentLngs[language || 'en-US'] || 'en';
log(lng);
moment.locale(lng);
}
setMomentLng(localStorage.getItem('locale'));
i18n.on('languageChanged', (lng) => {
localStorage.setItem('locale', lng);
setMomentLng(lng);
});
// export const t = i18n.t;
export default i18n;

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# IconPicker

View File

@ -1,5 +1,23 @@
import React from 'react';
import debug from 'debug';
debug.log = console.log.bind(console);
export function Hello() {
return <div>aaa</div>;
}
export * from './i18n';
export * from './acl';
export * from './admin-layout';
export * from './antd-config-provider';
export * from './api-client';
export * from './auth-layout';
export * from './collection-manager';
export * from './current-user';
export * from './document-title';
export * from './route-switch';
export * from './schema-component';
export * from './system-settings';
export * from './action';
export * from './form';
export * from './form-item';
export * from './block-item';
export * from './dnd-context';
export * from './grid';
export * from './application';
export * from './record-provider';

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# InputNumber

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Input

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Kanban <Badge>待定</Badge>

View File

@ -0,0 +1,280 @@
{
"{{count}} filter items_one": "{{count}} filter item",
"{{count}} filter items_other": "{{count}} filter items",
"{{count}} more items_one": "{{count}} more item",
"{{count}} more items_other": "{{count}} more items",
"Delete menu item": "Delete menu item",
"Today": "Today",
"Month": "Month",
"Week": "Week",
"Work week": "Work week",
"Day": "Day",
"Agenda": "Agenda",
"Date": "Date",
"Time": "Time",
"Event": "Event",
"None": "None",
"System settings": "System settings",
"System title": "System title",
"Logo": "Logo",
"Add menu item": "Add menu item",
"Page": "Page",
"Name": "Name",
"Icon": "Icon",
"Group": "Group",
"Link": "Link",
"Edit menu item": "Edit menu item",
"Move to": "Move to",
"Insert left": "Insert left",
"Insert right": "Insert right",
"Insert inner": "Insert inner",
"Delete": "Delete",
"UI editor": "UI editor",
"Collections & Fields": "Collections & Fields",
"Roles & Permissions": "Roles & Permissions",
"Edit profile": "Edit profile",
"Change password": "Change password",
"Old password": "Old password",
"New password": "New password",
"Switch role": "Switch role",
"Super admin": "Super admin",
"Language": "Language",
"Allow sign up": "Allow sign up",
"Sign out": "Sign out",
"Cancel": "Cancel",
"Submit": "Submit",
"Set the data scope": "Set the data scope",
"Data blocks": "Data blocks",
"Table": "Table",
"Form": "Form",
"Select data source": "Select data source",
"Calendar": "Calendar",
"Kanban": "Kanban",
"Select group field": "Select group field",
"Media": "Media",
"Markdown": "Markdown",
"Wysiwyg": "Wysiwyg",
"Charts": "Charts",
"Column chart": "Column chart",
"Bar chart": "Bar chart",
"Line chart": "Line chart",
"Pie chart": "Pie chart",
"Select template": "Select template",
"Action logs": "Action logs",
"Create template": "Create template",
"Edit markdown": "Edit markdown",
"Add block": "Add block",
"Add new": "Add new",
"Add record": "Add record",
"Custom field display name": "Custom field display name",
"Display fields": "Display fields",
"Edit record": "Edit record",
"Add page": "Add page",
"Add group": "Add group",
"Add link": "Add link",
"Insert above": "Insert above",
"Insert below": "Insert below",
"Save": "Save",
"Delete block": "Delete block",
"Are you sure you want to delete it?": "Are you sure you want to delete it?",
"This is a demo text, **supports Markdown syntax**.": "This is a demo text, **supports Markdown syntax**.",
"Filter": "Filter",
"Action type": "Action type",
"Actions": "Actions",
"Insert": "Insert",
"Update": "Update",
"View": "View",
"View record": "View record",
"Data changes": "Data changes",
"Field name": "Field name",
"Before change": "Before change",
"After change": "After change",
"Delete record": "Delete record",
"Create collection": "Create collection",
"Collection display name": "Collection display name",
"Collection name": "Collection name",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.": "Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.",
"Storage type": "Storage type",
"Edit": "Edit",
"Edit collection": "Edit collection",
"Configure fields": "Configure fields",
"Edit field": "Edit field",
"Configure fields of {{title}}": "Configure fields of {{title}}",
"Basic": "Basic",
"Single line text": "Single line text",
"Long text": "Long text",
"Phone": "Phone",
"Email": "Email",
"Number": "Number",
"Percent": "Percent",
"Password": "Password",
"Choices": "Choices",
"Checkbox": "Checkbox",
"Single select": "Single select",
"Multiple select": "Multiple select",
"Radio group": "Radio group",
"Checkbox group": "Checkbox group",
"China region": "China region",
"Attachment": "Attachment",
"Date & Time": "Date & Time",
"Datetime": "Datetime",
"Relation": "Relation",
"Link to": "Link to",
"Sub-table": "Sub-table",
"System info": "System info",
"Created at": "Created at",
"Last updated at": "Last updated at",
"Created by": "Created by",
"Last updated by": "Last updated by",
"Add field": "Add field",
"Field display name": "Field display name",
"Field type": "Field type",
"Date format": "Date format",
"Year/Month/Day": "Year/Month/Day",
"Year-Month-Day": "Year-Month-Day",
"Day/Month/Year": "Day/Month/Year",
"Show time": "Show time",
"Time format": "Time format",
"12 hour": "12 hour",
"24 hour": "24 hour",
"Meet <1><0>All</0><1>Any</1></1> conditions in the group": "Meet <1><0>All</0><1>Any</1></1> conditions in the group",
"Add filter": "Add filter",
"Add filter group": "Add filter group",
"is": "is",
"is not": "is not",
"contains": "contains",
"does not contain": "does not contain",
"is empty": "is empty",
"is not empty": "is not empty",
"Display <1><0>10</0><1>20</1><2>50</2><3>100</3></1> items per page": "Display <1><0>10</0><1>20</1><2>50</2><3>100</3></1> items per page",
"Edit chart": "Edit chart",
"Add text": "Add text",
"Filterable fields": "Filterable fields",
"Edit button": "Edit button",
"Hide": "Hide",
"Enable actions": "Enable actions",
"Export": "Export",
"Customize": "Customize",
"Function": "Function",
"Popup form": "Popup form",
"Flexible popup": "Flexible popup",
"Configure actions": "Configure actions",
"Display order number": "Display order number",
"Enable drag and drop sorting": "Enable drag and drop sorting",
"Triggered when the row is clicked": "Triggered when the row is clicked",
"Add tab": "Add tab",
"Disable tabs": "Disable tabs",
"Details": "Details",
"Edit tab": "Edit tab",
"Relationship blocks": "Relationship blocks",
"Select record": "Select record",
"Display name": "Display name",
"Select icon": "Select icon",
"Custom column name": "Custom column name",
"Edit description": "Edit description",
"Required": "Required",
"Label field": "Label field",
"Default is the ID field": "Default is the ID field",
"Set default sorting rules": "Set default sorting rules",
"is before": "is before",
"is after": "is after",
"is on or after": "is on or after",
"is on or before": "is on or before",
"Upload": "Upload",
"Select level": "Select level",
"Province": "Province",
"City": "City",
"Area": "Area",
"Street": "Street",
"Village": "Village",
"Must select to the last level": "Must select to the last level",
"Move {{title}} to": "Move {{title}} to",
"Target position": "Target position",
"After": "After",
"Before": "Before",
"Add {{type}} before \"{{title}}\"": "Add {{type}} before \"{{title}}\"",
"Add {{type}} after \"{{title}}\"": "Add {{type}} after \"{{title}}\"",
"Add {{type}} in \"{{title}}\"": "Add {{type}} in \"{{title}}\"",
"Original name": "Original name",
"Custom name": "Custom name",
"Custom Title": "Custom Title",
"Options": "Options",
"Option value": "Option value",
"Option label": "Option label",
"Color": "Color",
"Add option": "Add option",
"Related collection": "Related collection",
"Allow linking to multiple records": "Allow linking to multiple records",
"Allow uploading multiple files": "Allow uploading multiple files",
"Configure calendar": "Configure calendar",
"Title field": "Title field",
"Start date field": "Start date field",
"End date field": "End date field",
"Navigate": "Navigate",
"Title": "Title",
"Select view": "View",
"Reset": "Reset",
"Export fields": "Export fields",
"Saved successfully": "Saved successfully",
"Templates": "Templates",
"Nickname": "Nickname",
"Sign in": "Sign in",
"Create an account": "Create an account",
"Sign up": "Sign up",
"Confirm password": "Confirm password",
"Log in with an existing account": "Log in with an existing account",
"Password mismatch": "Password mismatch",
"Signed up successfully. It will jump to the login page.": "Signed up successfully. It will jump to the login page.",
"Users": "Users",
"Roles": "Roles",
"Add role": "Add role",
"Role name": "Role name",
"Configure": "Configure",
"Configure permissions": "Configure permissions",
"Edit role": "Edit role",
"Action permissions": "Action permissions",
"Menu permissions": "Menu permissions",
"Menu item name": "Menu item name",
"Allow access": "Allow access",
"Action name": "Action name",
"Allow action": "Allow action",
"Action scope": "Action scope",
"Operate on new data": "Operate on new data",
"Operate on existing data": "Operate on existing data",
"Yes": "Yes",
"No": "No",
"Red": "Red",
"Magenta": "Magenta",
"Volcano": "Volcano",
"Orange": "Orange",
"Gold": "Gold",
"Lime": "Lime",
"Green": "Green",
"Cyan": "Cyan",
"Blue": "Blue",
"Geek Blue": "Geek Blue",
"Purple": "Purple",
"Default": "Default",
"Add card": "Add card",
"edit title": "Edit Title"
}

View File

@ -0,0 +1,280 @@
{
"Display <1><0>10</0><1>20</1><2>50</2><3>100</3></1> items per page": "每页显示 <1><0>10</0><1>20</1><2>50</2><3>100</3></1> 条",
"Meet <1><0>All</0><1>Any</1></1> conditions in the group": "满足组内 <1><0>全部</0><1>任意</1></1> 条件",
"Open in<1><0>Modal</0><1>Drawer</1><2>Window</2></1>": "在 <1><0>对话框</0><1>抽屉</1><2>窗口</2></1> 内打开",
"{{count}} filter items": "{{count}} 个筛选项",
"{{count}} more items": "还有 {{count}} 项",
"Today": "今天",
"Month": "月",
"Week": "周",
"Work week": "工作日",
"Day": "天",
"Agenda": "列表",
"Date": "日期",
"Time": "时间",
"Event": "事件",
"None": "无",
"System settings": "系统设置",
"System title": "系统名称",
"Logo": "Logo",
"Add menu item": "添加菜单项",
"Page": "页面",
"Name": "名称",
"Icon": "图标",
"Group": "分组",
"Link": "链接",
"Edit menu item": "编辑菜单项",
"Move to": "移动到",
"Insert left": "在左边插入",
"Insert right": "在右边插入",
"Insert inner": "在里面插入",
"Delete": "删除",
"UI editor": "界面配置",
"Collections & Fields": "数据表配置",
"Roles & Permissions": "角色和权限",
"Edit profile": "个人资料",
"Change password": "修改密码",
"Old password": "旧密码",
"New password": "新密码",
"Switch role": "切换角色",
"Super admin": "超级管理员",
"Language": "语言设置",
"Allow sign up": "允许注册",
"Sign out": "注销",
"Cancel": "取消",
"Submit": "提交",
"Set the data scope": "设置数据范围",
"Data blocks": "数据区块",
"Table": "表格",
"Form": "表单",
"Select data source": "选择数据源",
"Calendar": "日历",
"Kanban": "看板",
"Select group field": "选择分组字段",
"Media": "多媒体",
"Markdown": "Markdown",
"Wysiwyg": "富文本",
"Charts": "图表",
"Column chart": "柱状图",
"Bar chart": "条形图",
"Line chart": "折线图",
"Pie chart": "饼图",
"Templates": "模板",
"Select template": "选择模板",
"Action logs": "操作日志",
"Create template": "创建模板",
"Edit markdown": "编辑 Markdown",
"Add block": "创建区块",
"Add new": "添加",
"Add record": "添加数据",
"Custom field display name": "自定义字段名称",
"Display fields": "显示字段",
"Edit record": "编辑数据",
"Delete menu item": "删除菜单项",
"Add page": "添加页面",
"Add group": "添加分组",
"Add link": "添加链接",
"Insert above": "在上面插入",
"Insert below": "在下面插入",
"Save": "保存",
"Delete block": "删除区块",
"Are you sure you want to delete it?": "你确定要删除吗?",
"This is a demo text, **supports Markdown syntax**.": "这是一段演示文本,**支持 Markdown 语法**。",
"Filter": "筛选",
"Action type": "操作类型",
"Actions": "操作",
"Insert": "新增",
"Update": "更新",
"View": "查看",
"View record": "查看数据",
"Data changes": "数据变更",
"Field name": "字段标识",
"Before change": "变更前",
"After change": "变更后",
"Delete record": "删除数据",
"Create collection": "创建数据表",
"Collection display name": "数据表名称",
"Collection name": "数据表标识",
"Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.": "随机生成,可修改。支持英文、数字和下划线,必须以英文字母开头。",
"Storage type": "存储类型",
"Edit": "编辑",
"Edit collection": "编辑数据表",
"Configure fields": "配置字段",
"Edit field": "编辑字段",
"Configure fields of {{title}}": "「{{title}}」的字段配置",
"Basic": "基本类型",
"Single line text": "单行文本",
"Long text": "多行文本",
"Phone": "手机号码",
"Email": "电子邮箱",
"Number": "数字",
"Percent": "百分比",
"Password": "密码",
"Choices": "选择类型",
"Checkbox": "勾选",
"Single select": "下拉菜单(单选)",
"Multiple select": "下拉菜单(多选)",
"Radio group": "单选框",
"Checkbox group": "复选框",
"China region": "中国行政区",
"Attachment": "附件",
"Date & Time": "日期 & 时间",
"Datetime": "日期",
"Relation": "关系类型",
"Link to": "关联",
"Sub-table": "子表格",
"System info": "系统信息",
"Created at": "创建日期",
"Last updated at": "最后修改日期",
"Created by": "创建人",
"Last updated by": "最后更新日期",
"Add field": "添加字段",
"Field display name": "字段名称",
"Field type": "字段类型",
"Date format": "日期格式",
"Year/Month/Day": "年/月/日",
"Year-Month-Day": "年-月-日",
"Day/Month/Year": "日/月/年",
"Show time": "显示时间",
"Time format": "时间格式",
"12 hour": "12 小时制",
"24 hour": "24 小时制",
"Add filter": "添加筛选条件",
"Add filter group": "添加筛选分组",
"is": "等于",
"is not": "不等于",
"contains": "包含",
"does not contain": "不包含",
"is empty": "为空",
"is not empty": "不为空",
"Edit chart": "编辑图表",
"Add text": "添加文本",
"Filterable fields": "可筛选字段",
"Edit button": "编辑按钮",
"Hide": "隐藏",
"Enable actions": "启用操作",
"Export": "导出",
"Customize": "自定义",
"Function": "Function",
"Popup form": "Popup form",
"Flexible popup": "Flexible popup",
"Configure actions": "配置操作",
"Display order number": "显示序号",
"Enable drag and drop sorting": "启用拖拽排序",
"Triggered when the row is clicked": "点击表格行时触发",
"Add tab": "添加标签页",
"Disable tabs": "禁用标签页",
"Details": "详情",
"Edit tab": "编辑标签页",
"Relationship blocks": "关系数据区块",
"Select record": "选择数据",
"Display name": "显示名称",
"Select icon": "选择图标",
"Custom column name": "自定义列名称",
"Edit description": "编辑描述",
"Required": "必填",
"Label field": "标签字段",
"Default is the ID field": "默认为 ID 字段",
"Set default sorting rules": "设置排序规则",
"is before": "早于",
"is after": "晚于",
"is on or after": "不早于",
"is on or before": "不晚于",
"Upload": "上传",
"Select level": "选择层级",
"Province": "省",
"City": "市",
"Area": "区/县",
"Street": "乡镇/街道",
"Village": "村/居委会",
"Must select to the last level": "必须选到最后一级",
"Move {{title}} to": "将 {{title}} 移动到",
"Target position": "目标位置",
"After": "之后",
"Before": "之前",
"Add {{type}} before \"{{title}}\"": "在 \"{{title}}\" 前插入{{type}}",
"Add {{type}} after \"{{title}}\"": "在 \"{{title}}\" 前插入{{type}}",
"Add {{type}} in \"{{title}}\"": "在 \"{{title}}\" 里插入{{type}}",
"Original name": "原名称",
"Custom name": "自定义名称",
"Custom Title": "自定义标题",
"Options": "选项",
"Option value": "选项值",
"Option label": "选项",
"Color": "颜色",
"Add option": "添加选项",
"Related collection": "关系表",
"Allow linking to multiple records": "允许关联多条记录",
"Allow uploading multiple files": "允许上传多个文件",
"Configure calendar": "配置日历",
"Title field": "标题字段",
"Start date field": "开始日期字段",
"End date field": "结束日期字段",
"Navigate": "分页",
"Title": "标题",
"Select view": "切换视图",
"Reset": "重置",
"Export fields": "导出字段",
"Saved successfully": "保存成功",
"Nickname": "昵称",
"Sign in": "登录",
"Create an account": "注册账号",
"Sign up": "注册",
"Confirm password": "确认密码",
"Log in with an existing account": "使用已有账号登录",
"Signed up successfully. It will jump to the login page.": "注册成功,将跳转登录页。",
"Password mismatch": "确认密码不匹配",
"Users": "用户",
"Roles": "角色",
"Add role": "添加角色",
"Role name": "角色名称",
"Configure": "配置",
"Configure permissions": "配置权限",
"Edit role": "编辑角色",
"Action permissions": "数据表操作权限",
"Menu permissions": "菜单访问权限",
"Menu item name": "菜单名称",
"Allow access": "允许访问",
"Action name": "操作名称",
"Allow action": "允许操作",
"Action scope": "可操作数据范围",
"Operate on new data": "对新增数据操作",
"Operate on existing data": "对已有数据操作",
"Yes": "是",
"No": "否",
"Red": "薄暮",
"Magenta": "法式洋红",
"Volcano": "火山",
"Orange": "日暮",
"Gold": "金盏花",
"Lime": "青柠",
"Green": "极光绿",
"Cyan": "明青",
"Blue": "拂晓蓝",
"Geek Blue": "极客蓝",
"Purple": "酱紫",
"Default": "默认",
"Add card": "添加卡片",
"edit title": "修改标题"
}

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Markdown

View File

@ -0,0 +1,19 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Menu
## Nodes
- Menu
- Menu.ItemGroup
- Menu.Item
- Menu.SubMenu
- Menu.Divider
- Menu.URL
- Menu.Link
- Menu.Action

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Password

View File

@ -0,0 +1,8 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Radio

Some files were not shown because too many files have changed in this diff Show More