@nocobase/client-v2 2.1.26 → 2.1.28

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.
@@ -59,6 +59,51 @@ vi.mock('ahooks', async (importOriginal) => {
59
59
  };
60
60
  });
61
61
 
62
+ type RootPageTabModelClass = typeof import('../PageTabModel').RootPageTabModel;
63
+
64
+ type TestRoute = {
65
+ schemaUid?: string;
66
+ options?: Record<string, unknown>;
67
+ } & Record<string, unknown>;
68
+
69
+ type RootPageTabModelTestOptions = {
70
+ props?: {
71
+ route?: TestRoute;
72
+ };
73
+ stepParams?: {
74
+ pageTabSettings?: {
75
+ tab?: {
76
+ title?: string;
77
+ icon?: string;
78
+ documentTitle?: string;
79
+ };
80
+ };
81
+ };
82
+ context: {
83
+ api: {
84
+ request: ReturnType<typeof vi.fn>;
85
+ };
86
+ routeRepository?: {
87
+ getRouteBySchemaUid?: (schemaUid: string) => TestRoute | undefined;
88
+ refreshAccessible?: () => Promise<unknown>;
89
+ };
90
+ refreshDesktopRoutes?: () => Promise<unknown>;
91
+ logger?: {
92
+ warn?: (context: { err: unknown }, message: string) => void;
93
+ };
94
+ t: (value: string) => string;
95
+ };
96
+ };
97
+
98
+ type RootPageTabModelTestConstructor = new (
99
+ options: RootPageTabModelTestOptions,
100
+ ) => InstanceType<RootPageTabModelClass>;
101
+
102
+ function createRootPageTabModel(Model: RootPageTabModelClass, options: RootPageTabModelTestOptions) {
103
+ const TestModel = Model as unknown as RootPageTabModelTestConstructor;
104
+ return new TestModel(options);
105
+ }
106
+
62
107
  describe('PageTabModel', () => {
63
108
  beforeEach(() => {
64
109
  vi.resetModules();
@@ -113,7 +158,7 @@ describe('PageTabModel', () => {
113
158
  it('should persist tab documentTitle to route options on save', async () => {
114
159
  const { RootPageTabModel } = await import('../PageTabModel');
115
160
  const request = vi.fn().mockResolvedValue({});
116
- const model = new RootPageTabModel({
161
+ const model = createRootPageTabModel(RootPageTabModel, {
117
162
  props: {
118
163
  route: {
119
164
  schemaUid: 'tab-1',
@@ -132,7 +177,7 @@ describe('PageTabModel', () => {
132
177
  api: { request },
133
178
  t: (value: string) => value,
134
179
  },
135
- } as any);
180
+ });
136
181
 
137
182
  await model.save();
138
183
 
@@ -141,6 +186,413 @@ describe('PageTabModel', () => {
141
186
  expect(call.data.options.documentTitle).toBe('Tab doc title');
142
187
  });
143
188
 
189
+ it('should update a persisted tab route by id with only editable fields and latest options', async () => {
190
+ const { RootPageTabModel } = await import('../PageTabModel');
191
+ const request = vi.fn().mockResolvedValue({});
192
+ const model = createRootPageTabModel(RootPageTabModel, {
193
+ props: {
194
+ route: {
195
+ id: 123,
196
+ parentId: 456,
197
+ schemaUid: 'tab-1',
198
+ type: 'tabs',
199
+ sort: 1,
200
+ hidden: true,
201
+ tabSchemaName: 'tab-name-1',
202
+ hideInMenu: false,
203
+ enableTabs: false,
204
+ roles: [{ name: 'admin', title: 'Admin' }],
205
+ createdAt: '2026-07-16T00:00:00.000Z',
206
+ updatedAt: '2026-07-16T01:00:00.000Z',
207
+ options: {
208
+ badge: {
209
+ showZero: false,
210
+ },
211
+ pluginState: 'stale',
212
+ removedPluginState: 'stale-only',
213
+ flowRegistry: { stale: true },
214
+ documentTitle: 'Stale document title',
215
+ },
216
+ },
217
+ },
218
+ stepParams: {
219
+ pageTabSettings: {
220
+ tab: {
221
+ title: 'Renamed tab',
222
+ icon: 'TableOutlined',
223
+ documentTitle: 'Current document title',
224
+ },
225
+ },
226
+ },
227
+ context: {
228
+ api: { request },
229
+ routeRepository: {
230
+ getRouteBySchemaUid: vi.fn(() => ({
231
+ schemaUid: 'tab-1',
232
+ options: {
233
+ badge: {
234
+ showZero: true,
235
+ },
236
+ pluginState: 'fresh',
237
+ flowRegistry: { fresh: true },
238
+ documentTitle: 'Fresh document title',
239
+ },
240
+ })),
241
+ },
242
+ t: (value: string) => value,
243
+ },
244
+ });
245
+
246
+ await model.save();
247
+
248
+ const call = request.mock.calls[0][0];
249
+ expect(call).toEqual({
250
+ method: 'post',
251
+ url: 'desktopRoutes:update?filter[id]=123',
252
+ data: {
253
+ schemaUid: 'tab-1',
254
+ title: 'Renamed tab',
255
+ icon: 'TableOutlined',
256
+ options: {
257
+ badge: {
258
+ showZero: true,
259
+ },
260
+ pluginState: 'fresh',
261
+ flowRegistry: {},
262
+ documentTitle: 'Current document title',
263
+ },
264
+ },
265
+ });
266
+ expect(Object.keys(call.data).sort()).toEqual(['icon', 'options', 'schemaUid', 'title']);
267
+ });
268
+
269
+ it('should fall back to model route options when the current route is unavailable', async () => {
270
+ const { RootPageTabModel } = await import('../PageTabModel');
271
+ const request = vi.fn().mockResolvedValue({});
272
+ const model = createRootPageTabModel(RootPageTabModel, {
273
+ props: {
274
+ route: {
275
+ id: 123,
276
+ parentId: 456,
277
+ schemaUid: 'tab-1',
278
+ type: 'tabs',
279
+ sort: 1,
280
+ hidden: true,
281
+ options: {
282
+ badge: {
283
+ showZero: true,
284
+ },
285
+ },
286
+ },
287
+ },
288
+ stepParams: {
289
+ pageTabSettings: {
290
+ tab: {
291
+ title: 'Renamed tab',
292
+ },
293
+ },
294
+ },
295
+ context: {
296
+ api: { request },
297
+ routeRepository: {
298
+ getRouteBySchemaUid: vi.fn(() => undefined),
299
+ },
300
+ t: (value: string) => value,
301
+ },
302
+ });
303
+
304
+ await model.save();
305
+
306
+ const call = request.mock.calls[0][0];
307
+ expect(call).toEqual({
308
+ method: 'post',
309
+ url: 'desktopRoutes:update?filter[id]=123',
310
+ data: {
311
+ schemaUid: 'tab-1',
312
+ title: 'Renamed tab',
313
+ icon: undefined,
314
+ options: {
315
+ badge: {
316
+ showZero: true,
317
+ },
318
+ flowRegistry: {},
319
+ documentTitle: undefined,
320
+ },
321
+ },
322
+ });
323
+ });
324
+
325
+ it('should await a route refresh after the request before a subsequent persisted tab save', async () => {
326
+ const { RootPageTabModel } = await import('../PageTabModel');
327
+ const events: string[] = [];
328
+ let resolveFirstRefresh: () => void = () => {};
329
+ const firstRefreshDone = new Promise<void>((resolve) => {
330
+ resolveFirstRefresh = resolve;
331
+ });
332
+ let currentRoute = {
333
+ schemaUid: 'tab-1',
334
+ options: {
335
+ pluginState: 'before-save',
336
+ },
337
+ };
338
+ let requestCall = 0;
339
+ const request = vi.fn(async () => {
340
+ requestCall += 1;
341
+ events.push(`request:${requestCall}`);
342
+ if (requestCall === 1) {
343
+ return {
344
+ data: {
345
+ data: [
346
+ {
347
+ id: 123,
348
+ schemaUid: 'tab-1',
349
+ options: {
350
+ pluginState: 'after-save',
351
+ serverHookState: true,
352
+ },
353
+ },
354
+ ],
355
+ },
356
+ };
357
+ }
358
+ return {};
359
+ });
360
+ let refreshCall = 0;
361
+ const refreshAccessible = vi.fn(async () => {
362
+ refreshCall += 1;
363
+ events.push(`refresh:${refreshCall}:start`);
364
+ if (refreshCall === 1) {
365
+ await firstRefreshDone;
366
+ currentRoute = {
367
+ schemaUid: 'tab-1',
368
+ options: {
369
+ pluginState: 'after-save',
370
+ serverHookState: true,
371
+ },
372
+ };
373
+ }
374
+ events.push(`refresh:${refreshCall}:end`);
375
+ });
376
+ const model = createRootPageTabModel(RootPageTabModel, {
377
+ props: {
378
+ route: {
379
+ id: 123,
380
+ schemaUid: 'tab-1',
381
+ },
382
+ },
383
+ stepParams: {
384
+ pageTabSettings: {
385
+ tab: {
386
+ title: 'Renamed tab',
387
+ },
388
+ },
389
+ },
390
+ context: {
391
+ api: { request },
392
+ routeRepository: {
393
+ getRouteBySchemaUid: vi.fn(() => currentRoute),
394
+ refreshAccessible,
395
+ },
396
+ t: (value: string) => value,
397
+ },
398
+ });
399
+
400
+ let firstSaveResolved = false;
401
+ const firstSave = model.save().then(() => {
402
+ firstSaveResolved = true;
403
+ events.push('save:1:end');
404
+ });
405
+
406
+ await vi.waitFor(() => expect(refreshAccessible).toHaveBeenCalledTimes(1));
407
+ expect(events.slice(0, 2)).toEqual(['request:1', 'refresh:1:start']);
408
+ await Promise.resolve();
409
+ expect(firstSaveResolved).toBe(false);
410
+
411
+ resolveFirstRefresh();
412
+ await firstSave;
413
+ expect(events.indexOf('request:1')).toBeLessThan(events.indexOf('refresh:1:start'));
414
+ expect(events.indexOf('refresh:1:end')).toBeLessThan(events.indexOf('save:1:end'));
415
+
416
+ await model.save();
417
+
418
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
419
+ expect(request.mock.calls[1][0].data.options).toMatchObject({
420
+ pluginState: 'after-save',
421
+ serverHookState: true,
422
+ });
423
+ });
424
+
425
+ it('should serialize route refreshes when new tabs are saved concurrently', async () => {
426
+ const { RootPageTabModel } = await import('../PageTabModel');
427
+ let releaseFirstRefresh: () => void = () => {};
428
+ const firstRefreshDone = new Promise<void>((resolve) => {
429
+ releaseFirstRefresh = resolve;
430
+ });
431
+ let refreshCall = 0;
432
+ let activeRefreshes = 0;
433
+ let maxActiveRefreshes = 0;
434
+ const refreshAccessible = vi.fn(async () => {
435
+ refreshCall += 1;
436
+ activeRefreshes += 1;
437
+ maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes);
438
+ if (refreshCall === 1) {
439
+ await firstRefreshDone;
440
+ }
441
+ activeRefreshes -= 1;
442
+ });
443
+ const request = vi
444
+ .fn()
445
+ .mockResolvedValueOnce({
446
+ data: {
447
+ data: {
448
+ id: 101,
449
+ schemaUid: 'tab-1',
450
+ },
451
+ },
452
+ })
453
+ .mockResolvedValueOnce({
454
+ data: {
455
+ data: {
456
+ id: 102,
457
+ schemaUid: 'tab-2',
458
+ },
459
+ },
460
+ });
461
+ const routeRepository = {
462
+ refreshAccessible,
463
+ };
464
+ const createModel = (schemaUid: string, title: string) =>
465
+ createRootPageTabModel(RootPageTabModel, {
466
+ props: {
467
+ route: {
468
+ parentId: 999,
469
+ schemaUid,
470
+ tabSchemaName: `${schemaUid}-name`,
471
+ type: 'tabs',
472
+ },
473
+ },
474
+ stepParams: {
475
+ pageTabSettings: {
476
+ tab: {
477
+ title,
478
+ },
479
+ },
480
+ },
481
+ context: {
482
+ api: { request },
483
+ routeRepository,
484
+ t: (value: string) => value,
485
+ },
486
+ });
487
+
488
+ const saves = Promise.all([createModel('tab-1', 'Tab 1').save(), createModel('tab-2', 'Tab 2').save()]);
489
+
490
+ await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2));
491
+ await vi.waitFor(() => expect(refreshAccessible).toHaveBeenCalled());
492
+ await Promise.resolve();
493
+ await Promise.resolve();
494
+ const refreshCallsBeforeRelease = refreshAccessible.mock.calls.length;
495
+
496
+ releaseFirstRefresh();
497
+ await saves;
498
+
499
+ expect(refreshCallsBeforeRelease).toBe(1);
500
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
501
+ expect(maxActiveRefreshes).toBe(1);
502
+ });
503
+
504
+ it('should keep a successful route save when refreshing the route cache fails', async () => {
505
+ const { RootPageTabModel } = await import('../PageTabModel');
506
+ const refreshError = new Error('refresh failed');
507
+ const request = vi
508
+ .fn()
509
+ .mockResolvedValueOnce({
510
+ data: {
511
+ data: {
512
+ id: 123,
513
+ schemaUid: 'tab-1',
514
+ },
515
+ },
516
+ })
517
+ .mockResolvedValueOnce({});
518
+ const refreshAccessible = vi.fn().mockRejectedValueOnce(refreshError).mockResolvedValueOnce([]);
519
+ const warn = vi.fn();
520
+ const model = createRootPageTabModel(RootPageTabModel, {
521
+ props: {
522
+ route: {
523
+ parentId: 999,
524
+ schemaUid: 'tab-1',
525
+ tabSchemaName: 'tab-name-1',
526
+ type: 'tabs',
527
+ },
528
+ },
529
+ stepParams: {
530
+ pageTabSettings: {
531
+ tab: {
532
+ title: 'Tab title',
533
+ },
534
+ },
535
+ },
536
+ context: {
537
+ api: { request },
538
+ routeRepository: {
539
+ refreshAccessible,
540
+ },
541
+ logger: { warn },
542
+ t: (value: string) => value,
543
+ },
544
+ });
545
+
546
+ await expect(model.save()).resolves.toBeUndefined();
547
+
548
+ expect(model.props.route.id).toBe(123);
549
+ expect(warn).toHaveBeenCalledWith(
550
+ { err: refreshError },
551
+ '[client-v2] Failed to refresh desktop routes after saving a page tab',
552
+ );
553
+
554
+ await expect(model.save()).resolves.toBeUndefined();
555
+ expect(refreshAccessible).toHaveBeenCalledTimes(2);
556
+ });
557
+
558
+ it('should still reject when writing the tab route fails', async () => {
559
+ const { RootPageTabModel } = await import('../PageTabModel');
560
+ const writeError = new Error('write failed');
561
+ const request = vi.fn().mockRejectedValue(writeError);
562
+ const refreshAccessible = vi.fn().mockResolvedValue([]);
563
+ const warn = vi.fn();
564
+ const model = createRootPageTabModel(RootPageTabModel, {
565
+ props: {
566
+ route: {
567
+ parentId: 999,
568
+ schemaUid: 'tab-1',
569
+ tabSchemaName: 'tab-name-1',
570
+ type: 'tabs',
571
+ },
572
+ },
573
+ stepParams: {
574
+ pageTabSettings: {
575
+ tab: {
576
+ title: 'Tab title',
577
+ },
578
+ },
579
+ },
580
+ context: {
581
+ api: { request },
582
+ routeRepository: {
583
+ refreshAccessible,
584
+ },
585
+ logger: { warn },
586
+ t: (value: string) => value,
587
+ },
588
+ });
589
+
590
+ await expect(model.save()).rejects.toBe(writeError);
591
+ expect(refreshAccessible).not.toHaveBeenCalled();
592
+ expect(warn).not.toHaveBeenCalled();
593
+ expect(model.props.route.id).toBeUndefined();
594
+ });
595
+
144
596
  it('should sync persisted route id after save', async () => {
145
597
  const { RootPageTabModel } = await import('../PageTabModel');
146
598
  const request = vi.fn().mockResolvedValue({
@@ -152,16 +604,24 @@ describe('PageTabModel', () => {
152
604
  },
153
605
  },
154
606
  });
155
- const model = new RootPageTabModel({
607
+ const model = createRootPageTabModel(RootPageTabModel, {
156
608
  props: {
157
609
  route: {
610
+ parentId: 999,
158
611
  schemaUid: 'tab-1',
612
+ tabSchemaName: 'tab-name-1',
613
+ type: 'tabs',
614
+ params: [],
615
+ hideInMenu: false,
616
+ enableTabs: false,
159
617
  },
160
618
  },
161
619
  stepParams: {
162
620
  pageTabSettings: {
163
621
  tab: {
164
622
  title: 'Tab title',
623
+ icon: 'TableOutlined',
624
+ documentTitle: 'Tab document title',
165
625
  },
166
626
  },
167
627
  },
@@ -169,10 +629,33 @@ describe('PageTabModel', () => {
169
629
  api: { request },
170
630
  t: (value: string) => value,
171
631
  },
172
- } as any);
632
+ });
173
633
 
174
634
  await model.save();
175
635
 
636
+ const call = request.mock.calls[0][0];
637
+ expect(call).toEqual({
638
+ method: 'post',
639
+ url: 'desktopRoutes:updateOrCreate',
640
+ params: {
641
+ filterKeys: ['schemaUid'],
642
+ },
643
+ data: {
644
+ parentId: 999,
645
+ schemaUid: 'tab-1',
646
+ tabSchemaName: 'tab-name-1',
647
+ type: 'tabs',
648
+ params: [],
649
+ hideInMenu: false,
650
+ enableTabs: false,
651
+ title: 'Tab title',
652
+ icon: 'TableOutlined',
653
+ options: {
654
+ flowRegistry: {},
655
+ documentTitle: 'Tab document title',
656
+ },
657
+ },
658
+ });
176
659
  expect(model.props.route.id).toBe(123);
177
660
  expect(model.props.route.sort).toBe(9);
178
661
  });
@@ -192,7 +675,7 @@ describe('PageTabModel', () => {
192
675
  ],
193
676
  },
194
677
  });
195
- const model = new RootPageTabModel({
678
+ const model = createRootPageTabModel(RootPageTabModel, {
196
679
  props: {
197
680
  route: {
198
681
  schemaUid: 'tab-1',
@@ -212,7 +695,7 @@ describe('PageTabModel', () => {
212
695
  api: { request },
213
696
  t: (value: string) => value,
214
697
  },
215
- } as any);
698
+ });
216
699
 
217
700
  await model.save();
218
701
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { FlowEngine, FlowModelContext, MultiRecordResource } from '@nocobase/flow-engine';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { CollectionBlockModel } from '../CollectionBlockModel';
13
+
14
+ class AddAppendsCollectionBlockModel extends CollectionBlockModel {
15
+ createResource(ctx: FlowModelContext) {
16
+ return ctx.createResource(MultiRecordResource);
17
+ }
18
+ }
19
+
20
+ describe('CollectionBlockModel addAppends', () => {
21
+ it('does not throw for a nested field path when the collection is unavailable', () => {
22
+ const engine = new FlowEngine();
23
+ engine.registerModels({ AddAppendsCollectionBlockModel });
24
+
25
+ const model = engine.createModel<AddAppendsCollectionBlockModel>({
26
+ uid: 'missing-collection-add-appends-block',
27
+ use: 'AddAppendsCollectionBlockModel',
28
+ stepParams: {
29
+ resourceSettings: {
30
+ init: {
31
+ dataSourceKey: 'main',
32
+ collectionName: 'missing_collection',
33
+ },
34
+ },
35
+ },
36
+ });
37
+
38
+ expect(model.collection).toBeUndefined();
39
+ expect(() => model.addAppends('createdBy.departments')).not.toThrow();
40
+ });
41
+ });
@@ -54,6 +54,7 @@ export const languageCodes: Record<string, LocaleOptions> = {
54
54
  'ml-IN': { label: 'മലയാളം' },
55
55
  'mn-MN': { label: 'Монгол хэл' },
56
56
  'ms-MY': { label: 'بهاس ملايو' },
57
+ 'my-MM': { label: 'မြန်မာဘာသာ' },
57
58
  'nb-NO': { label: 'Norsk bokmål' },
58
59
  'ne-NP': { label: 'नेपाली' },
59
60
  'nl-BE': { label: 'Vlaams' },