diff --git a/README.md b/README.md index 1c9bdb042..6fa45117d 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ NocoBase is in early stage of development and is subject to frequent changes, pl ## Recent major updates -- [v0.14: New plugin manager, supports adding plugins through UI - 2023/09/11](https://docs.nocobase.com/welcome/release/v14-changelog) -- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog) -- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog) -- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog) -- [v0.10: Update instructions - 2023/06/23](http://docs.nocobase.com/welcome/release/v10-changelog) +- [v0.15: New plugin settings manager - 2023/11/13](https://blog.nocobase.com/posts/release-v015/) +- [v0.14: New plugin manager, supports adding plugins through UI - 2023/09/11](https://blog.nocobase.com/posts/release-v014/) +- [v0.13: New application status flow - 2023/08/24](https://blog.nocobase.com/posts/release-v013/) +- [v0.12: New plugin build tool - 2023/08/01](https://blog.nocobase.com/posts/release-v012/) +- [v0.11: New client application, plugin and router - 2023/07/08](https://blog.nocobase.com/posts/release-v011/) ## What is NocoBase diff --git a/README.zh-CN.md b/README.zh-CN.md index 8669299ac..9ed5a7077 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,11 +8,11 @@ NocoBase 正处在早期开发阶段,可能变动频繁,请谨慎用于生 ## 最近重要更新 -- [v0.14:全新的插件管理器,支持通过界面添加插件 - 2023/09/11](https://docs-cn.nocobase.com/welcome/release/v14-changelog) -- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog) -- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog) -- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog) -- [v0.10: Update instructions - 2023/06/23](https://docs-cn.nocobase.com/welcome/release/v10-changelog) +- [v0.15:全新的插件设置中心 - 2023/11/13](https://blog-cn.nocobase.com/posts/release-v015/) +- [v0.14:全新的插件管理器,支持通过界面添加插件 - 2023/09/11](https://blog-cn.nocobase.com/posts/release-v014/) +- [v0.13: 全新的应用状态流转 - 2023/08/24](https://blog-cn.nocobase.com/posts/release-v013/) +- [v0.12: 全新的插件构建工具 - 2023/08/01](https://blog-cn.nocobase.com/posts/release-v012/) +- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://blog-cn.nocobase.com/posts/release-v011/) ## NocoBase 是什么 diff --git a/docs/config.ts b/docs/config.ts index 287d7ab14..07b6e2ae0 100644 --- a/docs/config.ts +++ b/docs/config.ts @@ -200,7 +200,7 @@ const sidebar = { ], }, '/development/client/ui-router', - '/development/client/settings-center', + '/development/client/plugin-settings', '/development/client/i18n', '/development/client/test', ], diff --git a/docs/en-US/development/client/plugin-settings.md b/docs/en-US/development/client/plugin-settings.md new file mode 100644 index 000000000..f938bbec3 --- /dev/null +++ b/docs/en-US/development/client/plugin-settings.md @@ -0,0 +1,75 @@ +# Plugin Settings Manager + + + +## Example + +### Basic Usage + +```tsx | pure +import { Plugin } from '@nocobase/client'; +import React from 'react'; + +const HelloSettingPage = () =>
Hello Setting page
; + +export class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'Hello', // menu title and page title + icon: 'ApiOutlined', // menu icon + Component: HelloSettingPage, + }) + } +} +``` + +### Multiple Level Routes + +```tsx | pure +import { Outlet } from 'react-router-dom' +const SettingPageLayout = () =>
This
public part, the following is the outlet of the sub-route:
; + +class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'HelloWorld', + icon: '', + Component: SettingPageLayout + }) + + this.app.pluginSettingsManager.add('hello.demo1', { + title: 'Demo1 Page', + Component: () =>
Demo1 Page Content
+ }) + + this.app.pluginSettingsManager.add('hello.demo2', { + title: 'Demo2 Page', + Component: () =>
Demo2 Page Content
+ }) + } +} +``` + +### Get Route Path + +If you want to get the jump link of the setting page, you can get it through the `getRoutePath` method. + +```tsx | pure +import { useApp } from '@nocobase/client' + +const app = useApp(); +app.pluginSettingsManager.getRoutePath('hello'); // /admin/settings/hello +app.pluginSettingsManager.getRoutePath('hello.demo1'); // /admin/settings/hello/demo1 +``` + +### Get Config + +If you want to get the added configuration (already filtered by permissions), you can get it through the `get` method. + +```tsx | pure +const app = useApp(); +app.pluginSettingsManager.get('hello'); // { title: 'HelloWorld', icon: '', Component: HelloSettingPage, children: [{...}] } +``` + + +See [samples/hello](https://github.com/nocobase/nocobase/blob/main/packages/plugins/%40nocobase/plugin-sample-hello/src/client/index.tsx) for full examples. diff --git a/docs/en-US/development/client/settings-center/settings-tab.jpg b/docs/en-US/development/client/plugin-settings/settings-tab.jpg similarity index 100% rename from docs/en-US/development/client/settings-center/settings-tab.jpg rename to docs/en-US/development/client/plugin-settings/settings-tab.jpg diff --git a/docs/en-US/development/client/settings-center.md b/docs/en-US/development/client/settings-center.md deleted file mode 100644 index 848bb7dd7..000000000 --- a/docs/en-US/development/client/settings-center.md +++ /dev/null @@ -1,33 +0,0 @@ -# Settings Center - - - -## Example - -```tsx | pure -import { SettingsCenterProvider } from '@nocobase/client'; -import React, { useContext } from 'react'; - -const HelloTab => () =>
Hello Tab
; - -export default React.memo((props) => { - return ( - {props.children} - ); -}); -``` - -See [samples/hello](https://github.com/nocobase/nocobase/tree/develop/packages/samples/hello) for full examples. diff --git a/docs/en-US/development/index.md b/docs/en-US/development/index.md index 5e32b1139..fc9639c95 100644 --- a/docs/en-US/development/index.md +++ b/docs/en-US/development/index.md @@ -18,7 +18,7 @@ No-code Users can manage the activation and deactivation of local plugins throug -Developers can also manage the complete plugin process by way of the CLI: +Developers can also manage the complete plugin process by way of the CLI: ```bash # Create the plugin @@ -47,7 +47,7 @@ Whether it is generic functionality or personalization, it is recommended to wri Distribution of modules. - Server - - Collections & Fields: mainly used for system table configuration. Business tables are recommended to be configured in "Settings Center - Collection manager". + - Collections & Fields: mainly used for system table configuration. Business tables are recommended to be configured in "Plugin Settings Manager - Collection manager". - Resources & Actions: Mainly used to extend the Action API - Middleware: Middleware - Events: Events @@ -57,5 +57,5 @@ Distribution of modules. - Client - UI Schema Designer: Page Designer - UI Router: When there is a need for custom pages - - Settings Center: Provides configuration pages for plugins + - Plugin Settings Manager: Provides configuration pages for plugins - I18n: Client side internationalization diff --git a/docs/en-US/development/learning-guide.md b/docs/en-US/development/learning-guide.md index cc5744757..115402259 100644 --- a/docs/en-US/development/learning-guide.md +++ b/docs/en-US/development/learning-guide.md @@ -76,7 +76,7 @@ yarn pm remove hello - Client - UI Schema Designer:页面设计器 - UI Router:有自定义页面需求时 - - Settings Center:为插件提供配置页面 + - Plugin Settings Manager:为插件提供配置页面 - I18n:客户端国际化 - Devtools - Commands:自定义命令行 @@ -113,8 +113,6 @@ yarn pm remove hello - UI Router - RouteSwitchProvider - RouteSwitch - - Settings Center - - SettingsCenterProvider - I18n - app.i18n - useTranslation diff --git a/docs/en-US/welcome/release/v08-changelog.md b/docs/en-US/welcome/release/v08-changelog.md index 24fc16581..1d302a59a 100644 --- a/docs/en-US/welcome/release/v08-changelog.md +++ b/docs/en-US/welcome/release/v08-changelog.md @@ -6,7 +6,7 @@ Starting with v0.8, NocoBase begins to provide an available plugin manager and d - UI Editor - Plugin Manager -- Settings Center +- Plugin Settings Manager - Personal Center diff --git a/docs/zh-CN/development/client/plugin-settings.md b/docs/zh-CN/development/client/plugin-settings.md new file mode 100644 index 000000000..73dc392a9 --- /dev/null +++ b/docs/zh-CN/development/client/plugin-settings.md @@ -0,0 +1,75 @@ +# 配置中心 + + + +## 示例 + +### 基础用法 + +```tsx | pure +import { Plugin } from '@nocobase/client'; +import React from 'react'; + +const HelloSettingPage = () =>
Hello Setting page
; + +export class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'Hello', // 设置页面的标题和菜单名称 + icon: 'ApiOutlined', // 设置页面菜单图标 + Component: HelloSettingPage, + }) + } +} +``` + +### 多层级路由 + +```tsx | pure +import { Outlet } from 'react-router-dom' +const SettingPageLayout = () =>
公共部分,下面是子路由的出口:
; + +class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'HelloWorld', // 设置页面的标题和菜单名称 + icon: '', // 菜单图标 + Component: SettingPageLayout + }) + + this.app.pluginSettingsManager.add('hello.demo1', { + title: 'Demo1 Page', + Component: () =>
Demo1 Page Content
+ }) + + this.app.pluginSettingsManager.add('hello.demo2', { + title: 'Demo2 Page', + Component: () =>
Demo2 Page Content
+ }) + } +} +``` + +### 获取路由路径 + + +如果想获取设置页面的跳转链接,可以通过 `getRoutePath` 方法获取。 + +```tsx | pure +import { useApp } from '@nocobase/client' + +const app = useApp(); +app.pluginSettingsManager.getRoutePath('hello'); // /admin/settings/hello +app.pluginSettingsManager.getRoutePath('hello.demo1'); // /admin/settings/hello/demo1 +``` + +### 获取配置 + +如果想获取添加的配置(已进行权限过滤),可以通过 `get` 方法获取。 + +```tsx | pure +const app = useApp(); +app.pluginSettingsManager.get('hello'); // { title: 'HelloWorld', icon: '', Component: HelloSettingPage, children: [{...}] } +``` + +完整示例查看 [samples/hello](https://github.com/nocobase/nocobase/blob/main/packages/plugins/%40nocobase/plugin-sample-hello/src/client/index.tsx)。 diff --git a/docs/zh-CN/development/client/settings-center/settings-tab.jpg b/docs/zh-CN/development/client/plugin-settings/settings-tab.jpg similarity index 100% rename from docs/zh-CN/development/client/settings-center/settings-tab.jpg rename to docs/zh-CN/development/client/plugin-settings/settings-tab.jpg diff --git a/docs/zh-CN/development/client/settings-center.md b/docs/zh-CN/development/client/settings-center.md deleted file mode 100644 index 7639b793e..000000000 --- a/docs/zh-CN/development/client/settings-center.md +++ /dev/null @@ -1,33 +0,0 @@ -# 配置中心 - - - -## 示例 - -```tsx | pure -import { SettingsCenterProvider } from '@nocobase/client'; -import React, { useContext } from 'react'; - -const HelloTab => () =>
Hello Tab
; - -export default React.memo((props) => { - return ( - {props.children} - ); -}); -``` - -完整示例查看 [samples/hello](https://github.com/nocobase/nocobase/tree/develop/packages/samples/hello)。 \ No newline at end of file diff --git a/docs/zh-CN/development/index.md b/docs/zh-CN/development/index.md index bda2bc154..72b0ba32e 100644 --- a/docs/zh-CN/development/index.md +++ b/docs/zh-CN/development/index.md @@ -57,6 +57,6 @@ yarn pm remove hello - Client - UI Schema Designer:页面设计器 - UI Router:有自定义页面需求时 - - Settings Center:为插件提供配置页面 + - Plugin Settings Manager:为插件提供配置页面 - I18n:客户端国际化 diff --git a/docs/zh-CN/development/learning-guide.md b/docs/zh-CN/development/learning-guide.md index cc5744757..115402259 100644 --- a/docs/zh-CN/development/learning-guide.md +++ b/docs/zh-CN/development/learning-guide.md @@ -76,7 +76,7 @@ yarn pm remove hello - Client - UI Schema Designer:页面设计器 - UI Router:有自定义页面需求时 - - Settings Center:为插件提供配置页面 + - Plugin Settings Manager:为插件提供配置页面 - I18n:客户端国际化 - Devtools - Commands:自定义命令行 @@ -113,8 +113,6 @@ yarn pm remove hello - UI Router - RouteSwitchProvider - RouteSwitch - - Settings Center - - SettingsCenterProvider - I18n - app.i18n - useTranslation diff --git a/packages/core/client/.dumirc.ts b/packages/core/client/.dumirc.ts index 7be56e346..6821e2b94 100644 --- a/packages/core/client/.dumirc.ts +++ b/packages/core/client/.dumirc.ts @@ -46,7 +46,7 @@ export default defineConfig({ link: '/apis/api-client', }, { - title: 'SettingsCenter', + title: 'PluginSettingsManager', link: '#', }, ], diff --git a/packages/core/client/src/__tests__/e2e/block.test.ts b/packages/core/client/src/__tests__/e2e/block.test.ts index 28abbae63..9bdd6f65f 100644 --- a/packages/core/client/src/__tests__/e2e/block.test.ts +++ b/packages/core/client/src/__tests__/e2e/block.test.ts @@ -552,8 +552,8 @@ test.describe('blcok template', () => { await page.locator('.ant-drawer-mask').click(); //删除模板 - await page.getByTestId('settings-center-button').click(); - await page.getByRole('button', { name: 'All plugin settings' }).click(); + await page.getByTestId('plugin-settings-button').click(); + await page.getByLabel('ui-schema-storage').click(); await page.getByRole('menuitem', { name: 'layout Block templates' }).click(); await page.getByLabel('action-Action.Link-Delete-destroy-uiSchemaTemplates-table-Users_Form').click(); await page.getByRole('button', { name: 'OK' }).click(); diff --git a/packages/core/client/src/__tests__/e2e/pm.test.ts b/packages/core/client/src/__tests__/e2e/pm.test.ts index 8e82ebedc..463492bd9 100644 --- a/packages/core/client/src/__tests__/e2e/pm.test.ts +++ b/packages/core/client/src/__tests__/e2e/pm.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@nocobase/test/client'; async function waitForModalToBeHidden(page) { + test.slow(); await page.waitForFunction(() => { const modal = document.querySelector('.ant-modal'); if (modal) { @@ -57,11 +58,12 @@ test.describe('remove plugin', () => { await page.getByPlaceholder('Search plugin').fill('Hello'); await expect(page.getByLabel('Hello')).toBeVisible(); const isActive = await page.getByLabel('Hello').getByLabel('enable').isChecked(); - await expect(isActive).toBe(false); + expect(isActive).toBe(false); //将hello插件remove await page.getByLabel('Hello').getByText('Remove').click(); await page.getByRole('button', { name: 'Yes' }).click(); //等待页面刷新结束 + await waitForModalToBeHidden(page); await page.waitForLoadState('load'); await page.getByPlaceholder('Search plugin').fill('hello'); await expect(page.getByLabel('Hello')).not.toBeVisible(); @@ -102,7 +104,7 @@ test.describe('enable & disabled plugin', () => { await expect(page.getByLabel('Hello')).toBeVisible(); const isActive = await page.getByLabel('Hello').getByLabel('enable').isChecked(); expect(isActive).toBe(false); - //激活插件 + // 激活插件 await page.getByLabel('Hello').getByLabel('enable').click(); await page.waitForTimeout(1000); // 等待1秒钟 //等待弹窗消失和页面刷新结束 diff --git a/packages/core/client/src/acl/ACLProvider.tsx b/packages/core/client/src/acl/ACLProvider.tsx index 64f2318f8..c8f7b69df 100644 --- a/packages/core/client/src/acl/ACLProvider.tsx +++ b/packages/core/client/src/acl/ACLProvider.tsx @@ -9,6 +9,7 @@ import { useCollection, useCollectionManager } from '../collection-manager'; import { useResourceActionContext } from '../collection-manager/ResourceActionProvider'; import { useRecord } from '../record-provider'; import { SchemaComponentOptions, useDesignable } from '../schema-component'; +import { useApp } from '../application'; export const ACLContext = createContext({}); @@ -35,6 +36,7 @@ export const ACLRolesCheckProvider = (props) => { const { setDesignable } = useDesignable(); const { render } = useAppSpin(); const api = useAPIClient(); + const app = useApp(); const result = useRequest<{ data: { snippets: string[]; @@ -57,6 +59,7 @@ export const ACLRolesCheckProvider = (props) => { if (data?.data?.role !== api.auth.role) { api.auth.setRole(data?.data?.role); } + app.pluginSettingsManager.setAclSnippets(data?.data?.snippets || []); }, }, ); diff --git a/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx index e22392e8b..d2f3769a8 100644 --- a/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx +++ b/packages/core/client/src/acl/Configuration/ConfigureCenter.tsx @@ -1,11 +1,12 @@ import { Checkbox, message, Table } from 'antd'; -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useAPIClient, useRequest } from '../../api-client'; import { SettingsCenterContext } from '../../pm'; import { useRecord } from '../../record-provider'; -import { useCompile } from '../../schema-component'; import { useStyles } from '../style'; +import { useApp } from '../../application'; +import { useCompile } from '../../schema-component'; const getParentKeys = (tree, func, path = []) => { if (!tree) return []; @@ -22,7 +23,7 @@ const getParentKeys = (tree, func, path = []) => { }; const getChildrenKeys = (data = [], arr = []) => { for (const item of data) { - arr.push(item.key); + arr.push(item.aclSnippet); if (item.children && item.children.length) getChildrenKeys(item.children, arr); } return arr; @@ -35,62 +36,40 @@ export const SettingCenterProvider = (props) => { return {props.children}; }; -const formatPluginTabs = (data) => { - const tabs = []; - for (const key in data) { - const plugin = data?.[key]; - for (const tabKey in plugin?.tabs || {}) { - const tab = plugin?.tabs[tabKey]; - tabs.push({ - pluginTitle: plugin.title, - ...tab, - key: `pm.${key}.${tabKey}`, - }); - } - } - return tabs; - const arr: any[] = Object.entries(data); - const pluginsTabs = []; - console.log(tabs); - arr.forEach((v) => { - const children = Object.entries(v[1].tabs).map((k: any) => { - return { - key: 'pm.' + v[0] + '.' + k[0], - title: k[1].title, - }; - }); - - pluginsTabs.push({ - title: v[1].title, - key: 'pm.' + v[0], - children, - }); - }); - return pluginsTabs; -}; - export const SettingsCenterConfigure = () => { + const app = useApp(); const { styles } = useStyles(); const record = useRecord(); const api = useAPIClient(); - const pluginTags = useContext(SettingMenuContext); - const items: any[] = (pluginTags && formatPluginTabs(pluginTags)) || []; - const { t } = useTranslation(); const compile = useCompile(); - const { loading, refresh, data } = useRequest<{ - data: any; - }>({ - resource: 'roles.snippets', - resourceOf: record.name, - action: 'list', - params: { - paginate: false, + const settings = app.pluginSettingsManager.getList(false); + const allAclSnippets = app.pluginSettingsManager.getAclSnippets(); + const [snippets, setSnippets] = useState([]); + const allChecked = useMemo( + () => snippets.includes('pm.*') && snippets.every((item) => !item.startsWith('!pm.')), + [snippets], + ); + + const { t } = useTranslation(); + const { loading, refresh } = useRequest( + { + resource: 'roles.snippets', + resourceOf: record.name, + action: 'list', + params: { + paginate: false, + }, }, - }); + { + onSuccess(data) { + setSnippets(data?.data || []); + }, + }, + ); const resource = api.resource('roles.snippets', record.name); const handleChange = async (checked, record) => { const childrenKeys = getChildrenKeys(record?.children, []); - const totalKeys = childrenKeys.concat(record.key); + const totalKeys = childrenKeys.concat(record.aclSnippet); if (!checked) { await resource.remove({ values: totalKeys.map((v) => '!' + v), @@ -104,40 +83,54 @@ export const SettingsCenterConfigure = () => { } message.success(t('Saved successfully')); }; - return ( - items?.length && ( - { - return compile(value); - }, +
{ + return compile(value); }, - { - dataIndex: 'pluginTitle', - title: t('Plugin name'), - render: (value) => { - return compile(value); - }, + }, + { + dataIndex: 'accessible', + title: ( + <> + { + const values = allAclSnippets.map((v) => '!' + v); + if (!allChecked) { + await resource.remove({ + values, + }); + } else { + await resource.add({ + values, + }); + } + refresh(); + message.success(t('Saved successfully')); + }} + />{' '} + {t('Accessible')} + + ), + render: (_, record) => { + const checked = !snippets.includes('!' + record.aclSnippet); + return handleChange(checked, record)} />; }, - { - dataIndex: 'accessible', - title: t('Accessible'), - render: (_, record) => { - const checked = !data?.data?.includes('!' + record.key); - return !record.children && handleChange(checked, record)} />; - }, - }, - ]} - dataSource={items} - /> - ) + }, + ]} + dataSource={settings} + /> ); }; diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index 95b0d7ee3..4cdcb8632 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -8,18 +8,22 @@ import React, { ComponentType, FC, ReactElement } from 'react'; import { createRoot } from 'react-dom/client'; import { I18nextProvider } from 'react-i18next'; import { Link, NavLink, Navigate } from 'react-router-dom'; -import { APIClient, APIClientProvider } from '../api-client'; -import { i18n } from '../i18n'; -import type { Plugin } from './Plugin'; + import { PluginManager, PluginType } from './PluginManager'; import { ComponentTypeAndString, RouterManager, RouterOptions } from './RouterManager'; import { WebSocketClient, WebSocketClientOptions } from './WebSocketClient'; +import { PluginSettingsManager } from './PluginSettingsManager'; + +import { APIClient, APIClientProvider } from '../api-client'; +import { i18n } from '../i18n'; import { AppComponent, BlankComponent, defaultAppComponents } from './components'; import { compose, normalizeContainer } from './utils'; import { defineGlobalDeps } from './utils/globalDeps'; -import type { RequireJS } from './utils/requirejs'; import { getRequireJs } from './utils/requirejs'; +import type { RequireJS } from './utils/requirejs'; +import type { Plugin } from './Plugin'; + declare global { interface Window { define: RequireJS['define']; @@ -50,6 +54,7 @@ export class Application { public apiClient: APIClient; public components: Record = { ...defaultAppComponents }; public pm: PluginManager; + public pluginSettingsManager: PluginSettingsManager; public devDynamicImport: DevDynamicImport; public requirejs: RequireJS; public notification; @@ -57,6 +62,9 @@ export class Application { maintained = false; maintaining = false; error = null; + get pluginManager() { + return this.pm; + } constructor(protected options: ApplicationOptions = {}) { this.initRequireJs(); @@ -81,6 +89,8 @@ export class Application { this.addReactRouterComponents(); this.addProviders(options.providers || []); this.ws = new WebSocketClient(options.ws); + this.pluginSettingsManager = new PluginSettingsManager(this); + this.addRoutes(); } private initRequireJs() { @@ -102,6 +112,13 @@ export class Application { }); } + private addRoutes() { + this.router.add('not-found', { + path: '*', + Component: this.components['AppNotFound'] || BlankComponent, + }); + } + getComposeProviders() { const Providers = compose(...this.providers)(BlankComponent); Providers.displayName = 'Providers'; diff --git a/packages/core/client/src/application/PluginSettingsManager.ts b/packages/core/client/src/application/PluginSettingsManager.ts new file mode 100644 index 000000000..ca8ecef88 --- /dev/null +++ b/packages/core/client/src/application/PluginSettingsManager.ts @@ -0,0 +1,141 @@ +import { set } from 'lodash'; +import { createElement } from 'react'; + +import { Icon } from '../icon'; +import type { Application } from './Application'; +import type { RouteType } from './RouterManager'; + +export const ADMIN_SETTINGS_KEY = 'admin.settings.'; +export const ADMIN_SETTINGS_PATH = '/admin/settings/'; +export const SNIPPET_PREFIX = 'pm.'; + +export interface PluginSettingsManagerSettingOptionsType { + title: string; + Component: RouteType['Component']; + icon?: string; + /** + * sort, the smaller the number, the higher the priority + * @default 0 + */ + sort?: number; + isBookmark?: boolean; + aclSnippet?: string; + [index: string]: any; +} + +export interface PluginSettingsPageType { + label?: string; + title: string; + key: string; + icon: any; + path: string; + sort?: number; + name?: string; + pluginName?: string; + isBookmark?: boolean; + children?: PluginSettingsPageType[]; + [index: string]: any; +} + +export class PluginSettingsManager { + protected settings: Record = {}; + protected aclSnippets: string[] = []; + + constructor(protected app: Application) { + this.app = app; + } + + setAclSnippets(aclSnippets: string[]) { + this.aclSnippets = aclSnippets; + } + + getAclSnippet(name: string) { + const setting = this.settings[name]; + return setting?.aclSnippet ? setting.aclSnippet : `${SNIPPET_PREFIX}${name}`; + } + + getRouteName(name: string) { + return `${ADMIN_SETTINGS_KEY}${name}`; + } + + getRoutePath(name: string) { + return `${ADMIN_SETTINGS_PATH}${name.replaceAll('.', '/')}`; + } + + add(name: string, options: PluginSettingsManagerSettingOptionsType) { + const nameArr = name.split('.'); + const pluginName = nameArr[0]; + this.settings[name] = { ...options, name, pluginName }; + + // add children + if (nameArr.length > 1) { + set(this.settings, nameArr.join('.children.'), this.settings[name]); + } + + // add route + this.app.router.add(this.getRouteName(name), { + path: this.getRoutePath(name), + Component: options.Component, + }); + } + + remove(name: string) { + // delete self and children + Object.keys(this.settings).forEach((key) => { + if (key.startsWith(name)) { + delete this.settings[key]; + this.app.router.remove(`${ADMIN_SETTINGS_KEY}${key}`); + } + }); + } + + hasAuth(name: string) { + return this.aclSnippets.includes(`!${this.getAclSnippet(name)}`) === false; + } + + getSetting(name: string) { + return this.settings[name]; + } + + has(name: string) { + const hasAuth = this.hasAuth(name); + if (!hasAuth) return false; + return !!this.getSetting(name); + } + + get(name: string, filterAuth = true): PluginSettingsPageType { + const isAllow = this.hasAuth(name); + const pluginSetting = this.getSetting(name); + if ((filterAuth && !isAllow) || !pluginSetting) return null; + const children = Object.keys(pluginSetting.children || {}) + .sort((a, b) => a.localeCompare(b)) // sort by name + .map((key) => this.get(pluginSetting.children[key].name, filterAuth)) + .filter(Boolean) + .sort((a, b) => (a.sort || 0) - (b.sort || 0)); + const { title, icon, aclSnippet, ...others } = pluginSetting; + return { + ...others, + aclSnippet: this.getAclSnippet(name), + title, + isAllow, + label: title, + icon: typeof icon === 'string' ? createElement(Icon, { type: icon }) : icon, + path: this.getRoutePath(name), + key: name, + children: children.length ? children : undefined, + }; + } + + getList(filterAuth = true): PluginSettingsPageType[] { + return Object.keys(this.settings) + .filter((item) => !item.includes('.')) // top level + .sort((a, b) => a.localeCompare(b)) // sort by name + .map((name) => this.get(name, filterAuth)) + .filter(Boolean) + .sort((a, b) => (a.sort || 0) - (b.sort || 0)); + } + + getAclSnippets() { + return Object.keys(this.settings).map((name) => this.getAclSnippet(name)); + } +} diff --git a/packages/core/client/src/application/__tests__/Application.test.tsx b/packages/core/client/src/application/__tests__/Application.test.tsx index ef34fec7b..4944ec9e1 100644 --- a/packages/core/client/src/application/__tests__/Application.test.tsx +++ b/packages/core/client/src/application/__tests__/Application.test.tsx @@ -16,7 +16,7 @@ describe('Application', () => { }); const router: any = { type: 'memory', initialEntries: ['/'] }; - const initialComponentsLength = 6; + const initialComponentsLength = 7; const initialProvidersLength = 2; it('basic', () => { const app = new Application({ router }); diff --git a/packages/core/client/src/application/__tests__/SettingsCenter.test.ts b/packages/core/client/src/application/__tests__/SettingsCenter.test.ts new file mode 100644 index 000000000..bf6b5ede4 --- /dev/null +++ b/packages/core/client/src/application/__tests__/SettingsCenter.test.ts @@ -0,0 +1,142 @@ +import { Application } from '../Application'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; + +describe('PluginSettingsManager', () => { + let app: Application; + + const test = { + title: 'test title', + Component: () => null, + }; + + const test1 = { + title: 'test1 title', + Component: () => null, + }; + + const test2 = { + title: 'test2 title', + Component: () => null, + }; + + beforeAll(() => { + const mock = new MockAdapter(axios); + mock.onGet('pm:listEnabled').reply(200, { + data: [], + }); + }); + + beforeEach(() => { + app = new Application({}); + }); + + it('basic use', () => { + const name = 'test'; + + app.pluginSettingsManager.add(name, test); + + const settingRes = { ...test, name }; + const getRes = { + ...test, + name, + label: test.title, + path: '/admin/settings/test', + isAllow: true, + aclSnippet: 'pm.test', + key: name, + children: undefined, + }; + expect(app.pluginSettingsManager.getSetting('test')).toContain(settingRes); + expect(app.pluginSettingsManager.get('test')).toContain(getRes); + expect(app.pluginSettingsManager.hasAuth('test')).toBeTruthy(); + const list = app.pluginSettingsManager.getList(); + expect(list.length).toBe(1); + expect(list[0]).toContain(getRes); + }); + + it('multi', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test2', test2); + expect(app.pluginSettingsManager.get('test1')).toContain(test1); + expect(app.pluginSettingsManager.get('test2')).toContain(test2); + + const list = app.pluginSettingsManager.getList(); + expect(list.length).toBe(2); + expect(list[0]).toContain(test1); + expect(list[1]).toContain(test2); + }); + + it('nested', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test1.test2', test2); + expect(app.pluginSettingsManager.get('test1')).toContain(test1); + expect(app.pluginSettingsManager.get('test1.test2')).toContain(test2); + expect(app.pluginSettingsManager.get('test1').children.length).toBe(1); + expect(app.pluginSettingsManager.get('test1').children[0]).toContain(test2); + }); + + it('remove', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test1.test2', test2); + + app.pluginSettingsManager.remove('test1'); + expect(app.pluginSettingsManager.get('test1')).toBeFalsy(); + expect(app.pluginSettingsManager.get('test1.test2')).toBeFalsy(); + expect(app.pluginSettingsManager.getList().length).toBe(0); + }); + + it('acl', () => { + app.pluginSettingsManager.setAclSnippets(['!pm.test']); + app.pluginSettingsManager.add('test', test); + expect(app.pluginSettingsManager.get('test')).toBeFalsy(); + expect(app.pluginSettingsManager.hasAuth('test')).toBeFalsy(); + expect(app.pluginSettingsManager.get('test', false)).toContain({ ...test, isAllow: false }); + + expect(app.pluginSettingsManager.getList().length).toBe(0); + expect(app.pluginSettingsManager.getList(false).length).toBe(1); + expect(app.pluginSettingsManager.getList(false)[0]).toContain({ ...test, isAllow: false }); + }); + + it('has', () => { + app.pluginSettingsManager.add('test', test); + expect(app.pluginSettingsManager.has('test')).toBeTruthy(); + expect(app.pluginSettingsManager.has('test1')).toBeFalsy(); + }); + + it('getAclSnippet', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test2', { + ...test2, + aclSnippet: 'any.string', + }); + expect(app.pluginSettingsManager.getAclSnippet('test1')).toBe('pm.test1'); + expect(app.pluginSettingsManager.getAclSnippet('test2')).toBe('any.string'); + }); + + it('getRouteName', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test1.test2', test2); + expect(app.pluginSettingsManager.getRouteName('test1')).toBe('admin.settings.test1'); + expect(app.pluginSettingsManager.getRouteName('test1.test2')).toBe('admin.settings.test1.test2'); + }); + + it('getRoutePath', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test1.test2', test2); + expect(app.pluginSettingsManager.getRoutePath('test1')).toBe('/admin/settings/test1'); + expect(app.pluginSettingsManager.getRoutePath('test1.test2')).toBe('/admin/settings/test1/test2'); + }); + + it('router', () => { + app.pluginSettingsManager.add('test1', test1); + app.pluginSettingsManager.add('test1.test2', test2); + expect(app.router.getRoutes()[0]).toMatchInlineSnapshot(` + { + "children": undefined, + "element": , + "path": "*", + } + `); + }); +}); diff --git a/packages/core/client/src/application/components/defaultComponents.tsx b/packages/core/client/src/application/components/defaultComponents.tsx index d6b8eaec0..38b1884f7 100644 --- a/packages/core/client/src/application/components/defaultComponents.tsx +++ b/packages/core/client/src/application/components/defaultComponents.tsx @@ -9,8 +9,11 @@ const AppError: FC<{ error: Error }> = ({ error }) => ( ); +const AppNotFound: FC = () =>
Not Found
; + export const defaultAppComponents = { AppMain: MainComponent, AppSpin: Loading, AppError: AppError, + AppNotFound: AppNotFound, }; diff --git a/packages/core/client/src/application/index.md b/packages/core/client/src/application/index.md index b6677d9e3..af3e179be 100644 --- a/packages/core/client/src/application/index.md +++ b/packages/core/client/src/application/index.md @@ -27,6 +27,7 @@ Application 提供了强大的功能,包括: - scopes 管理 - providers 管理 - 插件管理 +- 插件设置页面管理 ### 组件管理 @@ -370,6 +371,76 @@ class MyPlugin extends Plugin { } ``` +### 插件设置页面管理 + +#### 基础用法 + +```tsx | pure +import { Plugin } from '@nocobase/client'; +import React from 'react'; + +const HelloSettingPage = () =>
Hello Setting page
; + +export class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'Hello', // 设置页面的标题和菜单名称 + icon: 'ApiOutlined', // 设置页面菜单图标 + Component: HelloSettingPage, + }) + } +} +``` + +#### 多层级路由 + +```tsx | pure +import { Outlet } from 'react-router-dom' +const SettingPageLayout = () =>
公共部分,下面是子路由的出口:
; + +class HelloPlugin extends Plugin { + async load() { + this.app.pluginSettingsManager.add('hello', { + title: 'HelloWorld', // 设置页面的标题和菜单名称 + icon: '', // 菜单图标 + Component: SettingPageLayout + }) + + this.app.pluginSettingsManager.add('hello.demo1', { + title: 'Demo1 Page', + Component: () =>
Demo1 Page Content
+ }) + + this.app.pluginSettingsManager.add('hello.demo2', { + title: 'Demo2 Page', + Component: () =>
Demo2 Page Content
+ }) + } +} +``` + +#### 获取路由路径 + + +如果想获取设置页面的跳转链接,可以通过 `getRoutePath` 方法获取。 + +```tsx | pure +import { useApp } from '@nocobase/client' + +const app = useApp(); +app.pluginSettingsManager.getRoutePath('hello'); // /admin/settings/hello +app.pluginSettingsManager.getRoutePath('hello.demo1'); // /admin/settings/hello/demo1 +``` + +#### 获取配置 + +如果想获取添加的配置(已进行权限过滤),可以通过 `get` 方法获取。 + +```tsx | pure +const app = useApp(); +app.pluginSettingsManager.get('hello'); // { title: 'HelloWorld', icon: '', Component: HelloSettingPage, children: [{...}] } +``` + ### 渲染 #### Root Component diff --git a/packages/core/client/src/application/index.ts b/packages/core/client/src/application/index.ts index b80152369..763f99006 100644 --- a/packages/core/client/src/application/index.ts +++ b/packages/core/client/src/application/index.ts @@ -3,3 +3,4 @@ export * from './hooks'; export * from './Plugin'; export * from './RouterManager'; export * from './utils'; +export * from './PluginSettingsManager'; diff --git a/packages/core/client/src/locale/en_US.ts b/packages/core/client/src/locale/en_US.ts index 868d8eee4..ec96d9778 100644 --- a/packages/core/client/src/locale/en_US.ts +++ b/packages/core/client/src/locale/en_US.ts @@ -38,6 +38,7 @@ export default { "Unconnected": "Unconnected", "System settings": "System settings", "System title": "System title", + "Settings": "Settings", "Logo": "Logo", "Add menu item": "Add menu item", "Page": "Page", diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index f69229664..1ebdc3870 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -42,6 +42,7 @@ export default { 'System settings': '系统设置', 'System title': '系统名称', Setting: '设置', + Settings: '设置', Enable: '启用', Disable: '禁用', On: '启用', diff --git a/packages/core/client/src/nocobase-buildin-plugin/index.tsx b/packages/core/client/src/nocobase-buildin-plugin/index.tsx index d1b6cd682..99cfbc831 100644 --- a/packages/core/client/src/nocobase-buildin-plugin/index.tsx +++ b/packages/core/client/src/nocobase-buildin-plugin/index.tsx @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { observer } from '@formily/reactive-react'; import { Button, Modal, Result, Spin } from 'antd'; import React, { FC } from 'react'; -import { Navigate } from 'react-router-dom'; +import { Navigate, useNavigate } from 'react-router-dom'; import { ACLPlugin } from '../acl'; import { Application } from '../application'; import { Plugin } from '../application/Plugin'; @@ -188,6 +188,22 @@ const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(({ ); }); +const AppNotFound = () => { + const navigate = useNavigate(); + return ( + navigate('/', { replace: true })} type="primary"> + Back Home + + } + /> + ); +}; + export class NocoBaseBuildInPlugin extends Plugin { async afterAdd() { this.app.addComponents({ @@ -195,6 +211,7 @@ export class NocoBaseBuildInPlugin extends Plugin { AppError, AppMaintaining, AppMaintainingDialog, + AppNotFound, }); await this.addPlugins(); } @@ -216,6 +233,11 @@ export class NocoBaseBuildInPlugin extends Plugin { element: , }); + this.router.add('not-found', { + path: '*', + Component: AppNotFound, + }); + this.router.add('admin', { path: '/admin', Component: 'AdminLayout', diff --git a/packages/core/client/src/pm/PluginCard.tsx b/packages/core/client/src/pm/PluginCard.tsx index 34f33ec64..5a97f7533 100644 --- a/packages/core/client/src/pm/PluginCard.tsx +++ b/packages/core/client/src/pm/PluginCard.tsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { DeleteOutlined, ReadOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'; import { css } from '@emotion/css'; import { useAPIClient } from '../api-client'; +import { useApp } from '../application'; import { PluginDetail } from './PluginDetail'; import { PluginUpgradeModal } from './PluginForm/modal/PluginUpgradeModal'; import { useStyles } from './style'; @@ -18,6 +19,7 @@ interface IPluginInfo extends IPluginCard { function PluginInfo(props: IPluginInfo) { const { data, onClick } = props; + const app = useApp(); const { name, displayName, isCompatible, packageName, updatable, builtIn, enabled, description, type, error } = data; const { styles, theme } = useStyles(); const navigate = useNavigate(); @@ -28,7 +30,6 @@ function PluginInfo(props: IPluginInfo) { const [enabledVal, setEnabledVal] = useState(enabled); const reload = () => window.location.reload(); const title = displayName || name || packageName; - return ( <> {showUploadForm && ( @@ -92,14 +93,16 @@ function PluginInfo(props: IPluginInfo) { )} {enabled ? ( - { - e.stopPropagation(); - navigate(`/admin/settings/${name}`); - }} - > - {t('Setting')} - + app.pluginSettingsManager.has(name) && ( + { + e.stopPropagation(); + navigate(app.pluginSettingsManager.getRoutePath(name)); + }} + > + {t('Settings')} + + ) ) : ( { const { t } = useTranslation(); @@ -27,56 +26,85 @@ export const PluginManagerLink = () => { ); }; -const getBookmarkTabs = _.memoize((data) => { - const bookmarkTabs = []; - data.forEach((plugin) => { - const tabs = plugin.tabs; - tabs.forEach((tab) => { - tab.isBookmark && tab.isAllow && bookmarkTabs.push({ ...tab, path: `${plugin.key}/${tab.key}` }); - }); - }); - return bookmarkTabs; -}); export const SettingsCenterDropdown = () => { - const { snippets = [] } = useACLRoleContext(); const [visible, setVisible] = useState(false); - const { t } = useTranslation(); const compile = useCompile(); - const navigate = useNavigate(); - const itemData = useContext(SettingsCenterContext); + const { t } = useTranslation(); const { token } = useToken(); - const pluginsTabs = getPluginsTabs(itemData, snippets); - const bookmarkTabs = getBookmarkTabs(pluginsTabs); - const menu = useMemo(() => { - return { - items: [ - ...bookmarkTabs.map((tab) => ({ - role: 'button', - key: `/admin/settings/${tab.path}`, - label: compile(tab.title), - })), - { type: 'divider' }, - { - role: 'button', - key: '/admin/settings', - label: t('All plugin settings'), - }, - ], - onClick({ key }) { - navigate(key); - }, - }; - }, [bookmarkTabs]); + const navigate = useNavigate(); + const app = useApp(); + const settings = app.pluginSettingsManager.getList(); + const [open, setOpen] = useState(false); return ( - + { + setOpen(open); + }} + arrow={false} + content={ + + } + >