refactor(client)!: application, router and plugin (#2068)

BREAKING CHANGE:

* refactor: update umi version 3.x to version 4.x

* refactor: update react-router-dom version to 6.x

* refactor(react-router-dom): change Layout Component `props.children` to `<Outlet />`

* refactor(react-router-dom): change <Route /> props and <RouteSwitch /> correct

* refactor(react-router-dom): replace `<Redirect />` to `<Navigate replace />`

* refactor(react-router-dom): replace `useHistory` to `useNavigate`

* refactor(react-router-dom): replace `useRouteMatch` to `useParams`

* refactor(react-router-dom & dumi): fix <RouteSwitch /> & umi document bug

* refactor(react-router-dom): `useRoutes` Optimize `<RouteSwitch />` code

* refactor(react-router-dom): update `Route` types and docs

* refactor(react-router-dom): optimize RouteSwitch code

* refactor(react-router-dom): `useLocation` no generics type

* refactor(react-router-dom): add `less v3.9.0` to `resolutions` to solve the error of `gulp-less`

* refactor(react-router-dom): fix `<RouteSwitch />`  `props.routes` as an array is not handled

* chore: upgrade `dumi` and refactor docs

* fix: completed code review, add `targets` to solve browser compatibility & removed `chainWebpack`

* refactor(dumi): upgraded dumi under `packages/core/client`

* refactor(dumi): delete `packages/core/dumi-theme-nocobase`

* refactor(dumi): degrade `react`  & replace `dumi-theme-antd` to `dumi-theme-nocobase`

* refactor(dumi): solve conflicts between multiple dumi applications

* fix: login page error in react 17

* refactor(dumi): remove less resolutions

* refactor(dumi): umi add `msfu: true` config

* fix: merge bug

* fix: self code review

* fix: code reivew and test bug

* refactor: upgrade react to 18

* refactor: degrade react types to 17

* chore: fix ci error

* fix: support routerBase & fix workflow page params

* fix(doc): menu externel link

* fix: build error

* fix: delete

* fix: vitest error

* fix: react-router new code replace

* fix: vitest markdown error

* fix: title is none when refresh

* fix: merge error

* fix: sidebar width is wrong

* fix: useProps error

* fix: side-menu-width

* fix: menu selectId is wrong & useProps is string

* fix: menu selected first default & side menu hide when change

* fix: test error & v0.10 change log

* fix: new compnent doc modify

* fix: set umi `fastRefresh=false`

* refactor: application v2

* fix: improve code

* fix: bug

* fix: page = 0 error

* fix: workflow navigate error

* feat: plugin manager

* fix: afterAdd

* feat: complete basic functional refactor

* fix: performance Application

* feat: support client and server build

* refactor: nocobase build-in plugin and providers

* fix: server can't start

* refactor: all plugins package `Prodiver` change to `Plugin`

* feat: nested router and change mobile client

* feat: delete application-v1 and router-switch

* feat: improve routes

* fix: change mobile not nested

* feat: delete RouteSwitchContext and change buildin Provider to Plugin

* feat: delete RouteSwitchContext plugins

* fix: refactor SchemaComponentOptions

* feat: improve SchemaComponentOptions

* fix: add useAdminSchemaUid

* fix: merge master error

* fix: vitest error

* fix: bug

* feat: bugs

* fix: improve code

* fix: restore code

* feat: vitest

* fix: bugs

* fix: bugs

* docs: update doc

* feat: improve code

* feat: add docs and imporve code

* fix: bugs

* feat: add tests

* fix: remove deps

* fix: muti app router error

* fix: router error

* fix: workflow error

* fix: cli error

* feat: change NoCobase -> Nocobase

* fix: code review

* fix: type error

* fix: cli error and plugin demo

* feat: update doc theme

* fix: build error

* fix: mobile router

* fix: code rewview

* fix: bug

* fix: test bug

* fix: bug

* refactor: add the "client" directory to all plugins

* refactor: modify samples client and plugin template

* fix: merge error

* fix: add files in package.json

* refactor: add README to files in package.json

* fix: adjust plugins depencies

* refactor: completing plugins' devDependencies and dependencies

* fix: bug

* refactor: remove @emotion/css

* refactor: jsonwebtoken deps

* refactor: remove sequelize

* refactor: dayjs and moment deps

* fix: bugs

* fix: bug

* fix: cycle detect

* fix: merge bug

* feat: new plugin bug

* fix: lang bug

* fix: dynamic import bug

* refactor: plugins and example add father config

* feat: improve code

* fix: add AppSpin and AppError components

* Revert "refactor: plugins and example add father config"

This reverts commit 483315bca5524e4b8cbbb20cbad77986f081089d.

# Conflicts:
#	packages/plugins/auth/package.json
#	packages/plugins/multi-app-manager/package.json
#	packages/samples/command/package.json
#	packages/samples/custom-collection-template/package.json
#	packages/samples/ratelimit/package.json
#	packages/samples/shop-actions/package.json
#	packages/samples/shop-events/package.json
#	packages/samples/shop-modeling/package.json

* feat: update doc

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
jack zhang 2023-07-07 14:35:22 +08:00 committed by GitHub
parent 50786621bb
commit 2cb1203aa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
743 changed files with 6253 additions and 6165 deletions

View File

@ -23,3 +23,4 @@ packages/core/database/src/sql-parser/index.js
**/.dumi/tmp **/.dumi/tmp
**/.dumi/tmp-test **/.dumi/tmp-test
**/.dumi/tmp-production **/.dumi/tmp-production
packages/core/cli/templates/plugin/src/client/*.tpl

View File

@ -34,6 +34,8 @@
} }
}, },
"rules": { "rules": {
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-types": "off",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"no-empty-function": "off", "no-empty-function": "off",

View File

@ -13,3 +13,4 @@ packages/core/client/src/locale/*
**/.dumi/tmp **/.dumi/tmp
**/.dumi/tmp-test **/.dumi/tmp-test
**/.dumi/tmp-production **/.dumi/tmp-production
packages/core/cli/templates/plugin/src/client/*.tpl

View File

@ -85,6 +85,7 @@ const sidebar = {
// '/welcome/release/index', // '/welcome/release/index',
// '/welcome/release/v08-changelog', // '/welcome/release/v08-changelog',
'/welcome/release/v10-changelog', '/welcome/release/v10-changelog',
'/welcome/release/v11-changelog',
], ],
}, },
{ {
@ -260,7 +261,7 @@ const sidebar = {
children: [ children: [
// '/api/client', // '/api/client',
'/api/client/application', '/api/client/application',
'/api/client/route-switch', '/api/client/router',
{ {
title: 'SchemaDesigner', title: 'SchemaDesigner',
'title.zh-CN': 'SchemaDesigner', 'title.zh-CN': 'SchemaDesigner',

View File

@ -32,7 +32,6 @@ Add Providers, build-in Providers are:
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,74 +0,0 @@
# RouteSwitch
## `<RouteSwitchProvider />`
```ts
interface RouteSwitchProviderProps {
components?: ReactComponent;
routes?: RouteRedirectProps[];
}
```
## `<RouteSwitch />`
```ts
interface RouteSwitchProps {
routes?: RouteRedirectProps[];
components?: ReactComponent;
}
type RouteRedirectProps = RedirectProps | RouteProps;
interface RedirectProps {
type: 'redirect';
to: any;
path?: string;
push?: boolean;
from?: string;
[key: string]: any;
}
interface RouteProps {
type: 'route';
path?: string | string[];
sensitive?: boolean;
component?: any;
routes?: RouteProps[];
[key: string]: any;
}
```
## Full Example
```tsx | pure
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: '/',
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>
);
};
```

View File

@ -0,0 +1,180 @@
# Router
## API
### Initial
```tsx | pure
const app = new Application({
router: {
type: 'browser' // type default value is `browser`
}
})
// or
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
```
### add Route
#### basic
```tsx | pure
import { RouteObject } from 'react-router-dom'
const app = new Application()
const Hello = () => {
return <div>Hello</div>
}
// first argument is `name` of route, second argument is `RouteObject`
app.router.add('root', {
path: '/',
element: <Hello />
})
app.router.add('root', {
path: '/',
Component: Hello
})
```
#### Component is String
```tsx | pure
app.addComponents({
Hello
})
app.router.add('root', {
path: '/',
Component: 'Hello'
})
```
#### nested
```tsx | pure
import { Outlet } from 'react-router-dom'
const Layout = () => {
return <div>
<Link to='/home'>Home</Link>
<Link to='/about'>about</Link>
<Outlet />
</div>
}
const Home = () => {
return <div>Home</div>
}
const About = () => {
return <div>About</div>
}
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/home',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
```
It will generate the following routes:
```tsx | pure
{
element: <Layout />,
children: [
{
path: '/home',
element: <Home />
},
{
path: '/about',
element: <About />
}
]
}
```
### remove Route
```tsx | pure
// remove route by name
app.router.remove('root.home')
app.router.remove('hello')
```
#### Router in plugin
```tsx | pure
class MyPlugin extends Plugin {
async load() {
// add route
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
// remove route
this.app.router.remove('world');
}
}
```
## Example
```tsx
/**
* defaultShowCode: true
*/
import React from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const Layout = () => {
return <div>
<div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
<Outlet />
</div>
}
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
```

View File

@ -7,7 +7,6 @@ Most of the extensions for the NocoBase client are provided as Providers.
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,62 +1,66 @@
# UI Routing # UI Routing
NocoBase Client's Router is based on [React Router](https://v5.reactrouter.com/web/guides/quick-start) and can be configured via `<RouteSwitch routes={[]} />` to configure ui routes with the following example. NocoBase Client's Router is based on [React Router](https://v5.reactrouter.com/web/guides/quick-start) and can be configured via `app.router` to configure ui routes with the following example.
```tsx ```tsx
/** /**
* defaultShowCode: true * defaultShowCode: true
*/ */
import React from 'react'; import React from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom'; import { Link, Outlet } from 'react-router-dom';
import { RouteRedirectProps, RouteSwitchProvider, RouteSwitch } from '@nocobase/client'; import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>; const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>; const About = () => <h1>About</h1>;
const routes: RouteRedirectProps[] = [ const Layout = () => {
{ return <div>
type: 'route', <div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
path: '/', <Outlet />
component: 'Home', </div>
}, }
{
type: 'route',
path: '/about',
component: 'About',
},
];
export default () => { const app = new Application({
return ( router: {
<RouteSwitchProvider components={{ Home, About }}> type: 'memory',
<Router initialEntries={['/']}> initialEntries: ['/']
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link> }
<RouteSwitch routes={routes} /> })
</Router>
</RouteSwitchProvider> app.router.add('root', {
); element: <Layout />
}; })
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
``` ```
In a full NocoBase application, the Route can be extended in a similar way as follows. In a full NocoBase application, the Route can be extended in a similar way as follows.
```tsx | pure ```tsx | pure
import { RouteSwitchContext } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import React, { useContext } from 'react';
const HelloWorld = () => { class MyPlugin extends Plugin {
return <div>Hello ui router</div>; async load() {
}; // add
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
export default React.memo((props) => { // remove
const ctx = useContext(RouteSwitchContext); this.app.router.remove('hello');
ctx.routes.push({ }
type: 'route', }
path: '/hello-world',
component: HelloWorld,
});
return <RouteSwitchContext.Provider value={ctx}>{props.children}</RouteSwitchContext.Provider>;
});
``` ```
See [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page) for the full example See [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page) for the full example

View File

@ -0,0 +1,100 @@
# v0.11: Update instructions
## Plugin registration and use
before you had to pass a component and the component needed to pass `props.children`, for example:
```tsx | pure
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
export default HelloProvider
```
now you need to change to the plugin way, for example:
```diff | pure
+import { Plugin } from '@nocobase/client'
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.addProvider(HelloProvider);
+ }
+ }
- export default HelloProvider;
+ export default HelloPlugin;
```
plugins are very powerful and can do a lot of things in the `load` phase:
- modify routes
- add Components
- add Providers
- add Scopes
- load other plugins
if you used `RouteSwitchContext` to modify the route before, you now need to replace it with a plugin:
```tsx | pure
import { RouteSwitchContext } from '@nocobase/client';
const HelloProvider = () => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift({
path: '/hello',
component: Hello,
});
return <div>
<RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
</RouteSwitchContext.Provider>
</div>
}
```
now you need to change to the plugin way, for example:
```diff | pure
- import { RouteSwitchContext } from '@nocobase/client';
+ import { Plugin } from '@nocobase/client';
const HelloProvider = (props) => {
- const { routes, ...others } = useContext(RouteSwitchContext);
- routes[1].routes.unshift({
- path: '/hello',
- component: Hello,
- });
return <div>
- <RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
- </RouteSwitchContext.Provider>
</div>
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.router.add('admin.hello', {
+ path: '/hello',
+ Component: Hello,
+ });
+ this.app.addProvider(HelloProvider);
+ }
+ }
+ export default HelloPlugin;
```
more details can be found in [plugin development](/development/client).

View File

@ -32,7 +32,6 @@ const app = new Application({
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,74 +0,0 @@
# RouteSwitch
## `<RouteSwitchProvider />`
```ts
interface RouteSwitchProviderProps {
components?: ReactComponent;
routes?: RouteRedirectProps[];
}
```
## `<RouteSwitch />`
```ts
interface RouteSwitchProps {
routes?: RouteRedirectProps[];
components?: ReactComponent;
}
type RouteRedirectProps = RedirectProps | RouteProps;
interface RedirectProps {
type: 'redirect';
to: any;
path?: string;
push?: boolean;
from?: string;
[key: string]: any;
}
interface RouteProps {
type: 'route';
path?: string | string[];
sensitive?: boolean;
component?: any;
routes?: RouteProps[];
[key: string]: any;
}
```
## 完整示例
```tsx | pure
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: '/',
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>
);
};
```

View File

@ -0,0 +1,180 @@
# Router
## API
### Initial
```tsx | pure
const app = new Application({
router: {
type: 'browser' // type default value is `browser`
}
})
// or
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
```
### add Route
#### basic
```tsx | pure
import { RouteObject } from 'react-router-dom'
const app = new Application()
const Hello = () => {
return <div>Hello</div>
}
// first argument is `name` of route, second argument is `RouteObject`
app.router.add('root', {
path: '/',
element: <Hello />
})
app.router.add('root', {
path: '/',
Component: Hello
})
```
#### Component is String
```tsx | pure
app.addComponents({
Hello
})
app.router.add('root', {
path: '/',
Component: 'Hello'
})
```
#### nested
```tsx | pure
import { Outlet } from 'react-router-dom'
const Layout = () => {
return <div>
<Link to='/home'>Home</Link>
<Link to='/about'>about</Link>
<Outlet />
</div>
}
const Home = () => {
return <div>Home</div>
}
const About = () => {
return <div>About</div>
}
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/home',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
```
It will generate the following routes:
```tsx | pure
{
element: <Layout />,
children: [
{
path: '/home',
element: <Home />
},
{
path: '/about',
element: <About />
}
]
}
```
### remove Route
```tsx | pure
// remove route by name
app.router.remove('root.home')
app.router.remove('hello')
```
#### Router in plugin
```tsx | pure
class MyPlugin extends Plugin {
async load() {
// add route
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
// remove route
this.app.router.remove('world');
}
}
```
## Example
```tsx
/**
* defaultShowCode: true
*/
import React from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const Layout = () => {
return <div>
<div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
<Outlet />
</div>
}
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
```

View File

@ -7,7 +7,6 @@ Most of the extensions for the NocoBase client are provided as Providers.
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,62 +1,66 @@
# UI Routing # UI Routing
NocoBase Client's Router is based on [React Router](https://v5.reactrouter.com/web/guides/quick-start) and can be configured via `<RouteSwitch routes={[]} />` to configure ui routes with the following example. NocoBase Client's Router is based on [React Router](https://v5.reactrouter.com/web/guides/quick-start) and can be configured via `app.router` to configure ui routes with the following example.
```tsx ```tsx
/** /**
* defaultShowCode: true * defaultShowCode: true
*/ */
import React from 'react'; import React from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom'; import { Link, Outlet } from 'react-router-dom';
import { RouteRedirectProps, RouteSwitchProvider, RouteSwitch } from '@nocobase/client'; import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>; const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>; const About = () => <h1>About</h1>;
const routes: RouteRedirectProps[] = [ const Layout = () => {
{ return <div>
type: 'route', <div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
path: '/', <Outlet />
component: 'Home', </div>
}, }
{
type: 'route',
path: '/about',
component: 'About',
},
];
export default () => { const app = new Application({
return ( router: {
<RouteSwitchProvider components={{ Home, About }}> type: 'memory',
<Router initialEntries={['/']}> initialEntries: ['/']
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link> }
<RouteSwitch routes={routes} /> })
</Router>
</RouteSwitchProvider> app.router.add('root', {
); element: <Layout />
}; })
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
``` ```
In a full NocoBase application, the Route can be extended in a similar way as follows. In a full NocoBase application, the Route can be extended in a similar way as follows.
```tsx | pure ```tsx | pure
import { RouteSwitchContext } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import React, { useContext } from 'react';
const HelloWorld = () => { class MyPlugin extends Plugin {
return <div>Hello ui router</div>; async load() {
}; // add
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
export default React.memo((props) => { // remove
const ctx = useContext(RouteSwitchContext); this.app.router.remove('hello');
ctx.routes.push({ }
type: 'route', }
path: '/hello-world',
component: HelloWorld,
});
return <RouteSwitchContext.Provider value={ctx}>{props.children}</RouteSwitchContext.Provider>;
});
``` ```
See [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page) for the full example See [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page) for the full example

View File

@ -0,0 +1,100 @@
# v0.11: Update instructions
## Plugin registration and use
before you had to pass a component and the component needed to pass `props.children`, for example:
```tsx | pure
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
export default HelloProvider
```
now you need to change to the plugin way, for example:
```diff | pure
+import { Plugin } from '@nocobase/client'
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.addProvider(HelloProvider);
+ }
+ }
- export default HelloProvider;
+ export default HelloPlugin;
```
plugins are very powerful and can do a lot of things in the `load` phase:
- modify routes
- add Components
- add Providers
- add Scopes
- load other plugins
if you used `RouteSwitchContext` to modify the route before, you now need to replace it with a plugin:
```tsx | pure
import { RouteSwitchContext } from '@nocobase/client';
const HelloProvider = () => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift({
path: '/hello',
component: Hello,
});
return <div>
<RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
</RouteSwitchContext.Provider>
</div>
}
```
now you need to change to the plugin way, for example:
```diff | pure
- import { RouteSwitchContext } from '@nocobase/client';
+ import { Plugin } from '@nocobase/client';
const HelloProvider = (props) => {
- const { routes, ...others } = useContext(RouteSwitchContext);
- routes[1].routes.unshift({
- path: '/hello',
- component: Hello,
- });
return <div>
- <RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
- </RouteSwitchContext.Provider>
</div>
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.router.add('admin.hello', {
+ path: '/hello',
+ Component: Hello,
+ });
+ this.app.addProvider(HelloProvider);
+ }
+ }
+ export default HelloPlugin;
```
more details can be found in [plugin development](/development/client).

View File

@ -32,7 +32,6 @@ const app = new Application({
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,74 +0,0 @@
# RouteSwitch
## `<RouteSwitchProvider />`
```ts
interface RouteSwitchProviderProps {
components?: ReactComponent;
routes?: RouteRedirectProps[];
}
```
## `<RouteSwitch />`
```ts
interface RouteSwitchProps {
routes?: RouteRedirectProps[];
components?: ReactComponent;
}
type RouteRedirectProps = RedirectProps | RouteProps;
interface RedirectProps {
type: 'redirect';
to: any;
path?: string;
push?: boolean;
from?: string;
[key: string]: any;
}
interface RouteProps {
type: 'route';
path?: string | string[];
sensitive?: boolean;
component?: any;
routes?: RouteProps[];
[key: string]: any;
}
```
## 完整示例
```tsx | pure
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: '/',
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>
);
};
```

View File

@ -0,0 +1,183 @@
# Router
## API
### 初始化
```tsx | pure
const app = new Application({
router: {
type: 'browser' // type 的默认值就是 `browser`
}
})
// or
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
```
### 添加路由
#### 基础用法
```tsx | pure
import { RouteObject } from 'react-router-dom'
const app = new Application()
const Hello = () => {
return <div>Hello</div>
}
// 第一个参数是名称, 第二个参数是 `RouteObject`
app.router.add('root', {
path: '/',
element: <Hello />
})
app.router.add('root', {
path: '/',
Component: Hello
})
```
#### 支持 Component 是字符串
```tsx | pure
// register Hello
app.addComponents({
Hello
})
// Component is `Hello` string
app.router.add('root', {
path: '/',
Component: 'Hello'
})
```
#### 嵌套路由
```tsx | pure
import { Outlet } from 'react-router-dom'
const Layout = () => {
return <div>
<Link to='/home'>Home</Link>
<Link to='/about'>about</Link>
<Outlet />
</div>
}
const Home = () => {
return <div>Home</div>
}
const About = () => {
return <div>About</div>
}
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/home',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
```
它将会被渲染为如下形式:
```tsx | pure
{
element: <Layout />,
children: [
{
path: '/home',
element: <Home />
},
{
path: '/about',
element: <About />
}
]
}
```
### 删除路由
```tsx | pure
// 传递 name 即可删除
app.router.remove('root.home')
app.router.remove('hello')
```
#### 插件中修改路由
```tsx | pure
class MyPlugin extends Plugin {
async load() {
// add route
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
// remove route
this.app.router.remove('world');
}
}
```
## 示例
```tsx
/**
* defaultShowCode: true
*/
import React from 'react';
import { Link, Outlet } from 'react-router-dom';
import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>;
const Layout = () => {
return <div>
<div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
<Outlet />
</div>
}
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/']
}
})
app.router.add('root', {
element: <Layout />
})
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
```

View File

@ -7,7 +7,6 @@ NocoBase 客户端的扩展大多以 Provider 的形式提供。
- APIClientProvider - APIClientProvider
- I18nextProvider - I18nextProvider
- AntdConfigProvider - AntdConfigProvider
- RemoteRouteSwitchProvider
- SystemSettingsProvider - SystemSettingsProvider
- PluginManagerProvider - PluginManagerProvider
- SchemaComponentProvider - SchemaComponentProvider

View File

@ -1,62 +1,66 @@
# UI 路由 # UI 路由
NocoBase Client 的 Router 基于 [React Router](https://v5.reactrouter.com/web/guides/quick-start),可以通过 `<RouteSwitch routes={[]} />` 来配置 ui routes例子如下 NocoBase Client 的 Router 基于 [React Router](https://v5.reactrouter.com/web/guides/quick-start),可以通过 `app.router` 来配置 ui routes例子如下
```tsx ```tsx
/** /**
* defaultShowCode: true * defaultShowCode: true
*/ */
import React from 'react'; import React from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom'; import { Link, Outlet } from 'react-router-dom';
import { RouteRedirectProps, RouteSwitchProvider, RouteSwitch } from '@nocobase/client'; import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>; const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>; const About = () => <h1>About</h1>;
const routes: RouteRedirectProps[] = [ const Layout = () => {
{ return <div>
type: 'route', <div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
path: '/', <Outlet />
component: 'Home', </div>
}, }
{
type: 'route',
path: '/about',
component: 'About',
},
];
export default () => { const app = new Application({
return ( router: {
<RouteSwitchProvider components={{ Home, About }}> type: 'memory',
<Router initialEntries={['/']}> initialEntries: ['/']
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link> }
<RouteSwitch routes={routes} /> })
</Router>
</RouteSwitchProvider> app.router.add('root', {
); element: <Layout />
}; })
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
``` ```
在完整的 NocoBase 应用里,可以类似以下的的方式扩展 Route 在完整的 NocoBase 应用里,可以类似以下的的方式扩展 Route
```tsx | pure ```tsx | pure
import { RouteSwitchContext } from '@nocobase/client'; import { Plugin } from '@nocobase/client';
import React, { useContext } from 'react';
const HelloWorld = () => { class MyPlugin extends Plugin {
return <div>Hello ui router</div>; async load() {
}; // 添加一条路由
this.app.router.add('hello', {
path: '/hello',
element: <div>hello</div>,
})
export default React.memo((props) => { // 删除一条路由
const ctx = useContext(RouteSwitchContext); this.app.router.remove('hello');
ctx.routes.push({ }
type: 'route', }
path: '/hello-world',
component: HelloWorld,
});
return <RouteSwitchContext.Provider value={ctx}>{props.children}</RouteSwitchContext.Provider>;
});
``` ```
完整示例查看 [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page) 完整示例查看 [packages/samples/custom-page](https://github.com/nocobase/nocobase/tree/develop/packages/samples/custom-page)

View File

@ -0,0 +1,120 @@
# v0.11:更新说明
## 新特性
- 全新的客户端 Application、Plugin 和 Router
- antd 升级到 v5
- 新插件
- 数据可视化
- API 秘钥
- Google 地图
## 不兼容的变化
### 全新的客户端 Application、Plugin 和 Router
#### 插件的变化
以前必须传递一个组件,并且组件需要透传 `props.children`,例如:
```tsx | pure
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
export default HelloProvider
```
现在需要改为插件的方式,例如:
```diff | pure
+import { Plugin } from '@nocobase/client'
const HelloProvider = (props) => {
// do something logic
return <div>
{props.children}
</div>;
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.addProvider(HelloProvider);
+ }
+ }
- export default HelloProvider;
+ export default HelloPlugin;
```
插件的功能很强大,可以在 `load` 阶段做很多事情:
- 修改路由
- 增加 Components
- 增加 Providers
- 增加 Scopes
- 加载其他插件
#### 路由的变化
如果之前使用了 `RouteSwitchContext` 进行路由修改,现在需要通过插件替换:
```tsx | pure
import { RouteSwitchContext } from '@nocobase/client';
const HelloProvider = () => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift({
path: '/hello',
component: Hello,
});
return <div>
<RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
</RouteSwitchContext.Provider>
</div>
}
```
需要改为:
```diff | pure
- import { RouteSwitchContext } from '@nocobase/client';
+ import { Plugin } from '@nocobase/client';
const HelloProvider = (props) => {
- const { routes, ...others } = useContext(RouteSwitchContext);
- routes[1].routes.unshift({
- path: '/hello',
- component: Hello,
- });
return <div>
- <RouteSwitchContext.Provider value={{ ...others, routes }}>
{props.children}
- </RouteSwitchContext.Provider>
</div>
}
+ export class HelloPlugin extends Plugin {
+ async load() {
+ this.app.router.add('admin.hello', {
+ path: '/hello',
+ Component: Hello,
+ });
+ this.app.addProvider(HelloProvider);
+ }
+ }
+ export default HelloPlugin;
```
更多文档和示例见 [packages/core/client/src/application/index.md](https://github.com/nocobase/nocobase/blob/main/packages/core/client/src/application/index.md)
### antd 升级到 v5
- antd 相关详情查看官网 [从 v4 到 v5](https://ant.design/docs/react/migration-v5-cn)
- `@formily/antd` 替换为 `@formily/antd-v5`

View File

@ -44,6 +44,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"commander": "^9.2.0",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"react": "^18.0.0", "react": "^18.0.0",
@ -57,7 +58,7 @@
"@vitejs/plugin-react": "^4.0.0", "@vitejs/plugin-react": "^4.0.0",
"auto-changelog": "^2.4.0", "auto-changelog": "^2.4.0",
"dumi": "^2.2.0", "dumi": "^2.2.0",
"dumi-theme-nocobase": "^0.2.12", "dumi-theme-nocobase": "^0.2.14",
"ghooks": "^2.0.4", "ghooks": "^2.0.4",
"jsdom-worker": "^0.3.0", "jsdom-worker": "^0.3.0",
"prettier": "^2.2.1", "prettier": "^2.2.1",

View File

@ -20,8 +20,7 @@ export default defineConfig({
{ rel: 'stylesheet', href: '/global.css' }, { rel: 'stylesheet', href: '/global.css' },
], ],
headScripts: [ headScripts: [
'/browser-checker.js', '/browser-checker.js'
'/set-router.js',
], ],
hash: true, hash: true,
alias: { alias: {

View File

@ -1,2 +0,0 @@
var match = location.pathname.match(/^\/apps\/([^/]*)\//);
window.routerBase = match ? match[0] : "/";

View File

@ -1,5 +1,6 @@
import '@/theme'; import '@/theme';
import { Application } from '@nocobase/client'; import { Application } from '@nocobase/client';
import { NocoBaseClientPresetPlugin } from '@nocobase/preset-nocobase/client';
export const app = new Application({ export const app = new Application({
apiClient: { apiClient: {
@ -8,6 +9,7 @@ export const app = new Application({
dynamicImport: (name: string) => { dynamicImport: (name: string) => {
return import(`../plugins/${name}`); return import(`../plugins/${name}`);
}, },
plugins: [NocoBaseClientPresetPlugin],
}); });
export default app.render(); export default app.getRootComponent();

View File

@ -0,0 +1,2 @@
export { default } from '@nocobase/plugin-acl/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-client/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-collection-manager/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-error-handler/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-system-settings/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-ui-routes-storage/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-ui-schema-storage/client';

View File

@ -11,13 +11,24 @@ import randomColor from './randomColor';
import registerBabel from './registerBabel'; import registerBabel from './registerBabel';
import rollup from './rollup'; import rollup from './rollup';
import { Dispose, IBundleOptions, IBundleTypeOutput, ICjs, IEsm, IOpts } from './types'; import { Dispose, IBundleOptions, IBundleTypeOutput, ICjs, IEsm, IOpts } from './types';
import { getExistFile, getLernaPackages } from './utils'; import { getExistFiles, getLernaPackages } from './utils';
export function getBundleOpts(opts: IOpts): IBundleOptions[] { export function getBundleOpts(opts: IOpts): IBundleOptions[] {
const { cwd, buildArgs = {}, rootConfig = {} } = opts; const { cwd, buildArgs = {}, rootConfig = {} } = opts;
const entry = getExistFile({ const entry = getExistFiles({
cwd, cwd,
files: ['src/index.tsx', 'src/index.ts', 'src/index.jsx', 'src/index.js'], files: [
'src/index.tsx',
'src/index.ts',
'src/index.jsx',
'src/index.js',
'src/server/index.ts',
'src/server/index.js',
'src/client/index.js',
'src/client/index.ts',
'src/client/index.tsx'
],
onlyOne: false,
returnRelative: true, returnRelative: true,
}); });
const userConfig = getUserConfig({ cwd, customPath: buildArgs.config }); const userConfig = getUserConfig({ cwd, customPath: buildArgs.config });

View File

@ -5,7 +5,7 @@ import signale from 'signale';
import slash from 'slash2'; import slash from 'slash2';
import schema from './schema'; import schema from './schema';
import { IBundleOptions } from './types'; import { IBundleOptions } from './types';
import { getExistFile } from './utils'; import { getExistFiles } from './utils';
function testDefault(obj) { function testDefault(obj) {
return obj.default || obj; return obj.default || obj;
@ -51,7 +51,7 @@ export default function({ cwd, customPath }: { cwd: string; customPath?: string
const configFile = const configFile =
finalPath || finalPath ||
getExistFile({ getExistFiles({
cwd, cwd,
files: CONFIG_FILES, files: CONFIG_FILES,
returnRelative: false, returnRelative: false,

View File

@ -1,13 +1,17 @@
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
export function getExistFile({ cwd, files, returnRelative }) { export function getExistFiles({ cwd, files, returnRelative, onlyOne = true }) {
const res = [];
for (const file of files) { for (const file of files) {
const absFilePath = join(cwd, file); const absFilePath = join(cwd, file);
if (existsSync(absFilePath)) { if (existsSync(absFilePath)) {
return returnRelative ? file : absFilePath; const filePath = returnRelative ? file : absFilePath;
res.push(filePath);
} }
} }
return onlyOne ? res[0] : res; // undefined or string[]
} }
export { getLernaPackages } from './getLernaPackages'; export { getLernaPackages } from './getLernaPackages';

View File

@ -1,3 +1,3 @@
// @ts-nocheck export * from './src/client';
export * from './lib/client'; export { default } from './src/client';
export { default } from './lib/client';

View File

@ -1,65 +1 @@
'use strict'; module.exports = require('./lib/client/index.js');
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== 'function') return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return { default: obj };
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {};
var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
for (var key in obj) {
if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
var _index = _interopRequireWildcard(require('./lib/client'));
Object.defineProperty(exports, '__esModule', {
value: true,
});
var _exportNames = {};
Object.defineProperty(exports, 'default', {
enumerable: true,
get: function get() {
return _index.default;
},
});
Object.keys(_index).forEach(function (key) {
if (key === 'default' || key === '__esModule') return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
},
});
});

View File

@ -2,6 +2,17 @@
"name": "{{{packageName}}}", "name": "{{{packageName}}}",
"version": "{{{packageVersion}}}", "version": "{{{packageVersion}}}",
"main": "lib/server/index.js", "main": "lib/server/index.js",
"files": [
"lib",
"src",
"README.md",
"README.zh-CN.md",
"CHANGELOG.md",
"server.js",
"server.d.ts",
"client.js",
"client.d.ts"
],
"devDependencies": { "devDependencies": {
"@nocobase/server": "{{{nocobaseVersion}}}", "@nocobase/server": "{{{nocobaseVersion}}}",
"@nocobase/test": "{{{nocobaseVersion}}}" "@nocobase/test": "{{{nocobaseVersion}}}"

View File

@ -1,3 +1,3 @@
// @ts-nocheck export * from './src/server';
export * from './lib/server'; export { default } from './src/server';
export { default } from './lib/server';

View File

@ -1,65 +1 @@
'use strict'; module.exports = require('./lib/server/index.js');
function _getRequireWildcardCache(nodeInterop) {
if (typeof WeakMap !== 'function') return null;
var cacheBabelInterop = new WeakMap();
var cacheNodeInterop = new WeakMap();
return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) {
return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
})(nodeInterop);
}
function _interopRequireWildcard(obj, nodeInterop) {
if (!nodeInterop && obj && obj.__esModule) {
return obj;
}
if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
return { default: obj };
}
var cache = _getRequireWildcardCache(nodeInterop);
if (cache && cache.has(obj)) {
return cache.get(obj);
}
var newObj = {};
var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
for (var key in obj) {
if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) {
var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
if (desc && (desc.get || desc.set)) {
Object.defineProperty(newObj, key, desc);
} else {
newObj[key] = obj[key];
}
}
}
newObj.default = obj;
if (cache) {
cache.set(obj, newObj);
}
return newObj;
}
var _index = _interopRequireWildcard(require('./lib/server'));
Object.defineProperty(exports, '__esModule', {
value: true,
});
var _exportNames = {};
Object.defineProperty(exports, 'default', {
enumerable: true,
get: function get() {
return _index.default;
},
});
Object.keys(_index).forEach(function (key) {
if (key === 'default' || key === '__esModule') return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
},
});
});

View File

@ -1,8 +0,0 @@
import React from 'react';
const MyProvider = React.memo((props) => {
return <>{props.children}</>;
});
MyProvider.displayName = 'MyProvider';
export default MyProvider;

View File

@ -0,0 +1,21 @@
import { Plugin } from '@nocobase/client';
export class {{{pascalCaseName}}}Plugin extends Plugin {
async afterAdd() {
// this.app.pm.add()
}
async beforeLoad() {}
// You can get and modify the app instance here
async load() {
console.log(this.app);
// this.app.addComponents({})
// this.app.addScopes({})
// this.app.addProvider()
// this.app.addProviders()
// this.app.router.add()
}
}
export default {{{pascalCaseName}}}Plugin;

View File

@ -2,4 +2,4 @@
sidebar: false sidebar: false
--- ---
<code src="../src/application/demos/demo2/index.tsx"></code> <code src="../src/application/demos/demo3.tsx"></code>

View File

@ -8,92 +8,48 @@ order: 1
示例: 示例:
```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, Routes, 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>
<Routes>
<Route path="/" element={<Home />}></Route>
<Route path="/about" element={<About />}></Route>
</Routes>
</Router>
);
export default App;
```
上述例子,组件经由路由转发,`/` 转发给 `Home``/about` 转发给 `About`。这种 JSX 的写法,对于熟悉 JSX 的开发来说,十分便捷,但需要开发来编写和维护,不符合 NocoBase 低代码、无代码的设计理念。所以将 Route 做了封装和配置化改造,如下:
```tsx ```tsx
/** /**
* defaultShowCode: true * defaultShowCode: true
* title: RouteSwitch
*/ */
import React from 'react'; import React from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom'; import { Link, Outlet } from 'react-router-dom';
import { RouteRedirectProps, RouteSwitchProvider, RouteSwitch } from '@nocobase/client'; import { Application } from '@nocobase/client';
const Home = () => <h1>Home</h1>; const Home = () => <h1>Home</h1>;
const About = () => <h1>About</h1>; const About = () => <h1>About</h1>;
const routes: RouteRedirectProps[] = [ const Layout = () => {
{ return <div>
type: 'route', <div><Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link></div>
path: '/', <Outlet />
component: 'Home', </div>
}, }
{
type: 'route',
path: '/about',
component: 'About',
},
];
export default () => { const app = new Application({
return ( router: {
<RouteSwitchProvider components={{ Home, About }}> type: 'memory',
<Router initialEntries={['/']}> initialEntries: ['/']
<Link to={'/'}>Home</Link>, <Link to={'/about'}>About</Link> }
<RouteSwitch routes={routes} /> })
</Router>
</RouteSwitchProvider> app.router.add('root', {
); element: <Layout />
}; })
app.router.add('root.home', {
path: '/',
element: <Home />
})
app.router.add('root.about', {
path: '/about',
element: <About />
})
export default app.getRootComponent();
``` ```
- 由 RouteSwitchProvider 配置 components由开发编写以 Layout 或 Template 的方式提供给 RouteSwitch 使用。
- 由 RouteSwitch 配置 routesJSON 的方式,可以由后端获取,方便后续的动态化、无代码的支持。
## SchemaComponent ## SchemaComponent
路由可以通过 JSON 的方式配置,可以注册诸多可供路由使用的组件模板,以方便各种场景支持,但是这些组件还是需要开发编写和维护,所以进一步将组件抽象,转换成配置化的方式。如: 路由可以通过 JSON 的方式配置,可以注册诸多可供路由使用的组件模板,以方便各种场景支持,但是这些组件还是需要开发编写和维护,所以进一步将组件抽象,转换成配置化的方式。如:
@ -459,101 +415,6 @@ export default function App() {
} }
``` ```
## RouteSwitch + SchemaComponent
当路由和组件都可以配置之后,可以进一步将二者结合,例子如下:
```tsx
/**
* defaultShowCode: true
* title: RouteSwitch + SchemaComponent
*/
import React, { useMemo, useEffect } from 'react';
import { Link, MemoryRouter as Router } from 'react-router-dom';
import {
RouteRedirectProps,
RouteSwitchProvider,
RouteSwitch,
useRoute,
SchemaComponentProvider,
SchemaComponent,
useDesignable,
useSchemaComponentContext,
} 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>
)
}, { displayName: 'Hello' });
const RouteSchemaComponent = (props) => {
const route = useRoute();
const { reset } = useSchemaComponentContext();
useEffect(() => {
reset();
}, route.schema);
return <SchemaComponent schema={route.schema}/>
}
const routes: RouteRedirectProps[] = [
{
type: 'route',
path: '/',
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 ## Designable
SchemaComponent 基于 Formily 的 SchemaFieldFormily 提供了 [Designable](https://github.com/alibaba/designable) 来解决 Schema 的配置问题,但是这套方案: SchemaComponent 基于 Formily 的 SchemaFieldFormily 提供了 [Designable](https://github.com/alibaba/designable) 来解决 Schema 的配置问题,但是这套方案:
@ -781,7 +642,6 @@ const { data, loading } = useRequest();
客户端的扩展以 Providers 的形式存在,提供各种可供组件使用的 Context可全局也可以局部使用。上文我们已经介绍了核心的三个 Providers 客户端的扩展以 Providers 的形式存在,提供各种可供组件使用的 Context可全局也可以局部使用。上文我们已经介绍了核心的三个 Providers
- RouteSwitchProvider提供配置路由所需的 Layout 和 Template 组件
- SchemaComponentProvider提供配置 Schema 所需的各种组件 - SchemaComponentProvider提供配置 Schema 所需的各种组件
- ApiClientProvider提供客户端 SDK - ApiClientProvider提供客户端 SDK
@ -800,16 +660,14 @@ const { data, loading } = useRequest();
```tsx | pure ```tsx | pure
<ApiClientProvider> <ApiClientProvider>
<SchemaComponentProvider> <SchemaComponentProvider>
<RouteSwitchProvider>
{...} {...}
</RouteSwitchProvider>
</SchemaComponentProvider> </SchemaComponentProvider>
</ApiClientProvider> </ApiClientProvider>
``` ```
但是这样的方式不利于 Providers 的管理和扩展,为此提炼了 `compose()` 函数用于配置多个 providers如下 但是这样的方式不利于 Providers 的管理和扩展,为此提炼了 `compose()` 函数用于配置多个 providers如下
<code id='intro-demo2' defaultShowCode="true" titile="compose" src="../src/application/demos/demo1/index.tsx"></code> <code id='intro-demo2' defaultShowCode="true" titile="compose" src="../src/application/demos/demo1.tsx"></code>
## Application ## Application

View File

@ -53,12 +53,12 @@
"react-is": ">=18.0.0" "react-is": ">=18.0.0"
}, },
"devDependencies": { "devDependencies": {
"dumi": "^2.2.0",
"dumi-theme-nocobase": "^0.2.14",
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@types/markdown-it": "12.2.3", "@types/markdown-it": "12.2.3",
"@types/markdown-it-highlightjs": "3.3.1", "@types/markdown-it-highlightjs": "3.3.1",
"axios-mock-adapter": "^1.20.0", "axios-mock-adapter": "^1.20.0"
"dumi": "^2.2.0",
"dumi-theme-nocobase": "^0.2.9"
}, },
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
} }

View File

@ -12,6 +12,7 @@ import { SchemaComponentOptions, useDesignable } from '../schema-component';
export const ACLContext = createContext<any>({}); export const ACLContext = createContext<any>({});
// TODO: delete thisreplace by `ACLPlugin`
export const ACLProvider = (props) => { export const ACLProvider = (props) => {
return ( return (
<SchemaComponentOptions <SchemaComponentOptions

View File

@ -1,4 +1,22 @@
export * from './ACLProvider'; export * from './ACLProvider';
export * from './ACLShortcut'; export * from './ACLShortcut';
import { Plugin } from '../application/Plugin';
import {
ACLActionProvider,
ACLCollectionFieldProvider,
ACLCollectionProvider,
ACLMenuItemProvider,
} from './ACLProvider';
import './style.less'; import './style.less';
export class ACLPlugin extends Plugin {
async load() {
this.app.addComponents({
ACLCollectionFieldProvider,
ACLActionProvider,
ACLMenuItemProvider,
ACLCollectionProvider,
});
}
}

View File

@ -3,6 +3,7 @@ import moment from 'moment';
import React, { createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../api-client'; import { useAPIClient, useRequest } from '../api-client';
import { Plugin } from '../application/Plugin';
import { loadConstrueLocale } from './loadConstrueLocale'; import { loadConstrueLocale } from './loadConstrueLocale';
export const AppLangContext = createContext<any>({}); export const AppLangContext = createContext<any>({});
@ -50,3 +51,9 @@ export function AntdConfigProvider(props) {
</AppLangContext.Provider> </AppLangContext.Provider>
); );
} }
export class AntdConfigPlugin extends Plugin {
async load() {
this.app.use(AntdConfigProvider, this.options?.config || {});
}
}

View File

@ -1,91 +0,0 @@
import { i18n } from 'i18next';
import { merge } from 'lodash';
import get from 'lodash/get';
import set from 'lodash/set';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import { APIClient, APIClientProvider } from '../api-client';
import { Plugin } from './Plugin';
import { PluginManager } from './PluginManager';
import { Router } from './Router';
import { AppComponent, defaultAppComponents } from './components';
import { ApplicationOptions } from './types';
export class Application {
providers: any[];
router: Router;
plugins: Map<string, Plugin>;
scopes: Record<string, any>;
i18n: i18n;
apiClient: APIClient;
components: any;
pm: PluginManager;
constructor(protected _options: ApplicationOptions) {
this.providers = [];
this.plugins = new Map<string, Plugin>();
this.scopes = merge(this.scopes, _options.scopes || {});
this.components = merge(defaultAppComponents, _options.components || {});
this.apiClient = new APIClient(_options.apiClient);
this.router = new Router(_options.router, { app: this });
this.pm = new PluginManager(this);
this.useDefaultProviders();
}
get options() {
return this._options;
}
useDefaultProviders() {
this.use([APIClientProvider, { apiClient: this.apiClient }]);
this.use(I18nextProvider, { i18n: this.i18n });
}
getPlugin(name: string) {
return this.plugins.get(name);
}
getComponent(name: string) {
return get(this.components, name);
}
renderComponent(name: string, props = {}) {
return React.createElement(this.getComponent(name), props);
}
registerComponent(name: string, component: any) {
set(this.components, name, component);
}
registerComponents(components: any) {
Object.keys(components).forEach((name) => {
this.registerComponent(name, components[name]);
});
}
registerScopes(scopes: Record<string, any>) {
this.scopes = merge(this.scopes, scopes);
}
use(component: any, props?: any) {
this.providers.push(props ? [component, props] : component);
}
async load() {
return this.pm.load();
}
getRootComponent() {
return () => <AppComponent app={this} />;
}
mount(selector: string) {
const container = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (container) {
const App = this.getRootComponent();
const root = createRoot(container);
root.render(<App />);
}
}
}

View File

@ -1,30 +0,0 @@
import { Application } from './Application';
import { PluginOptions } from './types';
export class Plugin {
constructor(protected _options: PluginOptions, protected app: Application) {
this.app = app;
}
get options() {
return this._options;
}
get name() {
return this._options.name;
}
get pm() {
return this.app.pm;
}
get router() {
return this.app.router;
}
async afterAdd() {}
async beforeLoad() {}
async load() {}
}

View File

@ -1,83 +0,0 @@
import { Application } from './Application';
import { Plugin } from './Plugin';
import { type PluginOptions } from './types';
export interface PluginManagerOptions {
plugins: string[];
}
type PluginNameOrClass = string | typeof Plugin;
export class PluginManager {
protected pluginInstances: Map<string, Plugin>;
protected pluginPrepares: Map<string, any>;
constructor(protected app: Application) {
this.pluginInstances = new Map();
this.pluginPrepares = new Map();
this.addPresetPlugins();
}
protected addPresetPlugins() {
const { plugins } = this.app.options;
for (const plugin of plugins) {
if (typeof plugin === 'string') {
this.prepare(plugin);
} else {
this.prepare(...plugin);
}
}
}
prepare(nameOrClass: PluginNameOrClass, options?: PluginOptions) {
let opts: any = {};
if (typeof nameOrClass === 'string') {
opts['name'] = nameOrClass;
} else {
opts = { ...options, Plugin: nameOrClass };
}
return this.pluginPrepares.set(opts.name, opts);
}
async add(nameOrClass: PluginNameOrClass, options?: PluginOptions) {
let opts: any = {};
if (typeof nameOrClass === 'string') {
opts['name'] = nameOrClass;
} else {
opts = { ...options, Plugin: nameOrClass };
}
const plugin = await this.makePlugin(opts);
this.pluginInstances.set(plugin.name, plugin);
await plugin.afterAdd();
return plugin;
}
async makePlugin(opts) {
const { importPlugins } = this.app.options;
let P: typeof Plugin = opts.Plugin;
if (!P) {
P = await importPlugins(opts.name);
}
if (!P) {
throw new Error(`Plugin "${opts.name} " not found`);
}
console.log(opts, P);
return new P(opts, this.app);
}
async load() {
for (const opts of this.pluginPrepares.values()) {
const plugin = await this.makePlugin(opts);
this.pluginInstances.set(plugin.name, plugin);
await plugin.afterAdd();
}
for (const plugin of this.pluginInstances.values()) {
await plugin.beforeLoad();
}
for (const plugin of this.pluginInstances.values()) {
await plugin.load();
}
}
}

View File

@ -1,67 +0,0 @@
import set from 'lodash/set';
import { createBrowserRouter, createHashRouter, createMemoryRouter } from 'react-router-dom';
import { Application } from './Application';
import { RouterOptions } from './types';
export class Router {
protected app: Application;
protected routes: Map<string, any>;
constructor(protected options?: RouterOptions, protected context?: any) {
this.routes = new Map<string, any>();
this.app = context.app;
}
getRoutes() {
const routes = {};
for (const [name, route] of this.routes) {
set(routes, name.split('.').join('.children.'), route);
}
const transform = (item) => {
if (item.component) {
item.Component = this.app.getComponent(item.component);
}
return item;
};
const toArr = (items: any) => {
return Object.values<any>(items || {}).map((item) => {
if (item.children) {
item.children = toArr(item.children);
}
return transform(item);
});
};
return toArr(routes);
}
createRouter() {
const { type, ...opts } = this.options;
const routes = this.getRoutes();
if (routes.length === 0) {
return null;
}
switch (type) {
case 'hash':
return createHashRouter(routes, opts) as any;
case 'browser':
return createBrowserRouter(routes, opts) as any;
case 'memory':
return createMemoryRouter(routes, opts) as any;
default:
return createMemoryRouter(routes, opts) as any;
}
}
add(name: string, route: any) {
this.routes.set(name, route);
Object.keys(route.children || {}).forEach((key) => {
this.routes.set(`${name}.${key}`, route.children[key]);
});
}
remove(name: string) {
this.routes.delete(name);
}
}

View File

@ -1,24 +0,0 @@
import React from 'react';
import { ApplicationContext } from '../context';
import { useApp, useLoad } from '../hooks';
const Internal = React.memo(() => {
const app = useApp();
const loading = useLoad();
if (loading) {
return app.renderComponent('App.Spin');
}
return app.renderComponent('App.Main', {
app,
providers: app.providers,
});
});
export const AppComponent = (props) => {
const { app } = props;
return (
<ApplicationContext.Provider value={app}>
<Internal />
</ApplicationContext.Provider>
);
};

View File

@ -1,9 +0,0 @@
import React, { useMemo } from 'react';
import { Application } from '../Application';
import { RouterProvider } from './RouterProvider';
export const MainComponent = React.memo((props: { app: Application; providers: any[] }) => {
const { app, providers } = props;
const router = useMemo(() => app.router.createRouter(), []);
return <RouterProvider router={router} providers={providers} />;
});

View File

@ -1,119 +0,0 @@
// @ts-nocheck
import type { RouterState } from '@remix-run/router';
import React from 'react';
import {
UNSAFE_DataRouterContext as DataRouterContext,
UNSAFE_DataRouterStateContext as DataRouterStateContext,
UNSAFE_LocationContext as LocationContext,
UNSAFE_RouteContext as RouteContext,
Router,
UNSAFE_useRoutesImpl as useRoutesImpl,
type DataRouteObject,
type RouterProviderProps,
} from 'react-router';
import { compose } from '../compose';
const START_TRANSITION = 'startTransition';
/**
* Given a Remix Router instance, render the appropriate UI
*/
export function RouterProvider({
fallbackElement,
router,
providers,
}: RouterProviderProps & { providers?: any }): React.ReactElement {
// Need to use a layout effect here so we are subscribed early enough to
// pick up on any render-driven redirects/navigations (useEffect/<Navigate>)
const [state, setStateImpl] = React.useState(router.state);
const setState = React.useCallback(
(newState: RouterState) => {
START_TRANSITION in React ? React[START_TRANSITION](() => setStateImpl(newState)) : setStateImpl(newState);
},
[setStateImpl],
);
React.useLayoutEffect(() => router.subscribe(setState), [router, setState]);
const navigator = React.useMemo((): Navigator => {
return {
createHref: router.createHref,
encodeLocation: router.encodeLocation,
go: (n) => router.navigate(n),
push: (to, state, opts) =>
router.navigate(to, {
state,
preventScrollReset: opts?.preventScrollReset,
}),
replace: (to, state, opts) =>
router.navigate(to, {
replace: true,
state,
preventScrollReset: opts?.preventScrollReset,
}),
};
}, [router]);
const basename = router.basename || '/';
const dataRouterContext = React.useMemo(
() => ({
router,
navigator,
static: false,
basename,
}),
[router, navigator, basename],
);
const Providers = compose(...providers)((props) => <>{props.children}</>);
// The fragment and {null} here are important! We need them to keep React 18's
// useId happy when we are server-rendering since we may have a <script> here
// containing the hydrated server-side staticContext (from StaticRouterProvider).
// useId relies on the component tree structure to generate deterministic id's
// so we need to ensure it remains the same on the client even though
// we don't need the <script> tag
return (
<>
<RouterContextCleaner>
<DataRouterContext.Provider value={dataRouterContext}>
<DataRouterStateContext.Provider value={state}>
<Router
basename={basename}
location={state.location}
navigationType={state.historyAction}
navigator={navigator}
>
<Providers>
{state.initialized ? <DataRoutes routes={router.routes} state={state} /> : fallbackElement}
</Providers>
</Router>
</DataRouterStateContext.Provider>
</DataRouterContext.Provider>
</RouterContextCleaner>
{null}
</>
);
}
export function DataRoutes({
routes,
state,
}: {
routes: DataRouteObject[];
state: RouterState;
}): React.ReactElement | null {
return useRoutesImpl(routes, undefined, state);
}
export const RouterContextCleaner = (props) => {
return (
<RouteContext.Provider
value={{
outlet: null,
matches: [],
isDataRoute: false,
}}
>
<LocationContext.Provider value={null as any}>{props.children}</LocationContext.Provider>
</RouteContext.Provider>
);
};

View File

@ -1,13 +0,0 @@
import React from 'react';
import { MainComponent } from './MainComponent';
export * from './AppComponent';
export * from './MainComponent';
export * from './RouterProvider';
export const defaultAppComponents = {
App: {
Main: MainComponent,
Spin: () => React.createElement('div', 'loading'),
},
};

View File

@ -1,18 +0,0 @@
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

@ -1,3 +0,0 @@
export * from './useApp';
export * from './useLoad';
export * from './useRouter';

View File

@ -1,16 +0,0 @@
import { useEffect, useState } from 'react';
import { useApp } from './useApp';
export const useLoad = () => {
const app = useApp();
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
(async () => {
await app.load();
setLoading(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return loading;
};

View File

@ -1,3 +0,0 @@
# Application V2
<code src="./demos/demo1.tsx">Demo</code>

View File

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

View File

@ -1,37 +0,0 @@
import { Plugin } from './Plugin';
export interface HashRouterOptions {
type: 'hash';
basename?: string;
// TODO: 补充 hash 参数
}
export interface BrowserRouterOptions {
type: 'browser';
basename?: string;
// TODO: 补充 browser 参数
}
export interface MemoryRouterOptions {
type: 'memory';
basename?: string;
// TODO: 补充 memory 参数
}
export type RouterOptions = HashRouterOptions | BrowserRouterOptions | MemoryRouterOptions;
export interface PluginOptions {
name: string;
}
export type PluginNameOrClass = string | [typeof Plugin, PluginOptions];
export interface ApplicationOptions {
apiClient?: any;
// List of preset plugins
plugins?: PluginNameOrClass[];
components?: any;
scopes?: Record<string, any>;
router?: RouterOptions;
importPlugins?: (name: string) => Promise<any>;
}

View File

@ -1,166 +1,158 @@
import { Spin } from 'antd'; import { APIClientOptions } from '@nocobase/sdk';
import { i18n as i18next } from 'i18next'; import { i18n as i18next } from 'i18next';
import React, { useEffect, useState } from 'react'; import get from 'lodash/get';
import { ErrorBoundary } from 'react-error-boundary'; import merge from 'lodash/merge';
import set from 'lodash/set';
import React, { ComponentType, FC, ReactElement } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom'; import { Link, Navigate, NavLink } from 'react-router-dom';
import { ACLProvider } from '../acl';
import { AntdConfigProvider } from '../antd-config-provider';
import { APIClient, APIClientProvider } from '../api-client'; import { APIClient, APIClientProvider } from '../api-client';
import { SigninPage, SignupPage } from '../auth';
import { SigninPageExtensionProvider } from '../auth/SigninPageExtension';
import { BlockSchemaComponentProvider } from '../block-provider';
import { RemoteDocumentTitleProvider } from '../document-title';
import { i18n } from '../i18n'; import { i18n } from '../i18n';
import { PinnedPluginListProvider } from '../plugin-manager'; import { AppComponent, BlankComponent, defaultAppComponents } from './components';
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm'; import { PluginManager, PluginType } from './PluginManager';
import { import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager';
AdminLayout, import { compose, normalizeContainer } from './utils';
AuthLayout,
RemoteRouteSwitchProvider,
RouteSchemaComponent,
RouteSwitch,
useRoutes,
} from '../route-switch';
import {
AntdSchemaComponentProvider,
DesignableSwitch,
MenuItemInitializers,
SchemaComponentProvider,
} from '../schema-component';
import { ErrorFallback } from '../schema-component/antd/error-fallback';
import { SchemaInitializerProvider } from '../schema-initializer';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsProvider } from '../system-settings';
import { compose } from './compose';
export type ComponentAndProps<T = any> = [ComponentType, T];
export interface ApplicationOptions { export interface ApplicationOptions {
apiClient?: any; apiClient?: APIClientOptions;
i18n?: any; i18n?: i18next;
plugins?: any[]; providers?: (ComponentType | ComponentAndProps)[];
plugins?: PluginType[];
components?: Record<string, ComponentType>;
scopes?: Record<string, any>;
router?: RouterOptions;
dynamicImport?: any; dynamicImport?: any;
} }
export const getCurrentTimezone = () => {
const timezoneOffset = new Date().getTimezoneOffset() / -60;
const timezone = String(timezoneOffset).padStart(2, '0') + ':00';
return (timezoneOffset > 0 ? '+' : '-') + timezone;
};
export type PluginCallback = () => Promise<any>;
const App = React.memo((props: any) => {
const C = compose(...props.providers)(() => {
const routes = useRoutes();
return <RouteSwitch routes={routes} />;
});
return <C />;
});
App.displayName = 'App';
export class Application { export class Application {
providers = []; public providers: ComponentAndProps[] = [];
mainComponent = null; public router: RouterManager;
apiClient: APIClient; public scopes: Record<string, any> = {};
i18n: i18next; public i18n: i18next;
plugins: PluginCallback[] = []; public apiClient: APIClient;
options: ApplicationOptions; public components: Record<string, ComponentType> = { ...defaultAppComponents };
public pm: PluginManager;
constructor(options: ApplicationOptions) { constructor(protected options: ApplicationOptions = {}) {
this.options = options; this.scopes = merge(this.scopes, options.scopes);
this.apiClient = new APIClient({ this.components = merge(this.components, options.components);
baseURL: process.env.API_BASE_URL, this.apiClient = new APIClient(options.apiClient);
headers: {
'X-Hostname': window?.location?.hostname,
'X-Timezone': getCurrentTimezone(),
},
...options.apiClient,
});
this.i18n = options.i18n || i18n; this.i18n = options.i18n || i18n;
this.router = new RouterManager({
...options.router,
renderComponent: this.renderComponent.bind(this),
});
this.pm = new PluginManager(options.plugins, this);
this.addDefaultProviders();
this.addReactRouterComponents();
this.addProviders(options.providers || []);
}
private addDefaultProviders() {
this.use(APIClientProvider, { apiClient: this.apiClient }); this.use(APIClientProvider, { apiClient: this.apiClient });
this.use(I18nextProvider, { i18n: this.i18n }); this.use(I18nextProvider, { i18n: this.i18n });
this.use(AntdConfigProvider, { remoteLocale: true }); }
this.use(RemoteRouteSwitchProvider, {
components: { private addReactRouterComponents() {
AuthLayout, this.addComponents({
AdminLayout, Link,
RouteSchemaComponent, Navigate: Navigate as ComponentType,
SigninPage, NavLink,
SignupPage,
BlockTemplatePage,
BlockTemplateDetails,
},
}); });
this.use(SystemSettingsProvider);
this.use(PinnedPluginListProvider, {
items: {
ui: { order: 100, component: 'DesignableSwitch', pin: true, snippet: 'ui.*' },
pm: { order: 200, component: 'PluginManagerLink', pin: true, snippet: 'pm' },
sc: { order: 300, component: 'SettingsCenterDropdown', pin: true, snippet: 'pm.*' },
},
});
this.use(SchemaComponentProvider, {
components: { Link, NavLink, DesignableSwitch, PluginManagerLink, SettingsCenterDropdown },
});
this.use(SchemaInitializerProvider, {
initializers: {
MenuItemInitializers,
},
});
this.use(BlockSchemaComponentProvider);
this.use(AntdSchemaComponentProvider);
this.use(SigninPageExtensionProvider);
this.use(ACLProvider);
this.use(RemoteDocumentTitleProvider);
this.use(PMProvider);
} }
use(component, props?: any) { get dynamicImport() {
this.providers.push(props ? [component, props] : component); return this.options.dynamicImport;
} }
main(mainComponent: any) { getComposeProviders() {
this.mainComponent = mainComponent; const Providers = compose(...this.providers)(BlankComponent);
Providers.displayName = 'Providers';
return Providers;
} }
/** use<T = any>(component: ComponentType, props?: T) {
* TODO return this.addProvider(component, props);
*/
plugin(plugin: PluginCallback) {
this.plugins.push(plugin);
} }
handleErrors(error: any) { addProvider<T = any>(component: ComponentType, props?: T) {
console.error(error); return this.providers.push([component, props]);
} }
render() { addProviders(providers: (ComponentType | [ComponentType, any])[]) {
return (props: any) => { providers.forEach((provider) => {
const { plugins = [], dynamicImport } = this.options; if (Array.isArray(provider)) {
const [loading, setLoading] = useState(false); this.addProvider(provider[0], provider[1]);
useEffect(() => { } else {
setLoading(true); this.addProvider(provider);
(async () => {
const res = await this.apiClient.request({ url: 'app:getPlugins' });
if (Array.isArray(res.data?.data)) {
plugins.push(...res.data.data);
}
for (const plugin of plugins) {
const pluginModule = await dynamicImport(plugin);
this.use(pluginModule.default);
}
setLoading(false);
})();
}, []);
if (loading) {
return <Spin />;
} }
return ( });
<ErrorBoundary FallbackComponent={ErrorFallback} onError={this.handleErrors}> }
<App providers={this.providers} />
</ErrorBoundary> async load() {
); return this.pm.load();
}; }
getComponent<T = any>(Component: ComponentTypeAndString<T>, isShowError = true): ComponentType<T> | undefined {
const showError = (msg: string) => isShowError && console.error(msg);
if (!Component) {
showError(`getComponent called with ${Component}`);
return;
}
// ClassComponent or FunctionComponent
if (typeof Component === 'function') return Component;
// Component is a string, try to get it from this.components
if (typeof Component === 'string') {
const res = get(this.components, Component) as ComponentType<T>;
if (!res) {
showError(`Component ${Component} not found`);
return;
}
return res;
}
showError(`Component ${Component} should be a string or a React component`);
return;
}
renderComponent<T extends {}>(Component: ComponentTypeAndString, props?: T): ReactElement {
return React.createElement(this.getComponent(Component), props);
}
addComponent(component: ComponentType, name?: string) {
const componentName = name || component.displayName || component.name;
if (!componentName) {
console.error('Component must have a displayName or pass name as second argument');
return;
}
set(this.components, componentName, component);
}
addComponents(components: Record<string, ComponentType>) {
Object.keys(components).forEach((name) => {
this.addComponent(components[name], name);
});
}
addScopes(scopes: Record<string, any>) {
this.scopes = merge(this.scopes, scopes);
}
getRootComponent() {
const Root: FC = () => <AppComponent app={this} />;
return Root;
}
mount(containerOrSelector: Element | ShadowRoot | string) {
const container = normalizeContainer(containerOrSelector);
if (!container) return;
const App = this.getRootComponent();
const root = createRoot(container);
root.render(<App />);
return root;
} }
} }

View File

@ -0,0 +1,22 @@
import type { Application } from './Application';
export class Plugin<T = any> {
constructor(protected options: T, protected app: Application) {
this.options = options;
this.app = app;
}
get pm() {
return this.app.pm;
}
get router() {
return this.app.router;
}
async afterAdd() {}
async beforeLoad() {}
async load() {}
}

View File

@ -0,0 +1,59 @@
import type { Application } from './Application';
import type { Plugin } from './Plugin';
export type PluginOptions<T = any> = { name?: string; config?: T };
export type PluginType<Opts = any> = typeof Plugin | [typeof Plugin, PluginOptions<Opts>];
export class PluginManager {
protected pluginInstances: Map<typeof Plugin, Plugin> = new Map();
protected pluginsAliases: Record<string, Plugin> = {};
private loadStaticPlugin: Promise<void>;
constructor(protected _plugins: PluginType[], protected app: Application) {
this.app = app;
this.loadStaticPlugin = this.initStaticPlugins(_plugins);
}
private async initStaticPlugins(_plugins: PluginType[] = []) {
for await (const plugin of _plugins) {
const pluginClass = Array.isArray(plugin) ? plugin[0] : plugin;
const opts = Array.isArray(plugin) ? plugin[1] : undefined;
await this.add(pluginClass, opts);
}
}
async add<T = any>(plugin: typeof Plugin, opts: PluginOptions<T> = {}) {
const instance = this.getInstance(plugin, opts);
this.pluginInstances.set(plugin, instance);
if (opts.name) {
this.pluginsAliases[opts.name] = instance;
}
await instance.afterAdd();
}
get<T extends typeof Plugin>(PluginClass: T): InstanceType<T>;
get<T extends {}>(name: string): T;
get(name: any) {
if (typeof name === 'string') {
return this.pluginsAliases[name];
}
return this.pluginInstances.get(name);
}
private getInstance<T>(plugin: typeof Plugin, opts?: T) {
return new plugin(opts, this.app);
}
async load() {
await this.loadStaticPlugin;
for (const plugin of this.pluginInstances.values()) {
await plugin.beforeLoad();
}
for (const plugin of this.pluginInstances.values()) {
await plugin.load();
}
}
}

View File

@ -0,0 +1,139 @@
import set from 'lodash/set';
import React, { ComponentType } from 'react';
import {
BrowserRouter,
BrowserRouterProps,
HashRouter,
HashRouterProps,
MemoryRouter,
MemoryRouterProps,
RouteObject,
useRoutes,
} from 'react-router-dom';
import { BlankComponent, RouterContextCleaner } from './components';
export interface BrowserRouterOptions extends Omit<BrowserRouterProps, 'children'> {
type?: 'browser';
}
export interface HashRouterOptions extends Omit<HashRouterProps, 'children'> {
type?: 'hash';
}
export interface MemoryRouterOptions extends Omit<MemoryRouterProps, 'children'> {
type?: 'memory';
}
export type RouterOptions = (HashRouterOptions | BrowserRouterOptions | MemoryRouterOptions) & {
renderComponent?: RenderComponentType;
};
export type ComponentTypeAndString<T = any> = ComponentType<T> | string;
export interface RouteType extends Omit<RouteObject, 'children' | 'Component'> {
Component?: ComponentTypeAndString;
}
export type RenderComponentType = (Component: ComponentTypeAndString, props?: any) => React.ReactNode;
export class RouterManager {
protected routes: Record<string, RouteType> = {};
constructor(protected options: RouterOptions) {
this.options = options || {};
}
setType(type: RouterOptions['type']) {
this.options.type = type;
}
setBasename(basename: string) {
this.options.basename = basename;
}
getRoutes(): RouteObject[] {
type RouteTypeWithChildren = RouteType & { children?: RouteTypeWithChildren };
const routes: Record<string, RouteTypeWithChildren> = {};
/**
* { 'a': { name: '1' }, 'a.b': { name: '2' }, 'a.c': { name: '3' } };
* =>
* { a: { name: '1', children: { b: { name: '2' }, c: {name: '3'} } } }
*/
for (const [name, route] of Object.entries(this.routes)) {
set(routes, name.split('.').join('.children.'), route);
}
/**
* get RouteObject tree
*
* @example
* { a: { name: '1', children: { b: { name: '2' }, c: { children: { d: { name: '3' } } } } } }
* =>
* { name: '1', children: [{ name: '2' }, { name: '3' }] }
*/
const buildRoutesTree = (routes: RouteTypeWithChildren): RouteObject[] => {
return Object.values(routes).reduce<RouteObject[]>((acc, item) => {
if (Object.keys(item).length === 1 && item.children) {
acc.push(...buildRoutesTree(item.children));
} else {
const { Component, element, children, ...reset } = item;
let ele = element;
if (Component) {
if (typeof Component === 'string') {
ele = this.options.renderComponent ? this.options.renderComponent(Component) : Component;
} else {
ele = React.createElement(Component);
}
}
const res = {
...reset,
element: ele,
children: children ? buildRoutesTree(children) : undefined,
} as RouteObject;
acc.push(res);
}
return acc;
}, []);
};
return buildRoutesTree(routes);
}
getRouterComponent() {
const { type = 'browser', ...opts } = this.options || {};
const Routers = {
hash: HashRouter,
browser: BrowserRouter,
memory: MemoryRouter,
};
const ReactRouter = Routers[type];
const RenderRoutes = () => {
const routes = this.getRoutes();
const element = useRoutes(routes);
return element;
};
const RenderRouter: React.FC<{ BaseLayout?: ComponentType }> = ({ BaseLayout = BlankComponent }) => {
return (
<RouterContextCleaner>
<ReactRouter {...opts}>
<BaseLayout>
<RenderRoutes />
</BaseLayout>
</ReactRouter>
</RouterContextCleaner>
);
};
return RenderRouter;
}
add(name: string, route: RouteType) {
this.routes[name] = route;
}
remove(name: string) {
delete this.routes[name];
}
}
export function createRouterManager(options?: RouterOptions) {
return new RouterManager(options);
}

View File

@ -0,0 +1,340 @@
import React, { Component } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { render, screen, sleep, userEvent } from 'testUtils';
import { describe } from 'vitest';
import { Application } from '../Application';
import { Plugin } from '../Plugin';
describe('Application', () => {
const router: any = { type: 'memory', initialEntries: ['/'] };
const initialComponentsLength = 6;
const initialProvidersLength = 2;
it('basic', () => {
const app = new Application({ router });
expect(app.i18n).toBeDefined();
expect(app.apiClient).toBeDefined();
expect(app.components).toBeDefined();
expect(app.pm).toBeDefined();
expect(app.providers).toBeDefined();
expect(app.router).toBeDefined();
expect(app.scopes).toBeDefined();
expect(app.providers).toHaveLength(initialProvidersLength);
expect(Object.keys(app.components)).toHaveLength(initialComponentsLength);
});
describe('components', () => {
const Hello = () => <div>Hello</div>;
Hello.displayName = 'Hello';
it('initial', () => {
const app = new Application({ router, components: { Hello } });
expect(app.components.Hello).toBe(Hello);
});
it('addComponents', () => {
const app = new Application({ router });
app.addComponents({ Hello });
expect(app.components.Hello).toBe(Hello);
});
it('addComponent', () => {
const app = new Application({ router });
app.addComponent(Hello);
expect(app.components.Hello).toBe(Hello);
app.addComponent(Hello, 'Hello2');
expect(app.components.Hello2).toBe(Hello);
});
it('addComponents without name, should error', () => {
const app = new Application({ router });
const fn = vitest.fn();
const originalConsoleError = console.error;
console.error = fn;
app.addComponent(() => <div>123</div>);
expect(fn).toBeCalled();
console.error = originalConsoleError;
});
describe('getComponent', () => {
let originalConsoleError: any;
beforeEach(() => {
originalConsoleError = console.error;
});
afterEach(() => {
console.error = originalConsoleError;
});
it('arg is Class component', () => {
class Foo extends Component {
render() {
return <div></div>;
}
}
const app = new Application({ router });
expect(app.getComponent(Foo)).toBe(Foo);
});
it('arg is Function component', () => {
const Foo = () => <div></div>;
const app = new Application({ router });
expect(app.getComponent(Foo)).toBe(Foo);
});
it('arg is string', () => {
const Foo = () => <div></div>;
const app = new Application({ router, components: { Foo } });
expect(app.getComponent('Foo')).toBe(Foo);
});
it('arg is string, but not found in components', () => {
const app = new Application({ router });
const fn = vitest.fn();
console.error = fn;
expect(app.getComponent('Foo')).toBeUndefined();
expect(fn).toBeCalled();
});
it('arg is null or undefined', () => {
const app = new Application({ router });
const fn = vitest.fn();
console.error = fn;
expect(app.getComponent(null)).toBeUndefined();
expect(fn).toBeCalled();
});
it('arg is other types', () => {
const app = new Application({ router });
const fn = vitest.fn();
console.error = fn;
expect(app.getComponent({} as any)).toBeUndefined();
expect(fn).toBeCalled();
});
});
it('renderComponent', () => {
const Foo = (props) => <div>{props.name}</div>;
const app = new Application({ router, components: { Foo } });
expect(app.renderComponent('Foo', { name: 'bar' })).toEqual(<Foo name="bar" />);
});
});
describe('scopes', () => {
it('initial', () => {
const scopes = { foo: 'bar' };
const app = new Application({ router, scopes });
expect(app.scopes).toEqual(scopes);
});
it('addScopes', () => {
const app = new Application({ router });
app.addScopes({ foo: 'bar' });
expect(app.scopes).toEqual({ foo: 'bar' });
});
});
describe('providers', () => {
const Hello = (props) => <div>Hello {props.children}</div>;
const World = (props) => (
<div>
<div>World</div>
<div>{props.name}</div>
<div>{props.children}</div>
</div>
);
const Foo = (props) => <div>Foo {props.children}</div>;
it('initial', () => {
const app = new Application({ router, providers: [Hello, [World, { name: 'aaa' }]] });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[Hello, undefined],
[World, { name: 'aaa' }],
]);
});
it('addProviders', () => {
const app = new Application({ router, providers: [Hello] });
app.addProviders([[World, { name: 'aaa' }], Foo]);
expect(app.providers.slice(initialProvidersLength)).toEqual([
[Hello, undefined],
[World, { name: 'aaa' }],
[Foo, undefined],
]);
});
it('addProvider', () => {
const app = new Application({ router, providers: [Hello] });
app.addProvider(World, { name: 'aaa' });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[Hello, undefined],
[World, { name: 'aaa' }],
]);
});
it('use', () => {
const app = new Application({ router, providers: [Hello] });
app.use(World, { name: 'aaa' });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[Hello, undefined],
[World, { name: 'aaa' }],
]);
});
it('getComposeProviders', () => {
const app = new Application({ router, providers: [Hello, [World, { name: 'aaa' }]] });
const Providers = app.getComposeProviders();
render(
<Providers>
<Foo />
</Providers>,
);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('World')).toBeInTheDocument();
expect(screen.getByText('aaa')).toBeInTheDocument();
expect(screen.getByText('Foo')).toBeInTheDocument();
});
});
describe('render', () => {
it('getRootComponent', async () => {
const app = new Application({
router,
});
const Layout = () => (
<div>
<div>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</div>
<Outlet />
</div>
);
const Home = () => {
return <div>HomeComponent</div>;
};
const About = () => {
return <div>AboutComponent</div>;
};
const HelloProvider = ({ children }) => {
return <div>Hello {children}</div>;
};
app.router.add('root', {
element: <Layout />,
});
app.router.add('root.home', {
path: '/',
element: <Home />,
});
app.router.add('root.about', {
path: '/about',
element: <About />,
});
app.addProviders([HelloProvider]);
const Root = app.getRootComponent();
render(<Root />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await sleep(10);
expect(screen.getByText('HomeComponent')).toBeInTheDocument();
await userEvent.click(screen.getByText('About'));
expect(screen.getByText('AboutComponent')).toBeInTheDocument();
});
it('mount', async () => {
const Hello = () => <div>Hello</div>;
const app = new Application({
router,
providers: [Hello],
});
render(<div id="app"></div>);
const root = app.mount('#app');
expect(root).toBeDefined();
await sleep(10);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('mount root error', () => {
const app = new Application({
router,
});
const originalConsoleWarn = console.warn;
const fn = vitest.fn();
console.warn = fn;
app.mount('#app');
expect(fn).toBeCalled();
console.warn = originalConsoleWarn;
});
it('plugin load error', async () => {
const app = new Application({
router,
});
class DemoPlugin extends Plugin {
async load() {
throw new Error('error');
}
}
app.pm.add(DemoPlugin, { name: 'demo' });
const Root = app.getRootComponent();
render(<Root />);
await sleep(10);
expect(screen.getByText('Load Plugin Error')).toBeInTheDocument();
});
it('replace Component', async () => {
const AppSpin = () => <div>AppSpin</div>;
const AppMain = () => <div>AppMain</div>;
const app = new Application({
router,
components: { AppSpin, AppMain },
});
const Root = app.getRootComponent();
render(<Root />);
expect(screen.getByText('AppSpin')).toBeInTheDocument();
await sleep(10);
expect(screen.getByText('AppMain')).toBeInTheDocument();
});
it('render component error', async () => {
const app = new Application({
router,
});
const ErrorFallback = () => {
return <div>ErrorFallback</div>;
};
const Foo = () => {
throw new Error('error');
return null;
};
app.use(Foo);
app.addComponents({
ErrorFallback,
});
const originalConsoleWarn = console.error;
const fn = vitest.fn();
console.error = fn;
const Root = app.getRootComponent();
render(<Root />);
await sleep(10);
expect(fn).toBeCalled();
expect(screen.getByText('ErrorFallback')).toBeInTheDocument();
screen.debug();
console.error = originalConsoleWarn;
});
});
});

View File

@ -0,0 +1,99 @@
import { Application } from '../Application';
import { Plugin } from '../Plugin';
describe('Plugin', () => {
it('lifecycle', async () => {
const afterAdd = vitest.fn();
const beforeLoad = vitest.fn();
const load = vitest.fn();
class DemoPlugin extends Plugin {
async afterAdd() {
afterAdd();
}
async beforeLoad() {
beforeLoad();
}
async load() {
load();
}
}
const app = new Application({
plugins: [[DemoPlugin, { name: 'demo1' }]],
});
await app.load();
expect(afterAdd).toBeCalledTimes(1);
expect(beforeLoad).toBeCalledTimes(1);
expect(load).toBeCalledTimes(1);
});
});
describe('PluginManager', () => {
it('static Plugins', async () => {
const fn1 = vitest.fn();
class Demo1Plugin extends Plugin {
async load() {
fn1();
}
}
const fn2 = vitest.fn();
const config = { a: 1 };
class Demo2Plugin extends Plugin {
async load() {
fn2(this.options.config);
}
}
const app = new Application({
plugins: [
[Demo1Plugin, { name: 'demo1' }],
[Demo2Plugin, { name: 'demo2', config }],
],
});
await app.load();
expect(fn1).toBeCalledTimes(1);
expect(fn2).toBeCalledTimes(1);
expect(fn2).toBeCalledWith(config);
});
it('dynamic Plugins', async () => {
const fn2 = vitest.fn();
const config = { a: 1 };
class Demo2 extends Plugin {
async load() {
fn2(this.options.config);
}
}
class Demo1Plugin extends Plugin {
async afterAdd() {
this.app.pm.add(Demo2, { name: 'demo2', config });
}
}
const app = new Application({
plugins: [[Demo1Plugin, { name: 'demo1' }]],
});
await app.load();
expect(fn2).toBeCalledTimes(1);
expect(fn2).toBeCalledWith(config);
});
it('getter', async () => {
class DemoPlugin extends Plugin {
async afterAdd() {
expect(this.pm).toBe(this.app.pm);
expect(this.router).toBe(this.app.router);
}
}
const app = new Application({ plugins: [[DemoPlugin, { name: 'demo' }]] });
await app.load();
});
it('get', async () => {
class DemoPlugin extends Plugin { }
const app = new Application({ plugins: [[DemoPlugin, { name: 'demo' }]] });
await app.load();
expect(app.pm.get('demo')).toBeInstanceOf(DemoPlugin);
});
});

View File

@ -0,0 +1,198 @@
import React, { FC } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { render, screen, userEvent } from 'testUtils';
import { beforeAll } from 'vitest';
import { Application } from '../Application';
import { RouterManager, RouteType } from '../RouterManager';
describe('Router', () => {
let app: Application;
beforeAll(() => {
app = new Application();
});
describe('add routes', () => {
let router: RouterManager;
beforeEach(() => {
router = new RouterManager({ type: 'memory', initialEntries: ['/'] });
});
it('basic', () => {
const route1: RouteType = {
path: '/',
element: <div />,
};
router.add('test', route1);
expect(router.getRoutes()).toHaveLength(1);
expect(router.getRoutes()).toEqual([route1]);
const route2: RouteType = {
path: '/test2',
element: <div />,
};
router.add('test2', route2);
expect(router.getRoutes()).toHaveLength(2);
expect(router.getRoutes()).toEqual([route1, route2]);
});
it('nested route', () => {
const route1: RouteType = {
path: '/',
element: <div />,
};
const route2: RouteType = {
path: '/test2',
element: <div />,
};
router.add('test', route1);
router.add('test.test2', route2);
expect(router.getRoutes()).toEqual([{ ...route1, children: [route2] }]);
});
it('nested route with empty parent', () => {
const route1: RouteType = {
path: '/',
element: <div />,
};
const route2: RouteType = {
path: '/test2',
element: <div />,
};
const route3: RouteType = {
path: '/test3',
element: <div />,
};
router.add('test', route1);
router.add('test.empty.test2', route2);
router.add('test.empty2.empty3.test3', route3);
expect(router.getRoutes()).toEqual([{ ...route1, children: [route2, route3] }]);
});
it('Component', () => {
const Hello = () => <div></div>;
const route: RouteType = {
path: '/',
Component: Hello,
};
router.add('test', route);
expect(router.getRoutes()).toEqual([{ path: '/', element: <Hello />, children: undefined }]);
});
it('Component is string', () => {
const router = new RouterManager({
type: 'memory',
initialEntries: ['/'],
renderComponent: app.renderComponent.bind(app),
});
const Hello = () => <div></div>;
app.addComponents({ Hello });
const route: RouteType = {
path: '/',
Component: 'Hello',
};
router.add('test', route);
expect(router.getRoutes()).toEqual([{ path: '/', element: <Hello />, children: undefined }]);
});
});
describe('remove', () => {
let router: RouterManager;
beforeEach(() => {
router = new RouterManager({ type: 'memory', initialEntries: ['/'] });
});
it('basic', () => {
const route1: RouteType = {
path: '/',
element: <div />,
};
router.add('test', route1);
expect(router.getRoutes()).toEqual([route1]);
router.remove('test');
expect(router.getRoutes()).toEqual([]);
});
});
describe('getRouterComponent', () => {
it('basic', async () => {
const router = new RouterManager({ type: 'memory', initialEntries: ['/'] });
const Layout = () => {
return (
<div>
<div>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</div>
<Outlet />
</div>
);
};
router.add('root', {
element: <Layout />,
});
router.add('root.home', {
path: '/',
element: <div>HomeComponent</div>,
});
router.add('root.about', {
path: '/about',
element: <div>AboutComponent</div>,
});
const RouterComponent = router.getRouterComponent();
render(<RouterComponent />);
expect(screen.getByText('HomeComponent')).toBeInTheDocument();
await userEvent.click(screen.getByText('About'));
expect(screen.getByText('AboutComponent')).toBeInTheDocument();
});
it('BaseLayout', () => {
const router = new RouterManager({ type: 'memory', initialEntries: ['/'] });
router.add('home', {
path: '/',
element: <div>HomeComponent</div>,
});
const RouterComponent = router.getRouterComponent();
const BaseLayout: FC = (props) => {
return <div>BaseLayout {props.children}</div>;
};
render(<RouterComponent BaseLayout={BaseLayout} />);
expect(screen.getByText('HomeComponent')).toBeInTheDocument();
expect(screen.getByText('BaseLayout')).toBeInTheDocument();
});
it('nested router', () => {
const router = new RouterManager({ type: 'memory', initialEntries: ['/'] });
const Test = () => {
const router2 = new RouterManager({ type: 'memory', initialEntries: ['/'] });
router2.add('rooter2', {
path: '/',
element: <div>Router2</div>,
});
const RouterComponent = router2.getRouterComponent();
return (
<div>
Router1
<RouterComponent />
</div>
);
};
router.add('home', {
path: '/',
element: <Test />,
});
const RouterComponent = router.getRouterComponent();
render(<RouterComponent />);
expect(screen.getByText('Router1')).toBeInTheDocument();
expect(screen.getByText('Router2')).toBeInTheDocument();
});
});
});

View File

@ -1,77 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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

@ -1,95 +0,0 @@
import { render } from '@testing-library/react';
import React from 'react';
import { compose } from '../compose';
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,62 @@
import React from 'react';
import { render, sleep } from 'testUtils';
import { describe } from 'vitest';
import { Plugin } from '../Plugin'
import { Application } from '../Application';
import { useApp, useRouter, usePlugin } from '../hooks';
describe('Application Hooks', () => {
describe('useApp', () => {
it('should return the application instance', async () => {
const app = new Application();
const Hello = () => {
const app1 = useApp();
expect(app).toBe(app1);
return null;
};
app.addProviders([Hello]);
const Root = app.getRootComponent();
render(<Root />);
await sleep(10);
});
});
describe('useRouter', () => {
it('should return the router instance', async () => {
const app = new Application();
const Hello = () => {
const router = useRouter();
expect(app.router).toBe(router);
return null;
};
app.addProviders([Hello]);
const Root = app.getRootComponent();
render(<Root />);
await sleep(10);
});
});
describe('usePlugin', () => {
it('should return the plugin instance', async () => {
class DemoPlugin extends Plugin {
test = 'test';
}
const Hello = () => {
const demo = usePlugin<{ test: string }>('demo');
const demo2 = usePlugin(DemoPlugin);
expect(demo).toBeInstanceOf(DemoPlugin);
expect(demo2).toBeInstanceOf(DemoPlugin);
expect(demo.test).toBe('test');
expect(demo2.test).toBe('test');
return null;
};
const app = new Application({ plugins: [[DemoPlugin, { name: 'demo' }]], providers: [Hello] });
const Root = app.getRootComponent();
render(<Root />);
await sleep(10);
});
});
});

View File

@ -0,0 +1,68 @@
import React from 'react';
import { render, screen } from 'testUtils';
import { describe } from 'vitest';
import { compose, normalizeContainer } from '../utils';
describe('utils', () => {
describe('normalizeContainer', () => {
let originalConsoleWarn: any;
beforeEach(() => {
originalConsoleWarn = console.warn;
});
afterEach(() => {
console.warn = originalConsoleWarn;
});
it('when container is undefined or null, return null', () => {
const fn = vitest.fn();
console.warn = fn;
expect(normalizeContainer(undefined)).toBeNull();
expect(fn).toBeCalledTimes(1);
});
it('when container is string and can not find element, return null', () => {
const fn = vitest.fn();
console.warn = fn;
expect(normalizeContainer('#app')).toBeNull();
expect(fn).toBeCalledTimes(1);
});
it('when container is string and can find element, return element', () => {
render(<div id="app">App</div>);
expect(normalizeContainer('#app')).toBeInTheDocument();
});
it('when container is element, return element', () => {
render(<div id="app">App</div>);
const element = document.getElementById('#app');
expect(normalizeContainer(element)).toBe(element);
});
});
describe('compose', () => {
const Hello = ({ children }: any) => <div>Hello {children}</div>;
const World = ({ name, children }: any) => (
<div>
<div>World</div>
<div>{name}</div>
<div>{children}</div>
</div>
);
const Foo = () => <div>Foo</div>;
it('compose without LastComponent', () => {
const composeFn = compose([Hello, undefined], [World, { name: 'aaa' }]);
const Compose = composeFn();
render(<Compose />);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('aaa')).toBeInTheDocument();
expect(screen.getByText('World')).toBeInTheDocument();
});
it('compose with LastComponent', () => {
const composeFn = compose([Hello, undefined]);
const Compose = composeFn(Foo);
render(<Compose />);
expect(screen.getByText('Hello')).toBeInTheDocument();
expect(screen.getByText('Foo')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,25 @@
import React, { FC, useCallback } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import type { Application } from '../Application';
import { ApplicationContext } from '../context';
import { useAppPluginLoad } from '../hooks';
export interface AppComponentProps {
app: Application;
}
export const AppComponent: FC<AppComponentProps> = (props) => {
const { app } = props;
const { loading, error } = useAppPluginLoad(app);
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
console.error(error, info);
}, []);
if (loading) return app.renderComponent('AppSpin', { app });
if (error) return app.renderComponent('AppError', { app, error });
return (
<ErrorBoundary FallbackComponent={app.getComponent<FallbackProps>('ErrorFallback')} onError={handleErrors}>
<ApplicationContext.Provider value={app}>{app.renderComponent('AppMain')}</ApplicationContext.Provider>
</ErrorBoundary>
);
};

View File

@ -0,0 +1,3 @@
import React, { FC, ReactNode } from 'react';
export const BlankComponent: FC<{ children?: ReactNode }> = ({ children }) => <>{children}</>;

View File

@ -0,0 +1,11 @@
import React, { useMemo } from 'react';
import { useApp } from '../hooks';
export const MainComponent = React.memo(() => {
const app = useApp();
const Router = useMemo(() => app.router.getRouterComponent(), [app]);
const Providers = useMemo(() => app.getComposeProviders(), [app]);
return <Router BaseLayout={Providers} />;
});
MainComponent.displayName = 'MainComponent';

View File

@ -0,0 +1,17 @@
import React, { FC, ReactNode } from 'react';
import { UNSAFE_LocationContext, UNSAFE_RouteContext } from 'react-router-dom';
export const RouterContextCleaner: FC<{ children?: ReactNode }> = React.memo((props) => {
return (
<UNSAFE_RouteContext.Provider
value={{
outlet: null,
matches: [],
isDataRoute: false,
}}
>
<UNSAFE_LocationContext.Provider value={null}>{props.children}</UNSAFE_LocationContext.Provider>
</UNSAFE_RouteContext.Provider>
);
});
RouterContextCleaner.displayName = 'RouterContextCleaner';

View File

@ -0,0 +1,16 @@
import React, { FC } from 'react';
import { MainComponent } from './MainComponent';
const Loading: FC = () => <div>Loading...</div>;
const AppError: FC<{ error: Error }> = ({ error }) => (
<div>
<div>Load Plugin Error</div>
{error?.message}
</div>
);
export const defaultAppComponents = {
AppMain: MainComponent,
AppSpin: Loading,
AppError: AppError,
};

View File

@ -0,0 +1,4 @@
export * from './AppComponent';
export * from './BlankComponent';
export * from './defaultComponents';
export * from './RouterContextCleaner';

View File

@ -1,18 +0,0 @@
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

@ -1,4 +1,4 @@
import { createContext } from 'react'; import { createContext } from 'react';
import { Application } from './Application'; import type { Application } from './Application';
export const ApplicationContext = createContext<Application>(null as any); export const ApplicationContext = createContext<Application>(null);

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { FC } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from 'react-router-dom';
import { Application } from '../Application'; import { Application } from '../Application';
import { Plugin } from '../Plugin'; import { Plugin } from '../Plugin';
@ -15,6 +15,10 @@ const Root = () => {
); );
}; };
const Home = () => {
return <div>this is Home</div>;
};
const Team = () => { const Team = () => {
return ( return (
<div> <div>
@ -35,7 +39,7 @@ class Test1Plugin extends Plugin {
async load() { async load() {
this.router.add('root.team', { this.router.add('root.team', {
path: 'team', path: 'team',
component: 'Team', Component: 'Team',
}); });
} }
} }
@ -44,21 +48,30 @@ class Test2Plugin extends Plugin {
async load() { async load() {
this.router.add('root.about', { this.router.add('root.about', {
path: 'about', path: 'about',
component: 'About', Component: 'About',
}); });
} }
} }
class NocobasePresetPlugin extends Plugin { class NocobasePresetPlugin extends Plugin {
async afterAdd() { async afterAdd() {
await this.pm.add('test1'); // mock load remote plugin
await this.pm.add(Test2Plugin, { name: 'test2' }); await this.addRemotePlugin();
}
async addRemotePlugin() {
await this.pm.add(Test1Plugin);
await this.pm.add(Test2Plugin);
} }
async load() { async load() {
this.router.add('root', { this.router.add('root', {
path: '/', path: '/',
component: 'Root', Component: 'Root',
});
this.router.add('root.home', {
path: '/',
Component: 'Home',
}); });
} }
} }
@ -70,21 +83,18 @@ const app = new Application({
router: { router: {
type: 'hash', type: 'hash',
}, },
plugins: [[NocobasePresetPlugin, { name: 'nocobase' }]], plugins: [NocobasePresetPlugin],
importPlugins: async (name) => { components: { Root, Home, Team, About },
return {
test1: Test1Plugin,
}[name];
},
components: { Root, Team, About },
}); });
app.use((props) => { const HelloProvider: FC = (props) => {
const location = useLocation(); const location = useLocation();
if (location.pathname === '/hello') { if (location.pathname === '/hello') {
return <div>Hello</div>; return <div>Hello</div>;
} }
return props.children; return <>{props.children}</>;
}); };
app.use(HelloProvider);
export default app.getRootComponent(); export default app.getRootComponent();

View File

@ -1,23 +0,0 @@
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>
);
},
{ displayName: 'Hello' },
);

View File

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

View File

@ -1,23 +0,0 @@
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

@ -1,28 +0,0 @@
import { RouteRedirectProps } from '@nocobase/client';
export default [
{
type: 'route',
path: '/',
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,99 @@
import React, { useMemo } from 'react';
import { Link, Navigate, Outlet, useParams } from 'react-router-dom';
import { Application } from '../Application';
import { useApp } from '../hooks';
import { Plugin } from '../Plugin';
import { RouterManager } from '../RouterManager';
const Root = () => {
return (
<div>
<Link to={'/'}>Home</Link>|<Link to={'/admin'}>Admin</Link>|<Link to={'/admin/setting'}>Admin Setting</Link>
<Outlet />
</div>
);
};
const Home = () => {
return <div>Home</div>;
};
const Admin = () => {
return (
<div>
<div>Admin</div>
<Outlet />
</div>
);
};
const MobileLayout = () => {
return (
<div>
<div>MobileLayout</div>
<Link to={'/mobile/123'}>Mobile 123</Link>|<Link to={'/mobile/456'}>Mobile 456</Link>
<Outlet />
</div>
);
};
const MobilePage = () => {
const { id } = useParams();
return <div>id: {id}</div>;
};
const AdminSetting = () => {
const app = useApp();
const MobileRouter = useMemo(() => {
const router = new RouterManager({ type: 'memory', initialEntries: ['/'] }, app);
router.add('mobile', {
element: <MobileLayout />,
});
router.add('mobile.index', {
path: '/',
element: <Navigate replace to="/mobile/123" />,
});
router.add('mobile.page', {
path: '/mobile/:id',
element: <MobilePage />,
});
return router.getRouterComponent();
}, [app]);
return (
<div>
<div>AdminSetting</div>
<MobileRouter />
</div>
);
};
class NocobasePresetPlugin extends Plugin {
async load() {
this.router.add('root', {
path: '/',
element: <Root />,
});
this.router.add('root.home', {
path: '/',
element: <Home />,
});
this.router.add('root.admin', {
path: '/admin',
element: <Admin />,
});
this.router.add('root.admin.setting', {
path: '/admin/setting',
element: <AdminSetting />,
});
}
}
const app = new Application({
router: { type: 'hash' },
plugins: [NocobasePresetPlugin],
});
export default app.getRootComponent();

View File

@ -1,10 +0,0 @@
import { APIClient } from '@nocobase/client';
import mock from './mock';
const apiClient = new APIClient({
baseURL: `${location.protocol}//${location.host}/api/`,
});
mock(apiClient);
export default apiClient;

View File

@ -1,58 +0,0 @@
import {
AdminLayout,
AntdConfigProvider,
AntdSchemaComponentProvider,
APIClientProvider,
AuthLayout,
CollectionManagerProvider,
compose,
DesignableSwitch,
DocumentTitleProvider,
i18n,
RouteSchemaComponent,
RouteSwitch,
RouteSwitchProvider,
SchemaComponentProvider,
SchemaInitializerProvider,
SigninPage,
SignupPage,
SystemSettingsProvider,
useRequest,
} from '@nocobase/client';
import { Spin } from 'antd';
import React from 'react';
import { I18nextProvider } from 'react-i18next';
import { Link, MemoryRouter, NavLink } from 'react-router-dom';
import apiClient from './apiClient';
const providers = [
// [HashRouter],
[MemoryRouter, { initialEntries: ['/'] }],
[APIClientProvider, { apiClient }],
[I18nextProvider, { i18n }],
[AntdConfigProvider, { remoteLocale: true }],
SystemSettingsProvider,
[{ components: { DesignableSwitch } }],
[SchemaComponentProvider, { components: { Link, NavLink } }],
CollectionManagerProvider,
AntdSchemaComponentProvider,
SchemaInitializerProvider,
[DocumentTitleProvider, { addonAfter: 'NocoBase' }],
[RouteSwitchProvider, { components: { AuthLayout, AdminLayout, RouteSchemaComponent, SigninPage, SignupPage } }],
];
const App = compose(...providers)(() => {
const { data, loading } = useRequest({
url: 'routes:getAccessible',
});
if (loading) {
return <Spin />;
}
return (
<div>
<RouteSwitch routes={data?.data || []} />
</div>
);
});
export default App;

View File

@ -1,197 +0,0 @@
import { uid } from '@formily/shared';
import { APIClient } from '@nocobase/client';
import MockAdapter from 'axios-mock-adapter';
export default (apiClient: APIClient) => {
const mock = new MockAdapter(apiClient.axios);
mock.onGet('/app:getLang').reply(200, {
data: { lang: 'en-US' },
});
mock.onGet('/systemSettings:get/1').reply(200, {
data: {
title: 'NocoBase',
},
});
const jsonSchema = {
qqzzjakwkwl: {
name: 'qqzzjakwkwl',
type: 'void',
'x-component': 'Menu',
'x-component-props': {
mode: 'mix',
theme: 'dark',
// defaultSelectedUid: 'u8',
onSelect: '{{ onSelect }}',
sideMenuRefScopeKey: 'sideMenuRef',
},
properties: {
item3: {
type: 'void',
title: 'SubMenu u3',
'x-uid': 'u3',
'x-component': 'Menu.SubMenu',
'x-component-props': {},
properties: {
item6: {
type: 'void',
title: 'SubMenu u6',
'x-uid': 'u6',
'x-component': 'Menu.SubMenu',
'x-component-props': {},
properties: {
item7: {
type: 'void',
title: 'Menu Item u7',
'x-uid': 'u7',
'x-component': 'Menu.Item',
'x-component-props': {},
},
item8: {
type: 'void',
title: 'Menu Item u8',
'x-uid': 'u8',
'x-component': 'Menu.Item',
'x-component-props': {},
},
},
},
item4: {
type: 'void',
title: 'Menu Item u4',
'x-uid': 'u4',
'x-component': 'Menu.Item',
'x-component-props': {},
},
item5: {
type: 'void',
title: 'Menu Item u5',
'x-uid': 'u5',
'x-component': 'Menu.Item',
'x-component-props': {},
},
},
},
item1: {
type: 'void',
title: 'Menu Item u1',
'x-uid': 'u1',
'x-component': 'Menu.Item',
'x-component-props': {},
},
item2: {
type: 'void',
title: 'Menu Item u2',
'x-uid': 'u2',
'x-component': 'Menu.Item',
'x-component-props': {},
},
item9: {
type: 'void',
title: 'SubMenu u9',
'x-uid': 'u9',
'x-component': 'Menu.SubMenu',
'x-component-props': {},
properties: {
item10: {
type: 'void',
title: 'Menu Item u10',
'x-uid': 'u10',
'x-component': 'Menu.Item',
'x-component-props': {},
},
},
},
},
},
};
mock.onGet(/\/uiSchemas\:getJsonSchema\/(\w+)/).reply(function (config) {
const name = config.url.split('/').pop();
console.log(name);
if (jsonSchema[name]) {
return [200, { data: jsonSchema[name] }];
}
const response = {
data: {
type: 'void',
name: name,
'x-uid': name,
'x-component': 'Page',
properties: {
[uid()]: {
type: 'void',
name: 'grid1',
'x-component': 'Grid',
'x-initializer': 'Grid.AddBlockItem',
'x-uid': uid(),
properties: {},
},
},
},
};
return [200, response];
});
mock.onGet(/\/uiSchemas\:getProperties\/(\w+)/).reply(function (config) {
// const name = config.url.split('/').pop();
// console.log(name);
// if (jsonSchema[name]) {
// return [200, { data: jsonSchema[name] }];
// }
const response = {
data: {
type: 'void',
name: uid(),
'x-uid': uid(),
'x-component': 'Page',
properties: {
[uid()]: {
type: 'void',
name: 'grid1',
'x-component': 'Grid',
'x-initializer': 'Grid.AddBlockItem',
'x-uid': uid(),
properties: {},
},
},
},
};
return [200, response];
});
mock.onGet('/routes:getAccessible').reply(200, {
data: [
{
type: 'redirect',
from: '/',
to: '/admin',
},
{
type: 'route',
uiSchemaUid: 'qqzzjakwkwl',
path: '/admin/:name?',
component: 'AdminLayout',
title: 'NocoBase Admin',
},
{
type: 'route',
component: 'AuthLayout',
routes: [
{
type: 'route',
path: '/signin',
component: 'SigninPage',
},
{
type: 'route',
path: '/signup',
component: 'SignupPage',
},
],
},
],
});
};

View File

@ -0,0 +1,10 @@
import { Application, NocoBaseBuildInPlugin } from '@nocobase/client';
export const app = new Application({
apiClient: {
baseURL: process.env.API_BASE_URL,
},
plugins: [NocoBaseBuildInPlugin],
});
export default app.getRootComponent();

View File

@ -0,0 +1,4 @@
export * from './useApp';
export * from './useAppPluginLoad';
export * from './usePlugin';
export * from './useRouter';

View File

@ -1,6 +1,7 @@
import { useContext } from 'react'; import { useContext } from 'react';
import type { Application } from '../Application';
import { ApplicationContext } from '../context'; import { ApplicationContext } from '../context';
export const useApp = () => { export const useApp = () => {
return useContext(ApplicationContext); return useContext(ApplicationContext) || ({} as Application);
}; };

View File

@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import type { Application } from '../Application';
export function useAppPluginLoad(app: Application) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function run() {
try {
await app.load();
} catch (err) {
setError(err);
}
setLoading(false);
}
run();
}, [app]);
return { loading, error };
}

View File

@ -0,0 +1,9 @@
import { Plugin } from '../Plugin';
import { useApp } from './useApp';
export function usePlugin<T extends typeof Plugin>(plugin: T): InstanceType<T>;
export function usePlugin<T extends {}>(name: string): T;
export function usePlugin(name: any) {
const app = useApp();
return app.pm.get(name);
}

View File

@ -4,10 +4,389 @@ group:
order: 1 order: 1
--- ---
# Application <Badge>待定</Badge> # Application V2
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/5be7ebc2f47effef85be7a0c75cf76f9.png" style="max-width: 800px;" /> <img src="https://nocobase.oss-cn-beijing.aliyuncs.com/5be7ebc2f47effef85be7a0c75cf76f9.png" style="max-width: 800px;" />
## compose ## Usage
<code src="./demos/demo1/index.tsx"></code> ### 基础用法
<code src="./demos/demo1.tsx">Demo1</code>
### 嵌套 Router
<code src="./demos/demo2.tsx">Demo2</code>
## API
Application 提供了强大的功能,包括:
- 组件管理
- 路由管理
- scopes 管理
- providers 管理
- 插件管理
### 组件管理
通过在 `Application` 实例上添加组件,可以在后续的路由或者 `schema` 中以字符串的方式使用。
#### 添加组件
```tsx | pure
const Hello = () => <div>Hello</div>;
const World = () => <div>Wold</div>;
// 初始化时添加组件
const app = new Application({
components: {
Hello,
World
}
});
// 通过实例添加多个组件
app.addComponents({
Hello,
World
});
// 添加单个组件
app.addComponent(Hello, 'Hello');
```
在插件中添加组件。
```tsx | pure
import { Application, Plugin } from '@nocobase/client';
const Hello = () => <div>Hello</div>;
class MyPlugin extends Plugin {
async load() {
this.app.addComponents({
Hello
});
}
}
const app = new Application({
plugins: [MyPlugin]
});
```
#### 获取组件
```tsx | pure
const app = new Application({
components: {
Hello
}
});
const Hello = app.component('Hello');
```
#### 渲染组件
```tsx | pure
const Test = (props) => <div>{props.name}</div>;
const app = new Application({
components: {
Test
}
});
const Hello = () => {
return <div>
{app.renderComponent('Test', { name: 'foo' })}
</div>
};
```
### 路由管理
提供了路由的增删改查功能。
#### 初始化路由
```tsx | pure
const app = new Application({
router: {
type: 'history' // 默认是 history 类型
}
});
// 其他类型
const app = new Application({
router: {
type: 'hash'
}
});
const app = new Application({
router: {
type: 'memory',
initialEntries: ['/', '/foo']
}
});
```
#### 添加路由
```tsx | pure
const app = new Application();
// 通过 add 方法添加路由
// 第一个是 name第二个是路由配置
app.router.add('home', {
path: '/',
element: <div>Home</div>
});
app.router.add('about', {
path: '/about',
element: <div>About</div>
});
```
嵌套路由。
```tsx | pure
import { Link, Outlet } from 'react-router-dom';
const app = new Application();
const Root = () => <div>
<div>
<Link to='/'>Home</Link>
<Link to='/about'>About</Link>
</div>
<Outlet />
</div>;
app.router.add('root', {
element: <Layout />
});
// 通过 . 区分路由层级
app.router.add('root.home', {
path: '/',
element: <div>Home</div>
});
app.router.add('root.about', {
path: '/about',
element: <div>About</div>
});
const AdminLayout = () => {
return <div>
<div>
<Link to='/admin'>Admin Home</Link>
<Link to='/admin/user'>Admin User</Link>
</div>
<Outlet />
</div>
}
app.router.add('root.admin', {
path: '/admin',
element: <AdminLayout />
});
app.router.add('root.admin.home', {
path: '/admin',
element: <div>Admin Home</div>
});
app.router.add('root.admin.about', {
path: '/admin/user',
element: <div>Admin User</div>
});
```
支持 `Component` is 是字符串类型。
```tsx | pure
const Hello = () => <div>Hello</div>;
const World = () => <div>World</div>;
app.router.add('hello', {
path: '/hello',
Component: Hello
});
// 先通过 addComponents 添加组件
app.addComponents({ World });
app.router.add('hello', {
path: '/hello',
Component: 'World' // 路由上使用 Component 字符串
})
```
在插件中添加路由。
```tsx | pure
import { Application, Plugin } from '@nocobase/client';
const Hello = () => <div>Hello</div>;
class MyPlugin extends Plugin {
async load() {
this.app.router.add('hello', {
path: '/hello',
element: <Hello />
});
}
}
const app = new Application({
plugins: [MyPlugin]
});
```
#### 删除路由
```tsx | pure
const app = new Application();
app.router.add('home', {
path: '/',
element: <div>Home</div>
});
// 通过 name 删除路由
app.router.remove('home');
```
### Scopes 管理
```tsx | pure
const scopes = { foo: 'xxx' };
// initial Application with scopes
const app = new Application({ scopes });
// add multiple scopes
app.addScopes({ bar: 'xxx' });
```
### Providers 管理
```tsx | pure
// Provider must render props.children
const Hello = (props) => <div>Hello {props.children}</div>;
const World = (props) => (
<div>
World {props.name} {props.children}
</div>
);
// initial Application with providers
const app = new Application({
providers: [Hello, [World, { name: 'aaa' }]]
});
```
It will render:
```tsx | pure
<Hello><World name='aaa'>{routes}</World></Hello>
```
```tsx | pure
// add multiple providers
app.addProviders([Hello, [World, { name: 'bbb' }]]);
// add single provider
app.addProvider(Hello);
app.addProvider(World, { name: 'ccc' });
```
add provider in plugin.
```tsx | pure
import { Application, Plugin } from '@nocobase/client';
const Hello = (props) => <div>Hello {props.children}</div>;
class MyPlugin extends Plugin {
async load() {
this.app.addProvider(Hello);
}
}
const app = new Application({
plugins: [MyPlugin]
});
```
### 插件管理
```tsx | pure
import { Plugin } from '@nocobase/client';
class MyPlugin extends Plugin {
async afterAdd() {
// You can load other plugins here.
// this.app.pm.add(OtherPlugin)
}
async load() {
// modify app
// this.app.router.add('hello', { xx })
// this.app.addComponents({ Hello })
// this.app.addProviders([Hello])
// this.app.addScopes({ foo: 'xxx' })
}
async afterLoad() {
// do something
}
}
```
load other plugin.
```tsx | pure
class HelloPlugin extends Plugin {
async load() {
this.app.router.add('hello', {
path: '/hello',
element: <div>Hello</div>
})
}
}
const World = () => <div>World</div>;
class WorldPlugin extends Plugin {
async load() {
this.app.addComponents({
World
})
}
}
class MyPlugin extends Plugin {
async afterAdd() {
this.app.pm.add(HelloPlugin);
this.app.pm.add(WorldPlugin);
}
}
```
### 渲染
#### Root Component
```tsx | pure
const RootComponent = app.getRootComponent();
const App = () => {
return <div>
My other logic
<RootComponent />
</div>
}
```
#### mount
```tsx | pure
app.mount('#app');
```

View File

@ -0,0 +1,5 @@
export * from './Application';
export * from './hooks';
export * from './Plugin';
export * from './RouterManager';
export * from './utils';

View File

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

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