@nocobase/client-v2 2.1.15 → 2.1.18

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.
@@ -284,13 +284,36 @@ function SetIsMobileLayout(props: { isMobile: boolean; children: any; model?: Ad
284
284
  const flowEngine = useFlowEngine();
285
285
  const adminLayoutModel = props.model || flowEngine.getModel<AdminLayoutModel>(ADMIN_LAYOUT_MODEL_UID);
286
286
 
287
- useEffect(() => {
287
+ useLayoutEffect(() => {
288
288
  adminLayoutModel?.setIsMobileLayout(props.isMobile);
289
289
  }, [adminLayoutModel, props.isMobile]);
290
290
 
291
291
  return props.children;
292
292
  }
293
293
 
294
+ function AdminLayoutContentWithMobileState(props: {
295
+ isMobile: boolean;
296
+ model?: AdminLayoutModel;
297
+ onContentElementChange?: (element: HTMLDivElement | null) => void;
298
+ }) {
299
+ const modelRef = useRef(props.model);
300
+ const isMobileRef = useRef(props.isMobile);
301
+ const onContentElementChangeRef = useRef(props.onContentElementChange);
302
+
303
+ modelRef.current = props.model;
304
+ isMobileRef.current = props.isMobile;
305
+ onContentElementChangeRef.current = props.onContentElementChange;
306
+
307
+ const handleContentElementChange = useCallback((element: HTMLDivElement | null) => {
308
+ if (element) {
309
+ modelRef.current?.setIsMobileLayout(isMobileRef.current);
310
+ }
311
+ onContentElementChangeRef.current?.(element);
312
+ }, []);
313
+
314
+ return <AdminLayoutContent onContentElementChange={handleContentElementChange} />;
315
+ }
316
+
294
317
  const DesignerButtonMenuItem: FC<{ item: AdminLayoutMenuNode; fallbackParentRoute?: NocoBaseDesktopRoute }> = (
295
318
  props,
296
319
  ) => {
@@ -697,7 +720,11 @@ export const AdminLayoutComponent = observer((props: any) => {
697
720
  <SetIsMobileLayout isMobile={isMobile} model={adminLayoutModel}>
698
721
  <ConfigProvider theme={isMobile ? mobileTheme : theme}>
699
722
  <GlobalStyle />
700
- <AdminLayoutContent onContentElementChange={handleLayoutContentElementChange} />
723
+ <AdminLayoutContentWithMobileState
724
+ isMobile={isMobile}
725
+ model={adminLayoutModel}
726
+ onContentElementChange={handleLayoutContentElementChange}
727
+ />
701
728
  </ConfigProvider>
702
729
  </SetIsMobileLayout>
703
730
  );
@@ -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,7 @@ 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 '..';
47
95
  import { getLayoutPageRouteName, getLayoutPageViewRouteName } from '../../../../layout-manager/utils';
48
96
  import { TopbarActionModel } from '../../../models/topbar/TopbarActionModel';
49
97
  import { UserCenterTopbarActionModel } from '../../../models/topbar/UserCenterTopbarActionModel';
@@ -83,6 +131,7 @@ const TestAdminLayoutHost = (props) => {
83
131
  describe('AdminLayoutModel runtime', () => {
84
132
  beforeEach(() => {
85
133
  flowModelRendererSpy.mockClear();
134
+ adminLayoutContentEffectSpy.mockReset();
86
135
  });
87
136
 
88
137
  it('should create model via getAdminLayoutModel and update props on rerender', async () => {
@@ -142,6 +191,46 @@ describe('AdminLayoutModel runtime', () => {
142
191
  expect(model.context.layoutContentElement).toBeNull();
143
192
  });
144
193
 
194
+ it('should expose mobile layout state before initial layout content effects run', async () => {
195
+ const engine = new FlowEngine();
196
+ engine.context.defineProperty('routeRepository', {
197
+ value: {
198
+ listAccessible: vi.fn(() => []),
199
+ subscribe: vi.fn(),
200
+ unsubscribe: vi.fn(),
201
+ ensureAccessibleLoaded: vi.fn(() => Promise.resolve()),
202
+ moveRoute: vi.fn(() => Promise.resolve()),
203
+ },
204
+ });
205
+ const model = getAdminLayoutModel<AdminLayoutModel>(engine, { create: true });
206
+ if (!model) {
207
+ throw new Error('[NocoBase] Failed to create admin-layout-model.');
208
+ }
209
+ const modelLifecycle = model as unknown as { onMount: () => void; onUnmount: () => void };
210
+ modelLifecycle.onMount();
211
+ const observedMobileStates: boolean[] = [];
212
+ adminLayoutContentEffectSpy.mockImplementation(() => {
213
+ observedMobileStates.push(model.context.isMobileLayout);
214
+ });
215
+
216
+ const { unmount } = render(
217
+ <FlowEngineProvider engine={engine}>
218
+ <MemoryRouter initialEntries={['/admin/lrmg36pcahi']}>
219
+ <AdminLayoutComponent model={model} />
220
+ </MemoryRouter>
221
+ </FlowEngineProvider>,
222
+ );
223
+
224
+ await waitFor(() => {
225
+ expect(adminLayoutContentEffectSpy).toHaveBeenCalledTimes(1);
226
+ });
227
+
228
+ expect(observedMobileStates).toEqual([true]);
229
+
230
+ unmount();
231
+ modelLifecycle.onUnmount();
232
+ });
233
+
145
234
  it('should expose layout definition only while mounted', async () => {
146
235
  const engine = new FlowEngine();
147
236
  const { unmount } = render(
@@ -395,6 +484,226 @@ describe('AdminLayoutModel runtime', () => {
395
484
  });
396
485
  });
397
486
 
487
+ it('should restore layout route from router context when a route page registers after stale cleanup', async () => {
488
+ const engine = new FlowEngine();
489
+ engine.context.defineProperty('route', {
490
+ value: {
491
+ name: 'admin.page',
492
+ pathname: '/admin/page-1',
493
+ params: { name: 'page-1' },
494
+ },
495
+ });
496
+ engine.context.defineProperty('routeRepository', {
497
+ value: {
498
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
499
+ },
500
+ });
501
+
502
+ render(
503
+ <FlowEngineProvider engine={engine}>
504
+ <TestAdminLayoutHost />
505
+ </FlowEngineProvider>,
506
+ );
507
+
508
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
509
+ expect(model).toBeTruthy();
510
+ const routeLike = {
511
+ name: 'admin.page',
512
+ pathname: '/admin/page-1',
513
+ layoutRouteName: 'admin',
514
+ layoutBasePathname: '/admin',
515
+ };
516
+
517
+ act(() => {
518
+ model.syncLayoutRoute(routeLike);
519
+ model.clearLayoutRoute(routeLike);
520
+ });
521
+
522
+ expect(model.context.layoutRoute).toBeNull();
523
+ const syncRouteSpy = vi.spyOn(model.getCoordinator(), 'syncRoute');
524
+ syncRouteSpy.mockClear();
525
+
526
+ act(() => {
527
+ model.registerRoutePage('page-1', {
528
+ active: true,
529
+ });
530
+ });
531
+
532
+ await waitFor(() => {
533
+ expect(model.context.layoutRoute).toMatchObject({
534
+ type: 'page',
535
+ pageUid: 'page-1',
536
+ pathname: '/admin/page-1',
537
+ });
538
+ });
539
+ expect(model.context.currentRoute.title).toBe('page-1');
540
+ expect(syncRouteSpy).toHaveBeenLastCalledWith(
541
+ expect.objectContaining({
542
+ layoutRouteName: 'admin',
543
+ pageUid: 'page-1',
544
+ pathname: '/admin/page-1',
545
+ layoutBasePathname: '/admin',
546
+ }),
547
+ );
548
+ });
549
+
550
+ it('should restore layout route from a legacy dotted page view route name', async () => {
551
+ const engine = new FlowEngine();
552
+ engine.context.defineProperty('route', {
553
+ value: {
554
+ name: 'admin.page.view',
555
+ pathname: '/admin/page-1/view/popup',
556
+ params: { name: 'page-1' },
557
+ },
558
+ });
559
+ engine.context.defineProperty('routeRepository', {
560
+ value: {
561
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
562
+ },
563
+ });
564
+
565
+ render(
566
+ <FlowEngineProvider engine={engine}>
567
+ <TestAdminLayoutHost />
568
+ </FlowEngineProvider>,
569
+ );
570
+
571
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
572
+ expect(model).toBeTruthy();
573
+
574
+ act(() => {
575
+ model.registerRoutePage('page-1', {
576
+ active: true,
577
+ });
578
+ });
579
+
580
+ expect(model.context.layoutRoute).toMatchObject({
581
+ type: 'page',
582
+ pageUid: 'page-1',
583
+ pathname: '/admin/page-1/view/popup',
584
+ viewStack: [{ viewUid: 'page-1' }, { viewUid: 'popup' }],
585
+ });
586
+ expect(model.context.currentRoute.title).toBe('page-1');
587
+ });
588
+
589
+ it('should not restore layout route from another layout when a route page registers', async () => {
590
+ const engine = new FlowEngine();
591
+ engine.context.defineProperty('route', {
592
+ value: {
593
+ name: 'admin.settings.publicForms.page',
594
+ pathname: '/admin/settings/public-forms/form-1',
595
+ params: { name: 'form-1' },
596
+ layoutRouteName: 'admin.settings.publicForms',
597
+ layoutBasePathname: '/admin/settings/public-forms',
598
+ },
599
+ });
600
+ engine.context.defineProperty('routeRepository', {
601
+ value: {
602
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
603
+ },
604
+ });
605
+
606
+ render(
607
+ <FlowEngineProvider engine={engine}>
608
+ <TestAdminLayoutHost />
609
+ </FlowEngineProvider>,
610
+ );
611
+
612
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
613
+ expect(model).toBeTruthy();
614
+
615
+ act(() => {
616
+ model.registerRoutePage('form-1', {
617
+ active: true,
618
+ });
619
+ });
620
+
621
+ expect(model.context.layoutRoute).toBeNull();
622
+ expect(model.context.currentRoute).toEqual({});
623
+ });
624
+
625
+ it('should not restore layout route from a nested layout route without layoutRouteName', async () => {
626
+ const engine = new FlowEngine();
627
+ engine.context.defineProperty('route', {
628
+ value: {
629
+ name: 'admin.settings.publicForms.page',
630
+ pathname: '/admin/settings/public-forms/form-1',
631
+ params: { name: 'form-1' },
632
+ layoutBasePathname: '/admin/settings/public-forms',
633
+ },
634
+ });
635
+ engine.context.defineProperty('routeRepository', {
636
+ value: {
637
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
638
+ },
639
+ });
640
+
641
+ render(
642
+ <FlowEngineProvider engine={engine}>
643
+ <TestAdminLayoutHost />
644
+ </FlowEngineProvider>,
645
+ );
646
+
647
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
648
+ expect(model).toBeTruthy();
649
+
650
+ act(() => {
651
+ model.registerRoutePage('form-1', {
652
+ active: true,
653
+ });
654
+ });
655
+
656
+ expect(model.context.layoutRoute).toBeNull();
657
+ expect(model.context.currentRoute).toEqual({});
658
+ });
659
+
660
+ it('should ignore stale layout route cleanup after a route page has registered on the same path', async () => {
661
+ const engine = new FlowEngine();
662
+ engine.context.defineProperty('route', {
663
+ value: {
664
+ name: 'admin.page',
665
+ pathname: '/admin/page-1',
666
+ params: { name: 'page-1' },
667
+ },
668
+ });
669
+ engine.context.defineProperty('routeRepository', {
670
+ value: {
671
+ getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
672
+ },
673
+ });
674
+
675
+ render(
676
+ <FlowEngineProvider engine={engine}>
677
+ <TestAdminLayoutHost />
678
+ </FlowEngineProvider>,
679
+ );
680
+
681
+ const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
682
+ expect(model).toBeTruthy();
683
+ const staleRouteLike = {
684
+ name: 'admin.page',
685
+ pathname: '/admin/page-1',
686
+ layoutRouteName: 'admin',
687
+ layoutBasePathname: '/admin',
688
+ };
689
+
690
+ act(() => {
691
+ model.syncLayoutRoute(staleRouteLike);
692
+ model.clearLayoutRoute(staleRouteLike);
693
+ model.registerRoutePage('page-1', {
694
+ active: true,
695
+ });
696
+ model.clearLayoutRoute(staleRouteLike);
697
+ });
698
+
699
+ expect(model.context.layoutRoute).toMatchObject({
700
+ type: 'page',
701
+ pageUid: 'page-1',
702
+ pathname: '/admin/page-1',
703
+ });
704
+ expect(model.context.currentRoute.title).toBe('page-1');
705
+ });
706
+
398
707
  it('should keep pageActive in sync after non-active route page updates', async () => {
399
708
  const engine = new FlowEngine();
400
709
  engine.context.defineProperty('routeRepository', {