@nocobase/client-v2 2.1.17 → 2.1.19
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 +6 -0
- package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
- package/es/flow/routeTransientInputArgs.d.ts +14 -0
- package/es/index.d.ts +1 -0
- package/es/index.mjs +93 -93
- package/es/utils/markdownSanitize.d.ts +11 -0
- package/lib/index.js +114 -114
- package/package.json +7 -7
- package/src/flow/FlowPage.tsx +29 -2
- package/src/flow/__tests__/FlowPage.test.tsx +152 -25
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
- package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
- package/src/flow/actions/dataScopeFilter.ts +10 -1
- package/src/flow/actions/openView.tsx +21 -1
- package/src/flow/admin-shell/BaseLayoutModel.tsx +111 -0
- package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +29 -2
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +359 -2
- package/src/flow/common/Markdown/Display.tsx +14 -3
- package/src/flow/common/Markdown/Edit.tsx +19 -7
- package/src/flow/internal/components/Markdown/util.ts +2 -1
- package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
- package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
- package/src/flow/routeTransientInputArgs.ts +27 -0
- package/src/index.ts +1 -0
- package/src/utils/markdownSanitize.ts +88 -0
|
@@ -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(
|
|
@@ -214,6 +303,54 @@ describe('AdminLayoutModel runtime', () => {
|
|
|
214
303
|
});
|
|
215
304
|
});
|
|
216
305
|
|
|
306
|
+
it('should keep route state when route page registers after route sync', async () => {
|
|
307
|
+
const engine = new FlowEngine();
|
|
308
|
+
|
|
309
|
+
render(
|
|
310
|
+
<FlowEngineProvider engine={engine}>
|
|
311
|
+
<TestAdminLayoutHost />
|
|
312
|
+
</FlowEngineProvider>,
|
|
313
|
+
);
|
|
314
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
315
|
+
const routeState = {
|
|
316
|
+
__nocobaseOpenViewInputArgs: {
|
|
317
|
+
popup: {
|
|
318
|
+
formData: {
|
|
319
|
+
start: '2026-06-24',
|
|
320
|
+
end: '2026-06-25',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
act(() => {
|
|
327
|
+
model.syncLayoutRoute({
|
|
328
|
+
name: getLayoutPageViewRouteName('admin'),
|
|
329
|
+
pathname: '/admin/page-1/view/popup',
|
|
330
|
+
layoutBasePathname: '/admin',
|
|
331
|
+
state: routeState,
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const coordinator = (model as any).getCoordinator();
|
|
336
|
+
const syncRoute = vi.spyOn(coordinator, 'syncRoute').mockImplementation(() => undefined);
|
|
337
|
+
|
|
338
|
+
act(() => {
|
|
339
|
+
model.registerRoutePage('page-1', {
|
|
340
|
+
active: true,
|
|
341
|
+
layoutContentElement: document.createElement('div'),
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
expect(syncRoute).toHaveBeenCalledWith(
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
pageUid: 'page-1',
|
|
348
|
+
pathname: '/admin/page-1/view/popup',
|
|
349
|
+
state: routeState,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
217
354
|
it('should parse RunJS openView route params into layout route view stack state', async () => {
|
|
218
355
|
const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
|
|
219
356
|
if (!token) {
|
|
@@ -395,6 +532,226 @@ describe('AdminLayoutModel runtime', () => {
|
|
|
395
532
|
});
|
|
396
533
|
});
|
|
397
534
|
|
|
535
|
+
it('should restore layout route from router context when a route page registers after stale cleanup', async () => {
|
|
536
|
+
const engine = new FlowEngine();
|
|
537
|
+
engine.context.defineProperty('route', {
|
|
538
|
+
value: {
|
|
539
|
+
name: 'admin.page',
|
|
540
|
+
pathname: '/admin/page-1',
|
|
541
|
+
params: { name: 'page-1' },
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
engine.context.defineProperty('routeRepository', {
|
|
545
|
+
value: {
|
|
546
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
render(
|
|
551
|
+
<FlowEngineProvider engine={engine}>
|
|
552
|
+
<TestAdminLayoutHost />
|
|
553
|
+
</FlowEngineProvider>,
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
557
|
+
expect(model).toBeTruthy();
|
|
558
|
+
const routeLike = {
|
|
559
|
+
name: 'admin.page',
|
|
560
|
+
pathname: '/admin/page-1',
|
|
561
|
+
layoutRouteName: 'admin',
|
|
562
|
+
layoutBasePathname: '/admin',
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
act(() => {
|
|
566
|
+
model.syncLayoutRoute(routeLike);
|
|
567
|
+
model.clearLayoutRoute(routeLike);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
expect(model.context.layoutRoute).toBeNull();
|
|
571
|
+
const syncRouteSpy = vi.spyOn(model.getCoordinator(), 'syncRoute');
|
|
572
|
+
syncRouteSpy.mockClear();
|
|
573
|
+
|
|
574
|
+
act(() => {
|
|
575
|
+
model.registerRoutePage('page-1', {
|
|
576
|
+
active: true,
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
await waitFor(() => {
|
|
581
|
+
expect(model.context.layoutRoute).toMatchObject({
|
|
582
|
+
type: 'page',
|
|
583
|
+
pageUid: 'page-1',
|
|
584
|
+
pathname: '/admin/page-1',
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
expect(model.context.currentRoute.title).toBe('page-1');
|
|
588
|
+
expect(syncRouteSpy).toHaveBeenLastCalledWith(
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
layoutRouteName: 'admin',
|
|
591
|
+
pageUid: 'page-1',
|
|
592
|
+
pathname: '/admin/page-1',
|
|
593
|
+
layoutBasePathname: '/admin',
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('should restore layout route from a legacy dotted page view route name', async () => {
|
|
599
|
+
const engine = new FlowEngine();
|
|
600
|
+
engine.context.defineProperty('route', {
|
|
601
|
+
value: {
|
|
602
|
+
name: 'admin.page.view',
|
|
603
|
+
pathname: '/admin/page-1/view/popup',
|
|
604
|
+
params: { name: 'page-1' },
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
engine.context.defineProperty('routeRepository', {
|
|
608
|
+
value: {
|
|
609
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
render(
|
|
614
|
+
<FlowEngineProvider engine={engine}>
|
|
615
|
+
<TestAdminLayoutHost />
|
|
616
|
+
</FlowEngineProvider>,
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
620
|
+
expect(model).toBeTruthy();
|
|
621
|
+
|
|
622
|
+
act(() => {
|
|
623
|
+
model.registerRoutePage('page-1', {
|
|
624
|
+
active: true,
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
expect(model.context.layoutRoute).toMatchObject({
|
|
629
|
+
type: 'page',
|
|
630
|
+
pageUid: 'page-1',
|
|
631
|
+
pathname: '/admin/page-1/view/popup',
|
|
632
|
+
viewStack: [{ viewUid: 'page-1' }, { viewUid: 'popup' }],
|
|
633
|
+
});
|
|
634
|
+
expect(model.context.currentRoute.title).toBe('page-1');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('should not restore layout route from another layout when a route page registers', async () => {
|
|
638
|
+
const engine = new FlowEngine();
|
|
639
|
+
engine.context.defineProperty('route', {
|
|
640
|
+
value: {
|
|
641
|
+
name: 'admin.settings.publicForms.page',
|
|
642
|
+
pathname: '/admin/settings/public-forms/form-1',
|
|
643
|
+
params: { name: 'form-1' },
|
|
644
|
+
layoutRouteName: 'admin.settings.publicForms',
|
|
645
|
+
layoutBasePathname: '/admin/settings/public-forms',
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
engine.context.defineProperty('routeRepository', {
|
|
649
|
+
value: {
|
|
650
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
render(
|
|
655
|
+
<FlowEngineProvider engine={engine}>
|
|
656
|
+
<TestAdminLayoutHost />
|
|
657
|
+
</FlowEngineProvider>,
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
661
|
+
expect(model).toBeTruthy();
|
|
662
|
+
|
|
663
|
+
act(() => {
|
|
664
|
+
model.registerRoutePage('form-1', {
|
|
665
|
+
active: true,
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
expect(model.context.layoutRoute).toBeNull();
|
|
670
|
+
expect(model.context.currentRoute).toEqual({});
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('should not restore layout route from a nested layout route without layoutRouteName', async () => {
|
|
674
|
+
const engine = new FlowEngine();
|
|
675
|
+
engine.context.defineProperty('route', {
|
|
676
|
+
value: {
|
|
677
|
+
name: 'admin.settings.publicForms.page',
|
|
678
|
+
pathname: '/admin/settings/public-forms/form-1',
|
|
679
|
+
params: { name: 'form-1' },
|
|
680
|
+
layoutBasePathname: '/admin/settings/public-forms',
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
engine.context.defineProperty('routeRepository', {
|
|
684
|
+
value: {
|
|
685
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
686
|
+
},
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
render(
|
|
690
|
+
<FlowEngineProvider engine={engine}>
|
|
691
|
+
<TestAdminLayoutHost />
|
|
692
|
+
</FlowEngineProvider>,
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
696
|
+
expect(model).toBeTruthy();
|
|
697
|
+
|
|
698
|
+
act(() => {
|
|
699
|
+
model.registerRoutePage('form-1', {
|
|
700
|
+
active: true,
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
expect(model.context.layoutRoute).toBeNull();
|
|
705
|
+
expect(model.context.currentRoute).toEqual({});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('should ignore stale layout route cleanup after a route page has registered on the same path', async () => {
|
|
709
|
+
const engine = new FlowEngine();
|
|
710
|
+
engine.context.defineProperty('route', {
|
|
711
|
+
value: {
|
|
712
|
+
name: 'admin.page',
|
|
713
|
+
pathname: '/admin/page-1',
|
|
714
|
+
params: { name: 'page-1' },
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
engine.context.defineProperty('routeRepository', {
|
|
718
|
+
value: {
|
|
719
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
render(
|
|
724
|
+
<FlowEngineProvider engine={engine}>
|
|
725
|
+
<TestAdminLayoutHost />
|
|
726
|
+
</FlowEngineProvider>,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
730
|
+
expect(model).toBeTruthy();
|
|
731
|
+
const staleRouteLike = {
|
|
732
|
+
name: 'admin.page',
|
|
733
|
+
pathname: '/admin/page-1',
|
|
734
|
+
layoutRouteName: 'admin',
|
|
735
|
+
layoutBasePathname: '/admin',
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
act(() => {
|
|
739
|
+
model.syncLayoutRoute(staleRouteLike);
|
|
740
|
+
model.clearLayoutRoute(staleRouteLike);
|
|
741
|
+
model.registerRoutePage('page-1', {
|
|
742
|
+
active: true,
|
|
743
|
+
});
|
|
744
|
+
model.clearLayoutRoute(staleRouteLike);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
expect(model.context.layoutRoute).toMatchObject({
|
|
748
|
+
type: 'page',
|
|
749
|
+
pageUid: 'page-1',
|
|
750
|
+
pathname: '/admin/page-1',
|
|
751
|
+
});
|
|
752
|
+
expect(model.context.currentRoute.title).toBe('page-1');
|
|
753
|
+
});
|
|
754
|
+
|
|
398
755
|
it('should keep pageActive in sync after non-active route page updates', async () => {
|
|
399
756
|
const engine = new FlowEngine();
|
|
400
757
|
engine.context.defineProperty('routeRepository', {
|
|
@@ -12,6 +12,7 @@ import { css } from '@emotion/css';
|
|
|
12
12
|
import { createRoot } from 'react-dom/client';
|
|
13
13
|
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react';
|
|
14
14
|
import Vditor from 'vditor';
|
|
15
|
+
import { removeMarkdownIframes, stripMarkdownIframes } from '../../../utils/markdownSanitize';
|
|
15
16
|
import { useCDN } from './useCDN';
|
|
16
17
|
import useStyle from './style';
|
|
17
18
|
|
|
@@ -51,8 +52,15 @@ function DisplayInner(props: { value: string; style?: CSSProperties; loadImages?
|
|
|
51
52
|
Vditor.preview(containerRef.current, props.value ?? '', {
|
|
52
53
|
mode: 'light',
|
|
53
54
|
cdn,
|
|
54
|
-
|
|
55
|
+
markdown: {
|
|
56
|
+
sanitize: true,
|
|
57
|
+
},
|
|
58
|
+
transform: stripMarkdownIframes,
|
|
59
|
+
})
|
|
60
|
+
.then(() => removeMarkdownIframes(containerRef.current))
|
|
61
|
+
.catch(() => removeMarkdownIframes(containerRef.current));
|
|
55
62
|
setTimeout(() => {
|
|
63
|
+
removeMarkdownIframes(containerRef.current);
|
|
56
64
|
containerRef.current?.querySelectorAll('img').forEach((img: HTMLImageElement) => {
|
|
57
65
|
img.style.cursor = 'zoom-in';
|
|
58
66
|
img.addEventListener('click', () => {
|
|
@@ -129,13 +137,16 @@ export const Display = (props) => {
|
|
|
129
137
|
Vditor.md2html(props.value, {
|
|
130
138
|
mode: 'light',
|
|
131
139
|
cdn,
|
|
140
|
+
markdown: {
|
|
141
|
+
sanitize: true,
|
|
142
|
+
},
|
|
132
143
|
})
|
|
133
144
|
.then((html) => {
|
|
134
|
-
setText(convertToText(html));
|
|
145
|
+
setText(convertToText(stripMarkdownIframes(html)));
|
|
135
146
|
})
|
|
136
147
|
.catch(() => setText(''));
|
|
137
148
|
}
|
|
138
|
-
}, [props.value, textOnly]);
|
|
149
|
+
}, [props.value, textOnly, cdn]);
|
|
139
150
|
|
|
140
151
|
const isOverflowTooltip = useCallback(() => {
|
|
141
152
|
if (!elRef.current) return false;
|
|
@@ -17,6 +17,7 @@ import React, { useEffect, useCallback, useLayoutEffect, useMemo, useRef, useSta
|
|
|
17
17
|
import { useTranslation } from 'react-i18next';
|
|
18
18
|
import Vditor from 'vditor';
|
|
19
19
|
import 'vditor/dist/index.css';
|
|
20
|
+
import { stripMarkdownIframeTags, stripMarkdownIframes } from '../../../utils/markdownSanitize';
|
|
20
21
|
import { useCDN } from './useCDN';
|
|
21
22
|
import useStyle from './style';
|
|
22
23
|
|
|
@@ -102,13 +103,20 @@ const Edit = (props) => {
|
|
|
102
103
|
if (!containerRef.current) return;
|
|
103
104
|
|
|
104
105
|
const toolbarConfig = placeToolbarTooltipsBelow(toolbar ?? defaultToolbar);
|
|
106
|
+
const safeValue = stripMarkdownIframeTags(value ?? '');
|
|
105
107
|
const vditor = new Vditor(containerRef.current, {
|
|
106
|
-
value:
|
|
108
|
+
value: safeValue,
|
|
107
109
|
lang,
|
|
108
110
|
cache: { enable: false },
|
|
109
111
|
undoDelay: 0,
|
|
110
112
|
mode: props.mode || 'ir',
|
|
111
|
-
preview: {
|
|
113
|
+
preview: {
|
|
114
|
+
markdown: {
|
|
115
|
+
sanitize: true,
|
|
116
|
+
},
|
|
117
|
+
math: { engine: 'KaTeX' },
|
|
118
|
+
transform: stripMarkdownIframes,
|
|
119
|
+
},
|
|
112
120
|
toolbar: toolbarConfig,
|
|
113
121
|
fullscreen: { index: 1200 },
|
|
114
122
|
cdn,
|
|
@@ -117,7 +125,6 @@ const Edit = (props) => {
|
|
|
117
125
|
after: () => {
|
|
118
126
|
vdRef.current = vditor;
|
|
119
127
|
setEditorReady(true); // Notify that the editor is ready
|
|
120
|
-
vditor.setValue(value ?? '');
|
|
121
128
|
if (disabled) {
|
|
122
129
|
vditor.disabled();
|
|
123
130
|
} else {
|
|
@@ -131,8 +138,12 @@ const Edit = (props) => {
|
|
|
131
138
|
});
|
|
132
139
|
}
|
|
133
140
|
},
|
|
134
|
-
input(
|
|
135
|
-
|
|
141
|
+
input(nextValue) {
|
|
142
|
+
const safeNextValue = stripMarkdownIframeTags(nextValue);
|
|
143
|
+
if (safeNextValue !== nextValue) {
|
|
144
|
+
vditor.setValue(safeNextValue);
|
|
145
|
+
}
|
|
146
|
+
onChange(safeNextValue);
|
|
136
147
|
},
|
|
137
148
|
upload: {
|
|
138
149
|
multiple: false,
|
|
@@ -229,8 +240,9 @@ const Edit = (props) => {
|
|
|
229
240
|
useEffect(() => {
|
|
230
241
|
if (editorReady && vdRef.current) {
|
|
231
242
|
const editor = vdRef.current;
|
|
232
|
-
|
|
233
|
-
|
|
243
|
+
const safeValue = stripMarkdownIframeTags(value ?? '');
|
|
244
|
+
if (safeValue !== editor.getValue()) {
|
|
245
|
+
editor.setValue(safeValue);
|
|
234
246
|
|
|
235
247
|
const preArea = containerRef.current?.querySelector(
|
|
236
248
|
'div.vditor-content > div.vditor-ir > pre',
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import _ from 'lodash';
|
|
11
11
|
import { useEffect, useState } from 'react';
|
|
12
|
+
import { stripMarkdownIframes } from '../../../../utils/markdownSanitize';
|
|
12
13
|
|
|
13
14
|
export const parseMarkdown = _.memoize(async (text: string) => {
|
|
14
15
|
if (!text) {
|
|
15
16
|
return text;
|
|
16
17
|
}
|
|
17
18
|
const m = await import('./md');
|
|
18
|
-
return m.default.render(text);
|
|
19
|
+
return stripMarkdownIframes(m.default.render(text));
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
export function useParseMarkdown(text: string) {
|
|
@@ -9,56 +9,79 @@
|
|
|
9
9
|
|
|
10
10
|
import { Input } from 'antd';
|
|
11
11
|
import type { TextAreaProps } from 'antd/es/input';
|
|
12
|
-
import
|
|
13
|
-
import React, { useEffect, useRef } from 'react';
|
|
12
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
14
13
|
import { largeField, EditableItemModel } from '@nocobase/flow-engine';
|
|
15
14
|
import { FieldModel } from '../base/FieldModel';
|
|
16
15
|
|
|
17
16
|
function IMESafeTextArea(props: TextAreaProps) {
|
|
18
17
|
const { value, onChange, onCompositionStart, onCompositionEnd, ...rest } = props;
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
18
|
+
const [textAreaValue, setTextAreaValue] = useState<TextAreaProps['value']>(() => normalizeTextAreaValue(value));
|
|
19
|
+
const textAreaValueRef = useRef<TextAreaProps['value']>(textAreaValue);
|
|
20
|
+
const pendingLocalValuesRef = useRef<TextAreaProps['value'][]>([]);
|
|
22
21
|
|
|
23
22
|
useEffect(() => {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
previousValueRef.current = value;
|
|
23
|
+
const nextValue = normalizeTextAreaValue(value);
|
|
24
|
+
const pendingValues = pendingLocalValuesRef.current;
|
|
25
|
+
const pendingIndex = pendingValues.findIndex((item) => Object.is(item, nextValue));
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
textArea.value = nextValue;
|
|
27
|
+
if (pendingIndex >= 0) {
|
|
28
|
+
pendingValues.splice(0, pendingIndex + 1);
|
|
29
|
+
if (!Object.is(textAreaValueRef.current, nextValue)) {
|
|
30
|
+
return;
|
|
34
31
|
}
|
|
35
32
|
}
|
|
33
|
+
|
|
34
|
+
pendingValues.length = 0;
|
|
35
|
+
if (Object.is(textAreaValueRef.current, nextValue)) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
textAreaValueRef.current = nextValue;
|
|
40
|
+
setTextAreaValue(nextValue);
|
|
36
41
|
}, [value]);
|
|
37
42
|
|
|
38
43
|
const getEventValue = (event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>) =>
|
|
39
44
|
event.currentTarget.value;
|
|
40
45
|
|
|
46
|
+
const recordLocalValue = (
|
|
47
|
+
event: React.ChangeEvent<HTMLTextAreaElement> | React.CompositionEvent<HTMLTextAreaElement>,
|
|
48
|
+
) => {
|
|
49
|
+
const nextValue = getEventValue(event);
|
|
50
|
+
const pendingValues = pendingLocalValuesRef.current;
|
|
51
|
+
pendingValues.push(nextValue);
|
|
52
|
+
if (pendingValues.length > 20) {
|
|
53
|
+
pendingValues.shift();
|
|
54
|
+
}
|
|
55
|
+
textAreaValueRef.current = nextValue;
|
|
56
|
+
setTextAreaValue(nextValue);
|
|
57
|
+
};
|
|
58
|
+
|
|
41
59
|
return (
|
|
42
60
|
<Input.TextArea
|
|
43
61
|
{...rest}
|
|
44
|
-
|
|
45
|
-
defaultValue={defaultValue}
|
|
62
|
+
value={textAreaValue}
|
|
46
63
|
onChange={(event) => {
|
|
47
|
-
|
|
64
|
+
recordLocalValue(event);
|
|
48
65
|
onChange?.(event);
|
|
49
66
|
}}
|
|
50
67
|
onCompositionStart={(event) => {
|
|
51
|
-
|
|
68
|
+
recordLocalValue(event);
|
|
52
69
|
onCompositionStart?.(event);
|
|
53
70
|
}}
|
|
54
71
|
onCompositionEnd={(event) => {
|
|
55
|
-
|
|
72
|
+
recordLocalValue(event);
|
|
56
73
|
onCompositionEnd?.(event);
|
|
57
74
|
}}
|
|
58
75
|
/>
|
|
59
76
|
);
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
function normalizeTextAreaValue(nextValue: unknown): TextAreaProps['value'] {
|
|
80
|
+
if (typeof nextValue === 'bigint') return String(nextValue);
|
|
81
|
+
if (nextValue == null) return '';
|
|
82
|
+
return nextValue as TextAreaProps['value'];
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
@largeField()
|
|
63
86
|
export class TextareaFieldModel extends FieldModel {
|
|
64
87
|
render() {
|