@nocobase/client-v2 2.2.0-beta.8 → 2.2.0-beta.9

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 (65) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouteRepository.d.ts +8 -0
  3. package/es/RouterManager.d.ts +15 -0
  4. package/es/authRedirect.d.ts +1 -0
  5. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  6. package/es/entry-actions/index.d.ts +9 -0
  7. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  8. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  9. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  10. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  11. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  13. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  14. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  15. package/es/index.d.ts +2 -0
  16. package/es/index.mjs +212 -126
  17. package/es/utils/markdownSanitize.d.ts +11 -0
  18. package/lib/index.js +198 -112
  19. package/package.json +7 -7
  20. package/src/BaseApplication.tsx +2 -0
  21. package/src/RouteRepository.ts +25 -0
  22. package/src/RouterManager.tsx +142 -1
  23. package/src/__tests__/RouteRepository.test.ts +23 -0
  24. package/src/__tests__/RouterManager.test.ts +125 -0
  25. package/src/__tests__/authRedirect.test.ts +17 -0
  26. package/src/authRedirect.ts +38 -0
  27. package/src/entry-actions/EntryActionManager.ts +76 -0
  28. package/src/entry-actions/index.ts +10 -0
  29. package/src/flow/FlowPage.tsx +29 -2
  30. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  31. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  32. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  33. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  34. package/src/flow/actions/dataScopeFilter.ts +10 -1
  35. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  36. package/src/flow/actions/openView.tsx +21 -1
  37. package/src/flow/admin-shell/BaseLayoutModel.tsx +124 -0
  38. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
  39. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
  40. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +41 -4
  41. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  42. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  43. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  44. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  45. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +436 -2
  46. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  47. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  48. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  49. package/src/flow/common/Markdown/Display.tsx +14 -3
  50. package/src/flow/common/Markdown/Edit.tsx +19 -7
  51. package/src/flow/components/FlowRoute.tsx +128 -16
  52. package/src/flow/internal/components/Markdown/util.ts +2 -1
  53. package/src/flow/models/base/ActionModel.tsx +10 -8
  54. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  55. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  56. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  57. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  58. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  59. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  60. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  61. package/src/flow/routeTransientInputArgs.ts +27 -0
  62. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  63. package/src/index.ts +2 -0
  64. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  65. package/src/utils/markdownSanitize.ts +88 -0
@@ -11,10 +11,12 @@ import React from 'react';
11
11
  import { observable } from '@formily/reactive';
12
12
  import { act, render, waitFor } from '@testing-library/react';
13
13
  import { beforeEach, describe, expect, it, vi } from 'vitest';
14
+ import { MemoryRouter } from 'react-router-dom';
14
15
 
15
- const { flowModelRendererSpy } = vi.hoisted(() => {
16
+ const { flowModelRendererSpy, adminLayoutContentEffectSpy } = vi.hoisted(() => {
16
17
  return {
17
18
  flowModelRendererSpy: vi.fn(),
19
+ adminLayoutContentEffectSpy: vi.fn(),
18
20
  };
19
21
  });
20
22
 
@@ -35,6 +37,52 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
35
37
  };
36
38
  });
37
39
 
40
+ vi.mock('@ant-design/pro-layout', async () => {
41
+ const ReactActual = await import('react');
42
+ const RouteContext = ReactActual.createContext({ isMobile: true });
43
+ const ProLayout = (props: { children?: React.ReactNode }) => {
44
+ return ReactActual.createElement(RouteContext.Provider, { value: { isMobile: true } }, props.children);
45
+ };
46
+
47
+ return {
48
+ default: ProLayout,
49
+ RouteContext,
50
+ };
51
+ });
52
+
53
+ vi.mock('../AdminLayoutSlotModels', async (importOriginal) => {
54
+ const actual = await importOriginal<typeof import('../AdminLayoutSlotModels')>();
55
+ const ReactActual = await import('react');
56
+
57
+ return {
58
+ ...actual,
59
+ AdminLayoutContent: (props: { onContentElementChange?: (element: HTMLDivElement | null) => void }) => {
60
+ const bindContentRef = ReactActual.useCallback(
61
+ (element: HTMLDivElement | null) => {
62
+ props.onContentElementChange?.(element);
63
+ if (element) {
64
+ adminLayoutContentEffectSpy();
65
+ }
66
+ },
67
+ [props.onContentElementChange],
68
+ );
69
+
70
+ return ReactActual.createElement('div', { ref: bindContentRef, 'data-testid': 'admin-layout-content' });
71
+ },
72
+ };
73
+ });
74
+
75
+ vi.mock('../useApplications', () => ({
76
+ useApplications: () => ({
77
+ Component: null,
78
+ appList: [],
79
+ }),
80
+ }));
81
+
82
+ vi.mock('../AppListRender', () => ({
83
+ useAppListRender: () => undefined,
84
+ }));
85
+
38
86
  import {
39
87
  FlowEngine,
40
88
  FlowEngineProvider,
@@ -43,7 +91,8 @@ import {
43
91
  useFlowEngine,
44
92
  type FlowModel,
45
93
  } from '@nocobase/flow-engine';
46
- import { AdminLayoutModel, getAdminLayoutModel } from '..';
94
+ import { AdminLayoutComponent, AdminLayoutModel, getAdminLayoutModel } from '..';
95
+ import { NocoBaseDesktopRouteType } from '../../../../flow-compat';
47
96
  import { getLayoutPageRouteName, getLayoutPageViewRouteName } from '../../../../layout-manager/utils';
48
97
  import { TopbarActionModel } from '../../../models/topbar/TopbarActionModel';
49
98
  import { UserCenterTopbarActionModel } from '../../../models/topbar/UserCenterTopbarActionModel';
@@ -83,6 +132,7 @@ const TestAdminLayoutHost = (props) => {
83
132
  describe('AdminLayoutModel runtime', () => {
84
133
  beforeEach(() => {
85
134
  flowModelRendererSpy.mockClear();
135
+ adminLayoutContentEffectSpy.mockReset();
86
136
  });
87
137
 
88
138
  it('should create model via getAdminLayoutModel and update props on rerender', async () => {
@@ -142,6 +192,46 @@ describe('AdminLayoutModel runtime', () => {
142
192
  expect(model.context.layoutContentElement).toBeNull();
143
193
  });
144
194
 
195
+ it('should expose mobile layout state before initial layout content effects run', async () => {
196
+ const engine = new FlowEngine();
197
+ engine.context.defineProperty('routeRepository', {
198
+ value: {
199
+ listAccessible: vi.fn(() => []),
200
+ subscribe: vi.fn(),
201
+ unsubscribe: vi.fn(),
202
+ ensureAccessibleLoaded: vi.fn(() => Promise.resolve()),
203
+ moveRoute: vi.fn(() => Promise.resolve()),
204
+ },
205
+ });
206
+ const model = getAdminLayoutModel<AdminLayoutModel>(engine, { create: true });
207
+ if (!model) {
208
+ throw new Error('[NocoBase] Failed to create admin-layout-model.');
209
+ }
210
+ const modelLifecycle = model as unknown as { onMount: () => void; onUnmount: () => void };
211
+ modelLifecycle.onMount();
212
+ const observedMobileStates: boolean[] = [];
213
+ adminLayoutContentEffectSpy.mockImplementation(() => {
214
+ observedMobileStates.push(model.context.isMobileLayout);
215
+ });
216
+
217
+ const { unmount } = render(
218
+ <FlowEngineProvider engine={engine}>
219
+ <MemoryRouter initialEntries={['/admin/lrmg36pcahi']}>
220
+ <AdminLayoutComponent model={model} />
221
+ </MemoryRouter>
222
+ </FlowEngineProvider>,
223
+ );
224
+
225
+ await waitFor(() => {
226
+ expect(adminLayoutContentEffectSpy).toHaveBeenCalledTimes(1);
227
+ });
228
+
229
+ expect(observedMobileStates).toEqual([true]);
230
+
231
+ unmount();
232
+ modelLifecycle.onUnmount();
233
+ });
234
+
145
235
  it('should expose layout definition only while mounted', async () => {
146
236
  const engine = new FlowEngine();
147
237
  const { unmount } = render(
@@ -214,6 +304,54 @@ describe('AdminLayoutModel runtime', () => {
214
304
  });
215
305
  });
216
306
 
307
+ it('should keep route state when route page registers after route sync', async () => {
308
+ const engine = new FlowEngine();
309
+
310
+ render(
311
+ <FlowEngineProvider engine={engine}>
312
+ <TestAdminLayoutHost />
313
+ </FlowEngineProvider>,
314
+ );
315
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
316
+ const routeState = {
317
+ __nocobaseOpenViewInputArgs: {
318
+ popup: {
319
+ formData: {
320
+ start: '2026-06-24',
321
+ end: '2026-06-25',
322
+ },
323
+ },
324
+ },
325
+ };
326
+
327
+ act(() => {
328
+ model.syncLayoutRoute({
329
+ name: getLayoutPageViewRouteName('admin'),
330
+ pathname: '/admin/page-1/view/popup',
331
+ layoutBasePathname: '/admin',
332
+ state: routeState,
333
+ });
334
+ });
335
+
336
+ const coordinator = (model as any).getCoordinator();
337
+ const syncRoute = vi.spyOn(coordinator, 'syncRoute').mockImplementation(() => undefined);
338
+
339
+ act(() => {
340
+ model.registerRoutePage('page-1', {
341
+ active: true,
342
+ layoutContentElement: document.createElement('div'),
343
+ });
344
+ });
345
+
346
+ expect(syncRoute).toHaveBeenCalledWith(
347
+ expect.objectContaining({
348
+ pageUid: 'page-1',
349
+ pathname: '/admin/page-1/view/popup',
350
+ state: routeState,
351
+ }),
352
+ );
353
+ });
354
+
217
355
  it('should parse RunJS openView route params into layout route view stack state', async () => {
218
356
  const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
219
357
  if (!token) {
@@ -295,6 +433,82 @@ describe('AdminLayoutModel runtime', () => {
295
433
  });
296
434
  });
297
435
 
436
+ it('should resolve group id paths as blank root routes', async () => {
437
+ const engine = new FlowEngine();
438
+ engine.context.defineProperty('routeRepository', {
439
+ value: {
440
+ getRouteById: (routeId: string) =>
441
+ routeId === '1'
442
+ ? {
443
+ id: 1,
444
+ type: NocoBaseDesktopRouteType.group,
445
+ }
446
+ : undefined,
447
+ },
448
+ });
449
+
450
+ render(
451
+ <FlowEngineProvider engine={engine}>
452
+ <TestAdminLayoutHost />
453
+ </FlowEngineProvider>,
454
+ );
455
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
456
+ expect(model).toBeTruthy();
457
+
458
+ expect(
459
+ model.resolveLayoutRoute({
460
+ name: getLayoutPageRouteName('admin'),
461
+ pathname: '/admin/1',
462
+ layoutBasePathname: '/admin',
463
+ }),
464
+ ).toMatchObject({
465
+ type: 'root',
466
+ pathname: '/admin/1',
467
+ relativePath: '1',
468
+ });
469
+ });
470
+
471
+ it('should prefer schema uid routes before treating numeric paths as group ids', async () => {
472
+ const engine = new FlowEngine();
473
+ engine.context.defineProperty('routeRepository', {
474
+ value: {
475
+ getRouteBySchemaUid: (pageUid: string) =>
476
+ pageUid === '1'
477
+ ? {
478
+ schemaUid: '1',
479
+ type: NocoBaseDesktopRouteType.flowPage,
480
+ }
481
+ : undefined,
482
+ getRouteById: (routeId: string) =>
483
+ routeId === '1'
484
+ ? {
485
+ id: 1,
486
+ type: NocoBaseDesktopRouteType.group,
487
+ }
488
+ : undefined,
489
+ },
490
+ });
491
+
492
+ render(
493
+ <FlowEngineProvider engine={engine}>
494
+ <TestAdminLayoutHost />
495
+ </FlowEngineProvider>,
496
+ );
497
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
498
+ expect(model).toBeTruthy();
499
+
500
+ expect(
501
+ model.resolveLayoutRoute({
502
+ name: getLayoutPageRouteName('admin'),
503
+ pathname: '/admin/1',
504
+ layoutBasePathname: '/admin',
505
+ }),
506
+ ).toMatchObject({
507
+ type: 'page',
508
+ pageUid: '1',
509
+ });
510
+ });
511
+
298
512
  it('should not consume global routes that belong to nested layouts', async () => {
299
513
  const engine = new FlowEngine();
300
514
  const routeRef = observable.ref({
@@ -395,6 +609,226 @@ describe('AdminLayoutModel runtime', () => {
395
609
  });
396
610
  });
397
611
 
612
+ it('should restore layout route from router context when a route page registers after stale cleanup', async () => {
613
+ const engine = new FlowEngine();
614
+ engine.context.defineProperty('route', {
615
+ value: {
616
+ name: 'admin.page',
617
+ pathname: '/admin/page-1',
618
+ params: { name: 'page-1' },
619
+ },
620
+ });
621
+ engine.context.defineProperty('routeRepository', {
622
+ value: {
623
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
624
+ },
625
+ });
626
+
627
+ render(
628
+ <FlowEngineProvider engine={engine}>
629
+ <TestAdminLayoutHost />
630
+ </FlowEngineProvider>,
631
+ );
632
+
633
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
634
+ expect(model).toBeTruthy();
635
+ const routeLike = {
636
+ name: 'admin.page',
637
+ pathname: '/admin/page-1',
638
+ layoutRouteName: 'admin',
639
+ layoutBasePathname: '/admin',
640
+ };
641
+
642
+ act(() => {
643
+ model.syncLayoutRoute(routeLike);
644
+ model.clearLayoutRoute(routeLike);
645
+ });
646
+
647
+ expect(model.context.layoutRoute).toBeNull();
648
+ const syncRouteSpy = vi.spyOn(model.getCoordinator(), 'syncRoute');
649
+ syncRouteSpy.mockClear();
650
+
651
+ act(() => {
652
+ model.registerRoutePage('page-1', {
653
+ active: true,
654
+ });
655
+ });
656
+
657
+ await waitFor(() => {
658
+ expect(model.context.layoutRoute).toMatchObject({
659
+ type: 'page',
660
+ pageUid: 'page-1',
661
+ pathname: '/admin/page-1',
662
+ });
663
+ });
664
+ expect(model.context.currentRoute.title).toBe('page-1');
665
+ expect(syncRouteSpy).toHaveBeenLastCalledWith(
666
+ expect.objectContaining({
667
+ layoutRouteName: 'admin',
668
+ pageUid: 'page-1',
669
+ pathname: '/admin/page-1',
670
+ layoutBasePathname: '/admin',
671
+ }),
672
+ );
673
+ });
674
+
675
+ it('should restore layout route from a legacy dotted page view route name', async () => {
676
+ const engine = new FlowEngine();
677
+ engine.context.defineProperty('route', {
678
+ value: {
679
+ name: 'admin.page.view',
680
+ pathname: '/admin/page-1/view/popup',
681
+ params: { name: 'page-1' },
682
+ },
683
+ });
684
+ engine.context.defineProperty('routeRepository', {
685
+ value: {
686
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
687
+ },
688
+ });
689
+
690
+ render(
691
+ <FlowEngineProvider engine={engine}>
692
+ <TestAdminLayoutHost />
693
+ </FlowEngineProvider>,
694
+ );
695
+
696
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
697
+ expect(model).toBeTruthy();
698
+
699
+ act(() => {
700
+ model.registerRoutePage('page-1', {
701
+ active: true,
702
+ });
703
+ });
704
+
705
+ expect(model.context.layoutRoute).toMatchObject({
706
+ type: 'page',
707
+ pageUid: 'page-1',
708
+ pathname: '/admin/page-1/view/popup',
709
+ viewStack: [{ viewUid: 'page-1' }, { viewUid: 'popup' }],
710
+ });
711
+ expect(model.context.currentRoute.title).toBe('page-1');
712
+ });
713
+
714
+ it('should not restore layout route from another layout when a route page registers', async () => {
715
+ const engine = new FlowEngine();
716
+ engine.context.defineProperty('route', {
717
+ value: {
718
+ name: 'admin.settings.publicForms.page',
719
+ pathname: '/admin/settings/public-forms/form-1',
720
+ params: { name: 'form-1' },
721
+ layoutRouteName: 'admin.settings.publicForms',
722
+ layoutBasePathname: '/admin/settings/public-forms',
723
+ },
724
+ });
725
+ engine.context.defineProperty('routeRepository', {
726
+ value: {
727
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
728
+ },
729
+ });
730
+
731
+ render(
732
+ <FlowEngineProvider engine={engine}>
733
+ <TestAdminLayoutHost />
734
+ </FlowEngineProvider>,
735
+ );
736
+
737
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
738
+ expect(model).toBeTruthy();
739
+
740
+ act(() => {
741
+ model.registerRoutePage('form-1', {
742
+ active: true,
743
+ });
744
+ });
745
+
746
+ expect(model.context.layoutRoute).toBeNull();
747
+ expect(model.context.currentRoute).toEqual({});
748
+ });
749
+
750
+ it('should not restore layout route from a nested layout route without layoutRouteName', async () => {
751
+ const engine = new FlowEngine();
752
+ engine.context.defineProperty('route', {
753
+ value: {
754
+ name: 'admin.settings.publicForms.page',
755
+ pathname: '/admin/settings/public-forms/form-1',
756
+ params: { name: 'form-1' },
757
+ layoutBasePathname: '/admin/settings/public-forms',
758
+ },
759
+ });
760
+ engine.context.defineProperty('routeRepository', {
761
+ value: {
762
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
763
+ },
764
+ });
765
+
766
+ render(
767
+ <FlowEngineProvider engine={engine}>
768
+ <TestAdminLayoutHost />
769
+ </FlowEngineProvider>,
770
+ );
771
+
772
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
773
+ expect(model).toBeTruthy();
774
+
775
+ act(() => {
776
+ model.registerRoutePage('form-1', {
777
+ active: true,
778
+ });
779
+ });
780
+
781
+ expect(model.context.layoutRoute).toBeNull();
782
+ expect(model.context.currentRoute).toEqual({});
783
+ });
784
+
785
+ it('should ignore stale layout route cleanup after a route page has registered on the same path', async () => {
786
+ const engine = new FlowEngine();
787
+ engine.context.defineProperty('route', {
788
+ value: {
789
+ name: 'admin.page',
790
+ pathname: '/admin/page-1',
791
+ params: { name: 'page-1' },
792
+ },
793
+ });
794
+ engine.context.defineProperty('routeRepository', {
795
+ value: {
796
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
797
+ },
798
+ });
799
+
800
+ render(
801
+ <FlowEngineProvider engine={engine}>
802
+ <TestAdminLayoutHost />
803
+ </FlowEngineProvider>,
804
+ );
805
+
806
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
807
+ expect(model).toBeTruthy();
808
+ const staleRouteLike = {
809
+ name: 'admin.page',
810
+ pathname: '/admin/page-1',
811
+ layoutRouteName: 'admin',
812
+ layoutBasePathname: '/admin',
813
+ };
814
+
815
+ act(() => {
816
+ model.syncLayoutRoute(staleRouteLike);
817
+ model.clearLayoutRoute(staleRouteLike);
818
+ model.registerRoutePage('page-1', {
819
+ active: true,
820
+ });
821
+ model.clearLayoutRoute(staleRouteLike);
822
+ });
823
+
824
+ expect(model.context.layoutRoute).toMatchObject({
825
+ type: 'page',
826
+ pageUid: 'page-1',
827
+ pathname: '/admin/page-1',
828
+ });
829
+ expect(model.context.currentRoute.title).toBe('page-1');
830
+ });
831
+
398
832
  it('should keep pageActive in sync after non-active route page updates', async () => {
399
833
  const engine = new FlowEngine();
400
834
  engine.context.defineProperty('routeRepository', {
@@ -222,6 +222,31 @@ describe('TopbarActionsBar helpers', () => {
222
222
  expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
223
223
  });
224
224
 
225
+ it('should open sub-app admin settings in a new tab outside sub-app admin runtime', () => {
226
+ const items = getTopbarPluginSettingsItems({
227
+ canManagePlugins: false,
228
+ t: (key) => key,
229
+ settings: [
230
+ {
231
+ key: 'system-settings',
232
+ name: 'system-settings',
233
+ title: 'System settings',
234
+ path: '/admin/settings/system-settings',
235
+ icon: null,
236
+ componentLoader: async () => null,
237
+ },
238
+ ] as any,
239
+ });
240
+
241
+ renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/crm-amd/ekeisumx1zu');
242
+
243
+ const link = screen.getByRole('link', { name: 'System settings' });
244
+ expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/system-settings');
245
+ expect(link).toHaveAttribute('target', '_blank');
246
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
247
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
248
+ });
249
+
225
250
  it('should keep regular admin settings as SPA links inside admin runtime', () => {
226
251
  const items = getTopbarPluginSettingsItems({
227
252
  canManagePlugins: false,
@@ -245,6 +270,29 @@ describe('TopbarActionsBar helpers', () => {
245
270
  expect(link).not.toHaveAttribute('target', '_blank');
246
271
  });
247
272
 
273
+ it('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
274
+ const items = getTopbarPluginSettingsItems({
275
+ canManagePlugins: false,
276
+ t: (key) => key,
277
+ settings: [
278
+ {
279
+ key: 'routes',
280
+ name: 'routes',
281
+ title: 'Routes',
282
+ path: '/admin/settings/routes',
283
+ icon: null,
284
+ componentLoader: async () => null,
285
+ },
286
+ ] as any,
287
+ });
288
+
289
+ renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
+
291
+ const link = screen.getByRole('link', { name: 'Routes' });
292
+ expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
+ expect(link).not.toHaveAttribute('target', '_blank');
294
+ });
295
+
248
296
  it('should not treat admin-like paths as admin runtime', () => {
249
297
  const items = getTopbarPluginSettingsItems({
250
298
  canManagePlugins: false,
@@ -9,6 +9,7 @@
9
9
 
10
10
  export * from './AdminLayoutComponent';
11
11
  export * from './AdminLayoutModel';
12
+ export * from './AppSwitcherActionPanelModel';
12
13
  export * from '../BaseLayoutModel';
13
14
  export * from '../BaseLayoutRouteCoordinator';
14
15
  export * from './AdminLayoutSlotModels';
@@ -8,41 +8,110 @@
8
8
  */
9
9
 
10
10
  import { useApp } from '../../../flow-compat';
11
+ import type { AppListProps } from '@ant-design/pro-layout/es/components/AppsLogoComponents/types';
11
12
  import React from 'react';
13
+ import type { AdminLayoutModel } from './AdminLayoutModel';
14
+ import { ADMIN_LAYOUT_MODEL_UID } from './constants';
15
+ import type { AppSwitcherActionPanelModel } from './AppSwitcherActionPanelModel';
12
16
 
13
- export const useApplications = () => {
17
+ export const APP_SWITCHER_ACTION_PANEL_MODEL_UID = `${ADMIN_LAYOUT_MODEL_UID}-app-switcher-actions`;
18
+
19
+ function getErrorStatus(error: unknown) {
20
+ if (!error || typeof error !== 'object') {
21
+ return;
22
+ }
23
+ const errorLike = error as { response?: { status?: unknown }; status?: unknown };
24
+ const status = errorLike.response?.status ?? errorLike.status;
25
+ return typeof status === 'number' ? status : undefined;
26
+ }
27
+
28
+ export const useApplications = (adminLayoutModel?: AdminLayoutModel) => {
14
29
  const app = useApp();
15
30
  const loadAppList = app.apps.loadAppList;
16
- const [appList, setAppList] = React.useState([]);
31
+ const [legacyAppList, setLegacyAppList] = React.useState<AppListProps>([]);
32
+ const [appSwitcherModel, setAppSwitcherModel] = React.useState<AppSwitcherActionPanelModel>();
33
+
34
+ React.useEffect(() => {
35
+ let canceled = false;
36
+ let modelWithLayoutContext: AppSwitcherActionPanelModel | undefined;
37
+
38
+ if (!adminLayoutModel) {
39
+ setAppSwitcherModel(undefined);
40
+ return;
41
+ }
42
+
43
+ const load = async () => {
44
+ try {
45
+ const model = await adminLayoutModel.flowEngine.loadOrCreateModel<AppSwitcherActionPanelModel>({
46
+ uid: APP_SWITCHER_ACTION_PANEL_MODEL_UID,
47
+ use: 'AppSwitcherActionPanelModel',
48
+ parentId: adminLayoutModel.uid,
49
+ subKey: 'appSwitcher',
50
+ subType: 'object',
51
+ });
52
+ if (canceled || !model) {
53
+ return;
54
+ }
55
+ model.context.addDelegate(adminLayoutModel.context);
56
+ modelWithLayoutContext = model;
57
+ setAppSwitcherModel(model);
58
+ } catch (error) {
59
+ if (canceled) {
60
+ return;
61
+ }
62
+ if (getErrorStatus(error) !== 404) {
63
+ throw error;
64
+ }
65
+ setAppSwitcherModel(undefined);
66
+ }
67
+ };
68
+ load();
69
+
70
+ return () => {
71
+ canceled = true;
72
+ modelWithLayoutContext?.context.removeDelegate(adminLayoutModel.context);
73
+ };
74
+ }, [adminLayoutModel]);
17
75
 
18
76
  React.useEffect(() => {
19
77
  let canceled = false;
20
78
 
79
+ if (adminLayoutModel) {
80
+ setLegacyAppList([]);
81
+ return;
82
+ }
83
+
21
84
  if (!loadAppList) {
22
- setAppList([]);
85
+ setLegacyAppList([]);
23
86
  return;
24
87
  }
25
88
 
26
- void Promise.resolve(loadAppList(app))
27
- .then((list) => {
89
+ const load = async () => {
90
+ try {
91
+ const list = await Promise.resolve(loadAppList(app));
28
92
  if (!canceled) {
29
- setAppList(Array.isArray(list) ? list : []);
93
+ setLegacyAppList(Array.isArray(list) ? list : []);
30
94
  }
31
- })
32
- .catch((error) => {
95
+ } catch (error) {
33
96
  console.error('[NocoBase] Failed to load application switcher list.', error);
34
97
  if (!canceled) {
35
- setAppList([]);
98
+ setLegacyAppList([]);
36
99
  }
37
- });
100
+ }
101
+ };
102
+ load();
38
103
 
39
104
  return () => {
40
105
  canceled = true;
41
106
  };
42
- }, [app, loadAppList]);
107
+ }, [adminLayoutModel, app, loadAppList]);
108
+
109
+ const shouldRenderConfiguredSwitcher =
110
+ !!appSwitcherModel && (appSwitcherModel.hasActions() || app.flowEngine.context.flowSettingsEnabled);
43
111
 
44
112
  return {
45
113
  Component: app.apps.Component,
46
- appList,
114
+ appList: shouldRenderConfiguredSwitcher ? [{ title: '', url: '#' }] : legacyAppList,
115
+ appSwitcherModel,
47
116
  };
48
117
  };