@nocobase/client-v2 2.1.0-beta.37 → 2.1.0-beta.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +3 -0
  3. package/es/RouterManager.d.ts +1 -0
  4. package/es/components/KeepAlive.d.ts +22 -0
  5. package/es/components/RouterBridge.d.ts +9 -0
  6. package/es/data-source/ExtendCollectionsProvider.d.ts +28 -2
  7. package/es/flow/FlowPage.d.ts +2 -1
  8. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  13. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  14. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  15. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  16. package/es/flow/components/FlowRoute.d.ts +10 -1
  17. package/es/flow/index.d.ts +4 -0
  18. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  19. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  21. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  22. package/es/index.d.ts +1 -0
  23. package/es/index.mjs +484 -437
  24. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  25. package/es/layout-manager/LayoutManager.d.ts +22 -0
  26. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  27. package/es/layout-manager/index.d.ts +13 -0
  28. package/es/layout-manager/types.d.ts +20 -0
  29. package/es/layout-manager/utils.d.ts +14 -0
  30. package/es/settings-center/index.d.ts +1 -1
  31. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  32. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  33. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  34. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  35. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  36. package/lib/index.js +484 -437
  37. package/package.json +8 -7
  38. package/src/Application.tsx +27 -12
  39. package/src/BaseApplication.tsx +6 -0
  40. package/src/PluginSettingsManager.ts +1 -1
  41. package/src/RouterManager.tsx +17 -1
  42. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  43. package/src/__tests__/app.test.tsx +8 -1
  44. package/src/__tests__/globalDeps.test.ts +1 -0
  45. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  46. package/src/__tests__/plugin-manager.test.tsx +177 -0
  47. package/src/__tests__/settings-center.test.tsx +24 -2
  48. package/src/components/KeepAlive.tsx +131 -0
  49. package/src/components/RouterBridge.tsx +28 -4
  50. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  51. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  52. package/src/data-source/ExtendCollectionsProvider.tsx +94 -20
  53. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  54. package/src/flow/FlowPage.tsx +35 -7
  55. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  56. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  57. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  58. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  59. package/src/flow/actions/aclCheck.tsx +4 -0
  60. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  61. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  62. package/src/flow/actions/linkageRules.tsx +122 -0
  63. package/src/flow/actions/openView.tsx +28 -4
  64. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  65. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  66. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  67. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  71. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  72. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  74. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  75. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  76. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  77. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  78. package/src/flow/components/AdminLayout.tsx +4 -154
  79. package/src/flow/components/FlowRoute.tsx +105 -15
  80. package/src/flow/index.ts +4 -0
  81. package/src/flow/models/base/ActionModel.tsx +8 -1
  82. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  83. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  84. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  85. package/src/flow/models/base/RouteModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  87. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  88. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  89. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  90. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  91. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  92. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  93. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  94. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  95. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  96. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  97. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  98. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  99. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  100. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  101. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  102. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  103. package/src/index.ts +1 -0
  104. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  105. package/src/layout-manager/LayoutManager.tsx +185 -0
  106. package/src/layout-manager/LayoutRoute.tsx +138 -0
  107. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  108. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  109. package/src/layout-manager/index.ts +14 -0
  110. package/src/layout-manager/types.ts +22 -0
  111. package/src/layout-manager/utils.ts +37 -0
  112. package/src/nocobase-buildin-plugin/index.tsx +56 -48
  113. package/src/settings-center/index.ts +1 -1
  114. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  115. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  116. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  117. package/src/settings-center/plugin-manager/index.tsx +254 -0
  118. package/src/settings-center/plugin-manager/types.ts +35 -0
  119. package/src/settings-center/utils.tsx +8 -1
  120. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  121. package/src/theme/globalStyles.ts +10 -0
  122. package/src/utils/globalDeps.ts +2 -0
  123. package/src/settings-center/PluginManagerPage.tsx +0 -162
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.1.0-beta.37",
3
+ "version": "2.1.0-beta.38",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -20,17 +20,18 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@ant-design/icons": "^5.6.1",
23
+ "@ctrl/tinycolor": "^3.6.0",
23
24
  "@dnd-kit/core": "^6.0.0",
24
25
  "@dnd-kit/sortable": "^7.0.0",
25
26
  "@emotion/css": "^11.7.1",
26
27
  "@formily/antd-v5": "1.2.3",
27
28
  "@formily/react": "^2.2.27",
28
29
  "@formily/shared": "^2.2.27",
29
- "@nocobase/evaluators": "2.1.0-beta.37",
30
- "@nocobase/flow-engine": "2.1.0-beta.37",
31
- "@nocobase/sdk": "2.1.0-beta.37",
32
- "@nocobase/shared": "2.1.0-beta.37",
33
- "@nocobase/utils": "2.1.0-beta.37",
30
+ "@nocobase/evaluators": "2.1.0-beta.38",
31
+ "@nocobase/flow-engine": "2.1.0-beta.38",
32
+ "@nocobase/sdk": "2.1.0-beta.38",
33
+ "@nocobase/shared": "2.1.0-beta.38",
34
+ "@nocobase/utils": "2.1.0-beta.38",
34
35
  "ahooks": "^3.7.2",
35
36
  "antd": "5.24.2",
36
37
  "antd-style": "3.7.1",
@@ -44,5 +45,5 @@
44
45
  "react-i18next": "^11.15.1",
45
46
  "react-router-dom": "^6.30.1"
46
47
  },
47
- "gitHead": "7132e5b83ecc0e42b54715eaf1429c72bcef34ae"
48
+ "gitHead": "d1c585108ff6e51c17b0b52bacb1a2d621d9c119"
48
49
  }
@@ -42,6 +42,7 @@ export class Application extends BaseApplication<
42
42
  PluginSettingsManager
43
43
  > {
44
44
  public declare dataSourceManager: any;
45
+ public hasLoadError = false;
45
46
 
46
47
  protected createApiClient(options: ApplicationOptions) {
47
48
  return new APIClient({
@@ -99,9 +100,29 @@ export class Application extends BaseApplication<
99
100
  }
100
101
 
101
102
  async load() {
102
- await this.loadWebSocket();
103
- await this.pm.load();
104
- await this.flowEngine.flowSettings.load();
103
+ try {
104
+ this.hasLoadError = false;
105
+ await this.loadWebSocket();
106
+ await this.pm.load();
107
+ await this.flowEngine.flowSettings.load();
108
+ } catch (error: any) {
109
+ this.hasLoadError = true;
110
+
111
+ if (error?.response?.data?.errors?.[0]?.code === 'BLOCKED_IP') {
112
+ this.hasLoadError = false;
113
+ }
114
+
115
+ if (this.ws.enabled) {
116
+ await new Promise((resolve) => {
117
+ setTimeout(() => resolve(null), 1000);
118
+ });
119
+ }
120
+ this.error = {
121
+ code: 'LOAD_ERROR',
122
+ ...this.apiClient.toErrMessages(error)?.[0],
123
+ };
124
+ console.error(error, this.error);
125
+ }
105
126
  this.updateFavicon();
106
127
  }
107
128
 
@@ -130,20 +151,14 @@ export class Application extends BaseApplication<
130
151
  return;
131
152
  }
132
153
 
133
- if (this.error && data.payload.code === 'APP_RUNNING') {
134
- this.maintained = true;
135
- this.setMaintaining(false);
136
- this.error = null;
137
- globalThis.window.location.reload();
138
- return;
139
- }
140
-
141
154
  const maintaining = data.type === 'maintaining' && data.payload.code !== 'APP_RUNNING';
142
- console.log('ws:message', { maintaining, data });
143
155
  if (maintaining) {
144
156
  this.setMaintaining(true);
145
157
  this.error = data.payload;
146
158
  } else {
159
+ if (this.hasLoadError) {
160
+ globalThis.window.location.reload();
161
+ }
147
162
  this.setMaintaining(false);
148
163
  this.maintained = true;
149
164
  this.error = null;
@@ -30,6 +30,7 @@ import { GlobalThemeProvider } from './theme';
30
30
  import { AIManager } from './ai';
31
31
  import { AppError, AppMaintaining, AppMaintainingDialog, AppNotFound, AppSpin, BlankComponent } from './components';
32
32
  import { SystemSettingsSource } from './flow/system-settings';
33
+ import { LayoutManager } from './layout-manager/LayoutManager';
33
34
  import type { PluginClass, PluginManager, PluginType } from './PluginManager';
34
35
  import { RouteRepository } from './RouteRepository';
35
36
  import type {
@@ -122,6 +123,7 @@ export abstract class BaseApplication<
122
123
  public components: Record<string, AnyComponent> = {};
123
124
  public pluginManager: TPluginManager;
124
125
  public pluginSettingsManager: TPluginSettingsManager;
126
+ public layoutManager: LayoutManager<this>;
125
127
  public aiManager!: AIManager;
126
128
  public devDynamicImport?: DevDynamicImport;
127
129
  public requirejs!: RequireJS;
@@ -192,6 +194,7 @@ export abstract class BaseApplication<
192
194
  this.initializeExtendedState();
193
195
  this.i18n = this.createI18n(options);
194
196
  this.router = this.createRouterManager(options);
197
+ this.layoutManager = this.createLayoutManager(options);
195
198
  this.pluginManager = this.createPluginManager(options);
196
199
  this.flowEngine = new FlowEngine();
197
200
  this.flowEngine.registerModels({ ApplicationModel });
@@ -567,6 +570,9 @@ export abstract class BaseApplication<
567
570
  protected abstract createRouterManager(options: TOptions): TRouterManager;
568
571
  protected abstract createPluginManager(options: TOptions): TPluginManager;
569
572
  protected abstract createPluginSettingsManager(options: TOptions): TPluginSettingsManager;
573
+ protected createLayoutManager(_options: TOptions) {
574
+ return new LayoutManager(this);
575
+ }
570
576
  protected createWebSocketClient(options: TOptions) {
571
577
  return new WebSocketClient(options.ws ?? false);
572
578
  }
@@ -524,7 +524,7 @@ export class PluginSettingsManager<TApp extends BaseApplication<any> = BaseAppli
524
524
 
525
525
  if (page.key === 'index') {
526
526
  this.app.router.add(this.getRouteName(page.name), {
527
- index: true,
527
+ path: '',
528
528
  Component: fallbackComponent,
529
529
  componentLoader: page.componentLoader,
530
530
  });
@@ -50,10 +50,25 @@ export interface RouteType extends Omit<RouteObject, 'children' | 'Component'> {
50
50
  Component?: ComponentTypeAndString;
51
51
  componentLoader?: ComponentLoader;
52
52
  skipAuthCheck?: boolean;
53
+ authCheck?: boolean;
53
54
  }
54
55
  export type RenderComponentType = (Component: ComponentTypeAndString, props?: any) => React.ReactNode;
55
56
  export type RouterComponentType = React.FC<{ BaseLayout?: ComponentType }>;
56
57
 
58
+ function removeBasename(pathname: string, basename?: string) {
59
+ if (!basename || basename === '/') {
60
+ return pathname;
61
+ }
62
+ const normalizedBasename = basename.replace(/\/$/, '');
63
+ if (pathname === normalizedBasename) {
64
+ return '/';
65
+ }
66
+ if (pathname.startsWith(`${normalizedBasename}/`)) {
67
+ return pathname.slice(normalizedBasename.length) || '/';
68
+ }
69
+ return pathname;
70
+ }
71
+
57
72
  export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<any>> {
58
73
  protected routes: Record<string, RouteType> = {};
59
74
  protected options: RouterOptions;
@@ -186,8 +201,9 @@ export class RouterManager<TApp extends BaseApplication<any> = BaseApplication<a
186
201
 
187
202
  matchRoutes(pathname: string) {
188
203
  const routes = this.getRoutesTree();
204
+ const basename = this.router?.basename || this.getBasename();
189
205
  // @ts-ignore
190
- return matchRoutes<RouteType>(routes, pathname, this.basename);
206
+ return matchRoutes<RouteType>(routes, removeBasename(pathname, basename));
191
207
  }
192
208
 
193
209
  isSkippedAuthCheckRoute(pathname: string) {
@@ -9,6 +9,8 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { createMockClient } from '@nocobase/client-v2';
12
+ import { createMemoryRouter } from 'react-router-dom';
13
+ import type { RouteObject } from 'react-router-dom';
12
14
 
13
15
  describe('PluginSettingsManager v2', () => {
14
16
  it('should return menu -> page two-level structure', () => {
@@ -72,7 +74,7 @@ describe('PluginSettingsManager v2', () => {
72
74
  expect(app.pluginSettingsManager.getRoutePath('demo.advanced')).toBe('/admin/settings/demo/advanced');
73
75
 
74
76
  expect(app.router.get('admin.settings.demo')).toMatchObject({ path: 'demo' });
75
- expect(app.router.get('admin.settings.demo.index')).toMatchObject({ index: true });
77
+ expect(app.router.get('admin.settings.demo.index')).toMatchObject({ path: '' });
76
78
  expect(app.router.get('admin.settings.demo.advanced')).toMatchObject({ path: 'advanced' });
77
79
  });
78
80
 
@@ -110,7 +112,44 @@ describe('PluginSettingsManager v2', () => {
110
112
  });
111
113
 
112
114
  expect(app.pluginSettingsManager.get('demo.index')).toMatchObject({ componentLoader });
113
- expect(app.router.get('admin.settings.demo.index')).toMatchObject({ componentLoader, index: true });
115
+ expect(app.router.get('admin.settings.demo.index')).toMatchObject({ componentLoader, path: '' });
116
+ });
117
+
118
+ it('should allow nested routes under index page route', () => {
119
+ const app = createMockClient();
120
+ const findRoute = (routes: RouteObject[], routeId: string): RouteObject | null => {
121
+ for (const route of routes) {
122
+ if (route.id === routeId) {
123
+ return route;
124
+ }
125
+ const matched = route.children ? findRoute(route.children, routeId) : null;
126
+ if (matched) {
127
+ return matched;
128
+ }
129
+ }
130
+ return null;
131
+ };
132
+
133
+ app.pluginSettingsManager.addMenuItem({ key: 'demo', title: 'Demo' });
134
+ app.pluginSettingsManager.addPageTabItem({ menuKey: 'demo', key: 'index', title: 'Overview' });
135
+ app.router.add('admin.settings.demo.index.layout', {
136
+ path: 'configure',
137
+ Component: () => React.createElement('div', null, 'configure'),
138
+ });
139
+
140
+ const routes = app.router.getRoutesTree();
141
+ const indexRoute = findRoute(routes, 'admin.settings.demo.index');
142
+
143
+ expect(indexRoute).toMatchObject({
144
+ id: 'admin.settings.demo.index',
145
+ path: '',
146
+ });
147
+ expect(indexRoute).not.toHaveProperty('index');
148
+ expect(findRoute(routes, 'admin.settings.demo.index.layout')).toMatchObject({
149
+ id: 'admin.settings.demo.index.layout',
150
+ path: 'configure',
151
+ });
152
+ expect(() => createMemoryRouter(routes, { initialEntries: ['/demo/configure/form-1'] })).not.toThrow();
114
153
  });
115
154
 
116
155
  it('should merge duplicate registration and refresh route config', () => {
@@ -243,6 +243,10 @@ describe('app', () => {
243
243
  });
244
244
  await renderApp(app);
245
245
  expect(await screen.findByText('Hello Basename Route')).toBeInTheDocument();
246
+ expect(app.router.matchRoutes('/v2/demo/app-info')?.some((match) => match.route.path === '/demo/app-info')).toBe(
247
+ true,
248
+ );
249
+ expect(app.router.matchRoutes('/demo/app-info')?.some((match) => match.route.path === '/demo/app-info')).toBe(true);
246
250
  });
247
251
 
248
252
  it('should support plugin settings componentLoader lazy functionality', async () => {
@@ -379,7 +383,10 @@ describe('app', () => {
379
383
 
380
384
  await waitFor(() => expect(screen.queryByText('maintaining error message')).not.toBeInTheDocument());
381
385
  expect(screen.getByText('Hello')).toBeInTheDocument();
382
- expect(reloadMock).toHaveBeenCalled();
386
+ // Aligned with v1: a routine maintaining→APP_RUNNING cycle does not
387
+ // reload the page. Only `hasLoadError === true` (set when the initial
388
+ // `app.load()` itself fails) triggers a recovery reload.
389
+ expect(reloadMock).not.toHaveBeenCalled();
383
390
  } finally {
384
391
  Object.defineProperty(globalThis.window, 'location', {
385
392
  configurable: true,
@@ -28,6 +28,7 @@ describe('client-v2 defineGlobalDeps', () => {
28
28
  expect(define).toHaveBeenCalledWith('@nocobase/evaluators/client', expect.any(Function));
29
29
  expect(define).toHaveBeenCalledWith('@dnd-kit/core', expect.any(Function));
30
30
  expect(define).toHaveBeenCalledWith('@dnd-kit/sortable', expect.any(Function));
31
+ expect(define).toHaveBeenCalledWith('@ctrl/tinycolor', expect.any(Function));
31
32
  expect(define).toHaveBeenCalledWith('ahooks', expect.any(Function));
32
33
  expect(define).toHaveBeenCalledWith('dayjs', expect.any(Function));
33
34
  expect(define).toHaveBeenCalledWith('lodash', expect.any(Function));
@@ -7,11 +7,21 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { createMockClient } from '@nocobase/client-v2';
11
- import { render, screen, waitFor } from '@testing-library/react';
10
+ import { createMockClient, Plugin } from '@nocobase/client-v2';
11
+ import { act, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
14
14
 
15
+ class SkippedPublicRoutePlugin extends Plugin {
16
+ async load() {
17
+ this.router.add('public', {
18
+ path: '/public',
19
+ skipAuthCheck: true,
20
+ Component: () => <div>public page</div>,
21
+ });
22
+ }
23
+ }
24
+
15
25
  describe('nocobase buildin plugin auth redirect', () => {
16
26
  const originalLocation = globalThis.window.location;
17
27
 
@@ -107,6 +117,39 @@ describe('nocobase buildin plugin auth redirect', () => {
107
117
  });
108
118
  });
109
119
 
120
+ it('should check current user after navigating from skipped route to v2 admin', async () => {
121
+ const app = createMockClient({
122
+ publicPath: '/v2/',
123
+ plugins: [NocoBaseBuildInPlugin as any, SkippedPublicRoutePlugin as any],
124
+ router: { type: 'memory', initialEntries: ['/v2/public'] },
125
+ });
126
+ app.apiMock.onGet('app:getLang').reply(200, {
127
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
128
+ });
129
+ app.apiMock.onGet('/auth:check').reply(200, { data: {} });
130
+
131
+ const Root = app.getRootComponent();
132
+ render(<Root />);
133
+
134
+ expect(await screen.findByText('public page')).toBeInTheDocument();
135
+ const authCheckRequestsBeforeNavigation = app.apiMock.history.get.filter(
136
+ (request) => request.url === '/auth:check',
137
+ ).length;
138
+ expect(authCheckRequestsBeforeNavigation).toBe(0);
139
+
140
+ await act(async () => {
141
+ await app.router.router.navigate('/v2/admin');
142
+ });
143
+
144
+ await waitFor(() => {
145
+ expect(app.apiMock.history.get.filter((request) => request.url === '/auth:check').length).toBeGreaterThan(
146
+ authCheckRequestsBeforeNavigation,
147
+ );
148
+ expect(app.router.router.state.location.pathname).toBe('/v2/signin');
149
+ expect(app.router.router.state.location.search).toBe('?redirect=%2Fv2%2Fadmin');
150
+ });
151
+ });
152
+
110
153
  it('should render v2 admin root without redirecting away', async () => {
111
154
  const app = createMockClient({
112
155
  publicPath: '/v2/',
@@ -0,0 +1,177 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { ACLRolesCheckProvider, createMockClient, Plugin } from '@nocobase/client-v2';
11
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
12
+ import React from 'react';
13
+ import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
14
+
15
+ class TestAclPlugin extends Plugin {
16
+ async load() {
17
+ this.app.use(ACLRolesCheckProvider);
18
+ }
19
+ }
20
+
21
+ type MockClientApplication = ReturnType<typeof createMockClient>;
22
+
23
+ const renderApp = (app: MockClientApplication) => {
24
+ const Root = app.getRootComponent();
25
+ render(<Root />);
26
+ };
27
+
28
+ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) => {
29
+ await waitFor(
30
+ () => {
31
+ const history = app.apiMock.history.get.map((request) => request.url);
32
+ expect(history).toEqual(expect.arrayContaining(urls));
33
+ },
34
+ { timeout: 3000 },
35
+ );
36
+ };
37
+
38
+ const setupApp = (pmList: any[]) => {
39
+ const app = createMockClient({
40
+ plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
41
+ router: { type: 'memory', initialEntries: ['/admin/settings/plugin-manager'] },
42
+ });
43
+
44
+ app.apiMock.onGet('/auth:check').reply(200, {
45
+ data: { id: 1, nickname: 'Super Admin' },
46
+ });
47
+ app.apiMock.onGet('app:getLang').reply(200, {
48
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
49
+ });
50
+ app.apiMock.onGet('app:getInfo').reply(200, { data: { id: 'mock-app' } });
51
+ app.apiMock.onGet('roles:check').reply(200, {
52
+ data: { role: 'root', snippets: ['pm', 'pm.system-settings.system-settings'] },
53
+ });
54
+ app.apiMock.onGet('/desktopRoutes:listAccessible').reply(200, { data: [] });
55
+ app.apiMock.onGet('systemSettings:get').reply(200, {
56
+ data: {
57
+ id: 1,
58
+ title: 'NocoBase',
59
+ raw_title: 'NocoBase',
60
+ enabledLanguages: ['en-US'],
61
+ logo: null,
62
+ },
63
+ });
64
+ app.apiMock.onGet('pm:list').reply(200, { data: pmList });
65
+ app.apiMock.onGet('pm:listEnabledV2').reply(200, { data: [] });
66
+
67
+ // pm:* mutations default to GET in axios when called via api.request without method
68
+ app.apiMock.onGet('pm:enable').reply(200, { data: {} });
69
+ app.apiMock.onGet('pm:disable').reply(200, { data: {} });
70
+ app.apiMock.onGet('pm:remove').reply(200, { data: {} });
71
+
72
+ return app;
73
+ };
74
+
75
+ describe('plugin-manager page', () => {
76
+ it('fires pm:enable when toggling switch on a disabled plugin', async () => {
77
+ const app = setupApp([
78
+ {
79
+ name: 'demo-plugin',
80
+ packageName: '@nocobase/demo-plugin',
81
+ displayName: 'Demo plugin',
82
+ description: 'A demo',
83
+ enabled: false,
84
+ builtIn: false,
85
+ removable: true,
86
+ version: '0.1.0',
87
+ isCompatible: true,
88
+ keywords: [],
89
+ },
90
+ ]);
91
+
92
+ renderApp(app);
93
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'pm:list']);
94
+
95
+ const card = await screen.findByRole('button', { name: 'Demo plugin' });
96
+ const switchControl = within(card.closest('.ant-card') as HTMLElement).getByRole('switch');
97
+ expect(switchControl).toHaveAttribute('aria-checked', 'false');
98
+
99
+ fireEvent.click(switchControl);
100
+
101
+ await waitFor(() => {
102
+ const enableCall = app.apiMock.history.get.find((req) => req.url === 'pm:enable');
103
+ expect(enableCall).toBeDefined();
104
+ expect(enableCall?.params).toMatchObject({ filterByTk: 'demo-plugin' });
105
+ });
106
+ });
107
+
108
+ it('fires pm:disable after confirm when toggling switch on an enabled plugin', async () => {
109
+ const app = setupApp([
110
+ {
111
+ name: 'demo-plugin',
112
+ packageName: '@nocobase/demo-plugin',
113
+ displayName: 'Demo plugin',
114
+ description: 'A demo',
115
+ enabled: true,
116
+ builtIn: false,
117
+ removable: true,
118
+ version: '0.1.0',
119
+ isCompatible: true,
120
+ keywords: [],
121
+ },
122
+ ]);
123
+
124
+ renderApp(app);
125
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'pm:list']);
126
+
127
+ const card = await screen.findByRole('button', { name: 'Demo plugin' });
128
+ const switchControl = within(card.closest('.ant-card') as HTMLElement).getByRole('switch');
129
+ expect(switchControl).toHaveAttribute('aria-checked', 'true');
130
+
131
+ fireEvent.click(switchControl);
132
+
133
+ const confirmTitle = await screen.findByText('Are you sure to disable this plugin?');
134
+ const confirmDialog = confirmTitle.closest('.ant-modal-confirm') as HTMLElement;
135
+ expect(confirmDialog).not.toBeNull();
136
+ const okButton = within(confirmDialog).getByText('OK');
137
+ fireEvent.click(okButton);
138
+
139
+ await waitFor(() => {
140
+ const disableCall = app.apiMock.history.get.find((req) => req.url === 'pm:disable');
141
+ expect(disableCall).toBeDefined();
142
+ expect(disableCall?.params).toMatchObject({ filterByTk: 'demo-plugin' });
143
+ });
144
+ });
145
+
146
+ it('fires pm:remove after Popconfirm on a removable plugin', async () => {
147
+ const app = setupApp([
148
+ {
149
+ name: 'demo-plugin',
150
+ packageName: '@nocobase/demo-plugin',
151
+ displayName: 'Demo plugin',
152
+ description: 'A demo',
153
+ enabled: false,
154
+ builtIn: false,
155
+ removable: true,
156
+ version: '0.1.0',
157
+ isCompatible: true,
158
+ keywords: [],
159
+ },
160
+ ]);
161
+
162
+ renderApp(app);
163
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'pm:list']);
164
+
165
+ const removeLink = await screen.findByText('Remove');
166
+ fireEvent.click(removeLink);
167
+
168
+ const yesButton = await screen.findByRole('button', { name: 'Yes' });
169
+ fireEvent.click(yesButton);
170
+
171
+ await waitFor(() => {
172
+ const removeCall = app.apiMock.history.get.find((req) => req.url === 'pm:remove');
173
+ expect(removeCall).toBeDefined();
174
+ expect(removeCall?.params).toMatchObject({ filterByTk: 'demo-plugin' });
175
+ });
176
+ });
177
+ });
@@ -14,6 +14,7 @@ import { message } from 'antd';
14
14
  import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
15
15
  import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
16
16
  import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
17
+ import { matchSettingsRoute } from '../settings-center/utils';
17
18
 
18
19
  class TestAclPlugin extends Plugin {
19
20
  async load() {
@@ -145,6 +146,28 @@ const mockAdminRuntime = (
145
146
  };
146
147
 
147
148
  describe('settings center', () => {
149
+ it('should match nested layout paths under a registered settings page', () => {
150
+ const settings = {
151
+ '/admin/settings/public-forms': {
152
+ name: 'public-forms.index',
153
+ topLevelName: 'public-forms',
154
+ path: '/admin/settings/public-forms',
155
+ },
156
+ '/admin/settings/public-forms/advanced': {
157
+ name: 'public-forms.advanced',
158
+ topLevelName: 'public-forms',
159
+ path: '/admin/settings/public-forms/advanced',
160
+ },
161
+ } as any;
162
+
163
+ expect(matchSettingsRoute(settings, '/admin/settings/public-forms/form-1')).toMatchObject({
164
+ name: 'public-forms.index',
165
+ });
166
+ expect(matchSettingsRoute(settings, '/admin/settings/public-forms/advanced/form-1')).toMatchObject({
167
+ name: 'public-forms.advanced',
168
+ });
169
+ });
170
+
148
171
  it('should redirect /admin/settings to system-settings by default', async () => {
149
172
  const app = createMockClient({
150
173
  plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
@@ -211,8 +234,7 @@ describe('settings center', () => {
211
234
  await renderApp(app);
212
235
  await waitForGetRequests(app, ['/auth:check', 'roles:check', 'pm:list']);
213
236
 
214
- expect(await screen.findByText('demo-plugin')).toBeInTheDocument();
215
- expect(screen.getByText('@nocobase/demo-plugin')).toBeInTheDocument();
237
+ expect(await screen.findByText('Demo plugin')).toBeInTheDocument();
216
238
  });
217
239
 
218
240
  it('should hide plugin-manager menu item when pm snippet is missing', async () => {