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

View File

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

View File

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

View File

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

View File

@ -4,12 +4,9 @@ export const app = new Application({
apiClient: {
baseURL: process.env.API_BASE_URL,
},
plugins: [
require('@nocobase/plugin-china-region/client').default,
require('@nocobase/plugin-export/client').default,
require('@nocobase/plugin-audit-logs/client').default,
require('@nocobase/plugin-workflow/client').default,
],
dynamicImport: (name: string) => {
return import(`../plugins/${name}`);
},
});
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": "./",
"strict": true,
"paths": {
"@nocobase/plugin-*-sample/client": ["../../samples/*/src/client"],
"@nocobase/plugin-*/client": ["../../plugins/*/src/client"],
"@nocobase/utils/client": ["../../core/utils/src/client"],
"@nocobase/*": ["../../core/*/src/"],

View File

@ -1,5 +1,5 @@
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`,
...process.argv.slice(2),
]);
} else {
} else if (isProd()) {
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();
};
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 = () => {
if (!exports.hasTsNode()) {
console.log('Please install all dependencies');
console.log(chalk.yellow('$ yarn install'));
process.exit(0);
process.exit(1);
}
};
@ -100,8 +115,9 @@ exports.runInstall = async () => {
'-s',
];
await exports.run('ts-node', argv);
} else {
const argv = [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, 'install', '-s'];
} else if (isProd()) {
const file = `./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`;
const argv = [file, 'install', '-s'];
await exports.run('node', argv);
}
};
@ -120,7 +136,7 @@ exports.runAppCommand = async (command, args = []) => {
...args,
];
await exports.run('ts-node', argv);
} else {
} else if (isProd()) {
const argv = [`./packages/${APP_PACKAGE_ROOT}/server/lib/index.js`, command, ...args];
await exports.run('node', argv);
}

View File

@ -1,8 +1,10 @@
import { LockOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Card } 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, SchemaComponent } from '../schema-component';
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 = () => {
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 { t } = useTranslation();
return (

View File

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

View File

@ -1,8 +1,10 @@
import { DatabaseOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Card } 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, SchemaComponent } from '../schema-component';
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 = () => {
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 { t } = useTranslation();
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 React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { PluginManager } from '..';
import { ActionContext, SchemaComponent } from '../schema-component';
import { storageSchema } from './schemas/storage';
@ -22,6 +23,20 @@ const schema = {
};
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 { t } = useTranslation();
return (

View File

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

View File

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

View File

@ -660,4 +660,16 @@ export default {
"View all plugins": "查看所有插件",
"Print": "打印",
'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 { ConfigProvider, Menu, MenuItemProps, Spin, Tooltip } from 'antd';
import { ConfigProvider, Menu, MenuItemProps, Tooltip } from 'antd';
import cls from 'classnames';
import { get } from 'lodash';
import React, { createContext, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../api-client';
import { useHistory } from 'react-router-dom';
import { PluginManagerContext } from './context';
export const usePrefixCls = (
@ -20,7 +20,7 @@ export const usePrefixCls = (
type PluginManagerType = {
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 [pinned, unpinned] = splitItems(items);
const { t } = useTranslation();
const history = useHistory();
return (
<div style={{ display: 'inline-block' }}>
<Menu style={{ width: '100%' }} selectable={false} mode={'horizontal'} theme={'dark'}>
@ -69,7 +70,7 @@ PluginManager.Toolbar = (props: ToolbarProps) => {
);
})}
{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) => {
const Action = get(components, item.component);
return (
@ -81,8 +82,14 @@ PluginManager.Toolbar = (props: ToolbarProps) => {
);
})}
{unpinned.length > 0 && <Menu.Divider key={'divider'}></Menu.Divider>}
<Menu.Item key={'plugins'} disabled icon={<AppstoreOutlined />}>
{t('View all plugins')}
<Menu.Item
key={'plugins'}
onClick={() => {
history.push('/admin/settings');
}}
icon={<SettingOutlined />}
>
{t('Settings center')}
</Menu.Item>
</Menu.SubMenu>
)}
@ -97,29 +104,34 @@ PluginManager.Toolbar.Item = (props) => {
const prefix = usePrefixCls();
const className = cls({ [`${prefix}-menu-item-selected`]: selected });
if (item.pin) {
const subtitleComponent = subtitle && (
<div
className={css`
font-size: 12px;
color: #999;
`}
>{subtitle}</div>
)
>
{subtitle}
</div>
);
const titleComponent = (
<div>
<div>{title}</div>
{subtitleComponent}
</div>
)
);
return (
return title ? (
<Tooltip title={titleComponent}>
<Menu.Item {...others} className={className} eventKey={item.component}>
{icon}
</Menu.Item>
</Tooltip>
) : (
<Menu.Item {...others} className={className} eventKey={item.component}>
{icon}
</Menu.Item>
);
}
return (
@ -130,13 +142,19 @@ PluginManager.Toolbar.Item = (props) => {
};
export const RemotePluginManagerToolbar = () => {
const api = useAPIClient();
const { data, loading } = useRequest({
resource: 'plugins',
action: 'getPinned',
});
if (loading) {
return <Spin />;
}
return <PluginManager.Toolbar items={data?.data} />;
// const api = useAPIClient();
// const { data, loading } = useRequest({
// resource: 'plugins',
// action: 'getPinned',
// });
// if (loading) {
// return <Spin />;
// }
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>
);
};
export const BlockTemplatesPane = () => {
return (
<CollectionManagerProvider collections={[uiSchemaTemplatesCollection]}>
<SchemaComponent schema={uiSchemaTemplatesSchema} />
</CollectionManagerProvider>
);
};

View File

@ -12,7 +12,7 @@ export const SchemaTemplateShortcut = () => {
icon={<LayoutOutlined />}
title={t('Block templates')}
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 { ISchema, useForm } from '@formily/react';
import { uid } from '@formily/shared';
import { Card, message } from 'antd';
import cloneDeep from 'lodash/cloneDeep';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useSystemSettings } from '.';
import { i18n, PluginManager, useAPIClient, useRequest } from '..';
import locale from '../locale';
@ -39,6 +41,7 @@ const useSaveSystemSettingsValues = () => {
const form = useForm();
const { mutate, data } = useSystemSettings();
const api = useAPIClient();
const { t } = useTranslation();
return {
async run() {
await form.submit();
@ -54,6 +57,7 @@ const useSaveSystemSettingsValues = () => {
method: 'post',
data: values,
});
message.success(t('Saved successfully'));
const lang = values.enabledLanguages?.[0] || 'en-US';
if (values.enabledLanguages.length < 2 && api.auth.getLocale() !== lang) {
api.auth.setLocale('');
@ -135,13 +139,6 @@ const schema: ISchema = {
type: 'void',
'x-component': 'Action.Drawer.Footer',
properties: {
cancel: {
title: 'Cancel',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCloseAction }}',
},
},
submit: {
title: 'Submit',
'x-component': 'Action',
@ -151,6 +148,13 @@ const schema: ISchema = {
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 = () => {
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 { t } = useTranslation();
return (

View File

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

View File

@ -65,6 +65,14 @@ function resolveNocobasePackagesAlias(config) {
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;

View File

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

View File

@ -128,21 +128,21 @@ export class ApplicationVersion {
}
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>();
@ -150,18 +150,61 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
constructor(public options: ApplicationOptions) {
super();
this.init();
}
this.acl = createACL();
this.db = this.createDatabase(options);
this.resourcer = createResourcer(options);
this.cli = new Command('nocobase').usage('[command] [options]');
this.i18n = createI18n(options);
get db() {
return this._db;
}
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,
});
this.appManager = new AppManager(this);
this._appManager = new AppManager(this);
registerMiddlewares(this, options);
@ -173,7 +216,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
registerCli(this);
this.version = new ApplicationVersion(this);
this._version = new ApplicationVersion(this);
}
private createDatabase(options: ApplicationOptions) {
@ -242,6 +285,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
await this.pm.load();
}
async reload() {
this.init();
await this.pm.load();
}
getPlugin<P extends Plugin>(name: string) {
return this.pm.get(name) as P;
}
@ -250,8 +298,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this.runAsCLI(argv);
}
async runAsCLI(argv?: readonly string[], options?: ParseOptions) {
await this.load();
async runAsCLI(argv = process.argv, options?: ParseOptions) {
if (argv?.[2] !== 'install') {
await this.load();
}
return this.cli.parseAsync(argv, options);
}
@ -328,8 +378,6 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
async install(options: InstallOptions = {}) {
await this.emitAsync('beforeInstall', this, options);
const r = await this.db.version.satisfies({
mysql: '>=8.0.17',
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.emitAsync('beforeInstall', this, options);
await this.load();
await this.db.sync(options?.sync);
await this.pm.install(options);
await this.version.update();

View File

@ -9,6 +9,7 @@ export function registerCli(app: Application) {
require('./migrator').default(app);
require('./start').default(app);
require('./upgrade').default(app);
require('./pm').default(app);
// development only with @nocobase/cli
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 { Plugin } from './plugin';
@ -12,14 +12,158 @@ export interface InstallOptions {
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 {
app: Application;
protected plugins = new Map<string, Plugin>();
collection: Collection;
repository: PluginManagerRepository;
plugins = new Map<string, Plugin>();
constructor(options: PluginManagerOptions) {
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() {
@ -30,13 +174,60 @@ export class PluginManager {
return this.plugins.get(name);
}
add<P extends Plugin = Plugin, O = any>(pluginClass: PluginConstructor<P, O>, options?: O): P {
const instance = new pluginClass(this.app, options);
remove(name: string) {
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();
if (this.plugins.has(name)) {
throw new Error(`plugin name [${name}] `);
throw new Error(`plugin name [${name}] exists`);
}
this.plugins.set(name, instance);
@ -48,10 +239,16 @@ export class PluginManager {
await this.app.emitAsync('beforeLoadAll');
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await plugin.beforeLoad();
}
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeLoadPlugin', plugin);
await plugin.load();
await this.app.emitAsync('afterLoadPlugin', plugin);
@ -62,6 +259,9 @@ export class PluginManager {
async install(options: InstallOptions = {}) {
for (const [name, plugin] of this.plugins) {
if (!plugin.enabled) {
continue;
}
await this.app.emitAsync('beforeInstallPlugin', plugin, options);
await plugin.install(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 { Application } from './application';
import { InstallOptions } from './plugin-manager';
@ -15,6 +15,7 @@ export interface PluginOptions {
displayName?: string;
description?: string;
version?: string;
enabled?: boolean;
install?: (this: Plugin) => void;
load?: (this: Plugin) => void;
plugin?: typeof Plugin;
@ -27,19 +28,31 @@ export abstract class Plugin<O = any> implements PluginInterface {
options: O;
app: Application;
db: Database;
model: Model;
constructor(app: Application, options?: O) {
this.app = app;
this.db = app.db;
this.setOptions(options);
this.initialize();
}
setOptions(options: O) {
this.options = options || ({} as any);
}
setModel(model) {
this.model = model;
}
get enabled() {
return (this.options as any).enabled;
}
public abstract getName(): string;
initialize() {}
beforeLoad() {}
async install(options?: InstallOptions) {}
@ -53,6 +66,10 @@ export abstract class Plugin<O = any> implements PluginInterface {
}
}
async disable() {
}
collectionPath() {
return null;
}

View File

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

View File

@ -1,8 +1,8 @@
import { ACL } from '@nocobase/acl';
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 UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare';
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 PluginErrorHandler from '@nocobase/plugin-error-handler';
import PluginUiSchema from '@nocobase/plugin-ui-schema-storage';
import PluginUsers from '@nocobase/plugin-users';
import { mockServer } from '@nocobase/test';
import PluginACL from '../server';

View File

@ -20,6 +20,7 @@ export class ClientPlugin extends Plugin {
async load() {
this.app.acl.allow('app', 'getLang');
this.app.acl.allow('app', 'getInfo');
this.app.acl.allow('app', 'getPlugins');
this.app.acl.allow('plugins', 'getPinned', 'loggedIn');
this.app.resource({
name: 'app',
@ -53,6 +54,24 @@ export class ClientPlugin extends Plugin {
};
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({
@ -61,8 +80,7 @@ export class ClientPlugin extends Plugin {
// TODO: 临时
async getPinned(ctx, next) {
ctx.body = [
{ component: 'DesignableSwitch', pin: true },
{ component: 'CollectionManagerShortcut', pin: true },
{ component: 'CollectionManagerShortcut' },
{ component: 'ACLShortcut' },
{ component: 'WorkflowShortcut' },
{ component: 'SchemaTemplateShortcut' },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ describe('hasMany field options', () => {
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
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 { createApp } from '..';
@ -10,7 +10,6 @@ describe('hasOne field options', () => {
beforeEach(async () => {
app = await createApp();
await app.db.sync();
db = app.db;
Collection = db.getCollection('collections');
Field = db.getCollection('fields');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { Migration } from '@nocobase/server';
export default class AlertSubTableMigration extends Migration {
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) {
return;
}
@ -10,8 +10,8 @@ export default class AlertSubTableMigration extends Migration {
const existed = await Field.count({
filter: {
name: 'phone',
collectionName: 'users'
}
collectionName: 'users',
},
});
if (!existed) {
await Field.create({
@ -30,12 +30,10 @@ export default class AlertSubTableMigration extends Migration {
},
},
// 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 { PluginManagerContext, RouteSwitchContext } from '@nocobase/client';
import { WorkflowPage } from './WorkflowPage';
import { WorkflowShortcut } from './WorkflowShortcut';
import { WorkflowPane, WorkflowShortcut } from './WorkflowShortcut';
export const WorkflowProvider = (props) => {
const ctx = useContext(PluginManagerContext);
@ -12,17 +12,32 @@ export const WorkflowProvider = (props) => {
component: 'WorkflowPage',
});
return (
<PluginManagerContext.Provider
value={{
components: {
...ctx?.components,
WorkflowShortcut,
<SettingsCenterProvider
settings={{
workflow: {
icon: 'PartitionOutlined',
title: '{{t("Workflow")}}',
tabs: {
workflows: {
title: '{{t("Workflow")}}',
component: WorkflowPane,
},
},
},
}}
>
<RouteSwitchContext.Provider value={{ components: { ...components, WorkflowPage }, ...others, routes }}>
{props.children}
</RouteSwitchContext.Provider>
</PluginManagerContext.Provider>
<PluginManagerContext.Provider
value={{
components: {
...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 { ISchema } from '@formily/react';
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 { PluginManager, ActionContext, SchemaComponent } from '@nocobase/client';
import { useHistory } from 'react-router-dom';
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
import { workflowSchema } from './schemas/workflows';
import { WorkflowLink } from './WorkflowLink';
import { ExecutionResourceProvider } from './ExecutionResourceProvider';
const schema: ISchema = {
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 = () => {
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 [visible, setVisible] = useState(false);
return (
@ -42,7 +77,7 @@ export const WorkflowShortcut = () => {
schema={schema}
components={{
WorkflowLink,
ExecutionResourceProvider
ExecutionResourceProvider,
}}
/>
</ActionContext.Provider>

View File

@ -5,23 +5,33 @@ export class PresetNocoBase<O = any> extends Plugin {
return this.getPackageName(__dirname);
}
beforeLoad(): void {
this.app.loadPluginConfig([
'@nocobase/plugin-error-handler',
'@nocobase/plugin-collection-manager',
'@nocobase/plugin-ui-schema-storage',
'@nocobase/plugin-ui-routes-storage',
'@nocobase/plugin-file-manager',
'@nocobase/plugin-system-settings',
'@nocobase/plugin-verification',
'@nocobase/plugin-users',
'@nocobase/plugin-acl',
'@nocobase/plugin-china-region',
'@nocobase/plugin-workflow',
'@nocobase/plugin-client',
'@nocobase/plugin-export',
'@nocobase/plugin-audit-logs',
]);
initialize() {
this.app.on('beforeInstall', async () => {
const plugins = [
'error-handler',
'collection-manager',
'ui-schema-storage',
'ui-routes-storage',
'file-manager',
'system-settings',
'verification',
'users',
'acl',
'china-region',
'workflow',
'client',
'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-*": [
"packages/app/*/src"
],
"@nocobase/plugin-*-sample": [
"packages/samples/*/src"
],
"@nocobase/plugin-*": [
"packages/plugins/*/src"
],