@nocobase/client-v2 2.3.0-alpha.1 → 3.0.0-alpha.2

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/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -0,0 +1,77 @@
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 { css } from '@emotion/css';
11
+ import { observer } from '@nocobase/flow-engine';
12
+ import { theme as antdTheme } from 'antd';
13
+ import React, { useMemo } from 'react';
14
+ import { useTranslation } from 'react-i18next';
15
+ import { useSystemSettings } from '../../system-settings';
16
+ import type { CustomToken } from '../../../theme';
17
+
18
+ const logoContainerClassName = css`
19
+ height: var(--nb-header-height);
20
+ margin-right: 4px;
21
+ display: inline-flex;
22
+ flex-shrink: 0;
23
+ padding: 0;
24
+ align-items: center;
25
+ `;
26
+
27
+ const fixedWidthClassName = css`
28
+ ${logoContainerClassName}
29
+ width: 168px;
30
+ `;
31
+
32
+ const autoWidthClassName = css`
33
+ ${logoContainerClassName}
34
+ width: auto;
35
+ min-width: 168px;
36
+ `;
37
+
38
+ const logoImageClassName = css`
39
+ object-fit: contain;
40
+ width: 100%;
41
+ height: 100%;
42
+ `;
43
+
44
+ const titleClassName = css`
45
+ width: 100%;
46
+ height: 100%;
47
+ font-weight: 500;
48
+ text-align: center;
49
+ overflow: hidden;
50
+ text-overflow: ellipsis;
51
+ white-space: nowrap;
52
+ `;
53
+
54
+ export const NocoBaseLogo = observer(() => {
55
+ const { token } = antdTheme.useToken();
56
+ const customToken = token as CustomToken;
57
+ const result = useSystemSettings();
58
+ const { t } = useTranslation('lm-collections');
59
+ const title = t(result?.data?.data?.title || 'NocoBase');
60
+ const logoUrl = result?.data?.data?.logo?.url;
61
+ const titleStyle = useMemo<React.CSSProperties>(
62
+ () => ({ color: customToken.colorTextHeaderMenu, fontSize: customToken.fontSizeHeading3 }),
63
+ [customToken.colorTextHeaderMenu, customToken.fontSizeHeading3],
64
+ );
65
+
66
+ const logo = logoUrl ? (
67
+ <img alt={title} className={logoImageClassName} src={logoUrl} />
68
+ ) : (
69
+ <span style={titleStyle} className={titleClassName}>
70
+ {title}
71
+ </span>
72
+ );
73
+
74
+ return <div className={logoUrl ? fixedWidthClassName : autoWidthClassName}>{result?.loading ? null : logo}</div>;
75
+ });
76
+
77
+ NocoBaseLogo.displayName = 'NocoBaseLogo';
@@ -24,7 +24,9 @@ const topbarActionsBarClassName = css`
24
24
  align-items: center;
25
25
  color: var(--nb-topbar-action-color);
26
26
 
27
- .nb-topbar-actions-list {
27
+ .nb-topbar-actions-list,
28
+ .nb-topbar-utility-actions-list,
29
+ .nb-topbar-plugin-settings-action {
28
30
  display: inline-flex;
29
31
  align-items: center;
30
32
  height: 100%;
@@ -127,12 +129,18 @@ const getTopbarActionId = (action: TopbarActionModel) => {
127
129
  return action?.actionId || action?.uid || '';
128
130
  };
129
131
 
132
+ const PLUGIN_SETTINGS_ACTION_ID = 'plugin-settings';
133
+
130
134
  const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[]; onActionClick?: () => void }) => {
131
135
  const { allow } = useAclSnippets();
132
136
  const { token } = theme.useToken();
133
137
  const customToken = token as CustomToken;
134
138
  const actions = useMemo(() => getVisibleTopbarActions(props.actions, allow), [allow, props.actions]);
135
- const mainActions = actions.filter((action) => getTopbarActionId(action) !== USER_CENTER_ACTION_ID);
139
+ const mainActions = actions.filter((action) => {
140
+ const actionId = getTopbarActionId(action);
141
+ return actionId !== PLUGIN_SETTINGS_ACTION_ID && actionId !== USER_CENTER_ACTION_ID;
142
+ });
143
+ const pluginSettingsAction = actions.find((action) => getTopbarActionId(action) === PLUGIN_SETTINGS_ACTION_ID);
136
144
  const userCenterAction = actions.find((action) => getTopbarActionId(action) === USER_CENTER_ACTION_ID);
137
145
 
138
146
  return (
@@ -153,18 +161,33 @@ const TopbarActionsContent = React.memo((props: { actions: TopbarActionModel[];
153
161
  <ConfigProvider theme={dividerTheme}>
154
162
  <Divider type="vertical" />
155
163
  </ConfigProvider>
156
- <HelpLite />
157
- {userCenterAction ? (
158
- <ErrorBoundary
159
- key={userCenterAction.uid}
160
- FallbackComponent={TopbarActionErrorFallback}
161
- onError={(error) => {
162
- console.error('[NocoBase] Topbar action render failed.', error);
163
- }}
164
- >
165
- <FlowModelRenderer model={userCenterAction} />
166
- </ErrorBoundary>
167
- ) : null}
164
+ <div className="nb-topbar-utility-actions-list">
165
+ {pluginSettingsAction ? (
166
+ <span className="nb-topbar-plugin-settings-action" onClick={props.onActionClick}>
167
+ <ErrorBoundary
168
+ key={pluginSettingsAction.uid}
169
+ FallbackComponent={TopbarActionErrorFallback}
170
+ onError={(error) => {
171
+ console.error('[NocoBase] Topbar action render failed.', error);
172
+ }}
173
+ >
174
+ <FlowModelRenderer model={pluginSettingsAction} />
175
+ </ErrorBoundary>
176
+ </span>
177
+ ) : null}
178
+ <HelpLite />
179
+ {userCenterAction ? (
180
+ <ErrorBoundary
181
+ key={userCenterAction.uid}
182
+ FallbackComponent={TopbarActionErrorFallback}
183
+ onError={(error) => {
184
+ console.error('[NocoBase] Topbar action render failed.', error);
185
+ }}
186
+ >
187
+ <FlowModelRenderer model={userCenterAction} />
188
+ </ErrorBoundary>
189
+ ) : null}
190
+ </div>
168
191
  </div>
169
192
  );
170
193
  });
@@ -136,6 +136,16 @@ describe('TopbarActionsBar helpers', () => {
136
136
  title: 'Plugin manager',
137
137
  path: '/admin/settings/plugin-manager',
138
138
  icon: null,
139
+ sort: -200,
140
+ componentLoader: async () => null,
141
+ },
142
+ {
143
+ key: 'multi-portal',
144
+ name: 'multi-portal',
145
+ title: 'Portal manager',
146
+ path: '/admin/settings/multi-portal',
147
+ icon: null,
148
+ sort: -300,
139
149
  componentLoader: async () => null,
140
150
  },
141
151
  {
@@ -159,13 +169,14 @@ describe('TopbarActionsBar helpers', () => {
159
169
  });
160
170
 
161
171
  expect((items as any[]).map((item) => item.type || item.key)).toEqual([
172
+ 'multi-portal',
162
173
  'plugin-manager',
163
174
  'divider',
164
175
  'system-settings',
165
176
  'divider',
166
177
  'security',
167
178
  ]);
168
- expect((items as any[])[2]).toMatchObject({
179
+ expect((items as any[]).find((item) => item.key === 'system-settings')).toMatchObject({
169
180
  key: 'system-settings',
170
181
  name: 'system-settings',
171
182
  path: '/admin/settings/system-settings',
@@ -191,7 +202,7 @@ describe('TopbarActionsBar helpers', () => {
191
202
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
192
203
 
193
204
  const link = screen.getByRole('link', { name: 'System settings' });
194
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
205
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
195
206
  expect(link).toHaveAttribute('target', '_blank');
196
207
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
197
208
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -216,7 +227,7 @@ describe('TopbarActionsBar helpers', () => {
216
227
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
217
228
 
218
229
  const link = screen.getByRole('link', { name: 'Plugin manager' });
219
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
230
+ expect(link).toHaveAttribute('href', '/nocobase/settings/plugin-manager');
220
231
  expect(link).toHaveAttribute('target', '_blank');
221
232
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
222
233
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -241,13 +252,13 @@ describe('TopbarActionsBar helpers', () => {
241
252
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
242
253
 
243
254
  const link = screen.getByRole('link', { name: 'System settings' });
244
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
255
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/system-settings');
245
256
  expect(link).toHaveAttribute('target', '_blank');
246
257
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
247
258
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
248
259
  });
249
260
 
250
- it('should keep regular admin settings as SPA links inside admin runtime', () => {
261
+ it('should open regular admin settings in the standalone SPA from admin runtime', () => {
251
262
  const items = getTopbarPluginSettingsItems({
252
263
  canManagePlugins: false,
253
264
  t: (key) => key,
@@ -266,11 +277,12 @@ describe('TopbarActionsBar helpers', () => {
266
277
  renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
267
278
 
268
279
  const link = screen.getByRole('link', { name: 'Routes' });
269
- expect(link).toHaveAttribute('href', '/admin/settings/routes');
270
- expect(link).not.toHaveAttribute('target', '_blank');
280
+ expect(link).toHaveAttribute('href', '/nocobase/settings/routes');
281
+ expect(link).toHaveAttribute('target', '_blank');
282
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
271
283
  });
272
284
 
273
- it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
285
+ it('should open sub-app settings in the standalone SPA from sub-app admin runtime', () => {
274
286
  const items = getTopbarPluginSettingsItems({
275
287
  canManagePlugins: false,
276
288
  t: (key) => key,
@@ -289,8 +301,9 @@ describe('TopbarActionsBar helpers', () => {
289
301
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
302
 
291
303
  const link = screen.getByRole('link', { name: 'Routes' });
292
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
- expect(link).not.toHaveAttribute('target', '_blank');
304
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/routes');
305
+ expect(link).toHaveAttribute('target', '_blank');
306
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
294
307
  });
295
308
 
296
309
  it('should not treat admin-like paths as admin runtime', () => {
@@ -312,11 +325,11 @@ describe('TopbarActionsBar helpers', () => {
312
325
  renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
313
326
 
314
327
  const link = screen.getByRole('link', { name: 'System settings' });
315
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
328
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
316
329
  expect(link).toHaveAttribute('target', '_blank');
317
330
  });
318
331
 
319
- it('should not duplicate the basename when building new-tab settings hrefs', () => {
332
+ it('should remove the modern basename when building standalone settings hrefs', () => {
320
333
  const items = getTopbarPluginSettingsItems({
321
334
  canManagePlugins: false,
322
335
  t: (key) => key,
@@ -335,7 +348,30 @@ describe('TopbarActionsBar helpers', () => {
335
348
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
336
349
 
337
350
  const link = screen.getByRole('link', { name: 'System settings' });
338
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
351
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
352
+ });
353
+
354
+ it('should preserve the new sub-app scope in standalone settings hrefs', () => {
355
+ const items = getTopbarPluginSettingsItems({
356
+ canManagePlugins: false,
357
+ t: (key) => key,
358
+ settings: [
359
+ {
360
+ key: 'system-settings',
361
+ name: 'system-settings',
362
+ title: 'System settings',
363
+ path: '/admin/settings/system-settings',
364
+ icon: null,
365
+ componentLoader: async () => null,
366
+ },
367
+ ] as any,
368
+ });
369
+
370
+ renderSettingsLabel((items as any[])[0].label, '/_app/a_new/admin/settings/system-settings');
371
+
372
+ const link = screen.getByRole('link', { name: 'System settings' });
373
+ expect(link).toHaveAttribute('href', '/nocobase/settings/_app/a_new/system-settings');
374
+ expect(link).toHaveAttribute('target', '_blank');
339
375
  });
340
376
 
341
377
  it('should keep external settings opening in a new tab', () => {
@@ -402,6 +438,34 @@ describe('TopbarActionsBar', () => {
402
438
  vi.restoreAllMocks();
403
439
  });
404
440
 
441
+ it('should group plugin settings with Help and user center', () => {
442
+ render(
443
+ <TopbarActionsBar
444
+ actions={[
445
+ createAction({ uid: 'notification', actionId: 'notification' }),
446
+ createAction({ uid: 'plugin-settings', actionId: 'plugin-settings' }),
447
+ createAction({ uid: 'user-center', actionId: 'user-center' }),
448
+ ]}
449
+ />,
450
+ );
451
+
452
+ const notification = screen.getByTestId('flow-model-notification');
453
+ const pluginSettings = screen.getByTestId('flow-model-plugin-settings');
454
+ const help = screen.getByTestId('help-lite');
455
+ const userCenter = screen.getByTestId('flow-model-user-center');
456
+ const mainGroup = notification.closest('.nb-topbar-actions-list');
457
+ const utilityGroup = pluginSettings.closest('.nb-topbar-utility-actions-list');
458
+
459
+ expect(mainGroup).toContainElement(notification);
460
+ expect(mainGroup).not.toContainElement(pluginSettings);
461
+ expect(utilityGroup).not.toBeNull();
462
+ expect(utilityGroup).toContainElement(pluginSettings);
463
+ expect(utilityGroup).toContainElement(help);
464
+ expect(utilityGroup).toContainElement(userCenter);
465
+ expect(pluginSettings.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
466
+ expect(help.compareDocumentPosition(userCenter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
467
+ });
468
+
405
469
  it('should keep HelpLite rendered when one action fails', () => {
406
470
  vi.spyOn(console, 'error').mockImplementation(() => {});
407
471
 
@@ -26,6 +26,10 @@ export default function useStyle() {
26
26
  () => ({
27
27
  [`.${COMPONENT_CLS}`]: {
28
28
  '.vditor-reset': { fontSize: `${token.fontSize}px !important`, color: 'unset' },
29
+ '.vditor-reset h2': {
30
+ borderBottom: 'none !important',
31
+ boxShadow: 'none !important',
32
+ },
29
33
  '.vditor': {
30
34
  borderRadius: 8,
31
35
  },
@@ -150,7 +150,7 @@ export const BlockItemCard = React.forwardRef(
150
150
  ) => {
151
151
  const { t } = useTranslation();
152
152
  const { token } = theme.useToken();
153
- const { title: blockTitle, description, children, className, heightMode, ...rest } = props;
153
+ const { title: blockTitle, description, children, className, heightMode, style, ...rest } = props;
154
154
  const cardRef = useRef<HTMLDivElement | null>(null);
155
155
  const setCardRef = useCallback(
156
156
  (node: HTMLDivElement | null) => {
@@ -185,7 +185,7 @@ export const BlockItemCard = React.forwardRef(
185
185
  <Card
186
186
  ref={setCardRef as any}
187
187
  title={title}
188
- style={{ display: 'flex', flexDirection: 'column', height: height }}
188
+ style={{ display: 'flex', flexDirection: 'column', ...style, height: height ?? style?.height }}
189
189
  styles={{
190
190
  body: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'auto' },
191
191
  header: {
@@ -8,9 +8,11 @@
8
8
  */
9
9
 
10
10
  import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
11
+ import { Button, Result } from 'antd';
11
12
  import React, { useEffect, useMemo, useRef, useState } from 'react';
12
- import { deviceType } from 'react-device-detect';
13
+ import { useTranslation } from 'react-i18next';
13
14
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
15
+ import { getModernClientPrefix, stripModernClientPrefix } from '../../authRedirect';
14
16
  import { useApp } from '../../hooks/useApp';
15
17
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../flow-compat';
16
18
  import {
@@ -22,12 +24,14 @@ import { getLayoutModel, type BaseLayoutModel } from '../admin-shell/BaseLayoutM
22
24
  import { useLayoutRoutePage } from '../admin-shell/useLayoutRoutePage';
23
25
  import { AppNotFound } from '../../components';
24
26
  import { useKeepAlive } from '../../components/KeepAlive';
27
+ import { registerDeviceTypeContext } from '../internal/registerDeviceTypeContext';
25
28
 
26
29
  type FlowRouteGuardState = {
27
30
  pageUid?: string;
28
31
  pending: boolean;
29
32
  allowBridge: boolean;
30
33
  notFound: boolean;
34
+ legacyPageUnsupported?: boolean;
31
35
  };
32
36
 
33
37
  export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
@@ -156,31 +160,9 @@ const BridgeFlowRoute = ({
156
160
  const layoutContentRef = useRef<HTMLDivElement>(null);
157
161
 
158
162
  useEffect(() => {
159
- flowEngine.context.defineProperty('deviceType', {
160
- get: () => (deviceType === 'browser' ? 'computer' : deviceType),
161
- cache: false,
162
- meta: {
163
- type: 'string',
164
- title: flowEngine.translate('Current device type'),
165
- interface: 'select',
166
- uiSchema: {
167
- enum: [
168
- { label: flowEngine.translate('Computer'), value: 'computer' },
169
- { label: flowEngine.translate('Mobile'), value: 'mobile' },
170
- { label: flowEngine.translate('Tablet'), value: 'tablet' },
171
- { label: flowEngine.translate('SmartTv'), value: 'smarttv' },
172
- { label: flowEngine.translate('Console'), value: 'console' },
173
- { label: flowEngine.translate('Wearable'), value: 'wearable' },
174
- { label: flowEngine.translate('Embedded'), value: 'embedded' },
175
- ],
176
- 'x-component': 'Select',
177
- },
178
- },
179
- info: {
180
- description: 'Current device type (computer/mobile/tablet/...).',
181
- detail: 'string',
182
- },
183
- });
163
+ if (!flowEngine.context.getPropertyOptions('deviceType')) {
164
+ registerDeviceTypeContext(flowEngine);
165
+ }
184
166
  }, [flowEngine]);
185
167
 
186
168
  useLayoutRoutePage({
@@ -195,11 +177,58 @@ const BridgeFlowRoute = ({
195
177
  return <div ref={layoutContentRef} />;
196
178
  };
197
179
 
180
+ type RouteLocation = {
181
+ pathname: string;
182
+ search: string;
183
+ hash: string;
184
+ };
185
+
186
+ const getLegacyPageHref = (app: { getPublicPath: () => string }, location: RouteLocation) => {
187
+ const modernPublicPath = app.getPublicPath();
188
+ const browserLocationMatchesPublicPath = window.location.pathname.startsWith(modernPublicPath);
189
+ const currentLocation = browserLocationMatchesPublicPath ? window.location : location;
190
+ const pathWithinModernClient = currentLocation.pathname.startsWith(modernPublicPath)
191
+ ? currentLocation.pathname.slice(modernPublicPath.length)
192
+ : currentLocation.pathname.replace(/^\/+/, '');
193
+ return `${stripModernClientPrefix(modernPublicPath)}${pathWithinModernClient}${currentLocation.search}${
194
+ currentLocation.hash
195
+ }`;
196
+ };
197
+
198
+ const LegacyPageUnsupported = ({
199
+ app,
200
+ location,
201
+ }: {
202
+ app: { getPublicPath: () => string };
203
+ location: RouteLocation;
204
+ }) => {
205
+ const { t } = useTranslation();
206
+ const modernClientPath = `/${getModernClientPrefix()}/`;
207
+ const withModernClientPath = (message: string) => message.replaceAll('{{modernClientPath}}', modernClientPath);
208
+
209
+ return (
210
+ <Result
211
+ status="warning"
212
+ title={withModernClientPath(t('This page is not supported in the {{modernClientPath}} branch'))}
213
+ subTitle={withModernClientPath(
214
+ t(
215
+ 'The {{modernClientPath}} branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
216
+ ),
217
+ )}
218
+ extra={
219
+ <Button href={getLegacyPageHref(app, location)} type="primary">
220
+ {t('Open from the original entry')}
221
+ </Button>
222
+ }
223
+ />
224
+ );
225
+ };
226
+
198
227
  /**
199
228
  * 管理后台动态页面路由组件。
200
229
  *
201
- * 负责读取当前路由页面 UID,补充运行时设备变量,
202
- * 并把页面生命周期桥接到 AdminLayout host model。
230
+ * 负责读取当前路由页面 UID,并把页面生命周期桥接到 AdminLayout host model。
231
+ * 设备变量通常由 PluginFlowEngine 共享初始化提供;独立渲染时会在挂载后补充注册。
203
232
  *
204
233
  * @example
205
234
  * ```tsx
@@ -377,7 +406,13 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
377
406
 
378
407
  if (target.reason === 'unsupportedV2Runtime') {
379
408
  if (active && requestId === requestIdRef.current) {
380
- setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
409
+ setGuardState({
410
+ pageUid,
411
+ pending: false,
412
+ allowBridge: false,
413
+ notFound: false,
414
+ legacyPageUnsupported: true,
415
+ });
381
416
  }
382
417
  return;
383
418
  }
@@ -414,6 +449,10 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
414
449
  return <AppNotFound />;
415
450
  }
416
451
 
452
+ if (guardState.legacyPageUnsupported) {
453
+ return <LegacyPageUnsupported app={app} location={location} />;
454
+ }
455
+
417
456
  if (!guardState.allowBridge) {
418
457
  return null;
419
458
  }
@@ -421,11 +460,14 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
421
460
  return <BridgeFlowRoute pageUid={pageUid} active={active} getLayoutModel={getLayoutModel} />;
422
461
  }, [
423
462
  active,
463
+ app,
424
464
  getLayoutModel,
425
465
  guardState.allowBridge,
466
+ guardState.legacyPageUnsupported,
426
467
  guardState.notFound,
427
468
  guardState.pageUid,
428
469
  guardState.pending,
470
+ location,
429
471
  pageUid,
430
472
  ]);
431
473
 
package/src/flow/index.ts CHANGED
@@ -22,12 +22,14 @@ import { Markdown } from './common/Markdown/Markdown';
22
22
  import { LiquidEngine } from './common/Liquid';
23
23
  import type { PreviewRunJSResult } from './components/code-editor/runjsDiagnostics';
24
24
  import { TextAreaWithContextSelector } from './components/TextAreaWithContextSelector';
25
+ import { registerDeviceTypeContext } from './internal/registerDeviceTypeContext';
25
26
 
26
27
  export class PluginFlowEngine<TApp extends BaseApplication<any> = BaseApplication<any>> extends Plugin<
27
28
  PluginOptions<any>,
28
29
  TApp
29
30
  > {
30
31
  async load() {
32
+ registerDeviceTypeContext(this.flowEngine);
31
33
  this.app.addComponents({ FlowRoute });
32
34
  this.app.flowEngine.setModelRepository(new FlowModelRepository(this.app));
33
35
  const filteredModels = Object.fromEntries(
@@ -0,0 +1,39 @@
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 type { FlowEngine } from '@nocobase/flow-engine';
11
+ import { deviceType } from 'react-device-detect';
12
+
13
+ export const registerDeviceTypeContext = (flowEngine: FlowEngine) => {
14
+ flowEngine.context.defineProperty('deviceType', {
15
+ get: () => (deviceType === 'browser' ? 'computer' : deviceType),
16
+ cache: false,
17
+ meta: {
18
+ type: 'string',
19
+ title: flowEngine.translate('Current device type'),
20
+ interface: 'select',
21
+ uiSchema: {
22
+ enum: [
23
+ { label: flowEngine.translate('Computer'), value: 'computer' },
24
+ { label: flowEngine.translate('Mobile'), value: 'mobile' },
25
+ { label: flowEngine.translate('Tablet'), value: 'tablet' },
26
+ { label: flowEngine.translate('SmartTv'), value: 'smarttv' },
27
+ { label: flowEngine.translate('Console'), value: 'console' },
28
+ { label: flowEngine.translate('Wearable'), value: 'wearable' },
29
+ { label: flowEngine.translate('Embedded'), value: 'embedded' },
30
+ ],
31
+ 'x-component': 'Select',
32
+ },
33
+ },
34
+ info: {
35
+ description: 'Current device type (computer/mobile/tablet/...).',
36
+ detail: 'string',
37
+ },
38
+ });
39
+ };
@@ -7,7 +7,8 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { DefaultStructure, FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
10
+ import { FlowModel, tExpr, useFlowModel } from '@nocobase/flow-engine';
11
+ import type { DefaultStructure, FlowModelOptions } from '@nocobase/flow-engine';
11
12
  import { Icon } from '../../../components';
12
13
  import { Button, Tooltip } from 'antd';
13
14
  import type { ButtonProps } from 'antd/es/button';
@@ -87,8 +88,10 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
87
88
  return null;
88
89
  }
89
90
 
90
- onInit(options: any): void {
91
+ onInit(options: FlowModelOptions<T>): void {
91
92
  super.onInit(options);
93
+ // Action disabled state is computed at runtime and must never be restored from persisted props.
94
+ delete this.props.disabled;
92
95
  this.context.defineProperty('actionName', {
93
96
  get: () => {
94
97
  return this.getAclActionName();
@@ -97,6 +100,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
97
100
  });
98
101
  }
99
102
 
103
+ serialize() {
104
+ const data = super.serialize();
105
+ delete data.props.disabled;
106
+ return data;
107
+ }
108
+
100
109
  getInputArgs() {
101
110
  const inputArgs: Record<string, any> = {};
102
111
  const defaultKeys: string[] = [];
@@ -14,8 +14,10 @@ import {
14
14
  FlowSettingsButton,
15
15
  buildSubModelGroups,
16
16
  buildSubModelItems,
17
+ type FlowModel,
17
18
  type SubModelItem,
18
19
  type SubModelItemsType,
20
+ VIEW_ACTIVATED_EVENT,
19
21
  } from '@nocobase/flow-engine';
20
22
  import React from 'react';
21
23
  import { FilterManager } from '../blocks/filter-manager/FilterManager';
@@ -24,6 +26,8 @@ import { GridModel } from './GridModel';
24
26
  const SELECT_SCENE_ALLOWED_OTHER_BLOCK_MODELS = ['JSBlockModel', 'IframeBlockModel', 'MarkdownBlockModel'] as const;
25
27
 
26
28
  export class BlockGridModel extends GridModel {
29
+ private viewActivatedListener?: () => void;
30
+
27
31
  dragOverlayConfig: DragOverlayConfig = {
28
32
  // 列内插入
29
33
  columnInsert: {
@@ -50,6 +54,28 @@ export class BlockGridModel extends GridModel {
50
54
  });
51
55
  }
52
56
 
57
+ onMount() {
58
+ super.onMount();
59
+ if (this.context.view?.inputArgs?.scene !== 'select' || this.viewActivatedListener) {
60
+ return;
61
+ }
62
+
63
+ this.viewActivatedListener = () => {
64
+ this.mapSubModels('items', (item) => {
65
+ (item as FlowModel & { onActive?: () => void }).onActive?.();
66
+ });
67
+ };
68
+ this.flowEngine.emitter.on(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
69
+ }
70
+
71
+ protected onUnmount() {
72
+ if (this.viewActivatedListener) {
73
+ this.flowEngine.emitter.off(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
74
+ this.viewActivatedListener = undefined;
75
+ }
76
+ super.onUnmount();
77
+ }
78
+
53
79
  get subModelBaseClasses() {
54
80
  const inputArgs = this.context.view?.inputArgs ?? {};
55
81
  if (inputArgs.collectionName && !inputArgs.filterByTk) {