refactor: plugin manager (#775)

* feat: dynamic import plugin client

* refactor: pm

* chore: improve cli

* feat: improve code

* feat: update dependences

* feat: hello plugin

* fix: plugin.enabled

* fix: test error

* feat: improve code

* feat: pm command

* feat: add samples

* fix: redirect

* feat: transitions

* feat: bookmark

* feat: add pm script
This commit is contained in:
chenos 2022-09-18 14:10:01 +08:00 committed by GitHub
parent 12c3915a57
commit f9f8dc78f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 1676 additions and 198 deletions

View File

@ -4,12 +4,13 @@
# #
Step 1: Start app Step 1: Start app
yarn run:example plugins/custom-plugin start yarn run:example app/custom-plugin start
Step 2: View test list Step 2: View test list
http://localhost:13000/api/test:list http://localhost:13000/api/test:list
*/ */
import { Application, Plugin } from '@nocobase/server'; import { Application, Plugin } from '@nocobase/server';
import { uid } from '@nocobase/utils';
const app = new Application({ const app = new Application({
database: { database: {
@ -22,7 +23,7 @@ const app = new Application({
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT as any, port: process.env.DB_PORT as any,
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
tablePrefix: process.env.DB_TABLE_PREFIX, tablePrefix: `t_${uid()}_`,
}, },
resourcer: { resourcer: {
prefix: '/api', prefix: '/api',

View File

@ -10,6 +10,7 @@ Step 2:
curl http://localhost:13000/api/test:list curl http://localhost:13000/api/test:list
*/ */
import { Application } from '@nocobase/server'; import { Application } from '@nocobase/server';
import { uid } from '@nocobase/utils';
const app = new Application({ const app = new Application({
database: { database: {
@ -22,7 +23,7 @@ const app = new Application({
host: process.env.DB_HOST, host: process.env.DB_HOST,
port: process.env.DB_PORT as any, port: process.env.DB_PORT as any,
timezone: process.env.DB_TIMEZONE, timezone: process.env.DB_TIMEZONE,
tablePrefix: process.env.DB_TABLE_PREFIX, tablePrefix: `t_${uid()}_`,
}, },
resourcer: { resourcer: {
prefix: '/api', prefix: '/api',
@ -37,6 +38,7 @@ app.resource({
async list(ctx, next) { async list(ctx, next) {
ctx.body = 'test list'; ctx.body = 'test list';
await next(); await next();
process.stdout.write('rs');
}, },
}, },
}); });

View File

@ -13,6 +13,7 @@
], ],
"scripts": { "scripts": {
"nocobase": "nocobase", "nocobase": "nocobase",
"pm": "nocobase pm",
"dev": "nocobase dev", "dev": "nocobase dev",
"start": "nocobase start", "start": "nocobase start",
"build": "nocobase build", "build": "nocobase build",

View File

@ -10,6 +10,7 @@ export default defineConfig({
define: { define: {
...umiConfig.define, ...umiConfig.define,
}, },
dynamicImportSyntax: {},
// only proxy when using `umi dev` // only proxy when using `umi dev`
// if the assets are built, will not proxy // if the assets are built, will not proxy
proxy: { proxy: {

View File

@ -4,12 +4,9 @@ export const app = new Application({
apiClient: { apiClient: {
baseURL: process.env.API_BASE_URL, baseURL: process.env.API_BASE_URL,
}, },
plugins: [ dynamicImport: (name: string) => {
require('@nocobase/plugin-china-region/client').default, return import(`../plugins/${name}`);
require('@nocobase/plugin-export/client').default, },
require('@nocobase/plugin-audit-logs/client').default,
require('@nocobase/plugin-workflow/client').default,
],
}); });
export default app.render(); export default app.render();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@
"baseUrl": "./", "baseUrl": "./",
"strict": true, "strict": true,
"paths": { "paths": {
"@nocobase/plugin-*-sample/client": ["../../samples/*/src/client"],
"@nocobase/plugin-*/client": ["../../plugins/*/src/client"], "@nocobase/plugin-*/client": ["../../plugins/*/src/client"],
"@nocobase/utils/client": ["../../core/utils/src/client"], "@nocobase/utils/client": ["../../core/utils/src/client"],
"@nocobase/*": ["../../core/*/src/"], "@nocobase/*": ["../../core/*/src/"],

View File

@ -1,5 +1,5 @@
const { Command } = require('commander'); const { Command } = require('commander');
const { run, isDev, promptForTs } = require('../util'); const { run, isDev, isProd, promptForTs } = require('../util');
/** /**
* *
@ -23,7 +23,7 @@ module.exports = (cli) => {
`./packages/${APP_PACKAGE_ROOT}/server/src/index.ts`, `./packages/${APP_PACKAGE_ROOT}/server/src/index.ts`,
...process.argv.slice(2), ...process.argv.slice(2),
]); ]);
} else { } else if (isProd()) {
run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, ...process.argv.slice(2)]); run('node', [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, ...process.argv.slice(2)]);
} }
}); });

View File

@ -30,11 +30,26 @@ exports.isDev = function isDev() {
return exports.hasTsNode(); return exports.hasTsNode();
}; };
const isProd = () => {
const { APP_PACKAGE_ROOT } = process.env;
const file = `./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`;
if (!existsSync(resolve(process.cwd(), file))) {
console.log('For production environment, please build the code first.');
console.log();
console.log(chalk.yellow('$ yarn build'));
console.log();
process.exit(1);
}
return true;
};
exports.isProd = isProd;
exports.nodeCheck = () => { exports.nodeCheck = () => {
if (!exports.hasTsNode()) { if (!exports.hasTsNode()) {
console.log('Please install all dependencies'); console.log('Please install all dependencies');
console.log(chalk.yellow('$ yarn install')); console.log(chalk.yellow('$ yarn install'));
process.exit(0); process.exit(1);
} }
}; };
@ -100,8 +115,9 @@ exports.runInstall = async () => {
'-s', '-s',
]; ];
await exports.run('ts-node', argv); await exports.run('ts-node', argv);
} else { } else if (isProd()) {
const argv = [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'install', '-s']; const file = `./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`;
const argv = [file, 'install', '-s'];
await exports.run('node', argv); await exports.run('node', argv);
} }
}; };
@ -120,7 +136,7 @@ exports.runAppCommand = async (command, args = []) => {
...args, ...args,
]; ];
await exports.run('ts-node', argv); await exports.run('ts-node', argv);
} else { } else if (isProd()) {
const argv = [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, command, ...args]; const argv = [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, command, ...args];
await exports.run('node', argv); await exports.run('node', argv);
} }

View File

@ -1,8 +1,10 @@
import { LockOutlined } from '@ant-design/icons'; import { LockOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Card } from 'antd';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager'; import { PluginManager } from '../plugin-manager';
import { ActionContext, SchemaComponent } from '../schema-component'; import { ActionContext, SchemaComponent } from '../schema-component';
import * as components from './Configuration'; import * as components from './Configuration';
@ -24,7 +26,38 @@ const schema: ISchema = {
}, },
}; };
const schema2: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-component': 'RoleTable',
},
},
};
export const ACLPane = () => {
return (
<Card bordered={false}>
<SchemaComponent components={components} schema={schema2} />
</Card>
);
};
export const ACLShortcut = () => { export const ACLShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<LockOutlined />}
title={t('Roles & Permissions')}
onClick={() => {
history.push('/admin/settings/acl/roles');
}}
/>
);
};
export const ACLShortcut2 = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View File

@ -1,5 +1,6 @@
import { Spin } from 'antd';
import { i18n as i18next } from 'i18next'; import { i18n as i18next } from 'i18next';
import React from 'react'; import React, { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { ACLProvider, ACLShortcut } from '../acl'; import { ACLProvider, ACLShortcut } from '../acl';
@ -11,6 +12,7 @@ import { RemoteDocumentTitleProvider } from '../document-title';
import { FileStorageShortcut } from '../file-manager'; import { FileStorageShortcut } from '../file-manager';
import { i18n } from '../i18n'; import { i18n } from '../i18n';
import { PluginManagerProvider } from '../plugin-manager'; import { PluginManagerProvider } from '../plugin-manager';
import PMProvider, { PluginManagerLink, SettingsCenterDropdown } from '../pm';
import { import {
AdminLayout, AdminLayout,
AuthLayout, AuthLayout,
@ -35,6 +37,7 @@ export interface ApplicationOptions {
apiClient?: any; apiClient?: any;
i18n?: any; i18n?: any;
plugins?: any[]; plugins?: any[];
dynamicImport?: any;
} }
export const getCurrentTimezone = () => { export const getCurrentTimezone = () => {
@ -45,14 +48,28 @@ export const getCurrentTimezone = () => {
export type PluginCallback = () => Promise<any>; export type PluginCallback = () => Promise<any>;
const App = React.memo((props: any) => {
const C = compose(...props.providers)(() => {
const routes = useRoutes();
return (
<div>
<RouteSwitch routes={routes} />
</div>
);
});
return <C />;
});
export class Application { export class Application {
providers = []; providers = [];
mainComponent = null; mainComponent = null;
apiClient: APIClient; apiClient: APIClient;
i18n: i18next; i18n: i18next;
plugins: PluginCallback[] = []; plugins: PluginCallback[] = [];
options: ApplicationOptions;
constructor(options: ApplicationOptions) { constructor(options: ApplicationOptions) {
this.options = options;
this.apiClient = new APIClient({ this.apiClient = new APIClient({
baseURL: process.env.API_BASE_URL, baseURL: process.env.API_BASE_URL,
headers: { headers: {
@ -85,6 +102,8 @@ export class Application {
SystemSettingsShortcut, SystemSettingsShortcut,
SchemaTemplateShortcut, SchemaTemplateShortcut,
FileStorageShortcut, FileStorageShortcut,
PluginManagerLink,
SettingsCenterDropdown,
}, },
}); });
this.use(SchemaComponentProvider, { components: { Link, NavLink } }); this.use(SchemaComponentProvider, { components: { Link, NavLink } });
@ -97,11 +116,7 @@ export class Application {
this.use(AntdSchemaComponentProvider); this.use(AntdSchemaComponentProvider);
this.use(ACLProvider); this.use(ACLProvider);
this.use(RemoteDocumentTitleProvider); this.use(RemoteDocumentTitleProvider);
this.use(PMProvider);
for (const plugin of options.plugins) {
const [component, props] = Array.isArray(plugin) ? plugin : [plugin];
this.use(component, props);
}
} }
use(component, props?: any) { use(component, props?: any) {
@ -120,16 +135,27 @@ export class Application {
} }
render() { render() {
return compose(...this.providers)( return (props: any) => {
this.mainComponent || const { plugins = [], dynamicImport } = this.options;
(() => { const [loading, setLoading] = useState(false);
const routes = useRoutes(); useEffect(() => {
return ( setLoading(true);
<div> (async () => {
<RouteSwitch routes={routes} /> const res = await this.apiClient.request({ url: 'app:getPlugins' });
</div> 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 <App providers={this.providers} />;
};
} }
} }

View File

@ -1,8 +1,10 @@
import { DatabaseOutlined } from '@ant-design/icons'; import { DatabaseOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Card } from 'antd';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager'; import { PluginManager } from '../plugin-manager';
import { ActionContext, SchemaComponent } from '../schema-component'; import { ActionContext, SchemaComponent } from '../schema-component';
import { AddFieldAction, ConfigurationTable, EditFieldAction } from './Configuration'; import { AddFieldAction, ConfigurationTable, EditFieldAction } from './Configuration';
@ -23,7 +25,38 @@ const schema: ISchema = {
}, },
}; };
const schema2: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-component': 'ConfigurationTable',
},
},
};
export const CollectionManagerPane = () => {
return (
<Card bordered={false}>
<SchemaComponent schema={schema2} components={{ ConfigurationTable, AddFieldAction, EditFieldAction }} />
</Card>
);
};
export const CollectionManagerShortcut = () => { export const CollectionManagerShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<DatabaseOutlined />}
title={t('Collections & Fields')}
onClick={() => {
history.push('/admin/settings/collection-manager/collections');
}}
/>
);
};
export const CollectionManagerShortcut2 = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View File

@ -0,0 +1,26 @@
import { uid } from '@formily/shared';
import { Card } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SchemaComponent } from '../schema-component';
import { storageSchema } from './schemas/storage';
import { StorageOptions } from './StorageOptions';
const schema = {
type: 'object',
properties: {
[uid()]: storageSchema,
},
};
export const FileStoragePane = () => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
return (
<Card bordered={false}>
<SchemaComponent components={{ StorageOptions }} schema={schema} />
</Card>
);
};
// WZvC&6cR8@aAJu!

View File

@ -2,6 +2,7 @@ import { FileOutlined } from '@ant-design/icons';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '..'; import { PluginManager } from '..';
import { ActionContext, SchemaComponent } from '../schema-component'; import { ActionContext, SchemaComponent } from '../schema-component';
import { storageSchema } from './schemas/storage'; import { storageSchema } from './schemas/storage';
@ -22,6 +23,20 @@ const schema = {
}; };
export const FileStorageShortcut = () => { export const FileStorageShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<FileOutlined />}
title={t('File storages')}
onClick={() => {
history.push('/admin/settings/file-manager/storages');
}}
/>
);
};
export const FileStorageShortcut2 = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View File

@ -1 +1,3 @@
export * from './FileStorage';
export * from './FileStorageShortcut'; export * from './FileStorageShortcut';

View File

@ -15,6 +15,7 @@ export * from './file-manager';
export * from './i18n'; export * from './i18n';
export * from './icon'; export * from './icon';
export * from './plugin-manager'; export * from './plugin-manager';
export * from './pm';
export * from './powered-by'; export * from './powered-by';
export * from './record-provider'; export * from './record-provider';
export * from './route-switch'; export * from './route-switch';
@ -25,3 +26,4 @@ export * from './schema-templates';
export * from './settings-form'; export * from './settings-form';
export * from './system-settings'; export * from './system-settings';
export * from './user'; export * from './user';

View File

@ -660,4 +660,16 @@ export default {
"View all plugins": "查看所有插件", "View all plugins": "查看所有插件",
"Print": "打印", "Print": "打印",
'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面', 'Sign up successfully, and automatically jump to the sign in page': '注册成功,即将跳转到登录页面',
'File manager': '文件管理器',
'ACL': '访问控制',
'Collection manager': '数据表管理',
'Plugin manager': '插件管理器',
'Local': '本地',
'Built-in': '内置',
'Marketplace': '插件市场',
'Coming soon...': '敬请期待...',
'Settings center': '配置中心',
'Bookmark': '书签',
'Manage all settings': '管理所有配置',
} }

View File

@ -1,11 +1,11 @@
import { AppstoreOutlined, EllipsisOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ConfigProvider, Menu, MenuItemProps, Spin, Tooltip } from 'antd'; import { ConfigProvider, Menu, MenuItemProps, Tooltip } from 'antd';
import cls from 'classnames'; import cls from 'classnames';
import { get } from 'lodash'; import { get } from 'lodash';
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 { useHistory } from 'react-router-dom';
import { PluginManagerContext } from './context'; import { PluginManagerContext } from './context';
export const usePrefixCls = ( export const usePrefixCls = (
@ -20,7 +20,7 @@ export const usePrefixCls = (
type PluginManagerType = { type PluginManagerType = {
Toolbar?: React.FC<ToolbarProps> & { Toolbar?: React.FC<ToolbarProps> & {
Item?: React.FC<MenuItemProps & { selected?: boolean, subtitle?: string }>; Item?: React.FC<MenuItemProps & { selected?: boolean; subtitle?: string }>;
}; };
}; };
@ -55,6 +55,7 @@ PluginManager.Toolbar = (props: ToolbarProps) => {
const { items = [] } = props; const { items = [] } = props;
const [pinned, unpinned] = splitItems(items); const [pinned, unpinned] = splitItems(items);
const { t } = useTranslation(); const { t } = useTranslation();
const history = useHistory();
return ( return (
<div style={{ display: 'inline-block' }}> <div style={{ display: 'inline-block' }}>
<Menu style={{ width: '100%' }} selectable={false} mode={'horizontal'} theme={'dark'}> <Menu style={{ width: '100%' }} selectable={false} mode={'horizontal'} theme={'dark'}>
@ -69,7 +70,7 @@ PluginManager.Toolbar = (props: ToolbarProps) => {
); );
})} })}
{unpinned.length > 0 && ( {unpinned.length > 0 && (
<Menu.SubMenu popupClassName={'pm-sub-menu'} key={'more'} title={<EllipsisOutlined />}> <Menu.SubMenu popupClassName={'pm-sub-menu'} key={'more'} title={<SettingOutlined />}>
{unpinned.map((item, index) => { {unpinned.map((item, index) => {
const Action = get(components, item.component); const Action = get(components, item.component);
return ( return (
@ -81,8 +82,14 @@ PluginManager.Toolbar = (props: ToolbarProps) => {
); );
})} })}
{unpinned.length > 0 && <Menu.Divider key={'divider'}></Menu.Divider>} {unpinned.length > 0 && <Menu.Divider key={'divider'}></Menu.Divider>}
<Menu.Item key={'plugins'} disabled icon={<AppstoreOutlined />}> <Menu.Item
{t('View all plugins')} key={'plugins'}
onClick={() => {
history.push('/admin/settings');
}}
icon={<SettingOutlined />}
>
{t('Settings center')}
</Menu.Item> </Menu.Item>
</Menu.SubMenu> </Menu.SubMenu>
)} )}
@ -97,29 +104,34 @@ PluginManager.Toolbar.Item = (props) => {
const prefix = usePrefixCls(); const prefix = usePrefixCls();
const className = cls({ [`${prefix}-menu-item-selected`]: selected }); const className = cls({ [`${prefix}-menu-item-selected`]: selected });
if (item.pin) { if (item.pin) {
const subtitleComponent = subtitle && ( const subtitleComponent = subtitle && (
<div <div
className={css` className={css`
font-size: 12px; font-size: 12px;
color: #999; color: #999;
`} `}
>{subtitle}</div> >
) {subtitle}
</div>
);
const titleComponent = ( const titleComponent = (
<div> <div>
<div>{title}</div> <div>{title}</div>
{subtitleComponent} {subtitleComponent}
</div> </div>
) );
return ( return title ? (
<Tooltip title={titleComponent}> <Tooltip title={titleComponent}>
<Menu.Item {...others} className={className} eventKey={item.component}> <Menu.Item {...others} className={className} eventKey={item.component}>
{icon} {icon}
</Menu.Item> </Menu.Item>
</Tooltip> </Tooltip>
) : (
<Menu.Item {...others} className={className} eventKey={item.component}>
{icon}
</Menu.Item>
); );
} }
return ( return (
@ -130,13 +142,19 @@ PluginManager.Toolbar.Item = (props) => {
}; };
export const RemotePluginManagerToolbar = () => { export const RemotePluginManagerToolbar = () => {
const api = useAPIClient(); // const api = useAPIClient();
const { data, loading } = useRequest({ // const { data, loading } = useRequest({
resource: 'plugins', // resource: 'plugins',
action: 'getPinned', // action: 'getPinned',
}); // });
if (loading) { // if (loading) {
return <Spin />; // return <Spin />;
} // }
return <PluginManager.Toolbar items={data?.data} />; const items = [
{ component: 'DesignableSwitch', pin: true },
{ component: 'PluginManagerLink', pin: true },
{ component: 'SettingsCenterDropdown', pin: true },
// ...data?.data,
];
return <PluginManager.Toolbar items={items} />;
}; };

View File

@ -0,0 +1,106 @@
import { AppstoreAddOutlined, SettingOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Dropdown, Menu } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '../plugin-manager';
import { ActionContext } from '../schema-component';
const schema: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-component': 'Action.Drawer',
type: 'void',
title: '{{t("Collections & Fields")}}',
properties: {
configuration: {
'x-component': 'ConfigurationTable',
},
},
},
},
};
export const PluginManagerLink = () => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const history = useHistory();
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<PluginManager.Toolbar.Item
icon={<AppstoreAddOutlined />}
title={t('Plugin manager')}
onClick={() => {
history.push('/admin/plugins');
}}
/>
</ActionContext.Provider>
);
};
export const SettingsCenterDropdown = () => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
const history = useHistory();
const items = [
{
title: t('Collections & Fields'),
path: 'collection-manager/collections',
},
{
title: t('Roles & Permissions'),
path: 'acl/roles',
},
{
title: t('File storages'),
path: 'file-manager/storages',
},
{
title: t('System settings'),
path: 'system-settings/system-settings',
},
{
title: t('Workflow'),
path: 'workflow/workflows',
},
];
return (
<ActionContext.Provider value={{ visible, setVisible }}>
<Dropdown
overlay={
<Menu>
<Menu.ItemGroup title={t('Bookmark')}>
{items.map((item) => {
return (
<Menu.Item
onClick={() => {
history.push('/admin/settings/' + item.path);
}}
>
{item.title}
</Menu.Item>
);
})}
</Menu.ItemGroup>
<Menu.Divider></Menu.Divider>
<Menu.Item
onClick={() => {
history.push('/admin/settings');
}}
>
{t('Settings center')}
</Menu.Item>
</Menu>
}
>
<PluginManager.Toolbar.Item
icon={<SettingOutlined />}
// title={t('Settings center')}
></PluginManager.Toolbar.Item>
</Dropdown>
</ActionContext.Provider>
);
};

View File

@ -0,0 +1,384 @@
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Avatar, Card, Layout, Menu, message, PageHeader, Popconfirm, Spin, Switch, Tabs } from 'antd';
import React, { createContext, useContext, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Redirect, useHistory, useRouteMatch } from 'react-router-dom';
import { ACLPane } from '../acl';
import { useAPIClient, useRequest } from '../api-client';
import { CollectionManagerPane } from '../collection-manager';
import { useDocumentTitle } from '../document-title';
import { FileStoragePane } from '../file-manager';
import { Icon } from '../icon';
import { RouteSwitchContext } from '../route-switch';
import { useCompile } from '../schema-component';
import { BlockTemplatesPane } from '../schema-templates';
import { SystemSettingsPane } from '../system-settings';
const SettingsCenterContext = createContext<any>({});
const PluginCard = (props) => {
const history = useHistory<any>();
const { data = {} } = props;
const api = useAPIClient();
const { t } = useTranslation();
return (
<Card
bordered={false}
style={{ width: 'calc(20% - 24px)', marginRight: 24, marginBottom: 24 }}
actions={[
data.enabled ? (
<SettingOutlined
onClick={() => {
history.push(`/admin/settings/${data.name}`);
}}
/>
) : null,
<Popconfirm
title={t('Are you sure to delete this plugin?')}
onConfirm={async () => {
await api.request({
url: `pm:remove/${data.name}`,
});
message.success(t('插件删除成功'));
window.location.reload();
}}
onCancel={() => {}}
okText={t('Yes')}
cancelText={t('No')}
>
<DeleteOutlined />
</Popconfirm>,
<Switch
size={'small'}
onChange={async (checked) => {
await api.request({
url: `pm:${checked ? 'enable' : 'disable'}/${data.name}`,
});
message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
window.location.reload();
}}
defaultChecked={data.enabled}
></Switch>,
].filter(Boolean)}
>
<Card.Meta
className={css`
.ant-card-meta-avatar {
margin-top: 8px;
.ant-avatar {
border-radius: 2px;
}
}
`}
avatar={<Avatar />}
description={data.description}
title={
<span>
{data.name}
<span
className={css`
display: block;
color: rgba(0, 0, 0, 0.45);
font-weight: normal;
font-size: 13px;
// margin-left: 8px;
`}
>
{data.version}
</span>
</span>
}
/>
</Card>
);
};
const BuiltInPluginCard = (props) => {
const { data } = props;
return (
<Card
bordered={false}
style={{ width: 'calc(20% - 24px)', marginRight: 24, marginBottom: 24 }}
// actions={[<a>Settings</a>, <a>Remove</a>, <Switch size={'small'} defaultChecked={true}></Switch>]}
>
<Card.Meta
className={css`
.ant-card-meta-avatar {
margin-top: 8px;
.ant-avatar {
border-radius: 2px;
}
}
`}
avatar={<Avatar />}
description={data.description}
title={
<span>
{data.name}
<span
className={css`
display: block;
color: rgba(0, 0, 0, 0.45);
font-weight: normal;
font-size: 13px;
// margin-left: 8px;
`}
>
{data.version}
</span>
</span>
}
/>
</Card>
);
};
const LocalPlugins = () => {
const { data, loading } = useRequest({
url: 'applicationPlugins:list',
params: {
filter: {
'builtIn.$isFalsy': true,
},
sort: 'name',
},
});
if (loading) {
return <Spin />;
}
return (
<>
{data?.data?.map((item) => {
return <PluginCard data={item} />;
})}
</>
);
};
const BuiltinPlugins = () => {
const { data, loading } = useRequest({
url: 'applicationPlugins:list',
params: {
filter: {
'builtIn.$isTruly': true,
},
sort: 'name',
},
});
if (loading) {
return <Spin />;
}
return (
<>
{data?.data?.map((item) => {
return <BuiltInPluginCard data={item} />;
})}
</>
);
};
const MarketplacePlugins = () => {
const { t } = useTranslation();
return <div style={{ fontSize: 18 }}>{t('Coming soon...')}</div>;
};
const PluginList = (props) => {
const match = useRouteMatch<any>();
const history = useHistory<any>();
const { tabName = 'local' } = match.params || {};
const { setTitle } = useDocumentTitle();
const { t } = useTranslation();
return (
<div>
<PageHeader
ghost={false}
title={t('Plugin manager')}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/plugins/${activeKey}`);
}}
>
<Tabs.TabPane tab={t('Local')} key={'local'} />
<Tabs.TabPane tab={t('Built-in')} key={'built-in'} />
<Tabs.TabPane tab={t('Marketplace')} key={'marketplace'} />
</Tabs>
}
/>
<div style={{ margin: 24, display: 'flex', flexFlow: 'row wrap' }}>
{React.createElement(
{
local: LocalPlugins,
'built-in': BuiltinPlugins,
marketplace: MarketplacePlugins,
}[tabName],
)}
</div>
</div>
);
};
const settings = {
acl: {
title: '{{t("ACL")}}',
icon: 'LockOutlined',
tabs: {
roles: {
title: '{{t("Roles & Permissions")}}',
component: ACLPane,
},
},
},
'block-templates': {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
tabs: {
list: {
title: '{{t("Block templates")}}',
component: BlockTemplatesPane,
},
},
},
'collection-manager': {
icon: 'DatabaseOutlined',
title: '{{t("Collection manager")}}',
tabs: {
collections: {
title: '{{t("Collections & Fields")}}',
component: CollectionManagerPane,
},
},
},
'file-manager': {
title: '{{t("File manager")}}',
icon: 'FileOutlined',
tabs: {
storages: {
title: '{{t("File storages")}}',
component: FileStoragePane,
},
// test: {
// title: 'Test',
// component: FileStoragePane,
// },
},
},
'system-settings': {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',
tabs: {
'system-settings': {
title: '{{t("System settings")}}',
component: SystemSettingsPane,
},
},
},
};
const SettingsCenter = (props) => {
const match = useRouteMatch<any>();
const history = useHistory<any>();
const items = useContext(SettingsCenterContext);
const compile = useCompile();
const firstUri = useMemo(() => {
const keys = Object.keys(items).sort();
const pluginName = keys.shift();
const tabName = Object.keys(items?.[pluginName]?.tabs || {}).shift();
return `/admin/settings/${pluginName}/${tabName}`;
}, [items]);
const { pluginName, tabName } = match.params || {};
if (!pluginName) {
return <Redirect to={firstUri} />;
}
if (!items[pluginName]) {
return <Redirect to={firstUri} />;
}
if (!tabName) {
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
return <Redirect to={`/admin/settings/${pluginName}/${firstTabName}`} />;
}
const component = items[pluginName]?.tabs?.[tabName]?.component;
return (
<div>
<Layout>
<Layout.Sider theme={'light'}>
<Menu selectedKeys={[pluginName]} style={{ height: 'calc(100vh - 46px)' }}>
{Object.keys(items)
.sort()
.map((key) => {
const item = items[key];
const tabKey = Object.keys(item.tabs).shift();
return (
<Menu.Item
key={key}
icon={item.icon ? <Icon type={item.icon} /> : null}
onClick={() => {
history.push(`/admin/settings/${key}/${tabKey}`);
}}
>
{compile(item.title)}
</Menu.Item>
);
})}
</Menu>
</Layout.Sider>
<Layout.Content>
<PageHeader
ghost={false}
title={compile(items[pluginName]?.title)}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
history.push(`/admin/settings/${pluginName}/${activeKey}`);
}}
>
{Object.keys(items[pluginName]?.tabs).map((tabKey) => {
const tab = items[pluginName].tabs?.[tabKey];
return <Tabs.TabPane tab={compile(tab?.title)} key={tabKey} />;
})}
</Tabs>
}
/>
<div style={{ margin: 24 }}>{component && React.createElement(component)}</div>
</Layout.Content>
</Layout>
</div>
);
};
export const SettingsCenterProvider = (props) => {
const { settings = {} } = props;
const items = useContext(SettingsCenterContext);
return (
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
);
};
export const PMProvider = (props) => {
const { routes, ...others } = useContext(RouteSwitchContext);
routes[1].routes.unshift(
{
type: 'route',
path: '/admin/plugins/:tabName?',
component: PluginList,
},
{
type: 'route',
path: '/admin/settings/:pluginName?/:tabName?',
component: SettingsCenter,
},
);
return (
<SettingsCenterProvider settings={settings}>
<RouteSwitchContext.Provider value={{ ...others, routes }}>{props.children}</RouteSwitchContext.Provider>
</SettingsCenterProvider>
);
};
export default PMProvider;
export * from './PluginManagerLink';

View File

@ -19,3 +19,11 @@ export const BlockTemplatePage = () => {
</div> </div>
); );
}; };
export const BlockTemplatesPane = () => {
return (
<CollectionManagerProvider collections={[uiSchemaTemplatesCollection]}>
<SchemaComponent schema={uiSchemaTemplatesSchema} />
</CollectionManagerProvider>
);
};

View File

@ -12,7 +12,7 @@ export const SchemaTemplateShortcut = () => {
icon={<LayoutOutlined />} icon={<LayoutOutlined />}
title={t('Block templates')} title={t('Block templates')}
onClick={() => { onClick={() => {
history.push('/admin/plugins/block-templates'); history.push('/admin/settings/block-templates/list');
}} }}
/> />
); );

View File

@ -1,9 +1,11 @@
import { SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { ISchema, useForm } from '@formily/react'; import { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Card, message } from 'antd';
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useSystemSettings } from '.'; import { useSystemSettings } from '.';
import { i18n, PluginManager, useAPIClient, useRequest } from '..'; import { i18n, PluginManager, useAPIClient, useRequest } from '..';
import locale from '../locale'; import locale from '../locale';
@ -39,6 +41,7 @@ const useSaveSystemSettingsValues = () => {
const form = useForm(); const form = useForm();
const { mutate, data } = useSystemSettings(); const { mutate, data } = useSystemSettings();
const api = useAPIClient(); const api = useAPIClient();
const { t } = useTranslation();
return { return {
async run() { async run() {
await form.submit(); await form.submit();
@ -54,6 +57,7 @@ const useSaveSystemSettingsValues = () => {
method: 'post', method: 'post',
data: values, data: values,
}); });
message.success(t('Saved successfully'));
const lang = values.enabledLanguages?.[0] || 'en-US'; const lang = values.enabledLanguages?.[0] || 'en-US';
if (values.enabledLanguages.length < 2 && api.auth.getLocale() !== lang) { if (values.enabledLanguages.length < 2 && api.auth.getLocale() !== lang) {
api.auth.setLocale(''); api.auth.setLocale('');
@ -135,13 +139,6 @@ const schema: ISchema = {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
properties: { properties: {
cancel: {
title: 'Cancel',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
submit: { submit: {
title: 'Submit', title: 'Submit',
'x-component': 'Action', 'x-component': 'Action',
@ -151,6 +148,13 @@ const schema: ISchema = {
useAction: '{{ useSaveSystemSettingsValues }}', useAction: '{{ useSaveSystemSettingsValues }}',
}, },
}, },
cancel: {
title: 'Cancel',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
}, },
}, },
}, },
@ -158,7 +162,128 @@ const schema: ISchema = {
}, },
}; };
const schema2: ISchema = {
type: 'object',
properties: {
[uid()]: {
'x-decorator': 'Form',
'x-decorator-props': {
useValues: '{{ useSystemSettingsValues }}',
},
'x-component': 'div',
type: 'void',
title: '{{t("System settings")}}',
properties: {
title: {
type: 'string',
title: "{{t('System title')}}",
'x-decorator': 'FormItem',
'x-component': 'Input',
required: true,
},
logo: {
type: 'string',
title: "{{t('Logo')}}",
'x-decorator': 'FormItem',
'x-component': 'Upload.Attachment',
'x-component-props': {
action: 'attachments:upload',
multiple: false,
// accept: 'jpg,png'
},
},
enabledLanguages: {
type: 'array',
title: '{{t("Enabled languages")}}',
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
'x-decorator': 'FormItem',
enum: langs,
'x-reactions': (field) => {
field.dataSource = langs.map((item) => {
let label = item.label;
if (field.value?.[0] === item.value) {
label += `(${i18n.t('Default')})`;
}
return {
label,
value: item.value,
};
});
},
},
allowSignUp: {
type: 'boolean',
default: true,
'x-content': '{{t("Allow sign up")}}',
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
},
smsAuthEnabled: {
type: 'boolean',
default: false,
'x-content': '{{t("Enable SMS authentication")}}',
'x-component': 'Checkbox',
'x-decorator': 'FormItem',
},
footer1: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {
layout: 'one-column',
},
properties: {
submit: {
title: 'Submit',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useAction: '{{ useSaveSystemSettingsValues }}',
},
},
// cancel: {
// title: 'Cancel',
// 'x-component': 'Action',
// 'x-component-props': {
// useAction: '{{ useCloseAction }}',
// },
// },
},
},
},
},
},
};
export const SystemSettingsPane = () => {
return (
<Card bordered={false}>
<SchemaComponent
scope={{ useSaveSystemSettingsValues, useSystemSettingsValues, useCloseAction }}
schema={schema2}
/>
</Card>
);
};
export const SystemSettingsShortcut = () => { export const SystemSettingsShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<SettingOutlined />}
title={t('System settings')}
onClick={() => {
history.push('/admin/settings/system-settings/system-settings');
}}
/>
);
};
export const SystemSettingsShortcut2 = () => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View File

@ -6,6 +6,7 @@
], ],
"scripts": { "scripts": {
"nocobase": "nocobase", "nocobase": "nocobase",
"pm": "nocobase pm",
"dev": "nocobase dev", "dev": "nocobase dev",
"start": "nocobase start", "start": "nocobase start",
"clean": "nocobase clean", "clean": "nocobase clean",

View File

@ -65,6 +65,14 @@ function resolveNocobasePackagesAlias(config) {
config.resolve.alias.set(`@nocobase/plugin-${package}`, packageSrc); config.resolve.alias.set(`@nocobase/plugin-${package}`, packageSrc);
} }
} }
const samples = fs.readdirSync(resolve(process.cwd(), './packages/samples'));
for (const package of samples) {
const packageSrc = resolve(process.cwd(), './packages/samples/', package, 'src');
if (existsSync(packageSrc)) {
config.module.rules.get('ts-in-node_modules').include.add(packageSrc);
config.resolve.alias.set(`@nocobase/plugin-${package}-sample`, packageSrc);
}
}
} }
exports.getUmiConfig = getUmiConfig; exports.getUmiConfig = getUmiConfig;

View File

@ -208,6 +208,10 @@ export class Resourcer {
return this.resources.has(name); return this.resources.has(name);
} }
removeResource(name) {
return this.resources.delete(name);
}
registerAction(name: ActionName, handler: HandlerType) { registerAction(name: ActionName, handler: HandlerType) {
this.registerActionHandler(name, handler); this.registerActionHandler(name, handler);
} }

View File

@ -128,21 +128,21 @@ export class ApplicationVersion {
} }
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter { export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
public readonly db: Database; protected _db: Database;
public readonly resourcer: Resourcer; protected _resourcer: Resourcer;
public readonly cli: Command; protected _cli: Command;
public readonly i18n: i18n; protected _i18n: i18n;
public readonly pm: PluginManager; protected _pm: PluginManager;
public readonly acl: ACL; protected _acl: ACL;
public readonly appManager: AppManager; protected _appManager: AppManager;
public readonly version: ApplicationVersion; protected _version: ApplicationVersion;
protected plugins = new Map<string, Plugin>(); protected plugins = new Map<string, Plugin>();
@ -150,18 +150,61 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
constructor(public options: ApplicationOptions) { constructor(public options: ApplicationOptions) {
super(); super();
this.init();
}
this.acl = createACL(); get db() {
this.db = this.createDatabase(options); return this._db;
this.resourcer = createResourcer(options); }
this.cli = new Command('nocobase').usage('[command] [options]');
this.i18n = createI18n(options);
this.pm = new PluginManager({ get resourcer() {
return this._resourcer;
}
get cli() {
return this._cli;
}
get acl() {
return this._acl;
}
get i18n() {
return this._i18n;
}
get pm() {
return this._pm;
}
get version() {
return this._version;
}
get appManager() {
return this._appManager;
}
protected init() {
const options = this.options;
// @ts-ignore
this._events = [];
// @ts-ignore
this._eventsCount = [];
this.middleware = [];
// this.context = Object.create(context);
this.plugins = new Map<string, Plugin>();
this._acl = createACL();
this._db = this.createDatabase(options);
this._resourcer = createResourcer(options);
this._cli = new Command('nocobase').usage('[command] [options]');
this._i18n = createI18n(options);
this._pm = new PluginManager({
app: this, app: this,
}); });
this.appManager = new AppManager(this); this._appManager = new AppManager(this);
registerMiddlewares(this, options); registerMiddlewares(this, options);
@ -173,7 +216,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
registerCli(this); registerCli(this);
this.version = new ApplicationVersion(this); this._version = new ApplicationVersion(this);
} }
private createDatabase(options: ApplicationOptions) { private createDatabase(options: ApplicationOptions) {
@ -242,6 +285,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.pm.load(); await this.pm.load();
} }
async reload() {
this.init();
await this.pm.load();
}
getPlugin<P extends Plugin>(name: string) { getPlugin<P extends Plugin>(name: string) {
return this.pm.get(name) as P; return this.pm.get(name) as P;
} }
@ -250,8 +298,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.runAsCLI(argv); return this.runAsCLI(argv);
} }
async runAsCLI(argv?: readonly string[], options?: ParseOptions) { async runAsCLI(argv = process.argv, options?: ParseOptions) {
await this.load(); if (argv?.[2] !== 'install') {
await this.load();
}
return this.cli.parseAsync(argv, options); return this.cli.parseAsync(argv, options);
} }
@ -328,8 +378,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
} }
async install(options: InstallOptions = {}) { async install(options: InstallOptions = {}) {
await this.emitAsync('beforeInstall', this, options);
const r = await this.db.version.satisfies({ const r = await this.db.version.satisfies({
mysql: '>=8.0.17', mysql: '>=8.0.17',
sqlite: '3.x', sqlite: '3.x',
@ -345,6 +393,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.db.clean(isBoolean(options.clean) ? { drop: options.clean } : options.clean); await this.db.clean(isBoolean(options.clean) ? { drop: options.clean } : options.clean);
} }
await this.emitAsync('beforeInstall', this, options);
await this.load();
await this.db.sync(options?.sync); await this.db.sync(options?.sync);
await this.pm.install(options); await this.pm.install(options);
await this.version.update(); await this.version.update();

View File

@ -9,6 +9,7 @@ export function registerCli(app: Application) {
require('./migrator').default(app); require('./migrator').default(app);
require('./start').default(app); require('./start').default(app);
require('./upgrade').default(app); require('./upgrade').default(app);
require('./pm').default(app);
// development only with @nocobase/cli // development only with @nocobase/cli
app.command('build').argument('[packages...]'); app.command('build').argument('[packages...]');

View File

@ -0,0 +1,89 @@
import axios from 'axios';
import { resolve } from 'path';
import Application from '../application';
export default (app: Application) => {
app
.command('pm')
.argument('<method>')
.arguments('<plugins...>')
.action(async (method, plugins, options, ...args) => {
const { APP_PORT, API_BASE_PATH = '/api/', API_BASE_URL } = process.env;
const baseURL = API_BASE_URL || `http://localhost:${APP_PORT}${API_BASE_PATH}`;
let started = true;
try {
await axios.get(`${baseURL}app:getLang`);
} catch (error) {
started = false;
}
const pm = {
async create() {
const name = plugins[0];
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
const generator = new PluginGenerator({
cwd: resolve(process.cwd(), name),
args: options,
context: {
name,
},
});
await generator.run();
},
async add() {
if (started) {
const res = await axios.get(`${baseURL}pm:add/${plugins.join(',')}`);
console.log(res.data);
return;
}
await app.pm.add(plugins);
},
async enable() {
if (started) {
const res = await axios.get(`${baseURL}pm:enable/${plugins.join(',')}`);
console.log(res.data);
return;
}
const repository = app.db.getRepository('applicationPlugins');
await repository.update({
filter: {
'name.$in': plugins,
},
values: {
enabled: true,
},
});
},
async disable() {
if (started) {
const res = await axios.get(`${baseURL}pm:disable/${plugins.join(',')}`);
console.log(res.data);
return;
}
const repository = app.db.getRepository('applicationPlugins');
await repository.update({
filter: {
'name.$in': plugins,
},
values: {
enabled: false,
},
});
},
async remove() {
if (started) {
const res = await axios.get(`${baseURL}pm:disable/${plugins.join(',')}`);
console.log(res.data);
return;
}
const repository = app.db.getRepository('applicationPlugins');
await repository.destroy({
filter: {
'name.$in': plugins,
},
});
plugins.map((name) => app.pm.remove(name));
},
};
await pm[method]();
});
};

View File

@ -1,4 +1,4 @@
import { CleanOptions, SyncOptions } from '@nocobase/database'; import { CleanOptions, Collection, Model, Repository, SyncOptions } from '@nocobase/database';
import Application from './application'; import Application from './application';
import { Plugin } from './plugin'; import { Plugin } from './plugin';
@ -12,14 +12,158 @@ export interface InstallOptions {
sync?: SyncOptions; sync?: SyncOptions;
} }
type PluginConstructor<P, O = any> = { new(app: Application, options: O): P }; class PluginManagerRepository extends Repository {
getInstance(): Plugin {
return;
}
async add(name: string | string[], options) {}
async enable(name: string | string[], options) {}
async disable(name: string | string[], options) {}
async remove(name: string | string[], options) {}
async upgrade(name: string | string[], options) {}
}
export class PluginManager { export class PluginManager {
app: Application; app: Application;
protected plugins = new Map<string, Plugin>(); collection: Collection;
repository: PluginManagerRepository;
plugins = new Map<string, Plugin>();
constructor(options: PluginManagerOptions) { constructor(options: PluginManagerOptions) {
this.app = options.app; this.app = options.app;
this.collection = this.app.db.collection({
name: 'applicationPlugins',
fields: [
{ type: 'string', name: 'name', unique: true },
{ type: 'string', name: 'version' },
{ type: 'boolean', name: 'enabled' },
{ type: 'boolean', name: 'builtIn' },
{ type: 'json', name: 'options' },
],
});
const app = this.app;
const pm = this;
this.repository = this.collection.repository as PluginManagerRepository;
this.app.resourcer.define({
name: 'pm',
actions: {
async add(ctx, next) {
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'null');
}
await pm.add(filterByTk);
ctx.body = filterByTk;
await next();
},
async enable(ctx, next) {
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'filterByTk invalid');
}
const name = pm.getPackageName(filterByTk);
const plugin = pm.get(name);
if (plugin.model) {
plugin.model.set('enabled', true);
await plugin.model.save();
}
if (!plugin) {
ctx.throw(400, 'plugin invalid');
}
await app.reload();
await app.start();
ctx.body = 'ok';
await next();
},
async disable(ctx, next) {
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'filterByTk invalid');
}
const name = pm.getPackageName(filterByTk);
const plugin = pm.get(name);
if (plugin.model) {
plugin.model.set('enabled', false);
await plugin.model.save();
}
if (!plugin) {
ctx.throw(400, 'plugin invalid');
}
await app.reload();
await app.start();
ctx.body = 'ok';
await next();
},
async upgrade(ctx, next) {
ctx.body = 'ok';
await next();
},
async remove(ctx, next) {
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'filterByTk invalid');
}
const name = pm.getPackageName(filterByTk);
const plugin = pm.get(name);
if (plugin.model) {
await plugin.model.destroy();
}
pm.remove(name);
await app.reload();
await app.start();
ctx.body = 'ok';
await next();
},
},
});
this.app.acl.use(async (ctx, next) => {
if (ctx.action.resourceName === 'pm') {
ctx.permission = {
skip: true,
};
}
await next();
});
this.app.on('beforeInstall', async () => {
await this.collection.sync();
});
this.app.on('beforeLoadAll', async (options) => {
const exists = await this.app.db.collectionExistsInDb('applicationPlugins');
if (!exists) {
return;
}
const items = await this.repository.find();
for (const item of items) {
await this.add(item);
}
});
}
getPackageName(name: string) {
if (name.includes('@nocobase/plugin-')) {
return name;
}
if (name.includes('/')) {
return `@${name}`;
}
return `@nocobase/plugin-${name}`;
}
private addByModel(model) {
try {
const packageName = this.getPackageName(model.get('name'));
require.resolve(packageName);
const cls = require(packageName).default;
const instance = new cls(this.app, {
...model.get('options'),
name: model.get('name'),
version: model.get('version'),
enabled: model.get('enabled'),
});
instance.setModel(model);
this.plugins.set(packageName, instance);
return instance;
} catch (error) {}
} }
getPlugins() { getPlugins() {
@ -30,13 +174,60 @@ export class PluginManager {
return this.plugins.get(name); return this.plugins.get(name);
} }
add<P extends Plugin = Plugin, O = any>(pluginClass: PluginConstructor<P, O>, options?: O): P { remove(name: string) {
const instance = new pluginClass(this.app, options); return this.plugins.delete(name);
}
add<P = Plugin, O = any>(pluginClass: any, options?: O) {
if (Array.isArray(pluginClass)) {
const addMultiple = async () => {
for (const plugin of pluginClass) {
await this.add(plugin);
}
}
return addMultiple();
}
if (typeof pluginClass === 'string') {
const packageName = this.getPackageName(pluginClass);
try {
require.resolve(packageName);
} catch (error) {
throw new Error(`${pluginClass} plugin does not exist`);
}
const packageJson = require(`${packageName}/package.json`);
const addNew = async () => {
let model = await this.repository.findOne({
filter: { name: pluginClass },
});
if (model) {
throw new Error(`${pluginClass} plugin already exists`);
}
model = await this.repository.create({
values: {
name: pluginClass,
version: packageJson.version,
enabled: false,
options: {},
},
});
return this.addByModel(model);
};
return addNew();
}
if (pluginClass instanceof Model) {
return this.addByModel(pluginClass);
}
const instance = new pluginClass(this.app, {
...options,
enabled: true,
});
const name = instance.getName(); const name = instance.getName();
if (this.plugins.has(name)) { if (this.plugins.has(name)) {
throw new Error(`plugin name [${name}] `); throw new Error(`plugin name [${name}] exists`);
} }
this.plugins.set(name, instance); this.plugins.set(name, instance);
@ -48,10 +239,16 @@ export class PluginManager {
await this.app.emitAsync('beforeLoadAll'); await this.app.emitAsync('beforeLoadAll');
for (const [name, plugin] of this.plugins) { for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await plugin.beforeLoad(); await plugin.beforeLoad();
} }
for (const [name, plugin] of this.plugins) { for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeLoadPlugin', plugin); await this.app.emitAsync('beforeLoadPlugin', plugin);
await plugin.load(); await plugin.load();
await this.app.emitAsync('afterLoadPlugin', plugin); await this.app.emitAsync('afterLoadPlugin', plugin);
@ -62,6 +259,9 @@ export class PluginManager {
async install(options: InstallOptions = {}) { async install(options: InstallOptions = {}) {
for (const [name, plugin] of this.plugins) { for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeInstallPlugin', plugin, options); await this.app.emitAsync('beforeInstallPlugin', plugin, options);
await plugin.install(options); await plugin.install(options);
await this.app.emitAsync('afterInstallPlugin', plugin, options); await this.app.emitAsync('afterInstallPlugin', plugin, options);

View File

@ -1,4 +1,4 @@
import { Database } from '@nocobase/database'; import { Database, Model } from '@nocobase/database';
import finder from 'find-package-json'; import finder from 'find-package-json';
import { Application } from './application'; import { Application } from './application';
import { InstallOptions } from './plugin-manager'; import { InstallOptions } from './plugin-manager';
@ -15,6 +15,7 @@ export interface PluginOptions {
displayName?: string; displayName?: string;
description?: string; description?: string;
version?: string; version?: string;
enabled?: boolean;
install?: (this: Plugin) => void; install?: (this: Plugin) => void;
load?: (this: Plugin) => void; load?: (this: Plugin) => void;
plugin?: typeof Plugin; plugin?: typeof Plugin;
@ -27,19 +28,31 @@ export abstract class Plugin<O = any> implements PluginInterface {
options: O; options: O;
app: Application; app: Application;
db: Database; db: Database;
model: Model;
constructor(app: Application, options?: O) { constructor(app: Application, options?: O) {
this.app = app; this.app = app;
this.db = app.db; this.db = app.db;
this.setOptions(options); this.setOptions(options);
this.initialize();
} }
setOptions(options: O) { setOptions(options: O) {
this.options = options || ({} as any); this.options = options || ({} as any);
} }
setModel(model) {
this.model = model;
}
get enabled() {
return (this.options as any).enabled;
}
public abstract getName(): string; public abstract getName(): string;
initialize() {}
beforeLoad() {} beforeLoad() {}
async install(options?: InstallOptions) {} async install(options?: InstallOptions) {}
@ -53,6 +66,10 @@ export abstract class Plugin<O = any> implements PluginInterface {
} }
} }
async disable() {
}
collectionPath() { collectionPath() {
return null; return null;
} }

View File

@ -56,7 +56,6 @@ interface Resource {
export class MockServer extends Application { export class MockServer extends Application {
async loadAndInstall(options: any = {}) { async loadAndInstall(options: any = {}) {
await this.load();
await this.install({ await this.install({
...options, ...options,
sync: { sync: {

View File

@ -1,8 +1,8 @@
import { ACL } from '@nocobase/acl'; import { ACL } from '@nocobase/acl';
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { MockServer } from '@nocobase/test';
import UsersPlugin from '@nocobase/plugin-users';
import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage'; import { UiSchemaRepository } from '@nocobase/plugin-ui-schema-storage';
import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
describe('acl', () => { describe('acl', () => {

View File

@ -1,7 +1,7 @@
import PluginUsers from '@nocobase/plugin-users';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginCollectionManager from '@nocobase/plugin-collection-manager'; import PluginCollectionManager from '@nocobase/plugin-collection-manager';
import PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage'; import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
import PluginUsers from '@nocobase/plugin-users';
import { mockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
import PluginACL from '../server'; import PluginACL from '../server';

View File

@ -20,6 +20,7 @@ export class ClientPlugin extends Plugin {
async load() { async load() {
this.app.acl.allow('app', 'getLang'); this.app.acl.allow('app', 'getLang');
this.app.acl.allow('app', 'getInfo'); this.app.acl.allow('app', 'getInfo');
this.app.acl.allow('app', 'getPlugins');
this.app.acl.allow('plugins', 'getPinned', 'loggedIn'); this.app.acl.allow('plugins', 'getPinned', 'loggedIn');
this.app.resource({ this.app.resource({
name: 'app', name: 'app',
@ -53,6 +54,24 @@ export class ClientPlugin extends Plugin {
}; };
await next(); await next();
}, },
async getPlugins(ctx, next) {
const pm = ctx.db.getRepository('applicationPlugins');
const items = await pm.find({
filter: {
enabled: true,
},
});
ctx.body = items
.filter((item) => {
try {
require.resolve(`@nocobase/plugin-${item.name}/client`);
return true;
} catch (error) {}
return false;
})
.map((item) => item.name);
await next();
},
}, },
}); });
this.app.resource({ this.app.resource({
@ -61,8 +80,7 @@ export class ClientPlugin extends Plugin {
// TODO: 临时 // TODO: 临时
async getPinned(ctx, next) { async getPinned(ctx, next) {
ctx.body = [ ctx.body = [
{ component: 'DesignableSwitch', pin: true }, { component: 'CollectionManagerShortcut' },
{ component: 'CollectionManagerShortcut', pin: true },
{ component: 'ACLShortcut' }, { component: 'ACLShortcut' },
{ component: 'WorkflowShortcut' }, { component: 'WorkflowShortcut' },
{ component: 'SchemaTemplateShortcut' }, { component: 'SchemaTemplateShortcut' },

View File

@ -1,7 +1,5 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { mockServer, MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import CollectionManagerPlugin from '@nocobase/plugin-collection-manager';
import { UiSchemaStoragePlugin } from '@nocobase/plugin-ui-schema-storage';
import { createApp } from '.'; import { createApp } from '.';
describe('action test', () => { describe('action test', () => {
@ -10,7 +8,6 @@ describe('action test', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.install({ clean: true });
db = app.db; db = app.db;
}); });

View File

@ -10,7 +10,6 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -6,8 +6,6 @@ describe('field defaultValue', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.install({ clean: true });
await app.start();
await app await app
.agent() .agent()
.resource('collections') .resource('collections')

View File

@ -7,8 +7,6 @@ describe('field indexes', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.install({ clean: true });
await app.start();
agent = app.agent(); agent = app.agent();
await agent await agent
.resource('collections') .resource('collections')

View File

@ -10,7 +10,6 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('belongsTo', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('belongsToMany', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('children options', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('hasMany field options', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -1,4 +1,4 @@
import Database, { Collection as DBCollection, StringFieldOptions } from '@nocobase/database'; import Database, { Collection as DBCollection } from '@nocobase/database';
import Application from '@nocobase/server'; import Application from '@nocobase/server';
import { createApp } from '..'; import { createApp } from '..';
@ -10,7 +10,6 @@ describe('hasOne field options', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -10,7 +10,6 @@ describe('reverseField options', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -8,8 +8,6 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
agent = app.agent(); agent = app.agent();
await app.install({ clean: true });
await app.start();
await agent await agent
.resource('collections') .resource('collections')
.create({ .create({

View File

@ -15,6 +15,8 @@ export async function createApp(options = {}) {
app.plugin(Plugin); app.plugin(Plugin);
app.plugin(PluginUiSchema); app.plugin(PluginUiSchema);
await app.load(); // await app.load();
await app.install({ clean: true });
await app.start();
return app; return app;
} }

View File

@ -10,7 +10,6 @@ describe('collections repository', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.db.sync();
db = app.db; db = app.db;
Collection = db.getCollection('collections'); Collection = db.getCollection('collections');
Field = db.getCollection('fields'); Field = db.getCollection('fields');

View File

@ -6,7 +6,6 @@ describe('collections.fields', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.install({ clean: true });
}); });
afterEach(async () => { afterEach(async () => {

View File

@ -7,7 +7,6 @@ describe('collections', () => {
beforeEach(async () => { beforeEach(async () => {
app = await createApp(); app = await createApp();
await app.install({ clean: true });
}); });
afterEach(async () => { afterEach(async () => {

View File

@ -12,7 +12,6 @@ describe('collections repository', () => {
await app1.cleanDb(); await app1.cleanDb();
app1.plugin(PluginErrorHandler); app1.plugin(PluginErrorHandler);
app1.plugin(Plugin); app1.plugin(Plugin);
await app1.load();
await app1.install({ clean: true }); await app1.install({ clean: true });
await app1.start(); await app1.start();
await app1 await app1

View File

@ -1,5 +1,6 @@
import { Plugin, PluginManager } from '@nocobase/server'; import { Plugin, PluginManager } from '@nocobase/server';
import { mockServer } from '@nocobase/test'; import { mockServer } from '@nocobase/test';
import { uid } from '@nocobase/utils';
import { PluginMultiAppManager } from '../server'; import { PluginMultiAppManager } from '../server';
describe('test with start', () => { describe('test with start', () => {
@ -35,9 +36,11 @@ describe('test with start', () => {
const db = app.db; const db = app.db;
const name = `d_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'sub1', name,
options: { options: {
plugins: ['test-package'], plugins: ['test-package'],
}, },
@ -47,6 +50,8 @@ describe('test with start', () => {
expect(loadFn).toHaveBeenCalledTimes(1); expect(loadFn).toHaveBeenCalledTimes(1);
expect(installFn).toHaveBeenCalledTimes(1); expect(installFn).toHaveBeenCalledTimes(1);
const subApp = await app.appManager.getApplication(name);
await subApp.destroy();
await app.destroy(); await app.destroy();
}); });
@ -60,14 +65,18 @@ describe('test with start', () => {
const db = app.db; const db = app.db;
const name = `d_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'sub1', name,
options: { options: {
plugins: ['@nocobase/plugin-ui-schema-storage'], plugins: ['@nocobase/plugin-ui-schema-storage'],
}, },
}, },
}); });
const subApp = await app.appManager.getApplication(name);
await subApp.destroy();
await app.destroy(); await app.destroy();
}); });
@ -91,17 +100,21 @@ describe('test with start', () => {
mockGetPluginByName.mockReturnValue(TestPlugin); mockGetPluginByName.mockReturnValue(TestPlugin);
PluginManager.resolvePlugin = mockGetPluginByName; PluginManager.resolvePlugin = mockGetPluginByName;
const name = `d_${uid()}`;
console.log(name);
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'sub1', name,
options: { options: {
plugins: ['test-package'], plugins: ['test-package'],
}, },
}, },
}); });
expect(app.appManager.applications.get('sub1')).toBeDefined(); expect(app.appManager.applications.get(name)).toBeDefined();
await app.appManager.applications.get(name).destroy();
await app.stop(); await app.stop();
let newApp = mockServer({ let newApp = mockServer({
@ -115,14 +128,16 @@ describe('test with start', () => {
await newApp.start(); await newApp.start();
expect(await newApp.db.getRepository('applications').count()).toEqual(1); expect(await newApp.db.getRepository('applications').count()).toEqual(1);
expect(newApp.appManager.applications.get('sub1')).not.toBeDefined(); expect(newApp.appManager.applications.get(name)).not.toBeDefined();
newApp.appManager.setAppSelector(() => { newApp.appManager.setAppSelector(() => {
return 'sub1'; return name;
}); });
await newApp.agent().resource('test').test(); await newApp.agent().resource('test').test();
expect(newApp.appManager.applications.get('sub1')).toBeDefined(); expect(newApp.appManager.applications.get(name)).toBeDefined();
await newApp.appManager.applications.get(name).destroy();
await app.destroy(); await app.destroy();
}); });

View File

@ -1,5 +1,6 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { mockServer, MockServer } from '@nocobase/test'; import { mockServer, MockServer } from '@nocobase/test';
import { uid } from '@nocobase/utils';
import { ApplicationModel } from '..'; import { ApplicationModel } from '..';
import { PluginMultiAppManager } from '../server'; import { PluginMultiAppManager } from '../server';
@ -21,84 +22,89 @@ describe('multiple apps create', () => {
}); });
it('should create application', async () => { it('should create application', async () => {
const name = `td_${uid()}`;
const miniApp = await db.getRepository('applications').create({ const miniApp = await db.getRepository('applications').create({
values: { values: {
name: 'miniApp', name,
}, },
}); });
expect(app.appManager.applications.get('miniApp')).toBeDefined(); expect(app.appManager.applications.get(name)).toBeDefined();
}); });
it('should remove application', async () => { it('should remove application', async () => {
const name = `td_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'miniApp', name,
}, },
}); });
expect(app.appManager.applications.get('miniApp')).toBeDefined(); expect(app.appManager.applications.get(name)).toBeDefined();
await db.getRepository('applications').destroy({ await db.getRepository('applications').destroy({
filter: { filter: {
name: 'miniApp', name,
}, },
}); });
expect(app.appManager.applications.get('miniApp')).toBeUndefined(); expect(app.appManager.applications.get(name)).toBeUndefined();
}); });
it('should create with plugins', async () => { it('should create with plugins', async () => {
const name = `td_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'miniApp', name,
options: { options: {
plugins: [['@nocobase/plugin-ui-schema-storage', { test: 'B' }]], plugins: [['@nocobase/plugin-ui-schema-storage', { test: 'B' }]],
}, },
}, },
}); });
const miniApp = app.appManager.applications.get('miniApp'); const miniApp = app.appManager.applications.get(name);
expect(miniApp).toBeDefined(); expect(miniApp).toBeDefined();
const plugin = miniApp.pm.get('@nocobase/plugin-ui-schema-storage'); const plugin = miniApp.pm.get('@nocobase/plugin-ui-schema-storage');
expect(plugin).toBeDefined(); expect(plugin).toBeDefined();
expect(plugin.options).toEqual({ expect(plugin.options).toMatchObject({
test: 'B', test: 'B',
}); });
}); });
it('should lazy load applications', async () => { it('should lazy load applications', async () => {
const name = `td_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'miniApp', name,
options: { options: {
plugins: ['@nocobase/plugin-ui-schema-storage'], plugins: ['@nocobase/plugin-ui-schema-storage'],
}, },
}, },
}); });
await app.appManager.removeApplication('miniApp'); await app.appManager.removeApplication(name);
app.appManager.setAppSelector(() => { app.appManager.setAppSelector(() => {
return 'miniApp'; return name;
}); });
expect(app.appManager.applications.has('miniApp')).toBeFalsy(); expect(app.appManager.applications.has(name)).toBeFalsy();
await app.agent().resource('test').test(); await app.agent().resource('test').test();
expect(app.appManager.applications.has('miniApp')).toBeTruthy(); expect(app.appManager.applications.has(name)).toBeTruthy();
}); });
it('should change handleAppStart', async () => { it('should change handleAppStart', async () => {
const customHandler = jest.fn(); const customHandler = jest.fn();
ApplicationModel.handleAppStart = customHandler; ApplicationModel.handleAppStart = customHandler;
const name = `td_${uid()}`;
await db.getRepository('applications').create({ await db.getRepository('applications').create({
values: { values: {
name: 'miniApp', name,
options: { options: {
plugins: ['@nocobase/plugin-ui-schema-storage'], plugins: ['@nocobase/plugin-ui-schema-storage'],
}, },

View File

@ -17,12 +17,11 @@ export class ApplicationModel extends Model {
} }
static async handleAppStart(app: Application, options: registerAppOptions) { static async handleAppStart(app: Application, options: registerAppOptions) {
await app.load(); if (!options?.skipInstall) {
if (!lodash.get(options, 'skipInstall', false)) {
await app.install(); await app.install();
} else {
await app.load();
} }
await app.start(); await app.start();
} }
@ -32,14 +31,9 @@ export class ApplicationModel extends Model {
const AppModel = this.constructor as typeof ApplicationModel; const AppModel = this.constructor as typeof ApplicationModel;
const app = mainApp.appManager.createApplication(appName, { const createDatabase = async () => {
...AppModel.initOptions(appName, mainApp), const database = appName;
...appOptions, const { host, port, username, password, dialect } = mainApp.db.options;
});
// create database before installation if it not exists
app.on('beforeInstall', async function createDatabase() {
const { host, port, username, password, database, dialect } = AppModel.getDatabaseConfig(app);
if (dialect === 'mysql') { if (dialect === 'mysql') {
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
@ -56,7 +50,7 @@ export class ApplicationModel extends Model {
port, port,
user: username, user: username,
password, password,
database: 'postgres' database: 'postgres',
}); });
await client.connect(); await client.connect();
@ -67,6 +61,15 @@ export class ApplicationModel extends Model {
await client.end(); await client.end();
} }
};
if (!options?.skipInstall) {
await createDatabase();
}
const app = mainApp.appManager.createApplication(appName, {
...AppModel.initOptions(appName, mainApp),
...appOptions,
}); });
await AppModel.handleAppStart(app, options); await AppModel.handleAppStart(app, options);

View File

@ -2,7 +2,7 @@ import { Migration } from '@nocobase/server';
export default class AlertSubTableMigration extends Migration { export default class AlertSubTableMigration extends Migration {
async up() { async up() {
const match = await this.app.version.satisfies('<=0.7.4-alpha.8'); const match = await this.app.version.satisfies('<=0.7.4-alpha.7');
if (!match) { if (!match) {
return; return;
} }
@ -10,8 +10,8 @@ export default class AlertSubTableMigration extends Migration {
const existed = await Field.count({ const existed = await Field.count({
filter: { filter: {
name: 'phone', name: 'phone',
collectionName: 'users' collectionName: 'users',
} },
}); });
if (!existed) { if (!existed) {
await Field.create({ await Field.create({
@ -30,12 +30,10 @@ export default class AlertSubTableMigration extends Migration {
}, },
}, },
// NOTE: to trigger hook // NOTE: to trigger hook
context: {} context: {},
}); });
} }
} }
async down() { async down() {}
}
} }

View File

@ -1,7 +1,7 @@
import { PluginManagerContext, RouteSwitchContext, SettingsCenterProvider } from '@nocobase/client';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { PluginManagerContext, RouteSwitchContext } from '@nocobase/client';
import { WorkflowPage } from './WorkflowPage'; import { WorkflowPage } from './WorkflowPage';
import { WorkflowShortcut } from './WorkflowShortcut'; import { WorkflowPane, WorkflowShortcut } from './WorkflowShortcut';
export const WorkflowProvider = (props) => { export const WorkflowProvider = (props) => {
const ctx = useContext(PluginManagerContext); const ctx = useContext(PluginManagerContext);
@ -12,17 +12,32 @@ export const WorkflowProvider = (props) => {
component: 'WorkflowPage', component: 'WorkflowPage',
}); });
return ( return (
<PluginManagerContext.Provider <SettingsCenterProvider
value={{ settings={{
components: { workflow: {
...ctx?.components, icon: 'PartitionOutlined',
WorkflowShortcut, title: '{{t("Workflow")}}',
tabs: {
workflows: {
title: '{{t("Workflow")}}',
component: WorkflowPane,
},
},
}, },
}} }}
> >
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage }, ...others, routes }}> <PluginManagerContext.Provider
{props.children} value={{
</RouteSwitchContext.Provider> components: {
</PluginManagerContext.Provider> ...ctx?.components,
WorkflowShortcut,
},
}}
>
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage }, ...others, routes }}>
{props.children}
</RouteSwitchContext.Provider>
</PluginManagerContext.Provider>
</SettingsCenterProvider>
); );
}; };

View File

@ -1,16 +1,14 @@
import React, { useState } from 'react';
import { PartitionOutlined } from '@ant-design/icons'; import { PartitionOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { ActionContext, PluginManager, SchemaComponent } from '@nocobase/client';
import { Card } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager, ActionContext, SchemaComponent } from '@nocobase/client'; import { ExecutionResourceProvider } from './ExecutionResourceProvider';
import { workflowSchema } from './schemas/workflows'; import { workflowSchema } from './schemas/workflows';
import { WorkflowLink } from './WorkflowLink'; import { WorkflowLink } from './WorkflowLink';
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
const schema: ISchema = { const schema: ISchema = {
type: 'object', type: 'object',
@ -26,7 +24,44 @@ const schema: ISchema = {
}, },
}; };
const schema2: ISchema = {
type: 'object',
properties: {
[uid()]: workflowSchema,
},
};
export const WorkflowPane = () => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
return (
<Card bordered={false}>
<SchemaComponent
schema={schema2}
components={{
WorkflowLink,
ExecutionResourceProvider,
}}
/>
</Card>
);
};
export const WorkflowShortcut = () => { export const WorkflowShortcut = () => {
const { t } = useTranslation();
const history = useHistory();
return (
<PluginManager.Toolbar.Item
icon={<PartitionOutlined />}
title={t('Workflow')}
onClick={() => {
history.push('/admin/settings/workflow/workflows');
}}
/>
);
};
export const WorkflowShortcut2 = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
return ( return (
@ -42,7 +77,7 @@ export const WorkflowShortcut = () => {
schema={schema} schema={schema}
components={{ components={{
WorkflowLink, WorkflowLink,
ExecutionResourceProvider ExecutionResourceProvider,
}} }}
/> />
</ActionContext.Provider> </ActionContext.Provider>

View File

@ -5,23 +5,33 @@ export class PresetNocoBase<O = any> extends Plugin {
return this.getPackageName(__dirname); return this.getPackageName(__dirname);
} }
beforeLoad(): void { initialize() {
this.app.loadPluginConfig([ this.app.on('beforeInstall', async () => {
'@nocobase/plugin-error-handler', const plugins = [
'@nocobase/plugin-collection-manager', 'error-handler',
'@nocobase/plugin-ui-schema-storage', 'collection-manager',
'@nocobase/plugin-ui-routes-storage', 'ui-schema-storage',
'@nocobase/plugin-file-manager', 'ui-routes-storage',
'@nocobase/plugin-system-settings', 'file-manager',
'@nocobase/plugin-verification', 'system-settings',
'@nocobase/plugin-users', 'verification',
'@nocobase/plugin-acl', 'users',
'@nocobase/plugin-china-region', 'acl',
'@nocobase/plugin-workflow', 'china-region',
'@nocobase/plugin-client', 'workflow',
'@nocobase/plugin-export', 'client',
'@nocobase/plugin-audit-logs', 'export',
]); 'audit-logs',
];
for (const plugin of plugins) {
const instance = await this.app.pm.add(plugin);
if (instance.model && plugin !== 'hello') {
instance.model.enabled = true;
instance.model.builtIn = true;
await instance.model.save();
}
}
});
} }
} }

4
packages/samples/hello/client.d.ts vendored Executable file
View File

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

View File

@ -0,0 +1,30 @@
"use strict";
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

@ -0,0 +1,16 @@
{
"name": "@nocobase/plugin-hello-sample",
"version": "0.7.4-alpha.7",
"main": "lib/server/index.js",
"dependencies": {},
"devDependencies": {
"@nocobase/client": "0.7.4-alpha.7",
"@nocobase/server": "0.7.4-alpha.7",
"@nocobase/test": "0.7.4-alpha.7"
},
"peerDependencies": {
"@nocobase/client": "*",
"@nocobase/server": "*",
"@nocobase/test": "*"
}
}

4
packages/samples/hello/server.d.ts vendored Executable file
View File

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

View File

@ -0,0 +1,30 @@
"use strict";
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

@ -0,0 +1,22 @@
import { useFieldSchema } from '@formily/react';
import {
GeneralSchemaDesigner,
SchemaSettings,
useCollection
} from '@nocobase/client';
import React from 'react';
export const HelloDesigner = () => {
const { name, title } = useCollection();
const fieldSchema = useFieldSchema();
return (
<GeneralSchemaDesigner title={title || name}>
<SchemaSettings.Remove
removeParentsIfNoChildren
breakRemoveOn={{
'x-component': 'Grid',
}}
/>
</GeneralSchemaDesigner>
);
};

View File

@ -0,0 +1,68 @@
import { TableOutlined } from '@ant-design/icons';
import {
SchemaComponentOptions,
SchemaInitializer,
SchemaInitializerContext,
SettingsCenterProvider
} from '@nocobase/client';
import { Card } from 'antd';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { HelloDesigner } from './HelloDesigner';
export const HelloBlockInitializer = (props) => {
const { insert } = props;
const { t } = useTranslation();
return (
<SchemaInitializer.Item
{...props}
icon={<TableOutlined />}
onClick={() => {
insert({
type: 'void',
'x-component': 'CardItem',
'x-designer': 'HelloDesigner',
properties: {
hello: {
type: 'void',
'x-component': 'div',
'x-content': 'Hello World',
},
},
});
}}
title={t('Hello block')}
/>
);
};
export default React.memo((props) => {
const items = useContext(SchemaInitializerContext);
const children = items.BlockInitializers.items[2].children;
children.push({
key: 'hello',
type: 'item',
title: '{{t("Hello block")}}',
component: 'HelloBlockInitializer',
});
return (
<SettingsCenterProvider
settings={{
'hello-sample': {
title: 'Hello',
icon: 'ApiOutlined',
tabs: {
tab1: {
title: 'Hello tab',
component: () => <Card bordered={false}>Hello Settings</Card>,
},
},
},
}}
>
<SchemaComponentOptions components={{ HelloDesigner, HelloBlockInitializer }}>
<SchemaInitializerContext.Provider value={items}>{props.children}</SchemaInitializerContext.Provider>
</SchemaComponentOptions>
</SettingsCenterProvider>
);
});

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -0,0 +1,36 @@
import { InstallOptions, Plugin } from '@nocobase/server';
export class HelloPlugin extends Plugin {
getName(): string {
return this.getPackageName(__dirname);
}
beforeLoad() {
// TODO
}
async load() {
// TODO
// Visit: http://localhost:13000/api/testHello:getInfo
this.app.resource({
name: 'testHello',
actions: {
async getInfo(ctx, next) {
ctx.body = `Hello hello!`;
next();
},
},
});
this.app.acl.allow('testHello', 'getInfo');
}
async disable() {
// this.app.resourcer.removeResource('testHello');
}
async install(options: InstallOptions) {
// TODO
}
}
export default HelloPlugin;

View File

@ -20,6 +20,9 @@
"@nocobase/app-*": [ "@nocobase/app-*": [
"packages/app/*/src" "packages/app/*/src"
], ],
"@nocobase/plugin-*-sample": [
"packages/samples/*/src"
],
"@nocobase/plugin-*": [ "@nocobase/plugin-*": [
"packages/plugins/*/src" "packages/plugins/*/src"
], ],