@nocobase/client-v2 2.2.0-alpha.4 → 2.2.0-alpha.6
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/BaseApplication.d.ts +2 -0
- package/es/RouterManager.d.ts +15 -0
- package/es/entry-actions/EntryActionManager.d.ts +24 -0
- package/es/entry-actions/index.d.ts +9 -0
- package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
- package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
- package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
- package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
- package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
- package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
- package/es/flow/models/base/ActionModelCore.d.ts +2 -0
- package/es/flow/routeTransientInputArgs.d.ts +14 -0
- package/es/index.d.ts +4 -0
- package/es/index.mjs +178 -124
- package/es/utils/markdownSanitize.d.ts +11 -0
- package/lib/index.js +164 -110
- package/package.json +7 -7
- package/src/BaseApplication.tsx +2 -0
- package/src/RouterManager.tsx +142 -1
- package/src/__tests__/RouterManager.test.ts +125 -0
- package/src/entry-actions/EntryActionManager.ts +76 -0
- package/src/entry-actions/index.ts +10 -0
- package/src/flow/FlowPage.tsx +29 -2
- package/src/flow/__tests__/FlowPage.test.tsx +152 -25
- package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
- 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 +41 -4
- package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
- package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +359 -2
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
- package/src/flow/admin-shell/admin-layout/index.ts +1 -0
- package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
- package/src/flow/common/Markdown/Display.tsx +14 -3
- package/src/flow/common/Markdown/Edit.tsx +19 -7
- package/src/flow/components/FlowRoute.tsx +128 -16
- package/src/flow/internal/components/Markdown/util.ts +2 -1
- package/src/flow/models/base/ActionModel.tsx +10 -8
- package/src/flow/models/base/ActionModelCore.tsx +5 -0
- package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
- package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
- package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
- package/src/flow/routeTransientInputArgs.ts +27 -0
- package/src/index.ts +4 -0
- package/src/nocobase-buildin-plugin/index.tsx +7 -1
- package/src/utils/markdownSanitize.ts +88 -0
|
@@ -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',
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
|
|
11
11
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
12
|
import { deviceType } from 'react-device-detect';
|
|
13
|
-
import { useParams } from 'react-router-dom';
|
|
13
|
+
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
14
14
|
import { useApp } from '../../hooks/useApp';
|
|
15
|
-
import { NocoBaseDesktopRouteType } from '../../flow-compat';
|
|
16
|
-
import {
|
|
15
|
+
import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../flow-compat';
|
|
16
|
+
import {
|
|
17
|
+
resolveAdminRouteRuntimeTarget,
|
|
18
|
+
toRouterNavigationPath,
|
|
19
|
+
} from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
|
|
17
20
|
import { getAdminLayoutModel, type AdminLayoutModel } from '../admin-shell/admin-layout/AdminLayoutModel';
|
|
18
21
|
import { getLayoutModel, type BaseLayoutModel } from '../admin-shell/BaseLayoutModel';
|
|
19
22
|
import { useLayoutRoutePage } from '../admin-shell/useLayoutRoutePage';
|
|
@@ -21,6 +24,7 @@ import { AppNotFound } from '../../components';
|
|
|
21
24
|
import { useKeepAlive } from '../../components/KeepAlive';
|
|
22
25
|
|
|
23
26
|
type FlowRouteGuardState = {
|
|
27
|
+
pageUid?: string;
|
|
24
28
|
pending: boolean;
|
|
25
29
|
allowBridge: boolean;
|
|
26
30
|
notFound: boolean;
|
|
@@ -35,6 +39,14 @@ type FlowRouteLayoutContext = {
|
|
|
35
39
|
uid?: string;
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
type FlowRouteRepositoryLike = {
|
|
43
|
+
refreshAccessible?: () => Promise<unknown>;
|
|
44
|
+
isAccessibleLoaded?: () => boolean;
|
|
45
|
+
ensureAccessibleLoaded?: () => Promise<unknown>;
|
|
46
|
+
getRouteBySchemaUid?: (schemaUid: string) => NocoBaseDesktopRoute | undefined;
|
|
47
|
+
listAccessible?: () => NocoBaseDesktopRoute[];
|
|
48
|
+
};
|
|
49
|
+
|
|
38
50
|
export type FlowRouteProps = {
|
|
39
51
|
pageUid?: string;
|
|
40
52
|
active?: boolean;
|
|
@@ -70,6 +82,44 @@ const getDefaultLegacyPageBehavior = (
|
|
|
70
82
|
|
|
71
83
|
const shouldRequireAccessibleRoute = (contextLayout?: FlowRouteLayoutContext) => contextLayout?.authCheck !== false;
|
|
72
84
|
|
|
85
|
+
const findAccessibleRouteByIdentity = (
|
|
86
|
+
routes: NocoBaseDesktopRoute[] | undefined,
|
|
87
|
+
pageUid: string,
|
|
88
|
+
): NocoBaseDesktopRoute | undefined => {
|
|
89
|
+
if (!Array.isArray(routes)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const route of routes) {
|
|
94
|
+
if (
|
|
95
|
+
(route.type !== NocoBaseDesktopRouteType.group && route.schemaUid === pageUid) ||
|
|
96
|
+
(route.type === NocoBaseDesktopRouteType.group && route.id != null && String(route.id) === pageUid)
|
|
97
|
+
) {
|
|
98
|
+
return route;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const matched = findAccessibleRouteByIdentity(route.children, pageUid);
|
|
102
|
+
if (matched) {
|
|
103
|
+
return matched;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const getAccessibleRouteByPageUid = (
|
|
111
|
+
routeRepository: FlowRouteRepositoryLike | undefined,
|
|
112
|
+
pageUid: string,
|
|
113
|
+
): NocoBaseDesktopRoute | undefined => {
|
|
114
|
+
const routeBySchemaUid = routeRepository?.getRouteBySchemaUid?.(pageUid);
|
|
115
|
+
|
|
116
|
+
if (routeBySchemaUid && routeBySchemaUid.type !== NocoBaseDesktopRouteType.group) {
|
|
117
|
+
return routeBySchemaUid;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return findAccessibleRouteByIdentity(routeRepository?.listAccessible?.(), pageUid);
|
|
121
|
+
};
|
|
122
|
+
|
|
73
123
|
const hasFlowModel = async (flowEngine: FlowEngine, pageUid: string) => {
|
|
74
124
|
if (flowEngine.getModel(pageUid)) {
|
|
75
125
|
return true;
|
|
@@ -197,11 +247,15 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
197
247
|
);
|
|
198
248
|
const legacyPageBehavior = legacyPageBehaviorProp || getDefaultLegacyPageBehavior(flowEngine, routeLayout);
|
|
199
249
|
const app = useApp();
|
|
200
|
-
const
|
|
250
|
+
const location = useLocation();
|
|
251
|
+
const navigate = useNavigate();
|
|
252
|
+
const routeRepository = flowEngine.context.routeRepository as FlowRouteRepositoryLike | undefined;
|
|
201
253
|
const params = useParams();
|
|
202
254
|
const pageUid = pageUidProp || params?.name;
|
|
255
|
+
const hasNestedRoutePath = typeof params?.tabUid !== 'undefined' || typeof params?.['*'] !== 'undefined';
|
|
203
256
|
const skipRouteRepositoryCheck = !routeRepository;
|
|
204
257
|
const [guardState, setGuardState] = useState<FlowRouteGuardState>({
|
|
258
|
+
pageUid: undefined,
|
|
205
259
|
pending: true,
|
|
206
260
|
allowBridge: false,
|
|
207
261
|
notFound: false,
|
|
@@ -213,19 +267,23 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
213
267
|
throw new Error('[NocoBase] FlowRoute requires pageUid or route.params.name.');
|
|
214
268
|
}
|
|
215
269
|
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
replaceTriggeredRef.current = false;
|
|
272
|
+
}, [location.hash, location.pathname, location.search, pageUid]);
|
|
273
|
+
|
|
216
274
|
useEffect(() => {
|
|
217
275
|
let active = true;
|
|
218
276
|
const requestId = ++requestIdRef.current;
|
|
219
277
|
|
|
220
278
|
const run = async () => {
|
|
221
|
-
setGuardState({ pending: true, allowBridge: false, notFound: false });
|
|
279
|
+
setGuardState({ pageUid, pending: true, allowBridge: false, notFound: false });
|
|
222
280
|
|
|
223
281
|
if (!skipRouteRepositoryCheck && !routeRepository?.isAccessibleLoaded?.()) {
|
|
224
282
|
try {
|
|
225
283
|
await routeRepository?.ensureAccessibleLoaded?.();
|
|
226
284
|
} catch (_error) {
|
|
227
285
|
if (active && requestId === requestIdRef.current) {
|
|
228
|
-
setGuardState({ pending: false, allowBridge: true, notFound: false });
|
|
286
|
+
setGuardState({ pageUid, pending: false, allowBridge: true, notFound: false });
|
|
229
287
|
}
|
|
230
288
|
return;
|
|
231
289
|
}
|
|
@@ -235,28 +293,64 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
235
293
|
return;
|
|
236
294
|
}
|
|
237
295
|
|
|
238
|
-
const route = skipRouteRepositoryCheck ? undefined : routeRepository
|
|
296
|
+
const route = skipRouteRepositoryCheck ? undefined : getAccessibleRouteByPageUid(routeRepository, pageUid);
|
|
239
297
|
if (!route && !skipRouteRepositoryCheck && shouldRequireAccessibleRoute(routeLayout)) {
|
|
240
|
-
setGuardState({ pending: false, allowBridge: false, notFound: true });
|
|
298
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
|
|
241
299
|
return;
|
|
242
300
|
}
|
|
243
301
|
|
|
244
302
|
if (!route && legacyPageBehavior === 'notFound') {
|
|
245
303
|
const flowModelExists = await hasFlowModel(flowEngine, pageUid);
|
|
246
304
|
if (active && requestId === requestIdRef.current) {
|
|
247
|
-
setGuardState({ pending: false, allowBridge: flowModelExists, notFound: !flowModelExists });
|
|
305
|
+
setGuardState({ pageUid, pending: false, allowBridge: flowModelExists, notFound: !flowModelExists });
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (route?.type === NocoBaseDesktopRouteType.group) {
|
|
311
|
+
if (hasNestedRoutePath) {
|
|
312
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const target = resolveAdminRouteRuntimeTarget({
|
|
317
|
+
app,
|
|
318
|
+
route,
|
|
319
|
+
layout: routeLayout,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (target.reason === 'emptyGroup') {
|
|
323
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: false });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (target.runtimePath) {
|
|
328
|
+
if (replaceTriggeredRef.current) {
|
|
329
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: false });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
replaceTriggeredRef.current = true;
|
|
334
|
+
if (target.navigationMode === 'document') {
|
|
335
|
+
window.location.replace(target.runtimePath);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
navigate(toRouterNavigationPath(target.runtimePath, app.router?.getBasename?.()), { replace: true });
|
|
339
|
+
return;
|
|
248
340
|
}
|
|
341
|
+
|
|
342
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
|
|
249
343
|
return;
|
|
250
344
|
}
|
|
251
345
|
|
|
252
346
|
if (route?.type === NocoBaseDesktopRouteType.page) {
|
|
253
347
|
if (legacyPageBehavior === 'notFound') {
|
|
254
|
-
setGuardState({ pending: false, allowBridge: false, notFound: true });
|
|
348
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
|
|
255
349
|
return;
|
|
256
350
|
}
|
|
257
351
|
|
|
258
352
|
if (legacyPageBehavior === 'bridge') {
|
|
259
|
-
setGuardState({ pending: false, allowBridge: true, notFound: false });
|
|
353
|
+
setGuardState({ pageUid, pending: false, allowBridge: true, notFound: false });
|
|
260
354
|
return;
|
|
261
355
|
}
|
|
262
356
|
|
|
@@ -279,14 +373,14 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
279
373
|
|
|
280
374
|
if (target.reason === 'unsupportedV2Runtime') {
|
|
281
375
|
if (active && requestId === requestIdRef.current) {
|
|
282
|
-
setGuardState({ pending: false, allowBridge: false, notFound: true });
|
|
376
|
+
setGuardState({ pageUid, pending: false, allowBridge: false, notFound: true });
|
|
283
377
|
}
|
|
284
378
|
return;
|
|
285
379
|
}
|
|
286
380
|
}
|
|
287
381
|
|
|
288
382
|
if (active && requestId === requestIdRef.current) {
|
|
289
|
-
setGuardState({ pending: false, allowBridge: true, notFound: false });
|
|
383
|
+
setGuardState({ pageUid, pending: false, allowBridge: true, notFound: false });
|
|
290
384
|
}
|
|
291
385
|
};
|
|
292
386
|
|
|
@@ -295,10 +389,20 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
295
389
|
return () => {
|
|
296
390
|
active = false;
|
|
297
391
|
};
|
|
298
|
-
}, [
|
|
392
|
+
}, [
|
|
393
|
+
app,
|
|
394
|
+
flowEngine,
|
|
395
|
+
hasNestedRoutePath,
|
|
396
|
+
legacyPageBehavior,
|
|
397
|
+
navigate,
|
|
398
|
+
pageUid,
|
|
399
|
+
routeLayout,
|
|
400
|
+
routeRepository,
|
|
401
|
+
skipRouteRepositoryCheck,
|
|
402
|
+
]);
|
|
299
403
|
|
|
300
404
|
const content = useMemo(() => {
|
|
301
|
-
if (guardState.pending) {
|
|
405
|
+
if (guardState.pageUid !== pageUid || guardState.pending) {
|
|
302
406
|
return null;
|
|
303
407
|
}
|
|
304
408
|
|
|
@@ -311,7 +415,15 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
311
415
|
}
|
|
312
416
|
|
|
313
417
|
return <BridgeFlowRoute pageUid={pageUid} active={active} getLayoutModel={getLayoutModel} />;
|
|
314
|
-
}, [
|
|
418
|
+
}, [
|
|
419
|
+
active,
|
|
420
|
+
getLayoutModel,
|
|
421
|
+
guardState.allowBridge,
|
|
422
|
+
guardState.notFound,
|
|
423
|
+
guardState.pageUid,
|
|
424
|
+
guardState.pending,
|
|
425
|
+
pageUid,
|
|
426
|
+
]);
|
|
315
427
|
|
|
316
428
|
return content;
|
|
317
429
|
};
|
|
@@ -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) {
|
|
@@ -20,7 +20,7 @@ import { ActionModel } from './ActionModelCore';
|
|
|
20
20
|
|
|
21
21
|
function getMobileDefaultProps(ctx) {
|
|
22
22
|
const defaultProps = ctx.model.defaultProps || {};
|
|
23
|
-
if (ctx.isMobileLayout && defaultProps.icon) {
|
|
23
|
+
if (ctx.isMobileLayout && defaultProps.icon && ctx.model.enableEditIconOnly !== false) {
|
|
24
24
|
return {
|
|
25
25
|
...defaultProps,
|
|
26
26
|
iconOnly: true,
|
|
@@ -43,6 +43,7 @@ ActionModel.registerFlow({
|
|
|
43
43
|
'x-decorator': 'FormItem',
|
|
44
44
|
'x-component': 'Input',
|
|
45
45
|
title: tExpr('Button title'),
|
|
46
|
+
description: ctx.model.getTitleFieldDescription?.(),
|
|
46
47
|
}
|
|
47
48
|
: undefined,
|
|
48
49
|
tooltip: ctx.model.enableEditTooltip
|
|
@@ -59,13 +60,14 @@ ActionModel.registerFlow({
|
|
|
59
60
|
title: tExpr('Button icon'),
|
|
60
61
|
}
|
|
61
62
|
: undefined,
|
|
62
|
-
iconOnly:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
iconOnly:
|
|
64
|
+
ctx.model.enableEditIcon && ctx.model.enableEditIconOnly !== false
|
|
65
|
+
? {
|
|
66
|
+
'x-decorator': 'FormItem',
|
|
67
|
+
'x-component': 'Switch',
|
|
68
|
+
title: tExpr('Icon only'),
|
|
69
|
+
}
|
|
70
|
+
: undefined,
|
|
69
71
|
type: ctx.model.enableEditType
|
|
70
72
|
? {
|
|
71
73
|
'x-decorator': 'FormItem',
|
|
@@ -62,6 +62,7 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
|
|
|
62
62
|
enableEditTooltip = true;
|
|
63
63
|
enableEditTitle = true;
|
|
64
64
|
enableEditIcon = true;
|
|
65
|
+
enableEditIconOnly = true;
|
|
65
66
|
enableEditType = true;
|
|
66
67
|
enableEditDanger = true;
|
|
67
68
|
enableEditColor = false;
|
|
@@ -136,6 +137,10 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
|
|
|
136
137
|
return this.props.title;
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
getTitleFieldDescription() {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
getIcon() {
|
|
140
145
|
return this.props.icon;
|
|
141
146
|
}
|
|
@@ -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() {
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { FlowEngine } from '@nocobase/flow-engine';
|
|
10
|
+
import { FieldModelRenderer, FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
|
11
11
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
12
|
+
import { Form } from 'antd';
|
|
12
13
|
import React from 'react';
|
|
13
14
|
import { describe, expect, it, vi } from 'vitest';
|
|
14
15
|
import { TextareaFieldModel } from '../TextareaFieldModel';
|
|
@@ -23,6 +24,36 @@ function createTextareaFieldModel(props?: Record<string, unknown>) {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
describe('TextareaFieldModel', () => {
|
|
27
|
+
it('syncs multiline values assigned through the form renderer', async () => {
|
|
28
|
+
const flowEngine = new FlowEngine();
|
|
29
|
+
const model = createTextareaFieldModel();
|
|
30
|
+
model.dispatchEvent = vi.fn().mockResolvedValue([]);
|
|
31
|
+
|
|
32
|
+
function FormHost() {
|
|
33
|
+
const [form] = Form.useForm();
|
|
34
|
+
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
form.setFieldsValue({ address: 'aaaaaa\nbbbbbb' });
|
|
37
|
+
}, [form]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<FlowEngineProvider engine={flowEngine}>
|
|
41
|
+
<Form form={form}>
|
|
42
|
+
<Form.Item name="address">
|
|
43
|
+
<FieldModelRenderer model={model} />
|
|
44
|
+
</Form.Item>
|
|
45
|
+
</Form>
|
|
46
|
+
</FlowEngineProvider>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
render(<FormHost />);
|
|
51
|
+
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe('aaaaaa\nbbbbbb');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
26
57
|
it('keeps IME composition text visible before the parent value is committed', () => {
|
|
27
58
|
const onChange = vi.fn();
|
|
28
59
|
const onCompositionStart = vi.fn();
|
|
@@ -18,6 +18,7 @@ import { Link, useLocation } from 'react-router-dom';
|
|
|
18
18
|
import { useACLRoleContext } from '../../../acl';
|
|
19
19
|
import type { BaseApplication } from '../../../BaseApplication';
|
|
20
20
|
import type { PluginSettingsPageType } from '../../../PluginSettingsManager';
|
|
21
|
+
import { shouldOpenAdminRouteInNewWindow } from '../../../RouterManager';
|
|
21
22
|
import { useApp } from '../../../hooks/useApp';
|
|
22
23
|
import {
|
|
23
24
|
filterRenderableSettings,
|
|
@@ -65,7 +66,10 @@ const topbarActionTriggerClassName = css`
|
|
|
65
66
|
height: 100%;
|
|
66
67
|
`;
|
|
67
68
|
|
|
68
|
-
type TopbarSettingsAppLike = Pick<
|
|
69
|
+
type TopbarSettingsAppLike = Pick<
|
|
70
|
+
BaseApplication<any>,
|
|
71
|
+
'name' | 'router' | 'getPublicPath' | 'getHref' | 'layoutManager'
|
|
72
|
+
>;
|
|
69
73
|
|
|
70
74
|
const normalizeTopbarPath = (pathname?: string) => {
|
|
71
75
|
const trimmed = pathname?.trim();
|
|
@@ -103,10 +107,57 @@ const stripTopbarRouterBasePath = (pathname: string, basename?: string) => {
|
|
|
103
107
|
return normalizedPath;
|
|
104
108
|
};
|
|
105
109
|
|
|
110
|
+
const getTopbarAppPath = (pathname: string) => {
|
|
111
|
+
const normalizedPath = normalizeTopbarPath(pathname);
|
|
112
|
+
const match = /^(.*?\/(?:apps|_app)\/[^/]+)(?=\/|$)/.exec(normalizedPath);
|
|
113
|
+
const appPath = match?.[1] || '';
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
appPath,
|
|
117
|
+
routePath: appPath ? normalizeTopbarPath(normalizedPath.slice(appPath.length)) : normalizedPath,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const getTopbarContextAppPath = (app: TopbarSettingsAppLike | undefined, basename?: string) => {
|
|
122
|
+
const basenameAppPath = getTopbarAppPath(basename || '').appPath;
|
|
123
|
+
if (basenameAppPath) {
|
|
124
|
+
return basenameAppPath;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const publicPathAppPath = getTopbarAppPath(app?.getPublicPath?.() || '').appPath;
|
|
128
|
+
if (publicPathAppPath) {
|
|
129
|
+
return publicPathAppPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (app?.name && app.name !== 'main' && app.getHref) {
|
|
133
|
+
return normalizeTopbarBasePath(app.getHref('/'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return '';
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const prependTopbarAppPath = (pathname: string, appPath: string) => {
|
|
140
|
+
const normalizedPath = normalizeTopbarPath(pathname);
|
|
141
|
+
|
|
142
|
+
if (!appPath || normalizedPath === appPath || normalizedPath.startsWith(`${appPath}/`)) {
|
|
143
|
+
return normalizedPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return `${appPath}${normalizedPath}`;
|
|
147
|
+
};
|
|
148
|
+
|
|
106
149
|
const isAdminRuntimePath = (pathname: string) => {
|
|
107
150
|
return pathname === '/admin' || pathname.startsWith('/admin/');
|
|
108
151
|
};
|
|
109
152
|
|
|
153
|
+
const getTopbarAdminRoutePath = (app: TopbarSettingsAppLike | undefined) => {
|
|
154
|
+
try {
|
|
155
|
+
return app?.layoutManager?.getLayout?.('admin')?.routePath;
|
|
156
|
+
} catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
110
161
|
const buildTopbarDocumentHref = (targetPath: string, basename?: string) => {
|
|
111
162
|
const normalizedTarget = normalizeTopbarPath(targetPath);
|
|
112
163
|
const normalizedBase = normalizeTopbarBasePath(basename);
|
|
@@ -136,9 +187,33 @@ function TopbarInternalSettingsLabel(props: { title: React.ReactNode; path?: str
|
|
|
136
187
|
const targetPath = props.path || '/admin/settings';
|
|
137
188
|
const basename = getTopbarRouterBasePath(app);
|
|
138
189
|
const currentPath = stripTopbarRouterBasePath(location.pathname, basename);
|
|
190
|
+
const currentLocationAppPath = getTopbarAppPath(currentPath);
|
|
191
|
+
const currentAppPath = currentLocationAppPath.appPath || getTopbarContextAppPath(app, basename);
|
|
192
|
+
const currentRoutePath = currentLocationAppPath.appPath ? currentLocationAppPath.routePath : currentPath;
|
|
193
|
+
const targetPathInCurrentApp = prependTopbarAppPath(stripTopbarRouterBasePath(targetPath, basename), currentAppPath);
|
|
194
|
+
|
|
195
|
+
if (currentAppPath) {
|
|
196
|
+
const href = buildTopbarDocumentHref(targetPathInCurrentApp, basename);
|
|
197
|
+
const shouldOpenInNewWindow = shouldOpenAdminRouteInNewWindow({
|
|
198
|
+
currentPathname: currentPath,
|
|
199
|
+
targetPathname: targetPathInCurrentApp,
|
|
200
|
+
basePath: basename,
|
|
201
|
+
adminRoutePath: getTopbarAdminRoutePath(app),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<a
|
|
206
|
+
href={href}
|
|
207
|
+
target={shouldOpenInNewWindow ? '_blank' : undefined}
|
|
208
|
+
rel={shouldOpenInNewWindow ? 'noopener noreferrer' : undefined}
|
|
209
|
+
>
|
|
210
|
+
{props.title}
|
|
211
|
+
</a>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
139
214
|
|
|
140
|
-
if (isAdminRuntimePath(
|
|
141
|
-
return <Link to={
|
|
215
|
+
if (isAdminRuntimePath(currentRoutePath)) {
|
|
216
|
+
return <Link to={targetPathInCurrentApp}>{props.title}</Link>;
|
|
142
217
|
}
|
|
143
218
|
|
|
144
219
|
return (
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
export const ROUTE_TRANSIENT_INPUT_ARGS_KEY = '__nocobaseOpenViewInputArgs';
|
|
11
|
+
|
|
12
|
+
export type RouteTransientInputArgsState = {
|
|
13
|
+
[ROUTE_TRANSIENT_INPUT_ARGS_KEY]?: Record<string, Record<string, unknown>>;
|
|
14
|
+
usr?: RouteTransientInputArgsState;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getRouteTransientInputArgs = (state: unknown, viewUid?: string) => {
|
|
18
|
+
if (!viewUid || !state || typeof state !== 'object') {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const stateValue = state as RouteTransientInputArgsState;
|
|
23
|
+
const values = (stateValue[ROUTE_TRANSIENT_INPUT_ARGS_KEY] || stateValue.usr?.[ROUTE_TRANSIENT_INPUT_ARGS_KEY])?.[
|
|
24
|
+
viewUid
|
|
25
|
+
];
|
|
26
|
+
return values && typeof values === 'object' ? values : {};
|
|
27
|
+
};
|