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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/es/acl/ACLProvider.d.ts +10 -0
  2. package/es/authRedirect.d.ts +5 -0
  3. package/es/components/AttachmentUpload.d.ts +51 -0
  4. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  5. package/es/components/index.d.ts +1 -0
  6. package/es/flow/actions/linkageRules.d.ts +1 -0
  7. package/es/flow/admin-shell/admin-layout/NocoBaseLogo.d.ts +10 -0
  8. package/es/flow/components/FlowRoute.d.ts +2 -2
  9. package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
  10. package/es/flow/models/base/ActionModelCore.d.ts +4 -2
  11. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  12. package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
  13. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  14. package/es/index.d.ts +4 -1
  15. package/es/index.mjs +139 -125
  16. package/es/nocobase-buildin-plugin/currentUserAuthStatus.d.ts +11 -0
  17. package/es/nocobase-buildin-plugin/index.d.ts +9 -0
  18. package/es/settings-app/SettingsApplication.d.ts +18 -0
  19. package/es/settings-app/SettingsBrand.d.ts +16 -0
  20. package/es/settings-app/SettingsBuildInPlugin.d.ts +9 -0
  21. package/es/settings-app/SettingsDocumentRedirect.d.ts +10 -0
  22. package/es/settings-app/SettingsGroupNav.d.ts +17 -0
  23. package/es/settings-app/SettingsPluginSettingsManager.d.ts +14 -0
  24. package/es/settings-app/SettingsRouterManager.d.ts +15 -0
  25. package/es/settings-app/SettingsSearch.d.ts +17 -0
  26. package/es/settings-app/SettingsShell.d.ts +10 -0
  27. package/es/settings-app/isSettingsApp.d.ts +18 -0
  28. package/es/settings-app/runtimePaths.d.ts +18 -0
  29. package/es/settings-app/settingsDocumentPath.d.ts +14 -0
  30. package/es/settings-app/settingsTheme.d.ts +87 -0
  31. package/es/settings-app/useSettingsThemeConfig.d.ts +22 -0
  32. package/es/settings-center/AdminSettingsLayout.d.ts +2 -2
  33. package/es/settings-center/groups.d.ts +66 -0
  34. package/es/settings-center/useSettingsGroups.d.ts +36 -0
  35. package/es/settings-center/useSettingsSearch.d.ts +35 -0
  36. package/es/settings-center/utils.d.ts +24 -0
  37. package/es/theme/index.d.ts +1 -0
  38. package/es/theme/itemActive.d.ts +24 -0
  39. package/es/theme/type.d.ts +9 -0
  40. package/lib/index.js +141 -127
  41. package/lib/locale/languageCodes.js +1 -0
  42. package/package.json +8 -7
  43. package/src/__tests__/authRedirect.test.ts +137 -0
  44. package/src/__tests__/browserChecker.test.ts +124 -6
  45. package/src/__tests__/mockSettingsApplication.ts +29 -0
  46. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +51 -10
  47. package/src/__tests__/plugin-manager.test.tsx +7 -6
  48. package/src/__tests__/settings-application.test.ts +164 -0
  49. package/src/__tests__/settings-auth-routes.test.ts +71 -0
  50. package/src/__tests__/settings-center.test.tsx +38 -37
  51. package/src/__tests__/settings-layout-root.test.tsx +260 -0
  52. package/src/__tests__/settings-runtime-paths.test.ts +99 -0
  53. package/src/__tests__/settings-shell.test.tsx +200 -0
  54. package/src/acl/ACLProvider.tsx +21 -0
  55. package/src/authRedirect.ts +72 -3
  56. package/src/components/AttachmentUpload.tsx +275 -0
  57. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  58. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  59. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  60. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  61. package/src/components/index.ts +1 -0
  62. package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
  63. package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
  64. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
  65. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  66. package/src/flow/actions/linkageRules.tsx +26 -6
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -56
  68. package/src/flow/admin-shell/admin-layout/NocoBaseLogo.tsx +77 -0
  69. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +37 -14
  70. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +77 -13
  71. package/src/flow/common/Markdown/style.ts +4 -0
  72. package/src/flow/components/BlockItemCard.tsx +2 -2
  73. package/src/flow/components/FlowRoute.tsx +71 -29
  74. package/src/flow/index.ts +2 -0
  75. package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
  76. package/src/flow/models/base/ActionModelCore.tsx +11 -2
  77. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  78. package/src/flow/models/base/CollectionBlockModel.tsx +6 -2
  79. package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
  80. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
  81. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
  82. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  83. package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
  84. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  85. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  86. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  87. package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
  88. package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
  89. package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
  90. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
  91. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  92. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  93. package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
  94. package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
  95. package/src/flow/models/topbar/TopbarActionModel.tsx +52 -180
  96. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
  97. package/src/index.ts +10 -1
  98. package/src/locale/languageCodes.ts +1 -0
  99. package/src/nocobase-buildin-plugin/currentUserAuthStatus.ts +58 -0
  100. package/src/nocobase-buildin-plugin/index.tsx +99 -71
  101. package/src/settings-app/SettingsApplication.ts +31 -0
  102. package/src/settings-app/SettingsBrand.tsx +108 -0
  103. package/src/settings-app/SettingsBuildInPlugin.ts +10 -0
  104. package/src/settings-app/SettingsDocumentRedirect.tsx +26 -0
  105. package/src/settings-app/SettingsGroupNav.tsx +124 -0
  106. package/src/settings-app/SettingsPluginSettingsManager.ts +38 -0
  107. package/src/settings-app/SettingsRouterManager.ts +92 -0
  108. package/src/settings-app/SettingsSearch.tsx +238 -0
  109. package/src/settings-app/SettingsShell.tsx +167 -0
  110. package/src/settings-app/isSettingsApp.ts +21 -0
  111. package/src/settings-app/runtimePaths.ts +104 -0
  112. package/src/settings-app/settingsDocumentPath.ts +66 -0
  113. package/src/settings-app/settingsTheme.ts +301 -0
  114. package/src/settings-app/useSettingsThemeConfig.ts +100 -0
  115. package/src/settings-center/AdminSettingsLayout.tsx +122 -141
  116. package/src/settings-center/SystemSettingsPage.tsx +4 -170
  117. package/src/settings-center/groups.ts +108 -0
  118. package/src/settings-center/useSettingsGroups.ts +105 -0
  119. package/src/settings-center/useSettingsSearch.ts +173 -0
  120. package/src/settings-center/utils.tsx +50 -1
  121. package/src/theme/index.tsx +1 -0
  122. package/src/theme/itemActive.ts +31 -0
  123. package/src/theme/type.ts +10 -0
@@ -88,6 +88,51 @@ vi.mock('ahooks', async (importOriginal) => {
88
88
  };
89
89
  });
90
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
+
91
136
  describe('PageTabModel', () => {
92
137
  beforeEach(() => {
93
138
  vi.resetModules();
@@ -149,7 +194,7 @@ describe('PageTabModel', () => {
149
194
  it('should persist tab documentTitle to route options on save', async () => {
150
195
  const { RootPageTabModel } = await import('../PageTabModel');
151
196
  const request = vi.fn().mockResolvedValue({});
152
- const model = new RootPageTabModel({
197
+ const model = createRootPageTabModel(RootPageTabModel, {
153
198
  props: {
154
199
  route: {
155
200
  schemaUid: 'tab-1',
@@ -168,7 +213,7 @@ describe('PageTabModel', () => {
168
213
  api: { request },
169
214
  t: (value: string) => value,
170
215
  },
171
- } as any);
216
+ });
172
217
 
173
218
  await model.save();
174
219
 
@@ -177,6 +222,413 @@ describe('PageTabModel', () => {
177
222
  expect(call.data.options.documentTitle).toBe('Tab doc title');
178
223
  });
179
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
+
180
632
  it('should sync persisted route id after save', async () => {
181
633
  const { RootPageTabModel } = await import('../PageTabModel');
182
634
  const request = vi.fn().mockResolvedValue({
@@ -188,16 +640,24 @@ describe('PageTabModel', () => {
188
640
  },
189
641
  },
190
642
  });
191
- const model = new RootPageTabModel({
643
+ const model = createRootPageTabModel(RootPageTabModel, {
192
644
  props: {
193
645
  route: {
646
+ parentId: 999,
194
647
  schemaUid: 'tab-1',
648
+ tabSchemaName: 'tab-name-1',
649
+ type: 'tabs',
650
+ params: [],
651
+ hideInMenu: false,
652
+ enableTabs: false,
195
653
  },
196
654
  },
197
655
  stepParams: {
198
656
  pageTabSettings: {
199
657
  tab: {
200
658
  title: 'Tab title',
659
+ icon: 'TableOutlined',
660
+ documentTitle: 'Tab document title',
201
661
  },
202
662
  },
203
663
  },
@@ -205,10 +665,33 @@ describe('PageTabModel', () => {
205
665
  api: { request },
206
666
  t: (value: string) => value,
207
667
  },
208
- } as any);
668
+ });
209
669
 
210
670
  await model.save();
211
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
+ });
212
695
  expect(model.props.route.id).toBe(123);
213
696
  expect(model.props.route.sort).toBe(9);
214
697
  });
@@ -228,7 +711,7 @@ describe('PageTabModel', () => {
228
711
  ],
229
712
  },
230
713
  });
231
- const model = new RootPageTabModel({
714
+ const model = createRootPageTabModel(RootPageTabModel, {
232
715
  props: {
233
716
  route: {
234
717
  schemaUid: 'tab-1',
@@ -248,7 +731,7 @@ describe('PageTabModel', () => {
248
731
  api: { request },
249
732
  t: (value: string) => value,
250
733
  },
251
- } as any);
734
+ });
252
735
 
253
736
  await model.save();
254
737
 
@@ -495,7 +978,7 @@ describe('PageTabModel', () => {
495
978
  await model.saveStepParams();
496
979
 
497
980
  expect(request).toHaveBeenCalledTimes(1);
498
- expect(request.mock.calls[0][0].url).toBe('desktopRoutes:updateOrCreate');
981
+ expect(request.mock.calls[0][0].url).toBe('desktopRoutes:update?filter[id]=10');
499
982
  expect(updateRoute).not.toHaveBeenCalled();
500
983
  });
501
984
 
@@ -522,15 +1005,22 @@ describe('PageTabModel', () => {
522
1005
  },
523
1006
  };
524
1007
  const request = vi.fn(async ({ url }: { url: string }) => {
525
- if (url === 'desktopRoutes:updateOrCreate') {
526
- return { data: { data: { id: 10, schemaUid: 'tab-1' } } };
527
- }
528
1008
  if (url === 'flowModels:findOne') {
529
1009
  return { data: { data: latestAnchor } };
530
1010
  }
531
1011
  return { data: { data: {} } };
532
1012
  });
533
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
+ };
534
1024
  const linkageRules = { value: [{ key: 'new-rule' }] };
535
1025
  const model = new RootPageTabModel({
536
1026
  uid: 'tab-1',
@@ -557,7 +1047,11 @@ describe('PageTabModel', () => {
557
1047
  },
558
1048
  context: {
559
1049
  api: { request },
560
- routeRepository: { updateRoute },
1050
+ routeRepository: {
1051
+ getRouteBySchemaUid: vi.fn(() => currentRoute),
1052
+ refreshAccessible: vi.fn().mockResolvedValue([currentRoute]),
1053
+ updateRoute,
1054
+ },
561
1055
  t: (value: string) => value,
562
1056
  },
563
1057
  } as any);
@@ -565,10 +1059,11 @@ describe('PageTabModel', () => {
565
1059
  await model.saveStepParams();
566
1060
 
567
1061
  const urls = request.mock.calls.map(([config]) => config.url);
568
- expect(urls).toEqual(['desktopRoutes:updateOrCreate', 'flowModels:findOne', 'flowModels:save']);
1062
+ expect(urls).toEqual(['desktopRoutes:update?filter[id]=10', 'flowModels:findOne', 'flowModels:save']);
569
1063
  expect(request.mock.calls[0][0].data.options).toMatchObject({
570
1064
  badge: 'new',
571
1065
  pluginOption: { keep: true },
1066
+ serverHookState: true,
572
1067
  flowRegistry: { routeFlow: { steps: {} } },
573
1068
  documentTitle: 'Tab document title',
574
1069
  });
@@ -598,6 +1093,7 @@ describe('PageTabModel', () => {
598
1093
  options: expect.objectContaining({
599
1094
  badge: 'new',
600
1095
  pluginOption: { keep: true },
1096
+ serverHookState: true,
601
1097
  hasPersistedPageTabFlowModel: true,
602
1098
  }),
603
1099
  },
@@ -605,6 +1101,7 @@ describe('PageTabModel', () => {
605
1101
  );
606
1102
  expect(request.mock.invocationCallOrder[2]).toBeLessThan(updateRoute.mock.invocationCallOrder[0]);
607
1103
  expect(model.props.route.options.hasPersistedPageTabFlowModel).toBe(true);
1104
+ expect(model.props.route.options.serverHookState).toBe(true);
608
1105
  });
609
1106
 
610
1107
  it('should read the latest anchor again before every linkage save', async () => {
@@ -1,3 +1,12 @@
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
+
1
10
  import { render, screen } from '@nocobase/test/client';
2
11
  import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
3
12
  import { App, ConfigProvider } from 'antd';
@@ -35,3 +44,43 @@ describe('ActionModel rendering', () => {
35
44
  expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
36
45
  });
37
46
  });
47
+
48
+ describe('ActionModel disabled state persistence', () => {
49
+ it('drops disabled from persisted props during initialization', () => {
50
+ const engine = new FlowEngine();
51
+ engine.registerModels({ NoIconActionModel });
52
+ const model = engine.createModel<NoIconActionModel>({
53
+ use: 'NoIconActionModel',
54
+ props: { title: 'Open details', disabled: true },
55
+ });
56
+
57
+ expect(model.getProps()).not.toHaveProperty('disabled');
58
+ });
59
+
60
+ it('keeps runtime disabled state without serializing it', () => {
61
+ const engine = new FlowEngine();
62
+ engine.registerModels({ NoIconActionModel });
63
+ const model = engine.createModel<NoIconActionModel>({
64
+ use: 'NoIconActionModel',
65
+ props: { title: 'Open details' },
66
+ });
67
+
68
+ model.setProps('disabled', true);
69
+
70
+ expect(model.getProps().disabled).toBe(true);
71
+ expect(model.serialize().props).toEqual({ title: 'Open details' });
72
+ });
73
+
74
+ it('does not serialize fork-local disabled state', () => {
75
+ const engine = new FlowEngine();
76
+ engine.registerModels({ NoIconActionModel });
77
+ const model = engine.createModel<NoIconActionModel>({
78
+ use: 'NoIconActionModel',
79
+ props: { title: 'Open details' },
80
+ });
81
+ const fork = model.createFork({ disabled: true });
82
+
83
+ expect(fork.getProps().disabled).toBe(true);
84
+ expect(fork.serialize().props).toEqual({ title: 'Open details' });
85
+ });
86
+ });