@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.
- package/es/flow/admin-shell/BaseLayoutModel.d.ts +4 -0
- package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +6 -0
- package/es/index.mjs +91 -91
- package/lib/index.js +118 -118
- package/package.json +7 -7
- package/src/flow/FlowPage.tsx +29 -2
- package/src/flow/__tests__/FlowPage.test.tsx +152 -25
- package/src/flow/admin-shell/BaseLayoutModel.tsx +106 -0
- package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +160 -34
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +896 -119
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +29 -2
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +311 -2
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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', {
|