@nocobase/client-v2 2.1.0-beta.27 → 2.1.0-beta.30

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 (68) hide show
  1. package/es/components/form/JsonTextArea.d.ts +18 -0
  2. package/es/components/index.d.ts +1 -0
  3. package/es/flow/actions/dateRangeLimit.d.ts +9 -0
  4. package/es/flow/actions/index.d.ts +2 -1
  5. package/es/flow/actions/linkageRules.d.ts +2 -0
  6. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  7. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  8. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  9. package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
  10. package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
  11. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
  12. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +5 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
  14. package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
  15. package/es/index.mjs +79 -67
  16. package/lib/index.js +80 -68
  17. package/package.json +6 -5
  18. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  19. package/src/__tests__/settings-center.test.tsx +30 -0
  20. package/src/components/form/JsonTextArea.tsx +129 -0
  21. package/src/components/index.ts +1 -0
  22. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  23. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  24. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
  25. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  26. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  27. package/src/flow/actions/__tests__/pattern.test.ts +190 -0
  28. package/src/flow/actions/dateRangeLimit.tsx +66 -0
  29. package/src/flow/actions/index.ts +3 -0
  30. package/src/flow/actions/linkageRules.tsx +194 -42
  31. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  32. package/src/flow/actions/openView.tsx +2 -1
  33. package/src/flow/actions/pattern.tsx +25 -2
  34. package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
  35. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
  36. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  37. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  38. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  39. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  41. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  42. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  43. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  44. package/src/flow/components/AdminLayout.tsx +2 -2
  45. package/src/flow/components/FlowRoute.tsx +17 -4
  46. package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
  47. package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
  48. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
  49. package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
  50. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
  51. package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
  52. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  53. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +34 -3
  54. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
  55. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
  56. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
  57. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -1
  58. package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
  59. package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
  60. package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
  61. package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
  62. package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
  63. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
  64. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
  65. package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
  66. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
  67. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
  68. package/src/flow/system-settings/useSystemSettings.tsx +36 -1
@@ -8,15 +8,21 @@
8
8
  */
9
9
 
10
10
  import type { BaseApplication } from '../../../BaseApplication';
11
- import { convertV2AdminPathToLegacy } from '../../../authRedirect';
12
11
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
13
12
 
14
13
  export type AdminRouteNavigationMode = 'spa' | 'document';
14
+ export type AdminRouteRuntimeTargetReason =
15
+ | 'ok'
16
+ | 'missingSchemaUid'
17
+ | 'unsupportedV2Runtime'
18
+ | 'emptyGroup'
19
+ | 'unsupportedRouteType';
15
20
 
16
21
  export type AdminRouteRuntimeTarget = {
17
22
  runtimePath: string | null;
18
23
  navigationMode: AdminRouteNavigationMode;
19
24
  isLegacy: boolean;
25
+ reason: AdminRouteRuntimeTargetReason;
20
26
  };
21
27
 
22
28
  const V2_PUBLIC_PATH_SUFFIX = '/v2/';
@@ -45,6 +51,7 @@ const EMPTY_TARGET: AdminRouteRuntimeTarget = {
45
51
  runtimePath: null,
46
52
  navigationMode: 'spa',
47
53
  isLegacy: false,
54
+ reason: 'unsupportedRouteType',
48
55
  };
49
56
 
50
57
  function normalizeRootRelativePath(pathname: string) {
@@ -60,14 +67,8 @@ function normalizePublicPath(value = '/') {
60
67
  return normalized.endsWith('/') ? normalized : `${normalized}/`;
61
68
  }
62
69
 
63
- /**
64
- * 判断当前应用是否运行在需要兼容跳回 v1 的 `/v2/` 路径下。
65
- *
66
- * @param {ResolveAdminRouteRuntimeTargetOptions['app']} app 当前应用实例
67
- * @returns {boolean} 是否启用 v2 到 v1 的经典页跳转
68
- */
69
- function shouldUseLegacyDocumentNavigation(app: ResolveAdminRouteRuntimeTargetOptions['app']) {
70
- return normalizePublicPath(app.getPublicPath()).endsWith(V2_PUBLIC_PATH_SUFFIX);
70
+ export function isV2AdminRuntime(app?: ResolveAdminRouteRuntimeTargetOptions['app']) {
71
+ return !!app?.getPublicPath && normalizePublicPath(app.getPublicPath()).endsWith(V2_PUBLIC_PATH_SUFFIX);
71
72
  }
72
73
 
73
74
  export function toRouterNavigationPath(pathname: string, basename?: string) {
@@ -114,12 +115,6 @@ function appendLocationState(pathname: string, location?: LocationLike) {
114
115
  return `${pathname}${search}${hash}`;
115
116
  }
116
117
 
117
- function isSameOrDescendantPath(pathname: string, basePath: string) {
118
- const normalizedPathname = normalizeRootRelativePath(pathname);
119
- const normalizedBasePath = normalizeRootRelativePath(basePath);
120
- return normalizedPathname === normalizedBasePath || normalizedPathname.startsWith(`${normalizedBasePath}/`);
121
- }
122
-
123
118
  function logInvalidTarget(
124
119
  logger: ResolveAdminRouteRuntimeTargetOptions['log'],
125
120
  reason: string,
@@ -134,6 +129,47 @@ function isSkippableRoute(route: NocoBaseDesktopRoute | undefined) {
134
129
  );
135
130
  }
136
131
 
132
+ export function isV2MenuRoute(route: NocoBaseDesktopRoute | undefined): boolean {
133
+ if (!route || route.hidden === true || route.hideInMenu === true) {
134
+ return false;
135
+ }
136
+
137
+ if (route.type === NocoBaseDesktopRouteType.flowPage || route.type === NocoBaseDesktopRouteType.link) {
138
+ return true;
139
+ }
140
+
141
+ if (route.type === NocoBaseDesktopRouteType.group) {
142
+ return Array.isArray(route.children) && route.children.some((child) => isV2MenuRoute(child));
143
+ }
144
+
145
+ return false;
146
+ }
147
+
148
+ export function findFirstV2LandingRoute(routes: NocoBaseDesktopRoute[] | undefined): NocoBaseDesktopRoute | undefined {
149
+ if (!Array.isArray(routes)) {
150
+ return undefined;
151
+ }
152
+
153
+ for (const route of routes) {
154
+ if (!route || route.hidden === true || route.hideInMenu === true || route.type === NocoBaseDesktopRouteType.tabs) {
155
+ continue;
156
+ }
157
+
158
+ if (route.type === NocoBaseDesktopRouteType.flowPage) {
159
+ return route;
160
+ }
161
+
162
+ if (route.type === NocoBaseDesktopRouteType.group) {
163
+ const nested = findFirstV2LandingRoute(route.children);
164
+ if (nested) {
165
+ return nested;
166
+ }
167
+ }
168
+ }
169
+
170
+ return undefined;
171
+ }
172
+
137
173
  export function findFirstAccessiblePageRoute(
138
174
  routes: NocoBaseDesktopRoute[] | undefined,
139
175
  ): NocoBaseDesktopRoute | undefined {
@@ -161,64 +197,44 @@ export function findFirstAccessiblePageRoute(
161
197
  return undefined;
162
198
  }
163
199
 
164
- function resolvePageRuntimeTarget(options: ResolveAdminRouteRuntimeTargetOptions, route: NocoBaseDesktopRoute) {
200
+ function resolvePageRuntimeTarget(
201
+ options: ResolveAdminRouteRuntimeTargetOptions,
202
+ route: NocoBaseDesktopRoute,
203
+ ): AdminRouteRuntimeTarget {
165
204
  const { app, preserveLocationState, location, log = console.warn } = options;
166
205
 
167
206
  if (!route.schemaUid) {
168
207
  logInvalidTarget(log, 'Missing schemaUid.', route);
169
- return EMPTY_TARGET;
170
- }
171
-
172
- if (route.type === NocoBaseDesktopRouteType.flowPage) {
173
208
  return {
174
- runtimePath: getV2AdminPath(app, route.schemaUid),
175
- navigationMode: 'spa' as const,
176
- isLegacy: false,
209
+ ...EMPTY_TARGET,
210
+ reason: 'missingSchemaUid',
177
211
  };
178
212
  }
179
213
 
180
- if (!shouldUseLegacyDocumentNavigation(app)) {
214
+ if (route.type === NocoBaseDesktopRouteType.flowPage) {
181
215
  return {
182
- runtimePath:
183
- preserveLocationState && location
184
- ? appendLocationState(getV2AdminPath(app, route.schemaUid), location)
185
- : getV2AdminPath(app, route.schemaUid),
216
+ runtimePath: getV2AdminPath(app, route.schemaUid),
186
217
  navigationMode: 'spa' as const,
187
218
  isLegacy: false,
219
+ reason: 'ok' as const,
188
220
  };
189
221
  }
190
222
 
191
- const v2RuntimePath = getV2AdminPath(app, route.schemaUid);
192
- const legacyPath = convertV2AdminPathToLegacy(app, v2RuntimePath);
193
-
194
- if (!legacyPath) {
195
- logInvalidTarget(log, 'Failed to resolve legacy runtimePath.', route);
196
- return EMPTY_TARGET;
197
- }
198
-
199
- if (preserveLocationState && location) {
200
- if (isSameOrDescendantPath(location.pathname, v2RuntimePath)) {
201
- const correctedCurrentPath = convertV2AdminPathToLegacy(app, location);
202
- if (correctedCurrentPath) {
203
- return {
204
- runtimePath: correctedCurrentPath,
205
- navigationMode: 'document' as const,
206
- isLegacy: true,
207
- };
208
- }
209
- }
210
-
223
+ if (isV2AdminRuntime(app)) {
211
224
  return {
212
- runtimePath: appendLocationState(legacyPath, location),
213
- navigationMode: 'document' as const,
214
- isLegacy: true,
225
+ ...EMPTY_TARGET,
226
+ reason: 'unsupportedV2Runtime',
215
227
  };
216
228
  }
217
229
 
218
230
  return {
219
- runtimePath: legacyPath,
220
- navigationMode: 'document' as const,
221
- isLegacy: true,
231
+ runtimePath:
232
+ preserveLocationState && location
233
+ ? appendLocationState(getV2AdminPath(app, route.schemaUid), location)
234
+ : getV2AdminPath(app, route.schemaUid),
235
+ navigationMode: 'spa' as const,
236
+ isLegacy: false,
237
+ reason: 'ok' as const,
222
238
  };
223
239
  }
224
240
 
@@ -242,9 +258,14 @@ export function resolveAdminRouteRuntimeTarget(
242
258
  }
243
259
 
244
260
  if (route.type === NocoBaseDesktopRouteType.group) {
245
- const firstAccessibleRoute = findFirstAccessiblePageRoute(route.children);
261
+ const firstAccessibleRoute = isV2AdminRuntime(options.app)
262
+ ? findFirstV2LandingRoute(route.children)
263
+ : findFirstAccessiblePageRoute(route.children);
246
264
  if (!firstAccessibleRoute) {
247
- return EMPTY_TARGET;
265
+ return {
266
+ ...EMPTY_TARGET,
267
+ reason: 'emptyGroup',
268
+ };
248
269
  }
249
270
  return resolveAdminRouteRuntimeTarget({
250
271
  ...options,
@@ -14,7 +14,7 @@ import { useApp } from '../../hooks/useApp';
14
14
  import { useLocation, useNavigate, useParams } from 'react-router-dom';
15
15
  import { NocoBaseDesktopRouteType } from '../../flow-compat';
16
16
  import {
17
- findFirstAccessiblePageRoute,
17
+ findFirstV2LandingRoute,
18
18
  resolveAdminRouteRuntimeTarget,
19
19
  toRouterNavigationPath,
20
20
  } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
@@ -92,7 +92,7 @@ const AdminLayoutEntryGuard: FC<{ children: React.ReactNode }> = ({ children })
92
92
  return;
93
93
  }
94
94
 
95
- const firstAccessibleRoute = findFirstAccessiblePageRoute(routeRepository?.listAccessible?.() || []);
95
+ const firstAccessibleRoute = findFirstV2LandingRoute(routeRepository?.listAccessible?.() || []);
96
96
  if (!firstAccessibleRoute) {
97
97
  if (active) {
98
98
  setReady(true);
@@ -15,10 +15,12 @@ import { useParams } from 'react-router-dom';
15
15
  import { useApp } from '../../hooks/useApp';
16
16
  import { NocoBaseDesktopRouteType } from '../../flow-compat';
17
17
  import { resolveAdminRouteRuntimeTarget } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
18
+ import { AppNotFound } from '../../components';
18
19
 
19
20
  type FlowRouteGuardState = {
20
21
  pending: boolean;
21
22
  allowBridge: boolean;
23
+ notFound: boolean;
22
24
  };
23
25
 
24
26
  const BridgeFlowRoute = ({ pageUid }: { pageUid: string }) => {
@@ -91,6 +93,7 @@ const FlowRoute = () => {
91
93
  const [guardState, setGuardState] = useState<FlowRouteGuardState>({
92
94
  pending: true,
93
95
  allowBridge: false,
96
+ notFound: false,
94
97
  });
95
98
  const replaceTriggeredRef = useRef(false);
96
99
  const requestIdRef = useRef(0);
@@ -104,14 +107,14 @@ const FlowRoute = () => {
104
107
  const requestId = ++requestIdRef.current;
105
108
 
106
109
  const run = async () => {
107
- setGuardState({ pending: true, allowBridge: false });
110
+ setGuardState({ pending: true, allowBridge: false, notFound: false });
108
111
 
109
112
  if (!routeRepository?.isAccessibleLoaded?.()) {
110
113
  try {
111
114
  await routeRepository?.ensureAccessibleLoaded?.();
112
115
  } catch (_error) {
113
116
  if (active && requestId === requestIdRef.current) {
114
- setGuardState({ pending: false, allowBridge: true });
117
+ setGuardState({ pending: false, allowBridge: true, notFound: false });
115
118
  }
116
119
  return;
117
120
  }
@@ -139,10 +142,17 @@ const FlowRoute = () => {
139
142
  window.location.replace(target.runtimePath);
140
143
  return;
141
144
  }
145
+
146
+ if (target.reason === 'unsupportedV2Runtime') {
147
+ if (active && requestId === requestIdRef.current) {
148
+ setGuardState({ pending: false, allowBridge: false, notFound: true });
149
+ }
150
+ return;
151
+ }
142
152
  }
143
153
 
144
154
  if (active && requestId === requestIdRef.current) {
145
- setGuardState({ pending: false, allowBridge: true });
155
+ setGuardState({ pending: false, allowBridge: true, notFound: false });
146
156
  }
147
157
  };
148
158
 
@@ -159,11 +169,14 @@ const FlowRoute = () => {
159
169
  }
160
170
 
161
171
  if (!guardState.allowBridge) {
172
+ if (guardState.notFound) {
173
+ return <AppNotFound />;
174
+ }
162
175
  return null;
163
176
  }
164
177
 
165
178
  return <BridgeFlowRoute pageUid={pageUid} />;
166
- }, [guardState.allowBridge, guardState.pending, pageUid]);
179
+ }, [guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
167
180
 
168
181
  return content;
169
182
  };
@@ -49,6 +49,17 @@ export class PageModel extends FlowModel<PageModelStructure> {
49
49
  private unmounted = false;
50
50
  private documentTitleUpdateVersion = 0;
51
51
 
52
+ /**
53
+ * 根页面标签页开关以路由表为准,避免 flow model 里的旧配置覆盖路由管理设置。
54
+ */
55
+ private getEnableTabs(): boolean {
56
+ const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
57
+ if (this.props.routeId != null && typeof routeEnableTabs === 'boolean') {
58
+ return routeEnableTabs;
59
+ }
60
+ return !!this.props.enableTabs;
61
+ }
62
+
52
63
  private getActiveTabKey(): string | undefined {
53
64
  const viewParams = this.context.view?.navigation?.viewParams;
54
65
  if (viewParams) {
@@ -193,7 +204,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
193
204
  };
194
205
 
195
206
  let nextTitle = '';
196
- if (this.props.enableTabs) {
207
+ if (this.getEnableTabs()) {
197
208
  const activeTabKey = preferredActiveTabKey || this.getActiveTabKey();
198
209
  const activeTabModel = activeTabKey
199
210
  ? (this.flowEngine.getModel(activeTabKey) as BasePageTabModel | undefined)
@@ -356,13 +367,14 @@ export class PageModel extends FlowModel<PageModelStructure> {
356
367
  headerStyle.paddingBlock = token.paddingSM;
357
368
  headerStyle.paddingInline = token.paddingLG;
358
369
  }
359
- if (this.props.enableTabs) {
370
+ const enableTabs = this.getEnableTabs();
371
+ if (enableTabs) {
360
372
  headerStyle.paddingBottom = 0;
361
373
  }
362
374
  return (
363
375
  <>
364
376
  {this.props.displayTitle && <PageHeader title={this.props.title} style={headerStyle} />}
365
- {this.props.enableTabs ? this.renderTabs() : this.renderFirstTab()}
377
+ {enableTabs ? this.renderTabs() : this.renderFirstTab()}
366
378
  </>
367
379
  );
368
380
  }
@@ -17,6 +17,31 @@ import { PageModel } from './PageModel';
17
17
  export class RootPageModel extends PageModel {
18
18
  mounted = false;
19
19
 
20
+ /**
21
+ * 打开页面设置前,把标签页开关表单值同步为路由表中的当前状态。
22
+ */
23
+ private syncPageSettingsEnableTabsFromRoute() {
24
+ const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
25
+ if (typeof routeEnableTabs !== 'boolean') {
26
+ return;
27
+ }
28
+ this.setStepParams('pageSettings', 'general', {
29
+ enableTabs: routeEnableTabs,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * 保存页面设置后立即同步当前页面状态,让标签页显隐无需等路由列表刷新或页面重载。
35
+ */
36
+ private syncEnableTabsToCurrentPage(enableTabs: boolean) {
37
+ const currentRoute = (this.context as any)?.currentRoute;
38
+ const routeId = this.props.routeId;
39
+ if (currentRoute && (routeId == null || currentRoute.id == null || String(currentRoute.id) === String(routeId))) {
40
+ currentRoute.enableTabs = enableTabs;
41
+ }
42
+ this.setProps('enableTabs', enableTabs);
43
+ }
44
+
20
45
  /**
21
46
  * 新建 tab 在首次保存完成前,前端 route 里可能还没有数据库 id。
22
47
  * 拖拽前兜底触发一次保存,确保 move 接口拿到真实主键。
@@ -65,18 +90,28 @@ export class RootPageModel extends PageModel {
65
90
  );
66
91
  }
67
92
 
93
+ async openFlowSettings(options?: Parameters<PageModel['openFlowSettings']>[0]) {
94
+ if (options?.flowKey === 'pageSettings' && options?.stepKey === 'general') {
95
+ this.syncPageSettingsEnableTabsFromRoute();
96
+ }
97
+ return super.openFlowSettings(options);
98
+ }
99
+
68
100
  async saveStepParams() {
69
101
  await super.saveStepParams();
70
102
 
71
103
  if (this.stepParams.pageSettings) {
104
+ const enableTabs = !!this.stepParams.pageSettings.general.enableTabs;
72
105
  // 更新路由
73
- this.context.api.request({
106
+ await this.context.api.request({
74
107
  url: `desktopRoutes:update?filter[id]=${this.props.routeId}`,
75
108
  method: 'post',
76
109
  data: {
77
- enableTabs: !!this.stepParams.pageSettings.general.enableTabs,
110
+ enableTabs,
78
111
  },
79
112
  });
113
+ this.syncEnableTabsToCurrentPage(enableTabs);
114
+ await this.context.refreshDesktopRoutes?.();
80
115
  }
81
116
  }
82
117
 
@@ -412,6 +412,7 @@ describe('PageModel', () => {
412
412
  describe('render header spacing with tabs', () => {
413
413
  it('should compact page header bottom spacing when tabs are enabled', () => {
414
414
  pageModel.props = {
415
+ routeId: 'route-1',
415
416
  displayTitle: true,
416
417
  enableTabs: true,
417
418
  title: 'Title',
@@ -430,6 +431,7 @@ describe('PageModel', () => {
430
431
 
431
432
  it('should keep original header style when tabs are disabled', () => {
432
433
  pageModel.props = {
434
+ routeId: 'route-1',
433
435
  displayTitle: true,
434
436
  enableTabs: false,
435
437
  title: 'Title',
@@ -442,6 +444,57 @@ describe('PageModel', () => {
442
444
 
443
445
  expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
444
446
  });
447
+
448
+ it('should use desktop route enableTabs=false before flow model props', () => {
449
+ pageModel.props = {
450
+ routeId: 'route-1',
451
+ displayTitle: true,
452
+ enableTabs: true,
453
+ title: 'Title',
454
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
455
+ } as any;
456
+ (pageModel as any).context = {
457
+ currentRoute: {
458
+ enableTabs: false,
459
+ },
460
+ };
461
+ pageModel.renderTabs = vi.fn(() => null);
462
+ pageModel.renderFirstTab = vi.fn(() => null);
463
+
464
+ const result = pageModel.render() as any;
465
+ const header = result.props.children[0];
466
+
467
+ expect(pageModel.renderTabs).not.toHaveBeenCalled();
468
+ expect(pageModel.renderFirstTab).toHaveBeenCalled();
469
+ expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
470
+ });
471
+
472
+ it('should use desktop route enableTabs=true before flow model props', () => {
473
+ pageModel.props = {
474
+ routeId: 'route-1',
475
+ displayTitle: true,
476
+ enableTabs: false,
477
+ title: 'Title',
478
+ headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
479
+ } as any;
480
+ (pageModel as any).context = {
481
+ currentRoute: {
482
+ enableTabs: true,
483
+ },
484
+ };
485
+ pageModel.renderTabs = vi.fn(() => null);
486
+ pageModel.renderFirstTab = vi.fn(() => null);
487
+
488
+ const result = pageModel.render() as any;
489
+ const header = result.props.children[0];
490
+
491
+ expect(pageModel.renderTabs).toHaveBeenCalled();
492
+ expect(pageModel.renderFirstTab).not.toHaveBeenCalled();
493
+ expect(header.props.style).toMatchObject({
494
+ backgroundColor: 'var(--colorBgLayout)',
495
+ paddingBottom: 0,
496
+ });
497
+ });
445
498
  });
446
499
 
447
500
  describe('dirty refresh signal', () => {
@@ -574,6 +627,26 @@ describe('PageModel', () => {
574
627
  expect(document.title).toBe('Resolved tab doc title');
575
628
  });
576
629
 
630
+ it('should use page documentTitle when desktop route disables tabs even if flow model enables tabs', async () => {
631
+ pageModel.props = { routeId: 'route-1', enableTabs: true, title: 'Route page title' } as any;
632
+ (pageModel as any).context.currentRoute = {
633
+ enableTabs: false,
634
+ };
635
+ (pageModel as any).stepParams = {
636
+ pageSettings: {
637
+ general: {
638
+ documentTitle: 'Route page doc title',
639
+ },
640
+ },
641
+ };
642
+ (pageModel as any).context.resolveJsonTemplate = vi.fn(async () => 'Resolved route page doc title');
643
+
644
+ await (pageModel as any).updateDocumentTitle();
645
+
646
+ expect((pageModel as any).context.resolveJsonTemplate).toHaveBeenCalledWith('Route page doc title');
647
+ expect(document.title).toBe('Resolved route page doc title');
648
+ });
649
+
577
650
  it('should fallback to tab title when active tab documentTitle is empty', async () => {
578
651
  pageModel.props = { enableTabs: true } as any;
579
652
  const activeTab = {
@@ -12,10 +12,32 @@ import { RootPageModel } from '../RootPageModel';
12
12
 
13
13
  // Mock PageModel
14
14
  const mockPageModelSaveStepParams = vi.fn();
15
+ const mockPageModelOpenFlowSettings = vi.fn();
15
16
  vi.mock('../PageModel', () => ({
16
17
  PageModel: class {
18
+ props: any = {};
19
+ stepParams: any = {};
20
+
17
21
  static registerFlow() {}
18
22
 
23
+ setProps(key: string, value: any) {
24
+ this.props[key] = value;
25
+ }
26
+
27
+ setStepParams(flowKey: string, stepKey: string, params: Record<string, any>) {
28
+ if (!this.stepParams[flowKey]) {
29
+ this.stepParams[flowKey] = {};
30
+ }
31
+ this.stepParams[flowKey][stepKey] = {
32
+ ...this.stepParams[flowKey][stepKey],
33
+ ...params,
34
+ };
35
+ }
36
+
37
+ async openFlowSettings(options?: any) {
38
+ return mockPageModelOpenFlowSettings(options);
39
+ }
40
+
19
41
  async saveStepParams() {
20
42
  return mockPageModelSaveStepParams();
21
43
  }
@@ -26,6 +48,7 @@ describe('RootPageModel', () => {
26
48
  let rootPageModel: RootPageModel;
27
49
  let mockContext: any;
28
50
  let mockApi: any;
51
+ let mockRefreshDesktopRoutes: any;
29
52
  let mockFlowEngine: any;
30
53
 
31
54
  beforeEach(() => {
@@ -35,6 +58,7 @@ describe('RootPageModel', () => {
35
58
  mockApi = {
36
59
  request: vi.fn().mockResolvedValue({ data: { success: true } }),
37
60
  };
61
+ mockRefreshDesktopRoutes = vi.fn().mockResolvedValue(undefined);
38
62
 
39
63
  // Mock FlowEngine
40
64
  mockFlowEngine = {
@@ -45,6 +69,11 @@ describe('RootPageModel', () => {
45
69
  // Mock context
46
70
  mockContext = {
47
71
  api: mockApi,
72
+ refreshDesktopRoutes: mockRefreshDesktopRoutes,
73
+ currentRoute: {
74
+ id: 'route-123',
75
+ enableTabs: true,
76
+ },
48
77
  };
49
78
 
50
79
  // Create RootPageModel instance
@@ -63,6 +92,65 @@ describe('RootPageModel', () => {
63
92
  };
64
93
  });
65
94
 
95
+ describe('openFlowSettings', () => {
96
+ it('should use desktop route enableTabs as settings dialog initial value', async () => {
97
+ mockContext.currentRoute.enableTabs = false;
98
+ (rootPageModel as any).stepParams = {
99
+ pageSettings: {
100
+ general: {
101
+ displayTitle: true,
102
+ enableTabs: true,
103
+ },
104
+ },
105
+ };
106
+
107
+ await rootPageModel.openFlowSettings({ flowKey: 'pageSettings', stepKey: 'general' } as any);
108
+
109
+ expect((rootPageModel as any).stepParams.pageSettings.general).toMatchObject({
110
+ displayTitle: true,
111
+ enableTabs: false,
112
+ });
113
+ expect(mockPageModelOpenFlowSettings).toHaveBeenCalledWith({
114
+ flowKey: 'pageSettings',
115
+ stepKey: 'general',
116
+ });
117
+ });
118
+
119
+ it('should keep flow model enableTabs when route status is unavailable', async () => {
120
+ mockContext.currentRoute = {};
121
+ (rootPageModel as any).stepParams = {
122
+ pageSettings: {
123
+ general: {
124
+ enableTabs: true,
125
+ },
126
+ },
127
+ };
128
+
129
+ await rootPageModel.openFlowSettings({ flowKey: 'pageSettings', stepKey: 'general' } as any);
130
+
131
+ expect((rootPageModel as any).stepParams.pageSettings.general.enableTabs).toBe(true);
132
+ });
133
+
134
+ it('should not sync enableTabs when opening other settings steps', async () => {
135
+ mockContext.currentRoute.enableTabs = false;
136
+ (rootPageModel as any).stepParams = {
137
+ pageSettings: {
138
+ general: {
139
+ enableTabs: true,
140
+ },
141
+ },
142
+ };
143
+
144
+ await rootPageModel.openFlowSettings({ flowKey: 'otherSettings', stepKey: 'general' } as any);
145
+
146
+ expect((rootPageModel as any).stepParams.pageSettings.general.enableTabs).toBe(true);
147
+ expect(mockPageModelOpenFlowSettings).toHaveBeenCalledWith({
148
+ flowKey: 'otherSettings',
149
+ stepKey: 'general',
150
+ });
151
+ });
152
+ });
153
+
66
154
  describe('saveStepParams', () => {
67
155
  it('should call parent saveStepParams method', async () => {
68
156
  await rootPageModel.saveStepParams();
@@ -89,6 +177,34 @@ describe('RootPageModel', () => {
89
177
  },
90
178
  });
91
179
  });
180
+
181
+ it('should refresh desktop routes after route update is persisted', async () => {
182
+ await rootPageModel.saveStepParams();
183
+
184
+ expect(mockApi.request).toHaveBeenCalledTimes(1);
185
+ expect(mockRefreshDesktopRoutes).toHaveBeenCalledTimes(1);
186
+ expect(mockApi.request.mock.invocationCallOrder[0]).toBeLessThan(
187
+ mockRefreshDesktopRoutes.mock.invocationCallOrder[0],
188
+ );
189
+ });
190
+
191
+ it('should apply enableTabs to current page immediately after route update is persisted', async () => {
192
+ (rootPageModel as any).stepParams = {
193
+ pageSettings: {
194
+ general: {
195
+ enableTabs: false,
196
+ },
197
+ },
198
+ };
199
+
200
+ await rootPageModel.saveStepParams();
201
+
202
+ expect(mockContext.currentRoute.enableTabs).toBe(false);
203
+ expect((rootPageModel as any).props.enableTabs).toBe(false);
204
+ expect(mockApi.request.mock.invocationCallOrder[0]).toBeLessThan(
205
+ mockRefreshDesktopRoutes.mock.invocationCallOrder[0],
206
+ );
207
+ });
92
208
  });
93
209
 
94
210
  describe('handleDragEnd', () => {