@nocobase/client-v2 2.3.0-beta.9 → 2.4.0-alpha.10

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 (117) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/acl/ACLProvider.d.ts +10 -0
  3. package/es/acl/aclCheckReadiness.d.ts +11 -0
  4. package/es/authRedirect.d.ts +11 -2
  5. package/es/components/AttachmentUpload.d.ts +51 -0
  6. package/es/components/index.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  9. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  10. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  11. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  12. package/es/index.d.ts +4 -1
  13. package/es/index.mjs +129 -118
  14. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  15. package/es/nocobase-buildin-plugin/index.d.ts +10 -0
  16. package/es/settings-app/SettingsApplication.d.ts +18 -0
  17. package/es/settings-app/SettingsBrand.d.ts +16 -0
  18. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  19. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  20. package/es/settings-app/SettingsGroupNav.d.ts +18 -0
  21. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  22. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  23. package/es/settings-app/SettingsSearch.d.ts +17 -0
  24. package/es/settings-app/SettingsShell.d.ts +10 -0
  25. package/es/settings-app/isSettingsApp.d.ts +18 -0
  26. package/es/settings-app/runtimePaths.d.ts +18 -0
  27. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  28. package/es/settings-app/settingsTheme.d.ts +87 -0
  29. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  30. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  31. package/es/settings-center/groups.d.ts +66 -0
  32. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  33. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  34. package/es/settings-center/utils.d.ts +24 -0
  35. package/es/theme/index.d.ts +1 -0
  36. package/es/theme/itemActive.d.ts +24 -0
  37. package/es/theme/type.d.ts +9 -0
  38. package/lib/index.js +133 -122
  39. package/package.json +7 -7
  40. package/src/BaseApplication.tsx +2 -0
  41. package/src/RouteRepository.ts +14 -0
  42. package/src/__tests__/RouteRepository.test.ts +51 -0
  43. package/src/__tests__/app.test.tsx +135 -3
  44. package/src/__tests__/authRedirect.test.ts +154 -0
  45. package/src/__tests__/browserChecker.test.ts +172 -6
  46. package/src/__tests__/mockSettingsApplication.ts +29 -0
  47. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +81 -8
  48. package/src/__tests__/plugin-manager.test.tsx +7 -6
  49. package/src/__tests__/settings-application.test.ts +164 -0
  50. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  51. package/src/__tests__/settings-center.test.tsx +168 -130
  52. package/src/__tests__/settings-layout-root.test.tsx +600 -0
  53. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  54. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  55. package/src/__tests__/settings-shell.test.tsx +261 -0
  56. package/src/acl/ACLProvider.tsx +53 -3
  57. package/src/acl/aclCheckReadiness.ts +44 -0
  58. package/src/authRedirect.ts +85 -8
  59. package/src/components/AppComponents.tsx +16 -7
  60. package/src/components/AttachmentUpload.tsx +275 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  63. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  64. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  65. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  66. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +104 -20
  67. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  68. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  69. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  70. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  71. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  72. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  73. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  74. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  75. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  76. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  77. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  78. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  79. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  80. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  81. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
  82. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  83. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
  84. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
  85. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  86. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  87. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  88. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  89. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  91. package/src/index.ts +11 -1
  92. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  93. package/src/nocobase-buildin-plugin/index.tsx +112 -71
  94. package/src/settings-app/SettingsApplication.ts +31 -0
  95. package/src/settings-app/SettingsBrand.tsx +108 -0
  96. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  97. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  98. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  99. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  100. package/src/settings-app/SettingsRouterManager.ts +92 -0
  101. package/src/settings-app/SettingsSearch.tsx +343 -0
  102. package/src/settings-app/SettingsShell.tsx +184 -0
  103. package/src/settings-app/isSettingsApp.ts +21 -0
  104. package/src/settings-app/runtimePaths.ts +103 -0
  105. package/src/settings-app/settingsDocumentPath.ts +66 -0
  106. package/src/settings-app/settingsTheme.ts +369 -0
  107. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  108. package/src/settings-center/AdminSettingsLayout.tsx +154 -169
  109. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  110. package/src/settings-center/__tests__/groups.test.ts +25 -0
  111. package/src/settings-center/groups.ts +108 -0
  112. package/src/settings-center/useSettingsGroups.ts +105 -0
  113. package/src/settings-center/useSettingsSearch.ts +173 -0
  114. package/src/settings-center/utils.tsx +50 -1
  115. package/src/theme/index.tsx +1 -0
  116. package/src/theme/itemActive.ts +31 -0
  117. package/src/theme/type.ts +10 -0
@@ -0,0 +1,48 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { validation } from '../validation';
12
+
13
+ vi.mock('../../../flow-compat', () => ({ FieldValidation: () => null }));
14
+
15
+ describe('validation action', () => {
16
+ it('rejects excess decimal places with a translated error and retains collection rules', async () => {
17
+ const setProps = vi.fn();
18
+ const collectionRule = { validator: vi.fn().mockResolvedValue(undefined) };
19
+ const t = vi.fn((key: string, options?: Record<string, unknown>) => {
20
+ return `${options?.label} 精度不能超过 ${options?.limit} 位小数`;
21
+ });
22
+ const ctx = {
23
+ model: {
24
+ props: { label: '工龄' },
25
+ collectionField: { getComponentProps: () => ({ rules: [collectionRule] }) },
26
+ setProps,
27
+ },
28
+ t,
29
+ } as unknown as Parameters<typeof validation.handler>[0];
30
+
31
+ await validation.handler(ctx, {
32
+ validation: { type: 'number', rules: [{ name: 'precision', args: { limit: 2 } }] },
33
+ });
34
+
35
+ const { rules } = setProps.mock.calls[0][0] as {
36
+ rules: { validator: (rule: unknown, value: unknown) => Promise<void> }[];
37
+ };
38
+ expect(rules).toHaveLength(2);
39
+ expect(rules[0]).toBe(collectionRule);
40
+ await expect(rules[1].validator({}, 39.2234)).rejects.toBe('工龄 精度不能超过 2 位小数');
41
+ expect(t).toHaveBeenCalledWith(
42
+ 'number.precision',
43
+ expect.objectContaining({ ns: 'data-source-main', label: '工龄', limit: 2 }),
44
+ );
45
+ await expect(rules[1].validator({}, 39.22)).resolves.toBeUndefined();
46
+ await expect(rules[1].validator({}, null)).resolves.toBeUndefined();
47
+ });
48
+ });
@@ -18,7 +18,6 @@ import { theme as antdTheme, ConfigProvider, Grid, Popover } from 'antd';
18
18
  import { createStyles, createGlobalStyle } from 'antd-style';
19
19
  import React, { FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
20
20
  import ReactDOM from 'react-dom';
21
- import { useTranslation } from 'react-i18next';
22
21
  import { useLocation } from 'react-router-dom';
23
22
  import {
24
23
  AdminLayoutMenuModelRenderer,
@@ -35,7 +34,6 @@ import { ResetThemeTokenAndKeepAlgorithm } from './ResetThemeTokenAndKeepAlgorit
35
34
  import { PinnedPluginListLite } from './PinnedPluginListLite';
36
35
  import { useApplications } from './useApplications';
37
36
  import { useGlobalTheme, type CustomToken } from '../../../theme';
38
- import { useSystemSettings } from '../../system-settings';
39
37
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
40
38
  import {
41
39
  FLOW_SETTINGS_PREFERENCE_CHANGE_EVENT,
@@ -44,60 +42,7 @@ import {
44
42
  } from './flowSettingsPreference';
45
43
  import { joinAdminLayoutRoutePath, type AdminLayoutRoutePathLike } from './resolveAdminRouteRuntimeTarget';
46
44
  import { useAppListRender } from './AppListRender';
47
-
48
- const className1 = css`
49
- height: var(--nb-header-height);
50
- margin-right: 4px;
51
- display: inline-flex;
52
- flex-shrink: 0;
53
- color: #fff;
54
- padding: 0;
55
- align-items: center;
56
- `;
57
- const className1WithFixedWidth = css`
58
- ${className1}
59
- width: 168px;
60
- `;
61
- const className1WithAutoWidth = css`
62
- ${className1}
63
- width: auto;
64
- min-width: 168px;
65
- `;
66
- const className2 = css`
67
- object-fit: contain;
68
- width: 100%;
69
- height: 100%;
70
- `;
71
- const className3 = css`
72
- width: 100%;
73
- height: 100%;
74
- font-weight: 500;
75
- text-align: center;
76
- overflow: hidden;
77
- text-overflow: ellipsis;
78
- white-space: nowrap;
79
- `;
80
-
81
- const NocoBaseLogo = observer(() => {
82
- const { token } = antdTheme.useToken();
83
- const customToken = token as CustomToken;
84
- const result = useSystemSettings();
85
- const { t } = useTranslation('lm-collections');
86
- const fontSizeStyle = useMemo(() => ({ fontSize: customToken.fontSizeHeading3 }), [customToken.fontSizeHeading3]);
87
-
88
- const hasLogo = result?.data?.data?.logo?.url;
89
- const logo = hasLogo ? (
90
- <img className={className2} src={result?.data?.data?.logo?.url} />
91
- ) : (
92
- <span style={fontSizeStyle} className={className3}>
93
- {t(result?.data?.data?.title)}
94
- </span>
95
- );
96
-
97
- return (
98
- <div className={hasLogo ? className1WithFixedWidth : className1WithAutoWidth}>{result?.loading ? null : logo}</div>
99
- );
100
- });
45
+ import { NocoBaseLogo } from './NocoBaseLogo';
101
46
 
102
47
  const resetStyle = css`
103
48
  .ant-layout-sider-children {
@@ -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
  });
@@ -9,7 +9,7 @@
9
9
 
10
10
  import React from 'react';
11
11
  import { fireEvent, render, screen } from '@testing-library/react';
12
- import { MemoryRouter, useLocation } from 'react-router-dom';
12
+ import { MemoryRouter } from 'react-router-dom';
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
14
 
15
15
  const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
@@ -17,6 +17,7 @@ const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
17
17
  allowMock: vi.fn(),
18
18
  appMock: {
19
19
  current: {
20
+ name: 'main',
20
21
  router: {
21
22
  getBasename: () => '/nocobase/v',
22
23
  },
@@ -86,6 +87,7 @@ describe('TopbarActionsBar helpers', () => {
86
87
  allowMock.mockReset();
87
88
  flowModelRendererSpy.mockClear();
88
89
  appMock.current = {
90
+ name: 'main',
89
91
  router: {
90
92
  getBasename: () => '/nocobase/v',
91
93
  },
@@ -136,6 +138,16 @@ describe('TopbarActionsBar helpers', () => {
136
138
  title: 'Plugin manager',
137
139
  path: '/admin/settings/plugin-manager',
138
140
  icon: null,
141
+ sort: -200,
142
+ componentLoader: async () => null,
143
+ },
144
+ {
145
+ key: 'multi-portal',
146
+ name: 'multi-portal',
147
+ title: 'Portal manager',
148
+ path: '/admin/settings/multi-portal',
149
+ icon: null,
150
+ sort: -300,
139
151
  componentLoader: async () => null,
140
152
  },
141
153
  {
@@ -159,13 +171,14 @@ describe('TopbarActionsBar helpers', () => {
159
171
  });
160
172
 
161
173
  expect((items as any[]).map((item) => item.type || item.key)).toEqual([
174
+ 'multi-portal',
162
175
  'plugin-manager',
163
176
  'divider',
164
177
  'system-settings',
165
178
  'divider',
166
179
  'security',
167
180
  ]);
168
- expect((items as any[])[2]).toMatchObject({
181
+ expect((items as any[]).find((item) => item.key === 'system-settings')).toMatchObject({
169
182
  key: 'system-settings',
170
183
  name: 'system-settings',
171
184
  path: '/admin/settings/system-settings',
@@ -191,7 +204,7 @@ describe('TopbarActionsBar helpers', () => {
191
204
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
192
205
 
193
206
  const link = screen.getByRole('link', { name: 'System settings' });
194
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
207
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
195
208
  expect(link).toHaveAttribute('target', '_blank');
196
209
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
197
210
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -216,7 +229,7 @@ describe('TopbarActionsBar helpers', () => {
216
229
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
217
230
 
218
231
  const link = screen.getByRole('link', { name: 'Plugin manager' });
219
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/plugin-manager');
232
+ expect(link).toHaveAttribute('href', '/nocobase/settings/plugin-manager');
220
233
  expect(link).toHaveAttribute('target', '_blank');
221
234
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
222
235
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
@@ -241,13 +254,13 @@ describe('TopbarActionsBar helpers', () => {
241
254
  renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
242
255
 
243
256
  const link = screen.getByRole('link', { name: 'System settings' });
244
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
257
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/system-settings');
245
258
  expect(link).toHaveAttribute('target', '_blank');
246
259
  expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
247
260
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
248
261
  });
249
262
 
250
- it('should keep regular admin settings as SPA links inside admin runtime', () => {
263
+ it('should open regular admin settings in the standalone SPA from admin runtime', () => {
251
264
  const items = getTopbarPluginSettingsItems({
252
265
  canManagePlugins: false,
253
266
  t: (key) => key,
@@ -266,13 +279,38 @@ describe('TopbarActionsBar helpers', () => {
266
279
  renderSettingsLabel((items as any[])[0].label, '/admin/settings/routes');
267
280
 
268
281
  const link = screen.getByRole('link', { name: 'Routes' });
269
- expect(link).toHaveAttribute('href', '/admin/settings/routes');
270
- expect(link).not.toHaveAttribute('target', '_blank');
282
+ expect(link).toHaveAttribute('href', '/nocobase/settings/routes');
283
+ expect(link).toHaveAttribute('target', '_blank');
284
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
271
285
  });
272
286
 
287
+ it('should open sub-app settings in the standalone SPA from sub-app admin runtime', () => {
288
+ const items = getTopbarPluginSettingsItems({
289
+ canManagePlugins: false,
290
+ t: (key) => key,
291
+ settings: [
292
+ {
293
+ key: 'routes',
294
+ name: 'routes',
295
+ title: 'Routes',
296
+ path: '/admin/settings/routes',
297
+ icon: null,
298
+ componentLoader: async () => null,
299
+ },
300
+ ] as any,
301
+ });
302
+
303
+ renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
304
+
305
+ const link = screen.getByRole('link', { name: 'Routes' });
306
+ expect(link).toHaveAttribute('href', '/nocobase/settings/apps/a_9xlild35jir/routes');
307
+ expect(link).toHaveAttribute('target', '_blank');
308
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
309
+ });
273
310
  it.each(['/nocobase/v', '/v', '/nocobase/v/apps/jhb20'])(
274
- 'should navigate sub-app settings without document navigation with basename %s',
311
+ 'should open standalone sub-app settings with basename %s',
275
312
  (basename) => {
313
+ appMock.current.name = 'jhb20';
276
314
  appMock.current.router.getBasename = () => basename;
277
315
  const items = getTopbarPluginSettingsItems({
278
316
  canManagePlugins: false,
@@ -293,22 +331,17 @@ describe('TopbarActionsBar helpers', () => {
293
331
  throw new Error('Expected settings menu item');
294
332
  }
295
333
  const appBase = basename.endsWith('/apps/jhb20') ? basename : `${basename}/apps/jhb20`;
296
- const targetHref = `${appBase}/admin/settings/ai`;
297
- const LocationDisplay = () => <output aria-label="Current route">{useLocation().pathname}</output>;
334
+ const targetHref = `${basename === '/v' ? '' : '/nocobase'}/settings/apps/jhb20/ai`;
298
335
  render(
299
336
  <MemoryRouter basename={basename} initialEntries={[`${appBase}/admin/a3pq1t1773a`]}>
300
337
  {item.label}
301
- <LocationDisplay />
302
338
  </MemoryRouter>,
303
339
  );
304
340
 
305
341
  const link = screen.getByRole('link', { name: 'AI employees' });
306
342
  expect(link).toHaveAttribute('href', targetHref);
307
- expect(link).not.toHaveAttribute('target', '_blank');
308
- const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
309
- fireEvent(link, click);
310
- expect(click.defaultPrevented).toBe(true);
311
- expect(screen.getByLabelText('Current route')).toHaveTextContent(targetHref.slice(basename.length));
343
+ expect(link).toHaveAttribute('target', '_blank');
344
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
312
345
  },
313
346
  );
314
347
 
@@ -331,11 +364,11 @@ describe('TopbarActionsBar helpers', () => {
331
364
  renderSettingsLabel((items as any[])[0].label, '/admin2/foo');
332
365
 
333
366
  const link = screen.getByRole('link', { name: 'System settings' });
334
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
367
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
335
368
  expect(link).toHaveAttribute('target', '_blank');
336
369
  });
337
370
 
338
- it('should not duplicate the basename when building new-tab settings hrefs', () => {
371
+ it('should remove the modern basename when building standalone settings hrefs', () => {
339
372
  const items = getTopbarPluginSettingsItems({
340
373
  canManagePlugins: false,
341
374
  t: (key) => key,
@@ -354,7 +387,30 @@ describe('TopbarActionsBar helpers', () => {
354
387
  renderSettingsLabel((items as any[])[0].label, '/sales/p1');
355
388
 
356
389
  const link = screen.getByRole('link', { name: 'System settings' });
357
- expect(link).toHaveAttribute('href', '/nocobase/v/admin/settings/system-settings');
390
+ expect(link).toHaveAttribute('href', '/nocobase/settings/system-settings');
391
+ });
392
+
393
+ it('should preserve the new sub-app scope in standalone settings hrefs', () => {
394
+ const items = getTopbarPluginSettingsItems({
395
+ canManagePlugins: false,
396
+ t: (key) => key,
397
+ settings: [
398
+ {
399
+ key: 'system-settings',
400
+ name: 'system-settings',
401
+ title: 'System settings',
402
+ path: '/admin/settings/system-settings',
403
+ icon: null,
404
+ componentLoader: async () => null,
405
+ },
406
+ ] as any,
407
+ });
408
+
409
+ renderSettingsLabel((items as any[])[0].label, '/_app/a_new/admin/settings/system-settings');
410
+
411
+ const link = screen.getByRole('link', { name: 'System settings' });
412
+ expect(link).toHaveAttribute('href', '/nocobase/settings/_app/a_new/system-settings');
413
+ expect(link).toHaveAttribute('target', '_blank');
358
414
  });
359
415
 
360
416
  it('should keep external settings opening in a new tab', () => {
@@ -421,6 +477,34 @@ describe('TopbarActionsBar', () => {
421
477
  vi.restoreAllMocks();
422
478
  });
423
479
 
480
+ it('should group plugin settings with Help and user center', () => {
481
+ render(
482
+ <TopbarActionsBar
483
+ actions={[
484
+ createAction({ uid: 'notification', actionId: 'notification' }),
485
+ createAction({ uid: 'plugin-settings', actionId: 'plugin-settings' }),
486
+ createAction({ uid: 'user-center', actionId: 'user-center' }),
487
+ ]}
488
+ />,
489
+ );
490
+
491
+ const notification = screen.getByTestId('flow-model-notification');
492
+ const pluginSettings = screen.getByTestId('flow-model-plugin-settings');
493
+ const help = screen.getByTestId('help-lite');
494
+ const userCenter = screen.getByTestId('flow-model-user-center');
495
+ const mainGroup = notification.closest('.nb-topbar-actions-list');
496
+ const utilityGroup = pluginSettings.closest('.nb-topbar-utility-actions-list');
497
+
498
+ expect(mainGroup).toContainElement(notification);
499
+ expect(mainGroup).not.toContainElement(pluginSettings);
500
+ expect(utilityGroup).not.toBeNull();
501
+ expect(utilityGroup).toContainElement(pluginSettings);
502
+ expect(utilityGroup).toContainElement(help);
503
+ expect(utilityGroup).toContainElement(userCenter);
504
+ expect(pluginSettings.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
505
+ expect(help.compareDocumentPosition(userCenter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
506
+ });
507
+
424
508
  it('should keep HelpLite rendered when one action fails', () => {
425
509
  vi.spyOn(console, 'error').mockImplementation(() => {});
426
510
 
@@ -130,7 +130,9 @@ export interface VariableFilterItemProps {
130
130
  rightAsVariable?: boolean;
131
131
  /**
132
132
  * 右侧 VariableInput 的 metaTree 提供器;
133
- * 默认使用整棵 ctx 的 metaTree:model.context.getPropertyMetaTree()
133
+ * 默认使用视图上下文的 metaTree:useFlowViewContext().getPropertyMetaTree()。
134
+ * 注意它取自视图上下文而非 `model.context`:当调用方传入的 model 与打开设置弹窗的模型不是同一个时,
135
+ * 右侧变量不会跟随 model 变化,需要显式传入本属性。
134
136
  */
135
137
  rightMetaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
136
138
  /**
@@ -139,9 +141,16 @@ export interface VariableFilterItemProps {
139
141
  rightVariableConverters?: Pick<Converters, 'resolvePathFromValue' | 'resolveValueFromPath'>;
140
142
  ignoreFieldNames?: string[];
141
143
  maxAssociationFieldDepth?: number;
144
+ /**
145
+ * 右侧变量树的关联层级上限,默认与 `maxAssociationFieldDepth` 一致。
146
+ * 传 `null` 表示不限制:右侧是变量树而非集合字段树,其深度可能已由调用方约束
147
+ * (例如工作流的变量树由触发器的「预加载关系数据」决定),此时再按左侧的层级上限裁剪
148
+ * 会让已配置好的深层变量选不到。
149
+ */
150
+ rightMaxAssociationFieldDepth?: number | null;
142
151
  }
143
152
 
144
- function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number) {
153
+ function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number | null) {
145
154
  if (typeof maxAssociationFieldDepth !== 'number') {
146
155
  return nodes;
147
156
  }
@@ -367,6 +376,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
367
376
  rightVariableConverters,
368
377
  ignoreFieldNames,
369
378
  maxAssociationFieldDepth,
379
+ rightMaxAssociationFieldDepth = maxAssociationFieldDepth,
370
380
  }) => {
371
381
  // 使用 View 上下文,确保可访问 ctx.view 的异步子树
372
382
  const ctx = useFlowViewContext();
@@ -685,10 +695,10 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
685
695
  { title: t('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
686
696
  ...nodes,
687
697
  ],
688
- maxAssociationFieldDepth,
698
+ rightMaxAssociationFieldDepth,
689
699
  );
690
700
  };
691
- }, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t, maxAssociationFieldDepth]);
701
+ }, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t, rightMaxAssociationFieldDepth]);
692
702
 
693
703
  // 当启用右侧变量输入时,构造 VariableInput 的 converters:
694
704
  // - 变量模式:返回 null 让 VariableInput 渲染 VariableTag