@nocobase/client-v2 2.2.0-beta.9 → 3.0.0-alpha.1

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 (208) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/BaseApplication.d.ts +1 -0
  3. package/es/acl/ACLProvider.d.ts +10 -0
  4. package/es/authRedirect.d.ts +5 -0
  5. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  7. package/es/components/AttachmentUpload.d.ts +51 -0
  8. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  9. package/es/components/category-tabs/index.d.ts +9 -0
  10. package/es/components/form/ScanInput/useCodeScanner.d.ts +3 -2
  11. package/es/components/form/TypedVariableInput.d.ts +31 -5
  12. package/es/components/form/filter/CollectionFilter.d.ts +2 -0
  13. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  14. package/es/components/form/filter/CollectionFilterPanel.d.ts +2 -0
  15. package/es/components/form/filter/index.d.ts +2 -0
  16. package/es/components/form/filter/useFilterActionProps.d.ts +2 -0
  17. package/es/components/index.d.ts +2 -0
  18. package/es/flow/actions/index.d.ts +1 -1
  19. package/es/flow/actions/linkageRules.d.ts +3 -0
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  21. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  22. package/es/flow/components/FieldAssignExactDatePicker.d.ts +1 -0
  23. package/es/flow/components/FieldAssignValueInput.d.ts +9 -0
  24. package/es/flow/components/FlowRoute.d.ts +2 -2
  25. package/es/flow/components/RunJSValueEditor.d.ts +1 -0
  26. package/es/flow/components/filter/FilterGroup.d.ts +1 -0
  27. package/es/flow/components/filter/VariableFilterItem.d.ts +6 -1
  28. package/es/flow/index.d.ts +1 -0
  29. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  30. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  31. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  32. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  33. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  34. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  35. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  36. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  37. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  38. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  39. package/es/flow-compat/routeTypes.d.ts +1 -0
  40. package/es/index.d.ts +8 -1
  41. package/es/index.mjs +190 -141
  42. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  43. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  44. package/es/settings-app/SettingsApplication.d.ts +18 -0
  45. package/es/settings-app/SettingsBrand.d.ts +16 -0
  46. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  47. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  48. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  49. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  50. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  51. package/es/settings-app/SettingsSearch.d.ts +17 -0
  52. package/es/settings-app/SettingsShell.d.ts +10 -0
  53. package/es/settings-app/isSettingsApp.d.ts +18 -0
  54. package/es/settings-app/runtimePaths.d.ts +18 -0
  55. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  56. package/es/settings-app/settingsTheme.d.ts +87 -0
  57. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  58. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  59. package/es/settings-center/groups.d.ts +66 -0
  60. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  61. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  62. package/es/settings-center/utils.d.ts +24 -0
  63. package/es/theme/index.d.ts +1 -0
  64. package/es/theme/itemActive.d.ts +24 -0
  65. package/es/theme/type.d.ts +9 -0
  66. package/es/utils/getRouteRuntimeVersion.d.ts +23 -0
  67. package/es/utils/index.d.ts +1 -0
  68. package/lib/index.js +195 -146
  69. package/lib/locale/languageCodes.js +3 -1
  70. package/package.json +9 -7
  71. package/src/APIClient.ts +1 -10
  72. package/src/Application.tsx +4 -0
  73. package/src/BaseApplication.tsx +11 -6
  74. package/src/__tests__/app.test.tsx +73 -0
  75. package/src/__tests__/authRedirect.test.ts +137 -0
  76. package/src/__tests__/browserChecker.test.ts +185 -6
  77. package/src/__tests__/getRouteRuntimeVersion.test.ts +68 -0
  78. package/src/__tests__/mockSettingsApplication.ts +29 -0
  79. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +79 -10
  80. package/src/__tests__/plugin-manager.test.tsx +7 -6
  81. package/src/__tests__/settings-application.test.ts +164 -0
  82. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  83. package/src/__tests__/settings-center.test.tsx +38 -37
  84. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  85. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  86. package/src/__tests__/settings-shell.test.tsx +200 -0
  87. package/src/acl/ACLProvider.tsx +21 -0
  88. package/src/authRedirect.ts +72 -3
  89. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  90. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  91. package/src/collection-manager/field-validation.ts +1 -1
  92. package/src/components/AttachmentUpload.tsx +275 -0
  93. package/src/components/README.md +7 -1
  94. package/src/components/README.zh-CN.md +6 -1
  95. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  96. package/src/components/category-tabs/index.ts +10 -0
  97. package/src/components/form/JsonTextArea.tsx +4 -0
  98. package/src/components/form/ScanInput/CodeScanner.tsx +79 -35
  99. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  100. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +284 -11
  101. package/src/components/form/ScanInput/useCodeScanner.ts +332 -24
  102. package/src/components/form/TypedVariableInput.tsx +452 -101
  103. package/src/components/form/__tests__/TypedVariableInput.test.tsx +364 -12
  104. package/src/components/form/filter/CollectionFilter.tsx +4 -0
  105. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  106. package/src/components/form/filter/CollectionFilterPanel.tsx +4 -0
  107. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  108. package/src/components/form/filter/__tests__/useFilterActionProps.test.tsx +95 -0
  109. package/src/components/form/filter/index.ts +2 -0
  110. package/src/components/form/filter/useFilterActionProps.ts +37 -6
  111. package/src/components/index.ts +2 -0
  112. package/src/flow/FlowPage.tsx +9 -1
  113. package/src/flow/__tests__/FlowPage.test.tsx +50 -3
  114. package/src/flow/__tests__/FlowRoute.test.tsx +128 -10
  115. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  116. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  117. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  118. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  119. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  120. package/src/flow/actions/index.ts +2 -0
  121. package/src/flow/actions/linkageRules.tsx +111 -16
  122. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  123. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -57
  124. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  125. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  126. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +253 -6
  127. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  128. package/src/flow/common/Markdown/style.ts +4 -0
  129. package/src/flow/components/BlockItemCard.tsx +2 -2
  130. package/src/flow/components/FieldAssignExactDatePicker.tsx +25 -11
  131. package/src/flow/components/FieldAssignValueInput.tsx +91 -20
  132. package/src/flow/components/FlowRoute.tsx +78 -32
  133. package/src/flow/components/RunJSValueEditor.tsx +9 -1
  134. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +216 -0
  135. package/src/flow/components/filter/FilterGroup.tsx +33 -5
  136. package/src/flow/components/filter/VariableFilterItem.tsx +158 -10
  137. package/src/flow/components/filter/__tests__/FilterGroup.test.tsx +45 -0
  138. package/src/flow/components/filter/__tests__/VariableFilterItem.leftMetaTree.test.tsx +9 -0
  139. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +70 -1
  140. package/src/flow/index.ts +3 -0
  141. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  142. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  143. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  144. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  145. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  146. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  147. package/src/flow/models/base/PageModel/PageTabModel.tsx +249 -18
  148. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  149. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  150. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  151. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +1075 -7
  152. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  153. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  154. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  155. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +28 -3
  156. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  157. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  158. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +66 -4
  159. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  160. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  161. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  162. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +2 -1
  163. package/src/flow/models/blocks/table/TableBlockModel.tsx +4 -2
  164. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  165. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  166. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  167. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +2 -1
  168. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  169. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  170. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  171. package/src/flow/models/fields/VariableFieldFormModel.tsx +3 -3
  172. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  173. package/src/flow/models/fields/__tests__/VariableFieldFormModel.test.tsx +51 -0
  174. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  175. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  176. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  177. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  178. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  179. package/src/flow-compat/routeTypes.ts +1 -0
  180. package/src/index.ts +14 -1
  181. package/src/locale/languageCodes.ts +3 -1
  182. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  183. package/src/nocobase-buildin-plugin/index.tsx +110 -70
  184. package/src/settings-app/SettingsApplication.ts +31 -0
  185. package/src/settings-app/SettingsBrand.tsx +108 -0
  186. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  187. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  188. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  189. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  190. package/src/settings-app/SettingsRouterManager.ts +92 -0
  191. package/src/settings-app/SettingsSearch.tsx +238 -0
  192. package/src/settings-app/SettingsShell.tsx +167 -0
  193. package/src/settings-app/isSettingsApp.ts +21 -0
  194. package/src/settings-app/runtimePaths.ts +104 -0
  195. package/src/settings-app/settingsDocumentPath.ts +66 -0
  196. package/src/settings-app/settingsTheme.ts +301 -0
  197. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  198. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  199. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  200. package/src/settings-center/groups.ts +108 -0
  201. package/src/settings-center/useSettingsGroups.ts +105 -0
  202. package/src/settings-center/useSettingsSearch.ts +173 -0
  203. package/src/settings-center/utils.tsx +50 -1
  204. package/src/theme/index.tsx +1 -0
  205. package/src/theme/itemActive.ts +31 -0
  206. package/src/theme/type.ts +10 -0
  207. package/src/utils/getRouteRuntimeVersion.ts +185 -0
  208. package/src/utils/index.tsx +1 -0
@@ -18,24 +18,53 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
18
18
  return {
19
19
  ...actual,
20
20
  FlowModel: class {
21
+ uid: string;
22
+ parentId?: string;
21
23
  props: any;
22
24
  context: any;
23
25
  stepParams: any;
26
+ flowEngine?: {
27
+ modelRepository?: {
28
+ findOne?: (query: Record<string, unknown>) => Promise<unknown>;
29
+ };
30
+ flowSettings?: {
31
+ open?: (options: Record<string, unknown>) => Promise<unknown> | unknown;
32
+ };
33
+ };
34
+ flowRegistryData: Record<string, unknown>;
35
+ invalidateFlowCache = vi.fn();
36
+ rerender = vi.fn(async () => undefined);
24
37
 
25
38
  constructor(options: any = {}) {
39
+ this.uid = options.uid || 'mock-model';
40
+ this.parentId = options.parentId;
26
41
  this.props = options.props || {};
27
42
  this.context = options.context || {};
28
43
  this.stepParams = options.stepParams || {};
44
+ this.flowEngine = options.flowEngine;
45
+ this.flowRegistryData = options.flowRegistry || {};
29
46
  }
30
47
 
31
48
  serialize() {
32
- return { flowRegistry: {} };
49
+ return { flowRegistry: this.flowRegistryData };
33
50
  }
34
51
 
35
52
  setProps(key: string, value: any) {
36
53
  this.props[key] = value;
37
54
  }
38
55
 
56
+ setStepParams(flowKey: string, stepKey: string, params: Record<string, unknown>) {
57
+ this.stepParams[flowKey] = this.stepParams[flowKey] || {};
58
+ this.stepParams[flowKey][stepKey] = {
59
+ ...(this.stepParams[flowKey][stepKey] || {}),
60
+ ...params,
61
+ };
62
+ }
63
+
64
+ openFlowSettings(options: Record<string, unknown> = {}) {
65
+ return this.flowEngine?.flowSettings?.open?.({ model: this, ...options });
66
+ }
67
+
39
68
  onInit() {}
40
69
  static registerFlow(flow: any) {
41
70
  registerFlowMock(flow);
@@ -59,6 +88,51 @@ vi.mock('ahooks', async (importOriginal) => {
59
88
  };
60
89
  });
61
90
 
91
+ type RootPageTabModelClass = typeof import('../PageTabModel').RootPageTabModel;
92
+
93
+ type TestRoute = {
94
+ schemaUid?: string;
95
+ options?: Record<string, unknown>;
96
+ } & Record<string, unknown>;
97
+
98
+ type RootPageTabModelTestOptions = {
99
+ props?: {
100
+ route?: TestRoute;
101
+ };
102
+ stepParams?: {
103
+ pageTabSettings?: {
104
+ tab?: {
105
+ title?: string;
106
+ icon?: string;
107
+ documentTitle?: string;
108
+ };
109
+ };
110
+ };
111
+ context: {
112
+ api: {
113
+ request: ReturnType<typeof vi.fn>;
114
+ };
115
+ routeRepository?: {
116
+ getRouteBySchemaUid?: (schemaUid: string) => TestRoute | undefined;
117
+ refreshAccessible?: () => Promise<unknown>;
118
+ };
119
+ refreshDesktopRoutes?: () => Promise<unknown>;
120
+ logger?: {
121
+ warn?: (context: { err: unknown }, message: string) => void;
122
+ };
123
+ t: (value: string) => string;
124
+ };
125
+ };
126
+
127
+ type RootPageTabModelTestConstructor = new (
128
+ options: RootPageTabModelTestOptions,
129
+ ) => InstanceType<RootPageTabModelClass>;
130
+
131
+ function createRootPageTabModel(Model: RootPageTabModelClass, options: RootPageTabModelTestOptions) {
132
+ const TestModel = Model as unknown as RootPageTabModelTestConstructor;
133
+ return new TestModel(options);
134
+ }
135
+
62
136
  describe('PageTabModel', () => {
63
137
  beforeEach(() => {
64
138
  vi.resetModules();
@@ -86,6 +160,13 @@ describe('PageTabModel', () => {
86
160
  expect(keys.indexOf('documentTitle')).toBe(keys.indexOf('title') + 1);
87
161
  });
88
162
 
163
+ it('should expose tab linkage rules in page tab settings', async () => {
164
+ await import('../PageTabModel');
165
+ const flow = registerFlowMock.mock.calls.find((call) => call[0]?.key === 'pageTabSettings')?.[0];
166
+
167
+ expect(flow?.steps?.linkageRules?.use).toBe('tabLinkageRules');
168
+ });
169
+
89
170
  it('should trigger parent page title update in tab settings handler', async () => {
90
171
  await import('../PageTabModel');
91
172
  const flow = registerFlowMock.mock.calls.find((call) => call[0]?.key === 'pageTabSettings')?.[0];
@@ -113,7 +194,7 @@ describe('PageTabModel', () => {
113
194
  it('should persist tab documentTitle to route options on save', async () => {
114
195
  const { RootPageTabModel } = await import('../PageTabModel');
115
196
  const request = vi.fn().mockResolvedValue({});
116
- const model = new RootPageTabModel({
197
+ const model = createRootPageTabModel(RootPageTabModel, {
117
198
  props: {
118
199
  route: {
119
200
  schemaUid: 'tab-1',
@@ -132,7 +213,7 @@ describe('PageTabModel', () => {
132
213
  api: { request },
133
214
  t: (value: string) => value,
134
215
  },
135
- } as any);
216
+ });
136
217
 
137
218
  await model.save();
138
219
 
@@ -141,6 +222,413 @@ describe('PageTabModel', () => {
141
222
  expect(call.data.options.documentTitle).toBe('Tab doc title');
142
223
  });
143
224
 
225
+ it('should update a persisted tab route by id with only editable fields and latest options', async () => {
226
+ const { RootPageTabModel } = await import('../PageTabModel');
227
+ const request = vi.fn().mockResolvedValue({});
228
+ const model = createRootPageTabModel(RootPageTabModel, {
229
+ props: {
230
+ route: {
231
+ id: 123,
232
+ parentId: 456,
233
+ schemaUid: 'tab-1',
234
+ type: 'tabs',
235
+ sort: 1,
236
+ hidden: true,
237
+ tabSchemaName: 'tab-name-1',
238
+ hideInMenu: false,
239
+ enableTabs: false,
240
+ roles: [{ name: 'admin', title: 'Admin' }],
241
+ createdAt: '2026-07-16T00:00:00.000Z',
242
+ updatedAt: '2026-07-16T01:00:00.000Z',
243
+ options: {
244
+ badge: {
245
+ showZero: false,
246
+ },
247
+ pluginState: 'stale',
248
+ removedPluginState: 'stale-only',
249
+ flowRegistry: { stale: true },
250
+ documentTitle: 'Stale document title',
251
+ },
252
+ },
253
+ },
254
+ stepParams: {
255
+ pageTabSettings: {
256
+ tab: {
257
+ title: 'Renamed tab',
258
+ icon: 'TableOutlined',
259
+ documentTitle: 'Current document title',
260
+ },
261
+ },
262
+ },
263
+ context: {
264
+ api: { request },
265
+ routeRepository: {
266
+ getRouteBySchemaUid: vi.fn(() => ({
267
+ schemaUid: 'tab-1',
268
+ options: {
269
+ badge: {
270
+ showZero: true,
271
+ },
272
+ pluginState: 'fresh',
273
+ flowRegistry: { fresh: true },
274
+ documentTitle: 'Fresh document title',
275
+ },
276
+ })),
277
+ },
278
+ t: (value: string) => value,
279
+ },
280
+ });
281
+
282
+ await model.save();
283
+
284
+ const call = request.mock.calls[0][0];
285
+ expect(call).toEqual({
286
+ method: 'post',
287
+ url: 'desktopRoutes:update?filter[id]=123',
288
+ data: {
289
+ schemaUid: 'tab-1',
290
+ title: 'Renamed tab',
291
+ icon: 'TableOutlined',
292
+ options: {
293
+ badge: {
294
+ showZero: true,
295
+ },
296
+ pluginState: 'fresh',
297
+ flowRegistry: {},
298
+ documentTitle: 'Current document title',
299
+ },
300
+ },
301
+ });
302
+ expect(Object.keys(call.data).sort()).toEqual(['icon', 'options', 'schemaUid', 'title']);
303
+ });
304
+
305
+ it('should fall back to model route options when the current route is unavailable', async () => {
306
+ const { RootPageTabModel } = await import('../PageTabModel');
307
+ const request = vi.fn().mockResolvedValue({});
308
+ const model = createRootPageTabModel(RootPageTabModel, {
309
+ props: {
310
+ route: {
311
+ id: 123,
312
+ parentId: 456,
313
+ schemaUid: 'tab-1',
314
+ type: 'tabs',
315
+ sort: 1,
316
+ hidden: true,
317
+ options: {
318
+ badge: {
319
+ showZero: true,
320
+ },
321
+ },
322
+ },
323
+ },
324
+ stepParams: {
325
+ pageTabSettings: {
326
+ tab: {
327
+ title: 'Renamed tab',
328
+ },
329
+ },
330
+ },
331
+ context: {
332
+ api: { request },
333
+ routeRepository: {
334
+ getRouteBySchemaUid: vi.fn(() => undefined),
335
+ },
336
+ t: (value: string) => value,
337
+ },
338
+ });
339
+
340
+ await model.save();
341
+
342
+ const call = request.mock.calls[0][0];
343
+ expect(call).toEqual({
344
+ method: 'post',
345
+ url: 'desktopRoutes:update?filter[id]=123',
346
+ data: {
347
+ schemaUid: 'tab-1',
348
+ title: 'Renamed tab',
349
+ icon: undefined,
350
+ options: {
351
+ badge: {
352
+ showZero: true,
353
+ },
354
+ flowRegistry: {},
355
+ documentTitle: undefined,
356
+ },
357
+ },
358
+ });
359
+ });
360
+
361
+ it('should await a route refresh after the request before a subsequent persisted tab save', async () => {
362
+ const { RootPageTabModel } = await import('../PageTabModel');
363
+ const events: string[] = [];
364
+ let resolveFirstRefresh: () => void = () => {};
365
+ const firstRefreshDone = new Promise<void>((resolve) => {
366
+ resolveFirstRefresh = resolve;
367
+ });
368
+ let currentRoute = {
369
+ schemaUid: 'tab-1',
370
+ options: {
371
+ pluginState: 'before-save',
372
+ },
373
+ };
374
+ let requestCall = 0;
375
+ const request = vi.fn(async () => {
376
+ requestCall += 1;
377
+ events.push(`request:${requestCall}`);
378
+ if (requestCall === 1) {
379
+ return {
380
+ data: {
381
+ data: [
382
+ {
383
+ id: 123,
384
+ schemaUid: 'tab-1',
385
+ options: {
386
+ pluginState: 'after-save',
387
+ serverHookState: true,
388
+ },
389
+ },
390
+ ],
391
+ },
392
+ };
393
+ }
394
+ return {};
395
+ });
396
+ let refreshCall = 0;
397
+ const refreshAccessible = vi.fn(async () => {
398
+ refreshCall += 1;
399
+ events.push(`refresh:${refreshCall}:start`);
400
+ if (refreshCall === 1) {
401
+ await firstRefreshDone;
402
+ currentRoute = {
403
+ schemaUid: 'tab-1',
404
+ options: {
405
+ pluginState: 'after-save',
406
+ serverHookState: true,
407
+ },
408
+ };
409
+ }
410
+ events.push(`refresh:${refreshCall}:end`);
411
+ });
412
+ const model = createRootPageTabModel(RootPageTabModel, {
413
+ props: {
414
+ route: {
415
+ id: 123,
416
+ schemaUid: 'tab-1',
417
+ },
418
+ },
419
+ stepParams: {
420
+ pageTabSettings: {
421
+ tab: {
422
+ title: 'Renamed tab',
423
+ },
424
+ },
425
+ },
426
+ context: {
427
+ api: { request },
428
+ routeRepository: {
429
+ getRouteBySchemaUid: vi.fn(() => currentRoute),
430
+ refreshAccessible,
431
+ },
432
+ t: (value: string) => value,
433
+ },
434
+ });
435
+
436
+ let firstSaveResolved = false;
437
+ const firstSave = model.save().then(() => {
438
+ firstSaveResolved = true;
439
+ events.push('save:1:end');
440
+ });
441
+
442
+ await vi.waitFor(() => expect(refreshAccessible).toHaveBeenCalledTimes(1));
443
+ expect(events.slice(0, 2)).toEqual(['request:1', 'refresh:1:start']);
444
+ await Promise.resolve();
445
+ expect(firstSaveResolved).toBe(false);
446
+
447
+ resolveFirstRefresh();
448
+ await firstSave;
449
+ expect(events.indexOf('request:1')).toBeLessThan(events.indexOf('refresh:1:start'));
450
+ expect(events.indexOf('refresh:1:end')).toBeLessThan(events.indexOf('save:1:end'));
451
+
452
+ await model.save();
453
+
454
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
455
+ expect(request.mock.calls[1][0].data.options).toMatchObject({
456
+ pluginState: 'after-save',
457
+ serverHookState: true,
458
+ });
459
+ });
460
+
461
+ it('should serialize route refreshes when new tabs are saved concurrently', async () => {
462
+ const { RootPageTabModel } = await import('../PageTabModel');
463
+ let releaseFirstRefresh: () => void = () => {};
464
+ const firstRefreshDone = new Promise<void>((resolve) => {
465
+ releaseFirstRefresh = resolve;
466
+ });
467
+ let refreshCall = 0;
468
+ let activeRefreshes = 0;
469
+ let maxActiveRefreshes = 0;
470
+ const refreshAccessible = vi.fn(async () => {
471
+ refreshCall += 1;
472
+ activeRefreshes += 1;
473
+ maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);
474
+ if (refreshCall === 1) {
475
+ await firstRefreshDone;
476
+ }
477
+ activeRefreshes -= 1;
478
+ });
479
+ const request = vi
480
+ .fn()
481
+ .mockResolvedValueOnce({
482
+ data: {
483
+ data: {
484
+ id: 101,
485
+ schemaUid: 'tab-1',
486
+ },
487
+ },
488
+ })
489
+ .mockResolvedValueOnce({
490
+ data: {
491
+ data: {
492
+ id: 102,
493
+ schemaUid: 'tab-2',
494
+ },
495
+ },
496
+ });
497
+ const routeRepository = {
498
+ refreshAccessible,
499
+ };
500
+ const createModel = (schemaUid: string, title: string) =>
501
+ createRootPageTabModel(RootPageTabModel, {
502
+ props: {
503
+ route: {
504
+ parentId: 999,
505
+ schemaUid,
506
+ tabSchemaName: `${schemaUid}-name`,
507
+ type: 'tabs',
508
+ },
509
+ },
510
+ stepParams: {
511
+ pageTabSettings: {
512
+ tab: {
513
+ title,
514
+ },
515
+ },
516
+ },
517
+ context: {
518
+ api: { request },
519
+ routeRepository,
520
+ t: (value: string) => value,
521
+ },
522
+ });
523
+
524
+ const saves = Promise.all([createModel('tab-1', 'Tab 1').save(), createModel('tab-2', 'Tab 2').save()]);
525
+
526
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
527
+ await vi.waitFor(() => expect(refreshAccessible).toHaveBeenCalled());
528
+ await Promise.resolve();
529
+ await Promise.resolve();
530
+ const refreshCallsBeforeRelease = refreshAccessible.mock.calls.length;
531
+
532
+ releaseFirstRefresh();
533
+ await saves;
534
+
535
+ expect(refreshCallsBeforeRelease).toBe(1);
536
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
537
+ expect(maxActiveRefreshes).toBe(1);
538
+ });
539
+
540
+ it('should keep a successful route save when refreshing the route cache fails', async () => {
541
+ const { RootPageTabModel } = await import('../PageTabModel');
542
+ const refreshError = new Error('refresh failed');
543
+ const request = vi
544
+ .fn()
545
+ .mockResolvedValueOnce({
546
+ data: {
547
+ data: {
548
+ id: 123,
549
+ schemaUid: 'tab-1',
550
+ },
551
+ },
552
+ })
553
+ .mockResolvedValueOnce({});
554
+ const refreshAccessible = vi.fn().mockRejectedValueOnce(refreshError).mockResolvedValueOnce([]);
555
+ const warn = vi.fn();
556
+ const model = createRootPageTabModel(RootPageTabModel, {
557
+ props: {
558
+ route: {
559
+ parentId: 999,
560
+ schemaUid: 'tab-1',
561
+ tabSchemaName: 'tab-name-1',
562
+ type: 'tabs',
563
+ },
564
+ },
565
+ stepParams: {
566
+ pageTabSettings: {
567
+ tab: {
568
+ title: 'Tab title',
569
+ },
570
+ },
571
+ },
572
+ context: {
573
+ api: { request },
574
+ routeRepository: {
575
+ refreshAccessible,
576
+ },
577
+ logger: { warn },
578
+ t: (value: string) => value,
579
+ },
580
+ });
581
+
582
+ await expect(model.save()).resolves.toBeUndefined();
583
+
584
+ expect(model.props.route.id).toBe(123);
585
+ expect(warn).toHaveBeenCalledWith(
586
+ { err: refreshError },
587
+ '[client-v2] Failed to refresh desktop routes after saving a page tab',
588
+ );
589
+
590
+ await expect(model.save()).resolves.toBeUndefined();
591
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
592
+ });
593
+
594
+ it('should still reject when writing the tab route fails', async () => {
595
+ const { RootPageTabModel } = await import('../PageTabModel');
596
+ const writeError = new Error('write failed');
597
+ const request = vi.fn().mockRejectedValue(writeError);
598
+ const refreshAccessible = vi.fn().mockResolvedValue([]);
599
+ const warn = vi.fn();
600
+ const model = createRootPageTabModel(RootPageTabModel, {
601
+ props: {
602
+ route: {
603
+ parentId: 999,
604
+ schemaUid: 'tab-1',
605
+ tabSchemaName: 'tab-name-1',
606
+ type: 'tabs',
607
+ },
608
+ },
609
+ stepParams: {
610
+ pageTabSettings: {
611
+ tab: {
612
+ title: 'Tab title',
613
+ },
614
+ },
615
+ },
616
+ context: {
617
+ api: { request },
618
+ routeRepository: {
619
+ refreshAccessible,
620
+ },
621
+ logger: { warn },
622
+ t: (value: string) => value,
623
+ },
624
+ });
625
+
626
+ await expect(model.save()).rejects.toBe(writeError);
627
+ expect(refreshAccessible).not.toHaveBeenCalled();
628
+ expect(warn).not.toHaveBeenCalled();
629
+ expect(model.props.route.id).toBeUndefined();
630
+ });
631
+
144
632
  it('should sync persisted route id after save', async () => {
145
633
  const { RootPageTabModel } = await import('../PageTabModel');
146
634
  const request = vi.fn().mockResolvedValue({
@@ -152,16 +640,24 @@ describe('PageTabModel', () => {
152
640
  },
153
641
  },
154
642
  });
155
- const model = new RootPageTabModel({
643
+ const model = createRootPageTabModel(RootPageTabModel, {
156
644
  props: {
157
645
  route: {
646
+ parentId: 999,
158
647
  schemaUid: 'tab-1',
648
+ tabSchemaName: 'tab-name-1',
649
+ type: 'tabs',
650
+ params: [],
651
+ hideInMenu: false,
652
+ enableTabs: false,
159
653
  },
160
654
  },
161
655
  stepParams: {
162
656
  pageTabSettings: {
163
657
  tab: {
164
658
  title: 'Tab title',
659
+ icon: 'TableOutlined',
660
+ documentTitle: 'Tab document title',
165
661
  },
166
662
  },
167
663
  },
@@ -169,10 +665,33 @@ describe('PageTabModel', () => {
169
665
  api: { request },
170
666
  t: (value: string) => value,
171
667
  },
172
- } as any);
668
+ });
173
669
 
174
670
  await model.save();
175
671
 
672
+ const call = request.mock.calls[0][0];
673
+ expect(call).toEqual({
674
+ method: 'post',
675
+ url: 'desktopRoutes:updateOrCreate',
676
+ params: {
677
+ filterKeys: ['schemaUid'],
678
+ },
679
+ data: {
680
+ parentId: 999,
681
+ schemaUid: 'tab-1',
682
+ tabSchemaName: 'tab-name-1',
683
+ type: 'tabs',
684
+ params: [],
685
+ hideInMenu: false,
686
+ enableTabs: false,
687
+ title: 'Tab title',
688
+ icon: 'TableOutlined',
689
+ options: {
690
+ flowRegistry: {},
691
+ documentTitle: 'Tab document title',
692
+ },
693
+ },
694
+ });
176
695
  expect(model.props.route.id).toBe(123);
177
696
  expect(model.props.route.sort).toBe(9);
178
697
  });
@@ -192,7 +711,7 @@ describe('PageTabModel', () => {
192
711
  ],
193
712
  },
194
713
  });
195
- const model = new RootPageTabModel({
714
+ const model = createRootPageTabModel(RootPageTabModel, {
196
715
  props: {
197
716
  route: {
198
717
  schemaUid: 'tab-1',
@@ -212,7 +731,7 @@ describe('PageTabModel', () => {
212
731
  api: { request },
213
732
  t: (value: string) => value,
214
733
  },
215
- } as any);
734
+ });
216
735
 
217
736
  await model.save();
218
737
 
@@ -222,4 +741,553 @@ describe('PageTabModel', () => {
222
741
  documentTitle: 'Server doc title',
223
742
  });
224
743
  });
744
+
745
+ describe('root tab linkage hydrate', () => {
746
+ it('should hydrate linkage rules before opening settings in configuration mode', async () => {
747
+ const { RootPageTabModel } = await import('../PageTabModel');
748
+ const linkageRules = { value: [{ key: 'rule-1' }] };
749
+ const request = vi.fn().mockResolvedValue({
750
+ data: {
751
+ data: {
752
+ uid: 'tab-1',
753
+ use: 'RouteModel',
754
+ props: { title: 'Stale anchor title' },
755
+ stepParams: {
756
+ pageTabSettings: {
757
+ tab: { title: 'Stale tab title' },
758
+ linkageRules,
759
+ },
760
+ },
761
+ },
762
+ },
763
+ });
764
+ const open = vi.fn(({ model }: { model: { stepParams: Record<string, unknown> } }) => {
765
+ expect(model.stepParams).toMatchObject({ pageTabSettings: { linkageRules } });
766
+ return 'opened';
767
+ });
768
+ const model = new RootPageTabModel({
769
+ uid: 'tab-1',
770
+ flowEngine: {
771
+ flowSettings: { open },
772
+ },
773
+ props: {
774
+ route: {
775
+ schemaUid: 'tab-1',
776
+ title: 'Current route title',
777
+ options: {},
778
+ },
779
+ },
780
+ stepParams: {
781
+ pageTabSettings: {
782
+ tab: { title: 'Current tab title' },
783
+ },
784
+ },
785
+ context: {
786
+ api: { request },
787
+ flowSettingsEnabled: true,
788
+ defineProperty: vi.fn(),
789
+ t: (value: string) => value,
790
+ },
791
+ } as any);
792
+
793
+ model.onInit({});
794
+ await model.openFlowSettings({ flowKey: 'pageTabSettings', stepKey: 'linkageRules' });
795
+
796
+ expect(request).toHaveBeenCalledWith({
797
+ url: 'flowModels:findOne',
798
+ params: { uid: 'tab-1' },
799
+ });
800
+ expect(model.props.route.title).toBe('Current route title');
801
+ expect(model.stepParams.pageTabSettings.tab.title).toBe('Current tab title');
802
+ expect(model.stepParams.pageTabSettings.linkageRules).toEqual(linkageRules);
803
+ expect(model.invalidateFlowCache).toHaveBeenCalledWith('beforeRender', true);
804
+ expect(model.rerender).toHaveBeenCalledTimes(1);
805
+ expect(open).toHaveBeenCalledTimes(1);
806
+ });
807
+
808
+ it('should only hydrate marked root tabs at runtime', async () => {
809
+ const { RootPageTabModel } = await import('../PageTabModel');
810
+ const unmarkedRequest = vi.fn().mockResolvedValue({ data: { data: null } });
811
+ const markedRequest = vi.fn().mockResolvedValue({
812
+ data: {
813
+ data: {
814
+ stepParams: {
815
+ pageTabSettings: {
816
+ linkageRules: { value: [] },
817
+ },
818
+ },
819
+ },
820
+ },
821
+ });
822
+ const createModel = (request: typeof markedRequest, marked: boolean) =>
823
+ new RootPageTabModel({
824
+ uid: marked ? 'tab-marked' : 'tab-unmarked',
825
+ props: {
826
+ route: {
827
+ schemaUid: marked ? 'tab-marked' : 'tab-unmarked',
828
+ options: marked ? { hasPersistedPageTabFlowModel: true } : {},
829
+ },
830
+ },
831
+ context: {
832
+ api: { request },
833
+ flowSettingsEnabled: false,
834
+ defineProperty: vi.fn(),
835
+ t: (value: string) => value,
836
+ },
837
+ } as any);
838
+
839
+ const unmarkedModel = createModel(unmarkedRequest, false);
840
+ const markedModel = createModel(markedRequest, true);
841
+ unmarkedModel.onInit({});
842
+ markedModel.onInit({});
843
+
844
+ await vi.waitFor(() => {
845
+ expect(markedRequest).toHaveBeenCalledTimes(1);
846
+ expect(markedModel.stepParams.pageTabSettings.linkageRules).toEqual({ value: [] });
847
+ });
848
+ expect(unmarkedRequest).not.toHaveBeenCalled();
849
+ });
850
+
851
+ it('should ignore the linkage-specific legacy marker at runtime', async () => {
852
+ const { RootPageTabModel } = await import('../PageTabModel');
853
+ const request = vi.fn().mockResolvedValue({ data: { data: null } });
854
+ const legacyMarker = ['hasPersistedPageTab', 'LinkageRules'].join('');
855
+ const model = new RootPageTabModel({
856
+ uid: 'tab-legacy-marker',
857
+ props: {
858
+ route: {
859
+ schemaUid: 'tab-legacy-marker',
860
+ options: { [legacyMarker]: true },
861
+ },
862
+ },
863
+ context: {
864
+ api: { request },
865
+ flowSettingsEnabled: false,
866
+ defineProperty: vi.fn(),
867
+ t: (value: string) => value,
868
+ },
869
+ } as any);
870
+
871
+ model.onInit({});
872
+
873
+ expect(request).not.toHaveBeenCalled();
874
+ });
875
+
876
+ it('should share one in-flight hydrate request across concurrent settings opens', async () => {
877
+ const { RootPageTabModel } = await import('../PageTabModel');
878
+ let resolveAnchor: (value: unknown) => void = () => undefined;
879
+ const request = vi.fn(
880
+ () =>
881
+ new Promise<unknown>((resolve) => {
882
+ resolveAnchor = (value) => resolve({ data: { data: value } });
883
+ }),
884
+ );
885
+ const open = vi.fn().mockResolvedValue(undefined);
886
+ const model = new RootPageTabModel({
887
+ uid: 'tab-1',
888
+ flowEngine: {
889
+ flowSettings: { open },
890
+ },
891
+ props: { route: { schemaUid: 'tab-1', options: {} } },
892
+ context: {
893
+ api: { request },
894
+ flowSettingsEnabled: false,
895
+ t: (value: string) => value,
896
+ },
897
+ } as any);
898
+
899
+ const firstOpen = model.openFlowSettings({ flowKey: 'pageTabSettings', stepKey: 'linkageRules' });
900
+ const secondOpen = model.openFlowSettings({ flowKey: 'pageTabSettings', stepKey: 'linkageRules' });
901
+
902
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(1));
903
+ resolveAnchor({
904
+ stepParams: {
905
+ pageTabSettings: {
906
+ linkageRules: { value: [] },
907
+ },
908
+ },
909
+ });
910
+ await Promise.all([firstOpen, secondOpen]);
911
+
912
+ expect(request).toHaveBeenCalledTimes(1);
913
+ expect(open).toHaveBeenCalledTimes(2);
914
+ });
915
+
916
+ it('should safely handle missing anchors and missing linkage steps', async () => {
917
+ const { RootPageTabModel } = await import('../PageTabModel');
918
+ const request = vi.fn().mockResolvedValue({ data: { data: null } });
919
+ const model = new RootPageTabModel({
920
+ uid: 'tab-1',
921
+ flowEngine: {
922
+ flowSettings: { open: vi.fn().mockResolvedValue(undefined) },
923
+ },
924
+ props: { route: { schemaUid: 'tab-1', options: {} } },
925
+ stepParams: {
926
+ pageTabSettings: {
927
+ tab: { title: 'Current tab title' },
928
+ },
929
+ },
930
+ context: {
931
+ api: { request },
932
+ flowSettingsEnabled: true,
933
+ defineProperty: vi.fn(),
934
+ t: (value: string) => value,
935
+ },
936
+ } as any);
937
+
938
+ model.onInit({});
939
+ await expect(
940
+ model.openFlowSettings({ flowKey: 'pageTabSettings', stepKey: 'linkageRules' }),
941
+ ).resolves.toBeUndefined();
942
+
943
+ expect(request).toHaveBeenCalledWith({
944
+ url: 'flowModels:findOne',
945
+ params: { uid: 'tab-1' },
946
+ });
947
+ expect(model.stepParams.pageTabSettings.linkageRules).toBeUndefined();
948
+ expect(model.stepParams.pageTabSettings.tab.title).toBe('Current tab title');
949
+ });
950
+ });
951
+
952
+ describe('root tab linkage persistence', () => {
953
+ it('should not touch the anchor or marker when linkage rules were never loaded', async () => {
954
+ const { RootPageTabModel } = await import('../PageTabModel');
955
+ const request = vi.fn().mockResolvedValue({});
956
+ const updateRoute = vi.fn();
957
+ const model = new RootPageTabModel({
958
+ uid: 'tab-1',
959
+ props: {
960
+ route: {
961
+ id: 10,
962
+ schemaUid: 'tab-1',
963
+ options: { badge: 'new' },
964
+ },
965
+ },
966
+ stepParams: {
967
+ pageTabSettings: {
968
+ tab: { title: 'Tab title' },
969
+ },
970
+ },
971
+ context: {
972
+ api: { request },
973
+ routeRepository: { updateRoute },
974
+ t: (value: string) => value,
975
+ },
976
+ } as any);
977
+
978
+ await model.saveStepParams();
979
+
980
+ expect(request).toHaveBeenCalledTimes(1);
981
+ expect(request.mock.calls[0][0].url).toBe('desktopRoutes:update?filter[id]=10');
982
+ expect(updateRoute).not.toHaveBeenCalled();
983
+ });
984
+
985
+ it('should merge the latest anchor without sending subModels and then set the marker', async () => {
986
+ const { RootPageTabModel } = await import('../PageTabModel');
987
+ const latestAnchor = {
988
+ uid: 'tab-1',
989
+ use: 'CustomRouteModel',
990
+ props: { anchorProp: true },
991
+ decoratorProps: { compact: true },
992
+ stepParams: {
993
+ pageTabSettings: {
994
+ tab: { title: 'Anchor tab title' },
995
+ linkageRules: { value: [{ key: 'old-rule' }] },
996
+ },
997
+ otherFlow: {
998
+ otherStep: { value: 1 },
999
+ },
1000
+ },
1001
+ flowRegistry: { customFlow: { steps: {} } },
1002
+ unknownRoot: { keep: true },
1003
+ subModels: {
1004
+ grid: { uid: 'grid-1' },
1005
+ },
1006
+ };
1007
+ const request = vi.fn(async ({ url }: { url: string }) => {
1008
+ if (url === 'flowModels:findOne') {
1009
+ return { data: { data: latestAnchor } };
1010
+ }
1011
+ return { data: { data: {} } };
1012
+ });
1013
+ const updateRoute = vi.fn().mockResolvedValue(undefined);
1014
+ const currentRoute = {
1015
+ id: 10,
1016
+ schemaUid: 'tab-1',
1017
+ options: {
1018
+ badge: 'new',
1019
+ pluginOption: { keep: true },
1020
+ serverHookState: true,
1021
+ flowRegistry: { routeFlow: { steps: {} } },
1022
+ },
1023
+ };
1024
+ const linkageRules = { value: [{ key: 'new-rule' }] };
1025
+ const model = new RootPageTabModel({
1026
+ uid: 'tab-1',
1027
+ flowRegistry: { routeFlow: { steps: {} } },
1028
+ props: {
1029
+ route: {
1030
+ id: 10,
1031
+ schemaUid: 'tab-1',
1032
+ options: {
1033
+ badge: 'new',
1034
+ pluginOption: { keep: true },
1035
+ flowRegistry: { routeFlow: { steps: {} } },
1036
+ },
1037
+ },
1038
+ },
1039
+ stepParams: {
1040
+ pageTabSettings: {
1041
+ tab: {
1042
+ title: 'Tab title',
1043
+ documentTitle: 'Tab document title',
1044
+ },
1045
+ linkageRules,
1046
+ },
1047
+ },
1048
+ context: {
1049
+ api: { request },
1050
+ routeRepository: {
1051
+ getRouteBySchemaUid: vi.fn(() => currentRoute),
1052
+ refreshAccessible: vi.fn().mockResolvedValue([currentRoute]),
1053
+ updateRoute,
1054
+ },
1055
+ t: (value: string) => value,
1056
+ },
1057
+ } as any);
1058
+
1059
+ await model.saveStepParams();
1060
+
1061
+ const urls = request.mock.calls.map(([config]) => config.url);
1062
+ expect(urls).toEqual(['desktopRoutes:update?filter[id]=10', 'flowModels:findOne', 'flowModels:save']);
1063
+ expect(request.mock.calls[0][0].data.options).toMatchObject({
1064
+ badge: 'new',
1065
+ pluginOption: { keep: true },
1066
+ serverHookState: true,
1067
+ flowRegistry: { routeFlow: { steps: {} } },
1068
+ documentTitle: 'Tab document title',
1069
+ });
1070
+
1071
+ const anchorPayload = request.mock.calls[2][0].data;
1072
+ expect(anchorPayload).toMatchObject({
1073
+ uid: 'tab-1',
1074
+ use: 'CustomRouteModel',
1075
+ props: { anchorProp: true },
1076
+ decoratorProps: { compact: true },
1077
+ stepParams: {
1078
+ pageTabSettings: {
1079
+ tab: { title: 'Anchor tab title' },
1080
+ linkageRules,
1081
+ },
1082
+ otherFlow: {
1083
+ otherStep: { value: 1 },
1084
+ },
1085
+ },
1086
+ flowRegistry: { customFlow: { steps: {} } },
1087
+ unknownRoot: { keep: true },
1088
+ });
1089
+ expect(anchorPayload).not.toHaveProperty('subModels');
1090
+ expect(updateRoute).toHaveBeenCalledWith(
1091
+ 10,
1092
+ {
1093
+ options: expect.objectContaining({
1094
+ badge: 'new',
1095
+ pluginOption: { keep: true },
1096
+ serverHookState: true,
1097
+ hasPersistedPageTabFlowModel: true,
1098
+ }),
1099
+ },
1100
+ { refreshAfterMutation: false },
1101
+ );
1102
+ expect(request.mock.invocationCallOrder[2]).toBeLessThan(updateRoute.mock.invocationCallOrder[0]);
1103
+ expect(model.props.route.options.hasPersistedPageTabFlowModel).toBe(true);
1104
+ expect(model.props.route.options.serverHookState).toBe(true);
1105
+ });
1106
+
1107
+ it('should read the latest anchor again before every linkage save', async () => {
1108
+ const { RootPageTabModel } = await import('../PageTabModel');
1109
+ const request = vi.fn(async ({ url }: { url: string }) => {
1110
+ if (url === 'flowModels:findOne') {
1111
+ return { data: { data: { uid: 'tab-1', use: 'RouteModel' } } };
1112
+ }
1113
+ return {};
1114
+ });
1115
+ const model = new RootPageTabModel({
1116
+ uid: 'tab-1',
1117
+ props: { route: { id: 10, schemaUid: 'tab-1', options: {} } },
1118
+ stepParams: {
1119
+ pageTabSettings: {
1120
+ tab: { title: 'Tab title' },
1121
+ linkageRules: { value: [] },
1122
+ },
1123
+ },
1124
+ context: {
1125
+ api: { request },
1126
+ routeRepository: { updateRoute: vi.fn().mockResolvedValue(undefined) },
1127
+ t: (value: string) => value,
1128
+ },
1129
+ } as any);
1130
+
1131
+ await model.saveStepParams();
1132
+ await model.saveStepParams();
1133
+
1134
+ expect(request.mock.calls.filter(([config]) => config.url === 'flowModels:findOne')).toHaveLength(2);
1135
+ });
1136
+
1137
+ it('should persist empty rules without destroying the anchor and clear the marker', async () => {
1138
+ const { RootPageTabModel } = await import('../PageTabModel');
1139
+ const request = vi.fn(async ({ url }: { url: string }) => {
1140
+ if (url === 'flowModels:findOne') {
1141
+ return {
1142
+ data: {
1143
+ data: {
1144
+ uid: 'tab-1',
1145
+ use: 'RouteModel',
1146
+ subModels: { grid: { uid: 'grid-1' } },
1147
+ },
1148
+ },
1149
+ };
1150
+ }
1151
+ return {};
1152
+ });
1153
+ const updateRoute = vi.fn().mockResolvedValue(undefined);
1154
+ const model = new RootPageTabModel({
1155
+ uid: 'tab-1',
1156
+ props: {
1157
+ route: {
1158
+ id: 10,
1159
+ schemaUid: 'tab-1',
1160
+ options: {
1161
+ badge: 'new',
1162
+ hasPersistedPageTabFlowModel: true,
1163
+ },
1164
+ },
1165
+ },
1166
+ stepParams: {
1167
+ pageTabSettings: {
1168
+ tab: { title: 'Tab title' },
1169
+ linkageRules: { value: [] },
1170
+ },
1171
+ },
1172
+ context: {
1173
+ api: { request },
1174
+ routeRepository: { updateRoute },
1175
+ t: (value: string) => value,
1176
+ },
1177
+ } as any);
1178
+
1179
+ await model.saveStepParams();
1180
+
1181
+ const urls = request.mock.calls.map(([config]) => config.url);
1182
+ expect(urls).toContain('flowModels:save');
1183
+ expect(urls).not.toContain('flowModels:destroy');
1184
+ const anchorPayload = request.mock.calls.find(([config]) => config.url === 'flowModels:save')?.[0].data;
1185
+ expect(anchorPayload.stepParams.pageTabSettings.linkageRules).toEqual({ value: [] });
1186
+ expect(anchorPayload).not.toHaveProperty('subModels');
1187
+ expect(updateRoute.mock.calls[0][1].options).toMatchObject({ badge: 'new' });
1188
+ expect(updateRoute.mock.calls[0][1].options).not.toHaveProperty('hasPersistedPageTabFlowModel');
1189
+ expect(model.props.route.options).not.toHaveProperty('hasPersistedPageTabFlowModel');
1190
+ });
1191
+
1192
+ it('should create a minimal RouteModel anchor when the anchor is missing', async () => {
1193
+ const { RootPageTabModel } = await import('../PageTabModel');
1194
+ const request = vi.fn(async ({ url }: { url: string }) => {
1195
+ if (url === 'flowModels:findOne') {
1196
+ return { data: { data: null } };
1197
+ }
1198
+ return {};
1199
+ });
1200
+ const model = new RootPageTabModel({
1201
+ uid: 'tab-1',
1202
+ props: { route: { id: 10, schemaUid: 'tab-1', options: {} } },
1203
+ stepParams: {
1204
+ pageTabSettings: {
1205
+ tab: { title: 'Tab title' },
1206
+ linkageRules: { value: [{ key: 'rule-1' }] },
1207
+ },
1208
+ },
1209
+ context: {
1210
+ api: { request },
1211
+ routeRepository: { updateRoute: vi.fn().mockResolvedValue(undefined) },
1212
+ t: (value: string) => value,
1213
+ },
1214
+ } as any);
1215
+
1216
+ await model.saveStepParams();
1217
+
1218
+ const anchorPayload = request.mock.calls.find(([config]) => config.url === 'flowModels:save')?.[0].data;
1219
+ expect(anchorPayload).toMatchObject({
1220
+ uid: 'tab-1',
1221
+ use: 'RouteModel',
1222
+ stepParams: {
1223
+ pageTabSettings: {
1224
+ linkageRules: { value: [{ key: 'rule-1' }] },
1225
+ },
1226
+ },
1227
+ });
1228
+ expect(anchorPayload).not.toHaveProperty('props.route');
1229
+ expect(anchorPayload).not.toHaveProperty('subModels');
1230
+ });
1231
+
1232
+ it('should not update the marker when saving the anchor fails', async () => {
1233
+ const { RootPageTabModel } = await import('../PageTabModel');
1234
+ const error = new Error('anchor save failed');
1235
+ const request = vi.fn(async ({ url }: { url: string }) => {
1236
+ if (url === 'flowModels:findOne') {
1237
+ return { data: { data: { uid: 'tab-1', use: 'RouteModel' } } };
1238
+ }
1239
+ if (url === 'flowModels:save') {
1240
+ throw error;
1241
+ }
1242
+ return {};
1243
+ });
1244
+ const updateRoute = vi.fn();
1245
+ const model = new RootPageTabModel({
1246
+ uid: 'tab-1',
1247
+ props: { route: { id: 10, schemaUid: 'tab-1', options: {} } },
1248
+ stepParams: {
1249
+ pageTabSettings: {
1250
+ linkageRules: { value: [{ key: 'rule-1' }] },
1251
+ },
1252
+ },
1253
+ context: {
1254
+ api: { request },
1255
+ routeRepository: { updateRoute },
1256
+ t: (value: string) => value,
1257
+ },
1258
+ } as any);
1259
+
1260
+ await expect(model.saveStepParams()).rejects.toBe(error);
1261
+ expect(updateRoute).not.toHaveBeenCalled();
1262
+ });
1263
+
1264
+ it('should reject when updating the route marker fails', async () => {
1265
+ const { RootPageTabModel } = await import('../PageTabModel');
1266
+ const error = new Error('marker update failed');
1267
+ const request = vi.fn(async ({ url }: { url: string }) => {
1268
+ if (url === 'flowModels:findOne') {
1269
+ return { data: { data: { uid: 'tab-1', use: 'RouteModel' } } };
1270
+ }
1271
+ return {};
1272
+ });
1273
+ const updateRoute = vi.fn().mockRejectedValue(error);
1274
+ const model = new RootPageTabModel({
1275
+ uid: 'tab-1',
1276
+ props: { route: { id: 10, schemaUid: 'tab-1', options: {} } },
1277
+ stepParams: {
1278
+ pageTabSettings: {
1279
+ linkageRules: { value: [{ key: 'rule-1' }] },
1280
+ },
1281
+ },
1282
+ context: {
1283
+ api: { request },
1284
+ routeRepository: { updateRoute },
1285
+ t: (value: string) => value,
1286
+ },
1287
+ } as any);
1288
+
1289
+ await expect(model.saveStepParams()).rejects.toBe(error);
1290
+ expect(request.mock.calls.some(([config]) => config.url === 'flowModels:save')).toBe(true);
1291
+ });
1292
+ });
225
1293
  });