@nocobase/client-v2 2.1.0-beta.36 → 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 (154) hide show
  1. package/es/Application.d.ts +1 -0
  2. package/es/BaseApplication.d.ts +4 -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/components/form/DialogFormLayout.d.ts +5 -29
  7. package/es/components/form/filter/CollectionFilter.d.ts +41 -0
  8. package/es/components/form/filter/CollectionFilterItem.d.ts +41 -0
  9. package/es/components/form/filter/DateFilterDynamicComponent.d.ts +57 -0
  10. package/es/components/form/filter/FilterValueInput.d.ts +29 -0
  11. package/es/components/form/filter/index.d.ts +11 -0
  12. package/es/components/form/filter/useFilterActionProps.d.ts +96 -0
  13. package/es/components/form/index.d.ts +1 -0
  14. package/es/data-source/ExtendCollectionsProvider.d.ts +50 -0
  15. package/es/data-source/index.d.ts +9 -0
  16. package/es/flow/FlowPage.d.ts +2 -1
  17. package/es/flow/admin-shell/AdminLayoutRouteCoordinator.d.ts +8 -40
  18. package/es/flow/admin-shell/BaseLayoutModel.d.ts +89 -0
  19. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +74 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +12 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -92
  22. package/es/flow/admin-shell/admin-layout/index.d.ts +2 -0
  23. package/es/flow/admin-shell/useAdminLayoutRoutePage.d.ts +2 -2
  24. package/es/flow/admin-shell/useLayoutRoutePage.d.ts +23 -0
  25. package/es/flow/components/FlowRoute.d.ts +10 -1
  26. package/es/flow/components/filter/index.d.ts +2 -0
  27. package/es/flow/components/filter/useFilterOptions.d.ts +54 -0
  28. package/es/flow/index.d.ts +4 -0
  29. package/es/flow/models/base/PageModel/PageModel.d.ts +3 -1
  30. package/es/flow/models/blocks/form/FormActionGroupModel.d.ts +1 -0
  31. package/es/flow/models/blocks/table/TableBlockModel.d.ts +10 -0
  32. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.d.ts +1 -1
  33. package/es/flow-compat/passwordUtils.d.ts +1 -1
  34. package/es/index.d.ts +2 -0
  35. package/es/index.mjs +491 -439
  36. package/es/layout-manager/LayoutContentRoute.d.ts +14 -0
  37. package/es/layout-manager/LayoutManager.d.ts +22 -0
  38. package/es/layout-manager/LayoutRoute.d.ts +14 -0
  39. package/es/layout-manager/index.d.ts +13 -0
  40. package/es/layout-manager/types.d.ts +20 -0
  41. package/es/layout-manager/utils.d.ts +14 -0
  42. package/es/nocobase-buildin-plugin/index.d.ts +3 -10
  43. package/es/settings-center/index.d.ts +1 -1
  44. package/es/settings-center/plugin-manager/BulkEnableButton.d.ts +15 -0
  45. package/es/settings-center/plugin-manager/PluginCard.d.ts +15 -0
  46. package/es/settings-center/plugin-manager/PluginDetail.d.ts +16 -0
  47. package/es/settings-center/{PluginManagerPage.d.ts → plugin-manager/index.d.ts} +1 -7
  48. package/es/settings-center/plugin-manager/types.d.ts +34 -0
  49. package/lib/index.js +491 -439
  50. package/package.json +8 -7
  51. package/src/Application.tsx +27 -12
  52. package/src/BaseApplication.tsx +19 -0
  53. package/src/PluginSettingsManager.ts +1 -1
  54. package/src/RouterManager.tsx +17 -1
  55. package/src/__tests__/PluginSettingsManager.test.ts +41 -2
  56. package/src/__tests__/app.test.tsx +17 -1
  57. package/src/__tests__/globalDeps.test.ts +1 -0
  58. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +45 -2
  59. package/src/__tests__/plugin-manager.test.tsx +177 -0
  60. package/src/__tests__/settings-center.test.tsx +24 -2
  61. package/src/components/KeepAlive.tsx +131 -0
  62. package/src/components/README.md +89 -6
  63. package/src/components/README.zh-CN.md +89 -7
  64. package/src/components/RouterBridge.tsx +28 -4
  65. package/src/components/__tests__/KeepAlive.test.tsx +63 -0
  66. package/src/components/__tests__/RouterBridge.test.tsx +27 -0
  67. package/src/components/form/DialogFormLayout.tsx +5 -29
  68. package/src/components/form/filter/CollectionFilter.tsx +101 -0
  69. package/src/components/form/filter/CollectionFilterItem.tsx +176 -0
  70. package/src/components/form/filter/DateFilterDynamicComponent.tsx +283 -0
  71. package/src/components/form/filter/FilterValueInput.tsx +198 -0
  72. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +205 -0
  73. package/src/components/form/filter/__tests__/DateFilterDynamicComponent.test.tsx +148 -0
  74. package/src/components/form/filter/__tests__/FilterValueInput.test.tsx +243 -0
  75. package/src/components/form/filter/__tests__/compileFilterGroup.test.ts +146 -0
  76. package/src/components/form/filter/index.ts +13 -0
  77. package/src/components/form/filter/useFilterActionProps.ts +200 -0
  78. package/src/components/form/index.tsx +1 -0
  79. package/src/data-source/ExtendCollectionsProvider.tsx +144 -0
  80. package/src/data-source/__tests__/ExtendCollectionsProvider.test.tsx +264 -0
  81. package/src/data-source/index.ts +10 -0
  82. package/src/flow/FlowPage.tsx +35 -7
  83. package/src/flow/__tests__/FlowPage.test.tsx +79 -0
  84. package/src/flow/__tests__/FlowRoute.test.tsx +529 -2
  85. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +191 -0
  86. package/src/flow/actions/__tests__/openView.subModelKey.test.tsx +33 -0
  87. package/src/flow/actions/aclCheck.tsx +4 -0
  88. package/src/flow/actions/aclCheckRefresh.tsx +4 -0
  89. package/src/flow/actions/dateTimeFormat.tsx +12 -8
  90. package/src/flow/actions/linkageRules.tsx +122 -0
  91. package/src/flow/actions/openView.tsx +28 -4
  92. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +11 -329
  93. package/src/flow/admin-shell/BaseLayoutModel.tsx +455 -0
  94. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +502 -0
  95. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +547 -3
  96. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +4 -4
  97. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +160 -0
  98. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -12
  99. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +28 -201
  100. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +11 -2
  101. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +1 -26
  102. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +149 -27
  103. package/src/flow/admin-shell/admin-layout/index.ts +2 -0
  104. package/src/flow/admin-shell/useAdminLayoutRoutePage.ts +10 -26
  105. package/src/flow/admin-shell/useLayoutRoutePage.ts +61 -0
  106. package/src/flow/components/AdminLayout.tsx +4 -154
  107. package/src/flow/components/FlowRoute.tsx +105 -15
  108. package/src/flow/components/filter/index.ts +3 -0
  109. package/src/flow/components/filter/useFilterOptions.ts +80 -0
  110. package/src/flow/index.ts +4 -0
  111. package/src/flow/models/base/ActionModel.tsx +8 -1
  112. package/src/flow/models/base/PageModel/PageModel.tsx +51 -18
  113. package/src/flow/models/base/PageModel/RootPageModel.tsx +6 -13
  114. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +102 -1
  115. package/src/flow/models/base/RouteModel.tsx +1 -1
  116. package/src/flow/models/blocks/form/FormActionGroupModel.tsx +14 -0
  117. package/src/flow/models/blocks/form/FormItemModel.tsx +8 -1
  118. package/src/flow/models/blocks/form/__tests__/FormActionGroupModel.test.ts +46 -0
  119. package/src/flow/models/blocks/form/submitValues.ts +4 -1
  120. package/src/flow/models/blocks/table/TableBlockModel.tsx +118 -16
  121. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +114 -0
  122. package/src/flow/models/fields/AssociationFieldModel/SubFormFieldModel.tsx +7 -1
  123. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -1
  124. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -5
  125. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -1
  126. package/src/flow/models/fields/DisplayTimeFieldModel.tsx +1 -1
  127. package/src/flow/models/fields/TimeFieldModel.tsx +1 -1
  128. package/src/flow/models/fields/__tests__/TimeFieldModel.test.tsx +61 -0
  129. package/src/flow/models/fields/mobile-components/MobileDatePicker.tsx +19 -3
  130. package/src/flow/models/fields/mobile-components/__tests__/MobileDatePicker.test.tsx +94 -0
  131. package/src/flow/models/topbar/TopbarActionModel.tsx +1 -1
  132. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +91 -0
  133. package/src/index.ts +2 -0
  134. package/src/layout-manager/LayoutContentRoute.tsx +90 -0
  135. package/src/layout-manager/LayoutManager.tsx +185 -0
  136. package/src/layout-manager/LayoutRoute.tsx +138 -0
  137. package/src/layout-manager/__tests__/LayoutManager.test.tsx +335 -0
  138. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +473 -0
  139. package/src/layout-manager/index.ts +14 -0
  140. package/src/layout-manager/types.ts +22 -0
  141. package/src/layout-manager/utils.ts +37 -0
  142. package/src/nocobase-buildin-plugin/index.tsx +69 -67
  143. package/src/nocobase-buildin-plugin/plugins/LocalePlugin.ts +1 -0
  144. package/src/settings-center/index.ts +1 -1
  145. package/src/settings-center/plugin-manager/BulkEnableButton.tsx +111 -0
  146. package/src/settings-center/plugin-manager/PluginCard.tsx +270 -0
  147. package/src/settings-center/plugin-manager/PluginDetail.tsx +195 -0
  148. package/src/settings-center/plugin-manager/index.tsx +254 -0
  149. package/src/settings-center/plugin-manager/types.ts +35 -0
  150. package/src/settings-center/utils.tsx +8 -1
  151. package/src/theme/__tests__/globalStyles.test.ts +24 -0
  152. package/src/theme/globalStyles.ts +10 -0
  153. package/src/utils/globalDeps.ts +2 -0
  154. 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.36",
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.36",
30
- "@nocobase/flow-engine": "2.1.0-beta.36",
31
- "@nocobase/sdk": "2.1.0-beta.36",
32
- "@nocobase/shared": "2.1.0-beta.36",
33
- "@nocobase/utils": "2.1.0-beta.36",
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": "397d45c744f6eb48b3a0cd785c87cbf1257c3513"
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;
@@ -169,6 +171,18 @@ export abstract class BaseApplication<
169
171
  return this.wsAuthorized;
170
172
  }
171
173
 
174
+ public setDocumentLanguage(language?: string | null) {
175
+ if (typeof document === 'undefined') {
176
+ return;
177
+ }
178
+
179
+ if (language) {
180
+ document.documentElement.lang = language;
181
+ } else {
182
+ document.documentElement.removeAttribute('lang');
183
+ }
184
+ }
185
+
172
186
  constructor(protected options: TOptions = {} as TOptions) {
173
187
  this.initRequireJs();
174
188
  this.defineObservableState();
@@ -180,6 +194,7 @@ export abstract class BaseApplication<
180
194
  this.initializeExtendedState();
181
195
  this.i18n = this.createI18n(options);
182
196
  this.router = this.createRouterManager(options);
197
+ this.layoutManager = this.createLayoutManager(options);
183
198
  this.pluginManager = this.createPluginManager(options);
184
199
  this.flowEngine = new FlowEngine();
185
200
  this.flowEngine.registerModels({ ApplicationModel });
@@ -205,6 +220,7 @@ export abstract class BaseApplication<
205
220
  this.addRoutes();
206
221
  this.i18n.on('languageChanged', (lng) => {
207
222
  this.apiClient.auth.locale = lng;
223
+ this.setDocumentLanguage(lng);
208
224
  });
209
225
  this.initListeners();
210
226
  this.afterManagersInitialized();
@@ -554,6 +570,9 @@ export abstract class BaseApplication<
554
570
  protected abstract createRouterManager(options: TOptions): TRouterManager;
555
571
  protected abstract createPluginManager(options: TOptions): TPluginManager;
556
572
  protected abstract createPluginSettingsManager(options: TOptions): TPluginSettingsManager;
573
+ protected createLayoutManager(_options: TOptions) {
574
+ return new LayoutManager(this);
575
+ }
557
576
  protected createWebSocketClient(options: TOptions) {
558
577
  return new WebSocketClient(options.ws ?? false);
559
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', () => {
@@ -33,6 +33,7 @@ describe('app', () => {
33
33
 
34
34
  afterEach(() => {
35
35
  document.querySelectorAll('link[rel="shortcut icon"]').forEach((node) => node.remove());
36
+ document.documentElement.removeAttribute('lang');
36
37
  vi.restoreAllMocks();
37
38
  });
38
39
 
@@ -89,6 +90,14 @@ describe('app', () => {
89
90
  expect(favicon.getAttribute('href')).toBe('/favicon/favicon.ico');
90
91
  });
91
92
 
93
+ it('should sync document language when app language changes', async () => {
94
+ const app = new Application({ router });
95
+
96
+ await app.i18n.changeLanguage('ja-JP');
97
+
98
+ expect(document.documentElement.lang).toBe('ja-JP');
99
+ });
100
+
92
101
  it('should escape app version html placeholder content', () => {
93
102
  expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
94
103
  '<span class="nb-app-version">v&lt;script&gt;alert(1)&lt;/script&gt;&amp;&quot;</span>',
@@ -234,6 +243,10 @@ describe('app', () => {
234
243
  });
235
244
  await renderApp(app);
236
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);
237
250
  });
238
251
 
239
252
  it('should support plugin settings componentLoader lazy functionality', async () => {
@@ -370,7 +383,10 @@ describe('app', () => {
370
383
 
371
384
  await waitFor(() => expect(screen.queryByText('maintaining error message')).not.toBeInTheDocument());
372
385
  expect(screen.getByText('Hello')).toBeInTheDocument();
373
- 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();
374
390
  } finally {
375
391
  Object.defineProperty(globalThis.window, 'location', {
376
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 () => {