refactor: plugin settings manager (#2712)

* feat: add settingsCenter

* fix: style bug

* chore: optimized code

* refactor: settingCenter Auth

* feat: add aclSnippet option

* refactor: all plugin's setting center api

* feat: add plugin with name

* docs: add settings-center doc

* fix: settings center menu sort by name

* fix: change setting center layout

* fix: change hello sort

* test: add SettingsCenter.ts test case

* fix: bug

* fix: acl bug

* fix: bug

* fix: bug and 404 page

* fix: test bug

* fix: test bug

* fix: bug

* fix: locale

* fix: styling

* fix: rename settingsCenter to pluginSettingsManager

* fix: styling

* fix: e2e bug

* fix: e2e bug

* fix: locale

* feat: update docs

* fix: update

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
jack zhang 2023-11-13 11:01:18 +08:00 committed by GitHub
parent 454db91827
commit 35b06cbfa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1178 additions and 887 deletions

View File

@ -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

View File

@ -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 是什么

View File

@ -200,7 +200,7 @@ const sidebar = {
],
},
'/development/client/ui-router',
'/development/client/settings-center',
'/development/client/plugin-settings',
'/development/client/i18n',
'/development/client/test',
],

View File

@ -0,0 +1,75 @@
# Plugin Settings Manager
<img src="./plugin-settings/settings-tab.jpg" style="max-width: 100%;"/>
## Example
### Basic Usage
```tsx | pure
import { Plugin } from '@nocobase/client';
import React from 'react';
const HelloSettingPage = () => <div>Hello Setting page</div>;
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 = () => <div> <div>This</div> public part, the following is the outlet of the sub-route: <div><Outlet /></div></div>;
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: () => <div>Demo1 Page Content</div>
})
this.app.pluginSettingsManager.add('hello.demo2', {
title: 'Demo2 Page',
Component: () => <div>Demo2 Page Content</div>
})
}
}
```
### 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.

View File

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -1,33 +0,0 @@
# Settings Center
<img src="./settings-center/settings-tab.jpg" style="max-width: 100%;"/>
## Example
```tsx | pure
import { SettingsCenterProvider } from '@nocobase/client';
import React, { useContext } from 'react';
const HelloTab => () => <div>Hello Tab</div>;
export default React.memo((props) => {
return (
<SettingsCenterProvider
settings={{
'sample-hello': {
title: 'Hello',
icon: 'ApiOutlined',
tabs: {
tab1: {
title: 'Hello Tab',
component: HelloTab,
},
},
},
}}
>{props.children}</SettingsCenterProvider>
);
});
```
See [samples/hello](https://github.com/nocobase/nocobase/tree/develop/packages/samples/hello) for full examples.

View File

@ -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

View File

@ -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

View File

@ -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
<img src="./v08-changelog/topright.jpg" style="max-width: 500px;" />

View File

@ -0,0 +1,75 @@
# 配置中心
<img src="./plugin-settings/settings-tab.jpg" style="max-width: 100%;"/>
## 示例
### 基础用法
```tsx | pure
import { Plugin } from '@nocobase/client';
import React from 'react';
const HelloSettingPage = () => <div>Hello Setting page</div>;
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 = () => <div>公共部分,下面是子路由的出口: <div><Outlet /></div></div>;
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: () => <div>Demo1 Page Content</div>
})
this.app.pluginSettingsManager.add('hello.demo2', {
title: 'Demo2 Page',
Component: () => <div>Demo2 Page Content</div>
})
}
}
```
### 获取路由路径
如果想获取设置页面的跳转链接,可以通过 `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)。

View File

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

View File

@ -1,33 +0,0 @@
# 配置中心
<img src="./settings-center/settings-tab.jpg" style="max-width: 100%;"/>
## 示例
```tsx | pure
import { SettingsCenterProvider } from '@nocobase/client';
import React, { useContext } from 'react';
const HelloTab => () => <div>Hello Tab</div>;
export default React.memo((props) => {
return (
<SettingsCenterProvider
settings={{
'sample-hello': {
title: 'Hello',
icon: 'ApiOutlined',
tabs: {
tab1: {
title: 'Hello Tab',
component: HelloTab,
},
},
},
}}
>{props.children}</SettingsCenterProvider>
);
});
```
完整示例查看 [samples/hello](https://github.com/nocobase/nocobase/tree/develop/packages/samples/hello)。

View File

@ -57,6 +57,6 @@ yarn pm remove hello
- Client
- UI Schema Designer页面设计器
- UI Router有自定义页面需求时
- Settings Center为插件提供配置页面
- Plugin Settings Manager为插件提供配置页面
- I18n客户端国际化

View File

@ -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

View File

@ -46,7 +46,7 @@ export default defineConfig({
link: '/apis/api-client',
},
{
title: 'SettingsCenter',
title: 'PluginSettingsManager',
link: '#',
},
],

View File

@ -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();

View File

@ -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秒钟
//等待弹窗消失和页面刷新结束

View File

@ -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<any>({});
@ -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 || []);
},
},
);

View File

@ -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 <SettingMenuContext.Provider value={configureItems}>{props.children}</SettingMenuContext.Provider>;
};
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;
}>({
const settings = app.pluginSettingsManager.getList(false);
const allAclSnippets = app.pluginSettingsManager.getAclSnippets();
const [snippets, setSnippets] = useState<string[]>([]);
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,24 +83,18 @@ export const SettingsCenterConfigure = () => {
}
message.success(t('Saved successfully'));
};
return (
items?.length && (
<Table
className={styles}
loading={loading}
rowKey={'key'}
pagination={false}
expandable={{
defaultExpandAllRows: true,
}}
columns={[
{
dataIndex: 'title',
title: t('Plugin tab name'),
render: (value) => {
return compile(value);
},
},
{
dataIndex: 'pluginTitle',
title: t('Plugin name'),
render: (value) => {
return compile(value);
@ -129,15 +102,35 @@ export const SettingsCenterConfigure = () => {
},
{
dataIndex: 'accessible',
title: t('Accessible'),
title: (
<>
<Checkbox
checked={allChecked}
onChange={async () => {
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 = !data?.data?.includes('!' + record.key);
return !record.children && <Checkbox checked={checked} onChange={() => handleChange(checked, record)} />;
const checked = !snippets.includes('!' + record.aclSnippet);
return <Checkbox checked={checked} onChange={() => handleChange(checked, record)} />;
},
},
]}
dataSource={items}
dataSource={settings}
/>
)
);
};

View File

@ -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<string, ComponentType> = { ...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';

View File

@ -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<string, PluginSettingsManagerSettingOptionsType> = {};
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));
}
}

View File

@ -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 });

View File

@ -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": <AppNotFound />,
"path": "*",
}
`);
});
});

View File

@ -9,8 +9,11 @@ const AppError: FC<{ error: Error }> = ({ error }) => (
</div>
);
const AppNotFound: FC = () => <div>Not Found</div>;
export const defaultAppComponents = {
AppMain: MainComponent,
AppSpin: Loading,
AppError: AppError,
AppNotFound: AppNotFound,
};

View File

@ -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 = () => <div>Hello Setting page</div>;
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 = () => <div>公共部分,下面是子路由的出口: <div><Outlet /></div></div>;
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: () => <div>Demo1 Page Content</div>
})
this.app.pluginSettingsManager.add('hello.demo2', {
title: 'Demo2 Page',
Component: () => <div>Demo2 Page Content</div>
})
}
}
```
#### 获取路由路径
如果想获取设置页面的跳转链接,可以通过 `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

View File

@ -3,3 +3,4 @@ export * from './hooks';
export * from './Plugin';
export * from './RouterManager';
export * from './utils';
export * from './PluginSettingsManager';

View File

@ -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",

View File

@ -42,6 +42,7 @@ export default {
'System settings': '系统设置',
'System title': '系统名称',
Setting: '设置',
Settings: '设置',
Enable: '启用',
Disable: '禁用',
On: '启用',

View File

@ -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 (
<Result
status="404"
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Button onClick={() => navigate('/', { replace: true })} type="primary">
Back Home
</Button>
}
/>
);
};
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: <Navigate replace to="/admin" />,
});
this.router.add('not-found', {
path: '*',
Component: AppNotFound,
});
this.router.add('admin', {
path: '/admin',
Component: 'AdminLayout',

View File

@ -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) {
</a>
)}
{enabled ? (
app.pluginSettingsManager.has(name) && (
<a
onClick={(e) => {
e.stopPropagation();
navigate(`/admin/settings/${name}`);
navigate(app.pluginSettingsManager.getRoutePath(name));
}}
>
<SettingOutlined /> {t('Setting')}
<SettingOutlined /> {t('Settings')}
</a>
)
) : (
<Popconfirm
key={'delete'}

View File

@ -1,13 +1,12 @@
import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
import { Button, Dropdown, MenuProps, Tooltip } from 'antd';
import _ from 'lodash';
import React, { useContext, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import { Button, Card, Popover, Tooltip } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useACLRoleContext } from '../acl/ACLProvider';
import { useApp } from '../application';
import { ActionContextProvider, useCompile } from '../schema-component';
import { useToken } from '../style';
import { SettingsCenterContext, getPluginsTabs } from './index';
export const PluginManagerLink = () => {
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<MenuProps>(() => {
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 (
<ActionContextProvider value={{ visible, setVisible }}>
<Dropdown placement="bottom" menu={menu}>
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
arrow={false}
content={
<div style={{ maxWidth: '21rem' }}>
<Card
bordered={false}
className={css`
box-shadow: none;
`}
style={{ boxShadow: 'none' }}
>
{settings.map((setting) => (
<Card.Grid
className={css`
cursor: pointer;
padding: 0 !important;
box-shadow: none !important;
&:hover {
border-radius: ${token.borderRadius}px;
background: rgba(0, 0, 0, 0.045);
}
`}
key={setting.pluginName}
>
<a
role="button"
aria-label={setting.name}
onClick={(e) => {
e.preventDefault();
setOpen(false);
navigate(setting.path);
}}
title={compile(setting.title)}
style={{ display: 'block', color: 'inherit', padding: token.marginSM }}
href={setting.path}
>
<div style={{ fontSize: '1.2rem', textAlign: 'center', marginBottom: '0.3rem' }}>
{setting.icon || <SettingOutlined />}
</div>
<div
style={{
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: token.fontSizeSM,
}}
>
{compile(setting.title)}
</div>
</a>
</Card.Grid>
))}
</Card>
</div>
}
>
<Button
data-testid="settings-center-button"
data-testid="plugin-settings-button"
icon={<SettingOutlined style={{ color: token.colorTextHeaderMenu }} />}
// title={t('All plugin settings')}
/>
</Dropdown>
</Popover>
</ActionContextProvider>
);
};

View File

@ -1,135 +1,97 @@
export * from './PluginManagerLink';
import { PageHeader } from '@ant-design/pro-layout';
import { css } from '@emotion/css';
import { Layout, Menu, Result, Tabs } from 'antd';
import _, { sortBy } from 'lodash';
import React, { createContext, useContext, useMemo } from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { useACLRoleContext } from '../acl/ACLProvider';
import { ACLPane } from '../acl/ACLShortcut';
import { CollectionManagerPane } from '../collection-manager';
import { Icon } from '../icon';
import { useCompile } from '../schema-component';
import { BlockTemplatesPane } from '../schema-templates';
import { SystemSettingsPane } from '../system-settings';
import { Layout, Menu, Result } from 'antd';
import _, { get } from 'lodash';
import React, { createContext, useCallback, useMemo } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useStyles } from './style';
import { ADMIN_SETTINGS_PATH, PluginSettingsPageType, useApp } from '../application';
import { useCompile } from '../schema-component';
export const SettingsCenterContext = createContext<any>({});
export const settings = {
acl: {
title: '{{t("ACL")}}',
icon: 'LockOutlined',
tabs: {
roles: {
isBookmark: true,
title: '{{t("Roles & Permissions")}}',
component: () => <ACLPane />,
},
},
},
'ui-schema-storage': {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
tabs: {
'block-templates': {
title: '{{t("Block templates")}}',
component: BlockTemplatesPane,
},
},
},
'collection-manager': {
icon: 'DatabaseOutlined',
title: '{{t("Collection manager")}}',
tabs: {
collections: {
isBookmark: true,
title: '{{t("Collections & Fields")}}',
component: CollectionManagerPane,
},
},
},
'system-settings': {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',
tabs: {
'system-settings': {
isBookmark: true,
title: '{{t("System settings")}}',
component: SystemSettingsPane,
},
},
},
};
export const getPluginsTabs = _.memoize((items, snippets) => {
const pluginsTabs = Object.keys(items).map((plugin) => {
const tabsObj = items[plugin].tabs;
const tabs = sortBy(
Object.keys(tabsObj).map((tab) => {
function getMenuItems(list: PluginSettingsPageType[]) {
return list.map((item) => {
return {
key: tab,
...tabsObj[tab],
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
};
}),
(o) => !o.isAllow,
);
return {
...items[plugin],
key: plugin,
tabs,
isAllow: !tabs.every((v) => !v.isAllow),
key: item.name,
label: item.label,
title: item.title,
icon: item.icon,
children: item.children?.length ? getMenuItems(item.children) : undefined,
};
});
return sortBy(pluginsTabs, (o) => !o.isAllow);
});
}
export const SettingsCenter = () => {
const { styles } = useStyles();
const { snippets = [] } = useACLRoleContext();
const params = useParams<any>();
export const SettingsCenterComponent = () => {
const { styles, theme } = useStyles();
const app = useApp();
const navigate = useNavigate();
const items = useContext(SettingsCenterContext);
const pluginsTabs = getPluginsTabs(items, snippets);
const location = useLocation();
const compile = useCompile();
const firstUri = useMemo(() => {
const pluginName = pluginsTabs[0].key;
const tabName = pluginsTabs[0].tabs[0].key;
return `/admin/settings/${pluginName}/${tabName}`;
}, [pluginsTabs]);
const { pluginName, tabName } = params;
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
if (!pluginName) {
return <Navigate replace to={firstUri} />;
const settings = useMemo(() => {
const list = app.pluginSettingsManager.getList();
// compile title
function traverse(settings: PluginSettingsPageType[]) {
settings.forEach((item) => {
item.title = compile(item.title);
item.label = compile(item.title);
if (item.children?.length) {
traverse(item.children);
}
if (!items[pluginName]) {
return <Navigate replace to={firstUri} />;
}
if (!tabName) {
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
return <Navigate replace to={`/admin/settings/${pluginName}/${firstTabName}`} />;
}
const component = items[pluginName]?.tabs?.[tabName]?.component;
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
const menuItems: any = pluginsTabs
.filter((plugin) => plugin.isAllow)
.map((plugin) => {
return {
label: compile(plugin.title),
key: plugin.key,
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
};
});
}
traverse(list);
return list;
}, [app.pluginSettingsManager, compile]);
const getFirstDeepChildPath = useCallback((settings: PluginSettingsPageType[]) => {
if (!settings || !settings.length) {
return '/admin';
}
const first = settings[0];
if (first.children?.length) {
return getFirstDeepChildPath(first.children);
}
return first.path;
}, []);
const settingsMapByPath = useMemo<Record<string, PluginSettingsPageType>>(() => {
const map = {};
const traverse = (settings: PluginSettingsPageType[]) => {
settings.forEach((item) => {
map[item.path] = item;
if (item.children?.length) {
traverse(item.children);
}
});
};
traverse(settings);
return map;
}, [settings]);
const currentSetting = useMemo(() => settingsMapByPath[location.pathname], [location.pathname, settingsMapByPath]);
const currentPlugin = useMemo(() => {
if (!currentSetting) {
return null;
}
return settings.find((item) => item.name === currentSetting.pluginName);
}, [currentSetting, settings]);
const sidebarMenus = useMemo(() => {
return getMenuItems(settings.map((item) => ({ ...item, children: null })));
}, [settings]);
if (!currentSetting || location.pathname === ADMIN_SETTINGS_PATH || location.pathname === ADMIN_SETTINGS_PATH + '/') {
return <Navigate replace to={getFirstDeepChildPath(settings)} />;
}
if (location.pathname === currentPlugin.path && currentPlugin.children?.length > 0) {
return <Navigate replace to={getFirstDeepChildPath(currentPlugin.children)} />;
}
return (
<div>
<Layout>
<Layout.Sider
className={css`
height: 100%;
/* position: fixed;
padding-top: 46px; */
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
@ -144,44 +106,46 @@ export const SettingsCenter = () => {
theme={'light'}
>
<Menu
selectedKeys={[pluginName]}
selectedKeys={[currentSetting?.pluginName]}
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
onClick={(e) => {
const item = items[e.key];
const tabKey = Object.keys(item.tabs).shift();
navigate(`/admin/settings/${e.key}/${tabKey}`);
onClick={({ key }) => {
const plugin = settings.find((item) => item.name === key);
if (plugin.children?.length) {
return navigate(getFirstDeepChildPath(plugin.children));
} else {
return navigate(plugin.path);
}
}}
items={menuItems as any}
items={sidebarMenus}
/>
</Layout.Sider>
<Layout.Content>
{aclPluginTabCheck && (
{currentSetting && (
<PageHeader
className={styles.pageHeader}
ghost={false}
title={compile(items[pluginName]?.title)}
footer={
<Tabs
activeKey={tabName}
onChange={(activeKey) => {
navigate(`/admin/settings/${pluginName}/${activeKey}`);
style={{
paddingBottom: currentPlugin.children?.length > 0 ? 0 : theme.paddingSM,
}}
items={plugin.tabs?.map((tab) => {
if (!tab.isAllow) {
return null;
}
return {
label: compile(tab?.title),
key: tab.key,
};
})}
/>
ghost={false}
title={currentPlugin.title}
footer={
currentPlugin.children?.length > 0 && (
<Menu
style={{ marginLeft: -theme.margin }}
onClick={({ key }) => {
navigate(app.pluginSettingsManager.getRoutePath(key));
}}
selectedKeys={[currentSetting?.name]}
mode="horizontal"
items={getMenuItems(currentPlugin.children)}
></Menu>
)
}
/>
)}
<div className={styles.pageContent}>
{aclPluginTabCheck ? (
component && React.createElement(component)
{currentSetting ? (
<Outlet />
) : (
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
)}
@ -191,15 +155,3 @@ export const SettingsCenter = () => {
</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) => {
return <SettingsCenterProvider settings={settings}>{props.children}</SettingsCenterProvider>;
};

View File

@ -1,8 +1,14 @@
import React from 'react';
import { Plugin } from '../application/Plugin';
import { PluginManagerLink, SettingsCenterDropdown } from './PluginManagerLink';
import { PMProvider, SettingsCenter } from './PluginSetting';
import { SettingsCenterComponent } from './PluginSetting';
import { PluginManager } from './PluginManager';
import { ACLPane } from '../acl/ACLShortcut';
import { CollectionManagerPane } from '../collection-manager';
import { BlockTemplatesPane } from '../schema-templates';
import { SystemSettingsPane } from '../system-settings';
import { ADMIN_SETTINGS_PATH } from '../application';
import { Outlet } from 'react-router-dom';
export * from './PluginManagerLink';
export * from './PluginSetting';
@ -12,7 +18,43 @@ export class PMPlugin extends Plugin {
async load() {
this.addComponents();
this.addRoutes();
this.app.use(PMProvider);
this.addSettings();
}
addSettings() {
this.app.pluginSettingsManager.add('acl', {
title: '{{t("ACL")}}',
icon: 'LockOutlined',
Component: ACLPane,
aclSnippet: 'pm.acl.roles',
isBookmark: true,
});
this.app.pluginSettingsManager.add('ui-schema-storage', {
title: '{{t("Block templates")}}',
icon: 'LayoutOutlined',
Component: BlockTemplatesPane,
isBookmark: true,
aclSnippet: 'pm.ui-schema-storage.block-templates',
});
this.app.pluginSettingsManager.add('system-settings', {
icon: 'SettingOutlined',
title: '{{t("System settings")}}',
Component: SystemSettingsPane,
isBookmark: true,
aclSnippet: 'pm.system-settings.system-settings',
});
this.app.pluginSettingsManager.add('collection-manager', {
icon: 'DatabaseOutlined',
title: '{{t("Collection manager")}}',
Component: () => <Outlet />,
isBookmark: true,
});
this.app.pluginSettingsManager.add('collection-manager.collections', {
title: '{{t("Collections & Fields")}}',
Component: CollectionManagerPane,
});
}
addComponents() {
@ -36,17 +78,9 @@ export class PMPlugin extends Plugin {
element: <PluginManager />,
});
this.app.router.add('admin.settings.list', {
path: '/admin/settings',
element: <SettingsCenter />,
});
this.app.router.add('admin.settings.pluginName', {
path: '/admin/settings/:pluginName',
element: <SettingsCenter />,
});
this.app.router.add('admin.settings.pluginName-tabName', {
path: '/admin/settings/:pluginName/:tabName',
element: <SettingsCenter />,
this.app.router.add('admin.settings', {
path: ADMIN_SETTINGS_PATH,
element: <SettingsCenterComponent />,
});
}
}

View File

@ -7,9 +7,9 @@ export const useStyles = createStyles(({ token }) => {
cursor: 'not-allowed',
},
pageHeader: {
paddingBottom: 0,
backgroundColor: token.colorBgContainer,
paddingTop: token.paddingSM,
paddingBottom: 0,
paddingInline: token.paddingLG,
'.ant-page-header-footer': { marginBlockStart: '0' },
'& .ant-tabs-nav': {

View File

@ -25,7 +25,7 @@ export const ColorSelect = connect(
return (
<Select {...props}>
{Object.keys(colors).map((color) => (
<Select.Option value={color}>
<Select.Option key={color} value={color}>
<Tag color={color}>{compile(colors[color] || colors.default)}</Tag>
</Select.Option>
))}

View File

@ -50,18 +50,18 @@ export const Sortable = (props: any) => {
const useSortableItemProps = (props) => {
const id = useSortableItemId(props);
const schema = useFieldSchema();
if (props.schema) {
return { ...props, id };
}
const schema = useFieldSchema();
return { ...props, id, schema };
};
const useSortableItemId = (props) => {
const field = useField();
if (props.id) {
return props.id;
}
const field = useField();
return field.address.toString();
};

View File

@ -1,9 +1,9 @@
import { RightOutlined } from '@ant-design/icons';
import { Plugin, SettingsCenterProvider } from '@nocobase/client';
import { Plugin } from '@nocobase/client';
import { Button, Tooltip } from 'antd';
import { createStyles } from 'antd-style';
import React, { lazy } from 'react';
import { useTranslation } from '../locale';
import { NAMESPACE } from '../locale';
const DOCUMENTATION_PATH = '/api-documentation';
const Documentation = lazy(() => import('./Document'));
@ -37,32 +37,15 @@ const SCDocumentation = () => {
);
};
const APIDocumentationProvider = React.memo((props) => {
const { t } = useTranslation();
return (
<SettingsCenterProvider
settings={{
['api-doc']: {
title: t('API documentation'),
icon: 'BookOutlined',
tabs: {
documentation: {
title: t('Documentation'),
component: SCDocumentation,
},
},
},
}}
>
{props.children}
</SettingsCenterProvider>
);
});
APIDocumentationProvider.displayName = 'APIDocumentationProvider';
export class APIDocumentationPlugin extends Plugin {
async load() {
this.app.use(APIDocumentationProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("API documentation", { ns: "${NAMESPACE}" })}}`,
icon: 'BookOutlined',
Component: SCDocumentation,
aclSnippet: 'pm.api-doc.documentation',
});
this.app.router.add('api-documentation', {
path: DOCUMENTATION_PATH,
Component: Documentation,

View File

@ -1,5 +1,5 @@
export default {
'API documentation': 'Api 文档',
'API documentation': 'API 文档',
'Documentation': '文档',
'Select a definition': '选择端点',
};

View File

@ -1,34 +1,15 @@
import { Plugin, SchemaComponentOptions, SettingsCenterProvider } from '@nocobase/client';
import React from 'react';
import { Plugin } from '@nocobase/client';
import { NAMESPACE } from '../constants';
import { Configuration } from './Configuration';
import { useTranslation } from './locale';
const ApiKeysProvider = React.memo((props) => {
const { t } = useTranslation();
return (
<SettingsCenterProvider
settings={{
['api-keys']: {
title: t('API keys'),
icon: 'EnvironmentOutlined',
tabs: {
configuration: {
title: t('Keys manager'),
component: Configuration,
},
},
},
}}
>
<SchemaComponentOptions components={{}}>{props.children}</SchemaComponentOptions>
</SettingsCenterProvider>
);
});
ApiKeysProvider.displayName = 'ApiKeysProvider';
class APIKeysPlugin extends Plugin {
async load() {
this.app.addProvider(ApiKeysProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
icon: 'KeyOutlined',
title: '{{t("API keys", {"ns": "api-keys"})}}',
Component: Configuration,
aclSnippet: 'pm.api-keys.configuration',
});
}
}

View File

@ -5,7 +5,7 @@ export default {
namespace: 'api-keys',
duplicator: 'optional',
name: 'apiKeys',
title: '{{t("API keys")}}',
title: '{{t("API keys", {"ns": "api-keys"})}}',
sortable: 'sort',
model: 'ApiKeyModel',
createdBy: true,

View File

@ -1,14 +1,15 @@
const locale = {
'API key created successfully': 'API key 创建成功',
'API keys': 'API 密钥',
'API key created successfully': 'API 密钥创建成功',
'Make sure to copy your personal access key now as you will not be able to see this again.':
'请确保现在复制你的个人访问密钥,因为你将无法再次看到这个密钥。',
'Key name': '密钥名称',
Expiration: '过期时间',
'Delete API key': '删除 API key',
'Delete API key': '删除 API 密钥',
Role: '角色',
'Keys manager': '密钥管理',
'Created at': '创建时间',
'Add API key': '添加 API key',
'Add API key': '添加 API 密钥',
Never: '永不',
Custom: '自定义',
'Never expires': '永不过期',

View File

@ -1,34 +1,14 @@
import {
OptionsComponentProvider,
SettingsCenterProvider,
SigninPageProvider,
SignupPageProvider,
} from '@nocobase/client';
import { OptionsComponentProvider, SigninPageProvider, SignupPageProvider } from '@nocobase/client';
import React, { FC } from 'react';
import { Authenticator } from './settings/Authenticator';
import SigninPage from './basic/SigninPage';
import { presetAuthType } from '../preset';
import { Options } from './basic/Options';
import SigninPage from './basic/SigninPage';
import SignupPage from './basic/SignupPage';
import { useAuthTranslation } from './locale';
import { Options } from './basic/Options';
export const AuthPluginProvider: FC = (props) => {
const { t } = useAuthTranslation();
return (
<SettingsCenterProvider
settings={{
auth: {
title: t('Authentication'),
icon: 'LoginOutlined',
tabs: {
authenticators: {
title: t('Authenticators'),
component: () => <Authenticator />,
},
},
},
}}
>
<OptionsComponentProvider authType={presetAuthType} component={Options}>
<SigninPageProvider authType={presetAuthType} tabTitle={t('Sign in via password')} component={SigninPage}>
<SignupPageProvider authType={presetAuthType} component={SignupPage}>
@ -36,6 +16,5 @@ export const AuthPluginProvider: FC = (props) => {
</SignupPageProvider>
</SigninPageProvider>
</OptionsComponentProvider>
</SettingsCenterProvider>
);
};

View File

@ -1,9 +1,17 @@
import { Plugin } from '@nocobase/client';
import { AuthPluginProvider } from './AuthPluginProvider';
import { AuthProvider } from './AuthProvider';
import { NAMESPACE } from './locale';
import { Authenticator } from './settings/Authenticator';
export class AuthPlugin extends Plugin {
async load() {
this.app.pluginSettingsManager.add(NAMESPACE, {
icon: 'LoginOutlined',
title: `{{t("Authentication", { ns: "${NAMESPACE}" })}}`,
Component: Authenticator,
aclSnippet: 'pm.auth.authenticators',
});
this.app.providers.unshift([AuthProvider, {}]);
this.app.use(AuthPluginProvider);
}

View File

@ -4,7 +4,6 @@ import {
Plugin,
SchemaComponentOptions,
SchemaInitializerContext,
SettingsCenterProvider,
useAPIClient,
} from '@nocobase/client';
import JSON5 from 'json5';
@ -13,7 +12,7 @@ import { ChartBlockEngine } from './ChartBlockEngine';
import { ChartBlockInitializer } from './ChartBlockInitializer';
import { ChartQueryMetadataProvider } from './ChartQueryMetadataProvider';
import './Icons';
import { lang } from './locale';
import { lang, NAMESPACE } from './locale';
import { CustomSelect } from './select';
import { QueriesTable } from './settings/QueriesTable';
@ -53,7 +52,7 @@ const ChartsProvider = React.memo((props) => {
key: 'chart',
type: 'item',
icon: 'PieChartOutlined',
title: '{{t("Chart (Old)",{ns:"charts"})}}',
title: `{{t("Chart (Old)", { ns: "${NAMESPACE}" })}}`,
component: 'ChartBlockInitializer',
});
}
@ -78,27 +77,12 @@ const ChartsProvider = React.memo((props) => {
};
return (
<ChartQueryMetadataProvider>
<SettingsCenterProvider
settings={{
charts: {
title: '{{t("Charts", {ns:"charts"})}}',
icon: 'PieChartOutlined',
tabs: {
queries: {
title: '{{t("Queries", {ns:"charts"})}}',
component: QueriesTable,
},
},
},
}}
>
<SchemaComponentOptions
scope={{ validateSQL }}
components={{ CustomSelect, ChartBlockInitializer, ChartBlockEngine }}
>
<SchemaInitializerContext.Provider value={items}>{props.children}</SchemaInitializerContext.Provider>
</SchemaComponentOptions>
</SettingsCenterProvider>
</ChartQueryMetadataProvider>
);
});
@ -110,6 +94,12 @@ export class ChartsPlugin extends Plugin {
}
async load() {
this.app.use(ChartsProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Charts", { ns: "${NAMESPACE}" })}}`,
icon: 'PieChartOutlined',
Component: QueriesTable,
aclSnippet: 'pm.charts.queries',
});
}
}

View File

@ -1,36 +0,0 @@
import { SettingsCenterProvider } from '@nocobase/client';
import { Card } from 'antd';
import React, { FC } from 'react';
const DuplicatorPanel = () => {
return (
<Card bordered={false}>
<div>hello world</div>
</Card>
);
};
export const DuplicatorProvider: FC = function (props) {
return (
<SettingsCenterProvider
settings={
{
// duplicator: {
// title: '应用导入导出',
// icon: 'CloudDownloadOutlined',
// tabs: {
// tab1: {
// title: '应用导入导出',
// component: DuplicatorPanel,
// },
// },
// },
}
}
>
{props.children}
</SettingsCenterProvider>
);
};
DuplicatorProvider.displayName = 'DuplicatorProvider';

View File

@ -1,10 +1,7 @@
import { Plugin } from '@nocobase/client';
import { DuplicatorProvider } from './DuplicatorProvider';
export class DuplicatorPlugin extends Plugin {
async load() {
this.app.use(DuplicatorProvider);
}
async load() {}
}
export default DuplicatorPlugin;

View File

@ -6,16 +6,13 @@ import {
SchemaComponentOptions,
SchemaInitializerContext,
SchemaInitializerProvider,
SettingsCenterProvider,
useCollection,
} from '@nocobase/client';
import { forEach } from '@nocobase/utils/client';
import React, { FC, useContext } from 'react';
import { FileStoragePane } from './FileStorage';
import * as hooks from './hooks';
import * as initializers from './initializers';
import { attachment } from './interfaces/attachment';
import { NAMESPACE } from './locale';
import * as templates from './templates';
// 注册之后就可以在 Crete collection 按钮中选择创建了
@ -52,20 +49,6 @@ export const FileManagerProvider: FC = (props) => {
const ctx = useContext(PluginManagerContext);
return (
<SettingsCenterProvider
settings={{
'file-manager': {
title: `{{t("File manager", { ns: "${NAMESPACE}" })}}`,
icon: 'FileOutlined',
tabs: {
storages: {
title: `{{t("File storage", { ns: "${NAMESPACE}" })}}`,
component: FileStoragePane,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
@ -79,6 +62,5 @@ export const FileManagerProvider: FC = (props) => {
</SchemaComponentOptions>
</CollectionManagerProvider>
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);
};

View File

@ -12,7 +12,7 @@ test.describe('file manager', () => {
// 1、前置条件已登录
// 2、测试步骤进入“文件管理器”-“新建”按钮,填写表单,点击“确定”按钮
await page.goto('/admin/settings/file-manager/storages');
await page.goto('/admin/settings/file-manager');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'plus Add new' }).hover();
await page.getByRole('menuitem', { name: 'Local storage' }).click();

View File

@ -10,7 +10,7 @@ test.describe('File manager', () => {
let caseTitle = 'edit local storage title';
// 1、前置条件1.1已登录;1.2存在一个文件管理器
await page.goto('/admin/settings/file-manager/storages');
await page.goto('/admin/settings/file-manager');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'plus Add new' }).hover();
await page.getByRole('menuitem', { name: 'Local storage' }).click();

View File

@ -1,5 +1,7 @@
import { Plugin } from '@nocobase/client';
import { FileManagerProvider } from './FileManagerProvider';
import { FileStoragePane } from './FileStorage';
import { NAMESPACE } from './locale';
import { storageTypes } from './schemas/storageTypes';
export class FileManagerPlugin extends Plugin {
@ -7,6 +9,12 @@ export class FileManagerPlugin extends Plugin {
async load() {
this.app.use(FileManagerProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("File manager", { ns: "${NAMESPACE}" })}}`,
icon: 'FileOutlined',
Component: FileStoragePane,
aclSnippet: 'pm.file-manager.storages',
});
Object.values(storageTypes).forEach((storageType) => {
this.registerStorageType(storageType.name, storageType);
});

View File

@ -1,20 +1,10 @@
import { PluginManagerContext, SettingsCenterContext, SettingsCenterProvider } from '@nocobase/client';
import { PluginManagerContext } from '@nocobase/client';
import React, { useContext } from 'react';
import { GraphCollectionPane } from './GraphCollectionShortcut';
import { useGCMTranslation } from './utils';
export const GraphCollectionProvider = React.memo((props) => {
const ctx = useContext(PluginManagerContext);
const { t } = useGCMTranslation();
const items = useContext(SettingsCenterContext);
items['collection-manager']['tabs']['graph'] = {
title: t('Graphical interface'),
component: GraphCollectionPane,
};
return (
<SettingsCenterProvider settings={items}>
<PluginManagerContext.Provider
value={{
components: {
@ -24,7 +14,6 @@ export const GraphCollectionProvider = React.memo((props) => {
>
{props.children}
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);
});
GraphCollectionProvider.displayName = 'GraphCollectionProvider';

View File

@ -1,9 +1,17 @@
import { Plugin } from '@nocobase/client';
import { GraphCollectionProvider } from './GraphCollectionProvider';
import { GraphCollectionPane } from './GraphCollectionShortcut';
import { NAMESPACE } from './locale';
export class GraphCollectionPlugin extends Plugin {
async load() {
this.app.use(GraphCollectionProvider);
this.app.pluginSettingsManager.add('collection-manager.graph', {
title: `{{t("Graphical interface", { ns: "${NAMESPACE}" })}}`,
Component: GraphCollectionPane,
aclSnippet: 'pm.collection-manager.graph',
});
}
}

View File

@ -1,3 +1 @@
// export { default as enUS } from './en-US';
// export { default as zhCN } from './zh-CN';
// export { default as jaJP } from './ja-JP';
export const NAMESPACE = 'graph-collection-manager';

View File

@ -1,30 +1,14 @@
import { Plugin, SettingsCenterProvider } from '@nocobase/client';
import React from 'react';
import { Plugin } from '@nocobase/client';
import { Localization } from './Localization';
import { useLocalTranslation } from './locale';
import { NAMESPACE } from './locale';
export class LocalizationManagementPlugin extends Plugin {
async load() {
this.app.use((props) => {
const { t } = useLocalTranslation();
return (
<SettingsCenterProvider
settings={{
['localization-management']: {
title: t('Localization management'),
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Localization management", { ns: "${NAMESPACE}" })}}`,
icon: 'GlobalOutlined',
tabs: {
localization: {
title: t('Translations'),
component: () => <Localization />,
},
},
},
}}
>
{props.children}
</SettingsCenterProvider>
);
Component: Localization,
aclSnippet: 'pm.localization-management.localization',
});
}
}

View File

@ -1,8 +1,8 @@
import AMapLoader from '@amap/amap-jsapi-loader';
import '@amap/amap-jsapi-types';
import { SyncOutlined } from '@ant-design/icons';
import { useField, useFieldSchema } from '@formily/react';
import { css, useCollection } from '@nocobase/client';
import { useFieldSchema } from '@formily/react';
import { css, useApp, useCollection } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import { Alert, App, Button, Spin } from 'antd';
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
@ -373,11 +373,13 @@ export const AMapComponent = React.forwardRef<AMapForwardedRefProps, AMapCompone
errMessage,
}));
const app = useApp();
if (!accessKey || errMessage) {
return (
<Alert
action={
<Button type="primary" onClick={() => navigate('/admin/settings/map/configuration')}>
<Button type="primary" onClick={() => navigate(app.pluginSettingsManager.getRoutePath('map'))}>
{t('Go to the configuration page')}
</Button>
}

View File

@ -1,7 +1,7 @@
import { SyncOutlined } from '@ant-design/icons';
import { useFieldSchema } from '@formily/react';
import { Loader } from '@googlemaps/js-api-loader';
import { css, useAPIClient, useCollection } from '@nocobase/client';
import { css, useAPIClient, useApp, useCollection } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks';
import { Alert, App, Button, Spin } from 'antd';
import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
@ -369,12 +369,16 @@ export const GoogleMapsComponent = React.forwardRef<GoogleMapForwardedRefProps,
},
});
});
const app = useApp();
if (!accessKey || errMessage) {
return (
<Alert
action={
<Button type="primary" onClick={() => navigate('/admin/settings/map/configuration?tab=google')}>
<Button
type="primary"
onClick={() => navigate(app.pluginSettingsManager.getRoutePath('map') + '?tab=google')}
>
{t('Go to the configuration page')}
</Button>
}

View File

@ -1,37 +1,16 @@
import {
CollectionManagerContext,
CurrentAppInfoProvider,
Plugin,
SchemaComponentOptions,
SettingsCenterProvider,
} from '@nocobase/client';
import { CollectionManagerContext, CurrentAppInfoProvider, Plugin, SchemaComponentOptions } from '@nocobase/client';
import React, { useContext } from 'react';
import { MapBlockOptions } from './block';
import { Configuration, Map } from './components';
import { interfaces } from './fields';
import { MapInitializer } from './initialize';
import { useMapTranslation } from './locale';
import { NAMESPACE } from './locale';
const MapProvider = React.memo((props) => {
const ctx = useContext(CollectionManagerContext);
const { t } = useMapTranslation();
return (
<CurrentAppInfoProvider>
<MapInitializer>
<SettingsCenterProvider
settings={{
map: {
title: t('Map Manager'),
icon: 'EnvironmentOutlined',
tabs: {
configuration: {
title: t('Configuration'),
component: Configuration,
},
},
},
}}
>
<SchemaComponentOptions components={{ Map }}>
<MapBlockOptions>
<CollectionManagerContext.Provider value={{ ...ctx, interfaces: { ...ctx.interfaces, ...interfaces } }}>
@ -39,7 +18,6 @@ const MapProvider = React.memo((props) => {
</CollectionManagerContext.Provider>
</MapBlockOptions>
</SchemaComponentOptions>
</SettingsCenterProvider>
</MapInitializer>
</CurrentAppInfoProvider>
);
@ -49,6 +27,12 @@ MapProvider.displayName = 'MapProvider';
export class MapPlugin extends Plugin {
async load() {
this.app.use(MapProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Map Manager", { ns: "${NAMESPACE}" })}}`,
icon: 'EnvironmentOutlined',
Component: Configuration,
aclSnippet: 'pm.map.configuration',
});
}
}

View File

@ -1,12 +1,8 @@
import { SettingsCenterProvider } from '@nocobase/client';
import React, { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AppConfiguration, InterfaceConfiguration } from './configuration';
import { isJSBridge } from './core/bridge';
import { useTranslation } from './locale';
export const MobileClientProvider = React.memo((props) => {
const { t } = useTranslation();
const location = useLocation();
const navigation = useNavigate();
@ -16,26 +12,5 @@ export const MobileClientProvider = React.memo((props) => {
}
}, [location.pathname, navigation]);
return (
<SettingsCenterProvider
settings={{
['mobile-client']: {
title: t('Mobile Client-side'),
icon: 'MobileOutlined',
tabs: {
interface: {
title: t('Interface Configuration'),
component: InterfaceConfiguration,
},
app: {
title: t('App Configuration'),
component: AppConfiguration,
},
},
},
}}
>
{props.children}
</SettingsCenterProvider>
);
return <>{props.children}</>;
});

View File

@ -1,17 +1,38 @@
import { createRouterManager, Plugin, RouterManager, RouteSchemaComponent } from '@nocobase/client';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate, Outlet } from 'react-router-dom';
import { MobileClientProvider } from './MobileClientProvider';
import MApplication from './router/Application';
import { AppConfiguration, InterfaceConfiguration } from './configuration';
import { NAMESPACE } from './locale';
export class MobileClientPlugin extends Plugin {
public mobileRouter: RouterManager;
async load() {
this.setMobileRouter();
this.addRoutes();
this.addSettings();
this.app.use(MobileClientProvider);
}
addSettings() {
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Mobile Client-side", { ns: "${NAMESPACE}" })}}`,
icon: 'MobileOutlined',
Component: () => <Outlet />,
});
this.app.pluginSettingsManager.add(`${NAMESPACE}.interface`, {
title: `{{t("Interface Configuration", { ns: "${NAMESPACE}" })}}`,
Component: InterfaceConfiguration,
sort: 1,
});
this.app.pluginSettingsManager.add(`${NAMESPACE}.app`, {
title: `{{t("App Configuration", { ns: "${NAMESPACE}" })}}`,
Component: AppConfiguration,
sort: 2,
});
}
setMobileRouter() {
const router = createRouterManager({ type: 'hash' });
router.add('root', {

View File

@ -1,14 +1,7 @@
import {
Icon,
PinnedPluginListProvider,
SchemaComponentOptions,
SettingsCenterProvider,
useRequest,
} from '@nocobase/client';
import { Icon, PinnedPluginListProvider, SchemaComponentOptions, useApp, useRequest } from '@nocobase/client';
import { Button, Dropdown } from 'antd';
import React from 'react';
import { Link } from 'react-router-dom';
import { AppManager } from './AppManager';
import { AppNameInput } from './AppNameInput';
import { usePluginUtils } from './utils';
@ -25,6 +18,7 @@ const MultiAppManager = () => {
},
);
const { t } = usePluginUtils();
const app = useApp();
const items = [
...(data?.data || []).map((app) => {
let link = `/apps/${app.name}/admin/`;
@ -42,7 +36,7 @@ const MultiAppManager = () => {
}),
{
key: '.manager',
label: <Link to="/admin/settings/multi-app-manager/applications">{t('Manage applications')}</Link>,
label: <Link to={app.pluginSettingsManager.getRoutePath('multi-app-manager')}>{t('Manage applications')}</Link>,
},
];
return (
@ -58,35 +52,13 @@ const MultiAppManager = () => {
};
export const MultiAppManagerProvider = (props) => {
const { t } = usePluginUtils();
return (
<PinnedPluginListProvider
items={{
am: { order: 201, component: 'MultiAppManager', pin: true },
}}
>
<SchemaComponentOptions components={{ MultiAppManager, AppNameInput }}>
<SettingsCenterProvider
settings={{
'multi-app-manager': {
title: t('Multi-app manager'),
icon: 'AppstoreOutlined',
tabs: {
applications: {
title: t('Applications'),
component: () => <AppManager />,
},
// settings: {
// title: 'Settings',
// component: () => <Settings />,
// },
},
},
}}
>
{props.children}
</SettingsCenterProvider>
</SchemaComponentOptions>
<SchemaComponentOptions components={{ MultiAppManager, AppNameInput }}>{props.children}</SchemaComponentOptions>
</PinnedPluginListProvider>
);
};

View File

@ -1,9 +1,18 @@
import { Plugin } from '@nocobase/client';
import { MultiAppManagerProvider } from './MultiAppManagerProvider';
import { AppManager } from './AppManager';
import { NAMESPACE } from '../locale';
export class MultiAppManagerPlugin extends Plugin {
async load() {
this.app.use(MultiAppManagerProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Multi-app manager", { ns: "${NAMESPACE}" })}}`,
icon: 'AppstoreOutlined',
Component: AppManager,
aclSnippet: 'pm.multi-app-manager.applications',
});
}
}

View File

@ -0,0 +1 @@
export const NAMESPACE = 'multi-app-manager';

View File

@ -1,7 +1,7 @@
{
"name": "@nocobase/plugin-oidc",
"displayName": "OIDC (OpenID Connect) auth - SSO login",
"displayName.zh-CN": "OIDC (OpenID Connect) 认证 - SSO 登录",
"displayName": "OIDC auth - SSO login",
"displayName.zh-CN": "OIDC 认证 - SSO 登录",
"description": "OIDC (OpenID Connect) authentication for NocoBase",
"description.zh-CN": "OIDC (OpenID Connect) authentication for NocoBase",
"version": "0.14.0-alpha.8",

View File

@ -1,11 +1,5 @@
import { TableOutlined } from '@ant-design/icons';
import {
Plugin,
SchemaComponentOptions,
SchemaInitializer,
SchemaInitializerContext,
SettingsCenterProvider,
} from '@nocobase/client';
import { Plugin, SchemaComponentOptions, SchemaInitializer, SchemaInitializerContext, useApp } from '@nocobase/client';
import { Card } from 'antd';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
@ -56,31 +50,30 @@ const HelloProvider = React.memo((props) => {
}
return (
<SettingsCenterProvider
settings={{
'sample-hello': {
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>
);
});
HelloProvider.displayName = 'HelloProvider';
const HelloPluginSettingPage = () => {
return (
<Card bordered={false}>
<div>Hello plugin setting page</div>
</Card>
);
};
class HelloPlugin extends Plugin {
async load() {
this.app.addProvider(HelloProvider);
this.app.pluginSettingsManager.add('sample-hello', {
title: 'Hello',
icon: 'ApiOutlined',
Component: HelloPluginSettingPage,
sort: 100,
});
}
}

View File

@ -1,11 +1,11 @@
import { i18n, Plugin, PluginManagerContext, SettingsCenterProvider } from '@nocobase/client';
import { i18n, Plugin, PluginManagerContext } from '@nocobase/client';
import { Select } from 'antd';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
const ns = '@nocobase/plugin-sample-shop-i18n';
const NAMESPACE = 'sample-shop-i18n';
i18n.addResources('zh-CN', ns, {
i18n.addResources('zh-CN', NAMESPACE, {
Shop: '店铺',
I18n: '国际化',
Pending: '已下单',
@ -23,7 +23,7 @@ const ORDER_STATUS_LIST = [
];
function OrderStatusSelect() {
const { t } = useTranslation(ns);
const { t } = useTranslation(NAMESPACE);
return (
<Select style={{ minWidth: '8em' }}>
@ -40,20 +40,6 @@ const ShopI18nProvider = React.memo((props) => {
const ctx = useContext(PluginManagerContext);
return (
<SettingsCenterProvider
settings={{
workflow: {
icon: 'ShopOutlined',
title: `{{t("Shop", { ns: "${ns}" })}}`,
tabs: {
workflows: {
title: `{{t("I18n", { ns: "${ns}" })}}`,
component: OrderStatusSelect,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
@ -63,13 +49,17 @@ const ShopI18nProvider = React.memo((props) => {
>
{props.children}
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);
});
class ShopI18nPlugin extends Plugin {
async load() {
this.app.addProvider(ShopI18nProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Shop", { ns: "${NAMESPACE}" })}}`,
icon: 'ShopOutlined',
Component: OrderStatusSelect,
});
}
}

View File

@ -1,11 +1,4 @@
import {
Plugin,
SettingsCenterProvider,
createStyles,
defaultTheme,
useCurrentUserSettingsMenu,
useGlobalTheme,
} from '@nocobase/client';
import { Plugin, createStyles, defaultTheme, useCurrentUserSettingsMenu, useGlobalTheme } from '@nocobase/client';
import { ConfigProvider } from 'antd';
import _ from 'lodash';
import React, { useEffect, useMemo } from 'react';
@ -15,7 +8,7 @@ import ThemeList from './components/ThemeList';
import { ThemeListProvider } from './components/ThemeListProvider';
import CustomTheme from './components/theme-editor';
import { useThemeSettings } from './hooks/useThemeSettings';
import { useTranslation } from './locale';
import { NAMESPACE } from './locale';
const useStyles = createStyles(({ css, token }) => {
return {
@ -38,7 +31,6 @@ const useStyles = createStyles(({ css, token }) => {
const CustomThemeProvider = React.memo((props) => {
const { addMenuItem } = useCurrentUserSettingsMenu();
const themeItem = useThemeSettings();
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const { theme, setTheme } = useGlobalTheme();
const { styles } = useStyles();
@ -48,21 +40,6 @@ const CustomThemeProvider = React.memo((props) => {
addMenuItem(themeItem, { before: 'divider_3' });
}, [addMenuItem, themeItem]);
const settings = useMemo(() => {
return {
'theme-editor': {
title: t('Theme editor'),
icon: 'BgColorsOutlined',
tabs: {
themes: {
title: t('Themes'),
component: ThemeList,
},
},
},
};
}, []);
const contentStyle = useMemo(() => {
return open
? { transform: 'rotate(0)', flexGrow: 1, width: 0, height: '100%' }
@ -89,7 +66,7 @@ const CustomThemeProvider = React.memo((props) => {
<ThemeListProvider>
<InitializeTheme>
<ThemeEditorProvider open={open} setOpen={setOpen}>
<SettingsCenterProvider settings={settings}>{editor}</SettingsCenterProvider>
{editor}
</ThemeEditorProvider>
</InitializeTheme>
</ThemeListProvider>
@ -101,6 +78,12 @@ CustomThemeProvider.displayName = 'CustomThemeProvider';
export class ThemeEditorPlugin extends Plugin {
async load() {
this.app.use(CustomThemeProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Theme editor", {ns:"${NAMESPACE}"})}}`,
icon: 'BgColorsOutlined',
Component: ThemeList,
aclSnippet: 'pm.theme-editor.themes',
});
}
}

View File

@ -1,30 +1,11 @@
import React, { FC, useContext } from 'react';
import { PluginManagerContext, SettingsCenterProvider } from '@nocobase/client';
import { NAMESPACE } from './locale';
import { VerificationProviders } from './VerificationProviders';
import { PluginManagerContext } from '@nocobase/client';
export { default as verificationProviderTypes } from './providerTypes';
export const VerificationProvider: FC = (props) => {
const ctx = useContext(PluginManagerContext);
return (
<SettingsCenterProvider
settings={{
verification: {
icon: 'CheckCircleOutlined',
title: `{{t("Verification", { ns: "${NAMESPACE}" })}}`,
tabs: {
providers: {
title: `{{t("Verification providers", { ns: "${NAMESPACE}" })}}`,
component: VerificationProviders,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
@ -34,6 +15,5 @@ export const VerificationProvider: FC = (props) => {
>
{props.children}
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);
};

View File

@ -1,9 +1,17 @@
import { Plugin } from '@nocobase/client';
import { VerificationProvider } from './VerificationProvider';
import { VerificationProviders } from './VerificationProviders';
import { NAMESPACE } from './locale';
export class VerificationPlugin extends Plugin {
async load() {
this.app.use(VerificationProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
icon: 'CheckCircleOutlined',
title: `{{t("Verification", { ns: "${NAMESPACE}" })}}`,
Component: VerificationProviders,
aclSnippet: 'pm.verification.providers',
});
}
}

View File

@ -3,6 +3,7 @@ import {
cx,
SchemaComponent,
useAPIClient,
useApp,
useCompile,
useDocumentTitle,
useResourceActionContext,
@ -20,6 +21,7 @@ import useStyles from './style';
import { linkNodes } from './utils';
import { DownOutlined } from '@ant-design/icons';
import { StatusButton } from './components/StatusButton';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant';
function attachJobs(nodes, jobs: any[] = []): void {
const nodesMap = new Map();
@ -165,7 +167,7 @@ function ExecutionsDropdown(props) {
const onClick = useCallback(
({ key }) => {
if (key != execution.id) {
navigate(`/admin/settings/workflow/executions/${key}`);
navigate(getWorkflowExecutionsPath(key));
}
},
[execution],
@ -208,6 +210,7 @@ export function ExecutionCanvas() {
const { data, loading } = useResourceActionContext();
const { setTitle } = useDocumentTitle();
const [viewJob, setViewJob] = useState(null);
const app = useApp();
useEffect(() => {
const { workflow } = data?.data ?? {};
setTitle?.(`${workflow?.title ? `${workflow.title} - ` : ''}${lang('Execution history')}`);
@ -244,8 +247,8 @@ export function ExecutionCanvas() {
<header>
<Breadcrumb
items={[
{ title: <Link to={`/admin/settings/workflow/workflows`}>{lang('Workflow')}</Link> },
{ title: <Link to={`/admin/settings/workflow/workflows/${workflow.id}`}>{workflow.title}</Link> },
{ title: <Link to={app.pluginSettingsManager.getRoutePath('workflow')}>{lang('Workflow')}</Link> },
{ title: <Link to={getWorkflowDetailPath(workflow.id)}>{workflow.title}</Link> },
{ title: <ExecutionsDropdown /> },
]}
/>

View File

@ -3,13 +3,14 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useActionContext, useRecord } from '@nocobase/client';
import { getWorkflowExecutionsPath } from './constant';
export const ExecutionLink = () => {
const { t } = useTranslation();
const { id } = useRecord();
const { setVisible } = useActionContext();
return (
<Link to={`/admin/settings/workflow/executions/${id}`} onClick={() => setVisible(false)}>
<Link to={getWorkflowExecutionsPath(id)} onClick={() => setVisible(false)}>
{t('View')}
</Link>
);

View File

@ -4,6 +4,7 @@ import {
ResourceActionProvider,
SchemaComponent,
cx,
useApp,
useDocumentTitle,
useResourceActionContext,
useResourceContext,
@ -21,6 +22,7 @@ import { lang } from './locale';
import { executionSchema } from './schemas/executions';
import useStyles from './style';
import { linkNodes } from './utils';
import { getWorkflowDetailPath } from './constant';
function ExecutionResourceProvider({ request, filter = {}, ...others }) {
const { workflow } = useFlowContext();
@ -44,6 +46,7 @@ function ExecutionResourceProvider({ request, filter = {}, ...others }) {
export function WorkflowCanvas() {
const navigate = useNavigate();
const { t } = useTranslation();
const app = useApp();
const { data, refresh, loading } = useResourceActionContext();
const { resource } = useResourceContext();
const { setTitle } = useDocumentTitle();
@ -67,7 +70,7 @@ export function WorkflowCanvas() {
function onSwitchVersion({ key }) {
if (key != workflow.id) {
navigate(`/admin/settings/workflow/workflows/${key}`);
navigate(getWorkflowDetailPath(key));
}
}
@ -92,7 +95,7 @@ export function WorkflowCanvas() {
});
message.success(t('Operation succeeded'));
navigate(`/admin/settings/workflow/workflows/${revision.id}`);
navigate(`/admin/workflow/workflows/${revision.id}`);
}
async function onDelete() {
@ -110,8 +113,8 @@ export function WorkflowCanvas() {
navigate(
workflow.current
? '/admin/settings/workflow/workflows'
: `/admin/settings/workflow/workflows/${revisions.find((item) => item.current)?.id}`,
? app.pluginSettingsManager.getRoutePath('workflow')
: getWorkflowDetailPath(revisions.find((item) => item.current)?.id),
);
},
});
@ -147,7 +150,7 @@ export function WorkflowCanvas() {
<header>
<Breadcrumb
items={[
{ title: <Link to={`/admin/settings/workflow/workflows`}>{lang('Workflow')}</Link> },
{ title: <Link to={app.pluginSettingsManager.getRoutePath('workflow')}>{lang('Workflow')}</Link> },
{ title: <strong>{workflow.title}</strong> },
]}
/>

View File

@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { getWorkflowDetailPath } from './constant';
import { useActionContext, useGetAriaLabelOfAction, useRecord } from '@nocobase/client';
export const WorkflowLink = () => {
@ -11,7 +12,7 @@ export const WorkflowLink = () => {
const { getAriaLabel } = useGetAriaLabelOfAction('Configure');
return (
<Link aria-label={getAriaLabel()} to={`/admin/settings/workflow/workflows/${id}`} onClick={() => setVisible(false)}>
<Link aria-label={getAriaLabel()} to={getWorkflowDetailPath(id)} onClick={() => setVisible(false)}>
{t('Configure')}
</Link>
);

View File

@ -3,7 +3,6 @@ import {
PluginManagerContext,
SchemaComponent,
SchemaComponentContext,
SettingsCenterProvider,
} from '@nocobase/client';
import { Card } from 'antd';
import React, { useContext } from 'react';
@ -12,7 +11,6 @@ import { ExecutionResourceProvider } from './ExecutionResourceProvider';
import { WorkflowLink } from './WorkflowLink';
import OpenDrawer from './components/OpenDrawer';
import expressionField from './interfaces/expression';
import { lang } from './locale';
import { instructions } from './nodes';
import { workflowSchema } from './schemas/workflows';
import { getTriggersOptions, triggers } from './triggers';
@ -26,7 +24,7 @@ export function useWorkflowContext() {
return useContext(WorkflowContext);
}
function WorkflowPane() {
export function WorkflowPane() {
const ctx = useContext(SchemaComponentContext);
return (
<Card bordered={false}>
@ -53,22 +51,6 @@ export const WorkflowProvider = (props) => {
const pmCtx = useContext(PluginManagerContext);
const cmCtx = useContext(CollectionManagerContext);
return (
<SettingsCenterProvider
settings={{
workflow: {
icon: 'PartitionOutlined',
// title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
title: lang('Workflow'),
tabs: {
workflows: {
isBookmark: true,
title: lang('Workflow'),
component: WorkflowPane,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
@ -88,6 +70,5 @@ export const WorkflowProvider = (props) => {
<WorkflowContext.Provider value={{ triggers, instructions }}>{props.children}</WorkflowContext.Provider>
</CollectionManagerContext.Provider>
</PluginManagerContext.Provider>
</SettingsCenterProvider>
);
};

View File

@ -12,7 +12,7 @@ test.describe('workflow manage', () => {
// 1、前置条件已登录
// 2、测试步骤进入“工作流管理”-“新建”按钮,填写表单,点击“确定”按钮
await page.goto('/admin/settings/workflow/workflows');
await page.goto('/admin/settings/workflow');
await page.waitForLoadState('networkidle');
await page.getByLabel('action-Action-Add new-workflows').click();
const createWorkFlow = new CreateWorkFlow(page);

View File

@ -10,7 +10,7 @@ test.describe('workflow manage', () => {
const caseTitle = 'edit from event name';
// 1、前置条件1.1、已登录,1.2、存在一个工作流
await page.goto('/admin/settings/workflow/workflows');
await page.goto('/admin/settings/workflow');
await page.waitForLoadState('networkidle');
await page.getByLabel('action-Action-Add new-workflows').click();
const createWorkFlow = new CreateWorkFlow(page);

View File

@ -24,7 +24,7 @@ test.describe('trigger collection events', () => {
const newPage = mockPage(appendJsonCollectionName(e2e_GeneralFormsTable, appendText));
//配置工作流
await page.goto('/admin/settings/workflow/workflows');
await page.goto('/admin/settings/workflow');
await page.waitForLoadState('networkidle');
await page.getByLabel('action-Action-Add new-workflows').click();
const createWorkFlow = new CreateWorkFlow(page);
@ -85,7 +85,7 @@ test.describe('trigger collection events', () => {
// 3、预期结果数据添加成功工作流成功触发
await expect(page.getByText(fieldData)).toBeVisible();
await page.goto('/admin/settings/workflow/workflows');
await page.goto('/admin/settings/workflow');
await expect(page.getByRole('table').locator('a').filter({ hasText: '1' })).toBeVisible();
// 4、后置处理删除工作流

View File

@ -0,0 +1,2 @@
export const getWorkflowDetailPath = (id: string | number) => `/admin/workflow/workflows/${id}`;
export const getWorkflowExecutionsPath = (id: string | number) => `/admin/workflow/executions/${id}`;

View File

@ -10,13 +10,15 @@ import { Plugin } from '@nocobase/client';
import React from 'react';
import { ExecutionPage } from './ExecutionPage';
import { WorkflowPage } from './WorkflowPage';
import { WorkflowProvider } from './WorkflowProvider';
import { WorkflowPane, WorkflowProvider } from './WorkflowProvider';
import { DynamicExpression } from './components/DynamicExpression';
import { triggers, useTrigger, getTriggersOptions } from './triggers';
import { instructions } from './nodes';
import { WorkflowTodo } from './nodes/manual/WorkflowTodo';
import { WorkflowTodoBlockInitializer } from './nodes/manual/WorkflowTodoBlockInitializer';
import { useTriggerWorkflowsActionProps } from './triggers/form';
import { NAMESPACE } from './locale';
import { getWorkflowDetailPath, getWorkflowExecutionsPath } from './constant';
export class WorkflowPlugin extends Plugin {
triggers = triggers;
@ -28,6 +30,12 @@ export class WorkflowPlugin extends Plugin {
this.addScopes();
this.addComponents();
this.app.addProvider(WorkflowProvider);
this.app.pluginSettingsManager.add(NAMESPACE, {
icon: 'PartitionOutlined',
title: `{{t("Workflow", { ns: "${NAMESPACE}" })}}`,
Component: WorkflowPane,
aclSnippet: 'pm.workflow.workflows',
});
}
addScopes() {
@ -47,12 +55,12 @@ export class WorkflowPlugin extends Plugin {
}
addRoutes() {
this.app.router.add('admin.settings.workflow.workflows.id', {
path: '/admin/settings/workflow/workflows/:id',
this.app.router.add('admin.workflow.workflows.id', {
path: getWorkflowDetailPath(':id'),
element: <WorkflowPage />,
});
this.app.router.add('admin.settings.workflow.executions.id', {
path: '/admin/settings/workflow/executions/:id',
this.app.router.add('admin.workflow.executions.id', {
path: getWorkflowExecutionsPath(':id'),
element: <ExecutionPage />,
});
}

View File

@ -6,6 +6,7 @@ import { ExecutionStatusOptions } from '../constants';
import { NAMESPACE } from '../locale';
import { useTranslation } from 'react-i18next';
import { message } from 'antd';
import { getWorkflowDetailPath } from '../constant';
export const executionCollection = {
name: 'executions',
@ -32,12 +33,7 @@ export const executionCollection = {
title: `{{t("Version", { ns: "${NAMESPACE}" })}}`,
['x-component']({ value }) {
const { setVisible } = useActionContext();
return (
<Link
to={`/admin/settings/workflow/workflows/${value}`}
onClick={() => setVisible(false)}
>{`#${value}`}</Link>
);
return <Link to={getWorkflowDetailPath(value)} onClick={() => setVisible(false)}>{`#${value}`}</Link>;
},
} as ISchema,
},

View File

@ -24,7 +24,15 @@
"module": "commonjs"
}
},
"include": ["packages/**/*", ".dumi/**/*", ".dumirc.ts", "scripts/*", "playwright.config.ts", "vitest.config.ts"],
"include": [
"packages/**/*",
".dumi/**/*",
".dumirc.ts",
"scripts/*",
"playwright.config.ts",
"vitest.config.ts",
"jest.setupAfterEnv.ts"
],
"exclude": [
"packages/**/node_modules",
"packages/**/dist",