@nocobase/client-v2 2.2.0-alpha.4 → 2.2.0-alpha.5

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 (52) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouterManager.d.ts +15 -0
  3. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  4. package/es/entry-actions/index.d.ts +9 -0
  5. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  6. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  7. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  8. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  9. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  10. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  11. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  12. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  13. package/es/index.d.ts +4 -0
  14. package/es/index.mjs +178 -124
  15. package/es/utils/markdownSanitize.d.ts +11 -0
  16. package/lib/index.js +164 -110
  17. package/package.json +7 -7
  18. package/src/BaseApplication.tsx +2 -0
  19. package/src/RouterManager.tsx +142 -1
  20. package/src/__tests__/RouterManager.test.ts +125 -0
  21. package/src/entry-actions/EntryActionManager.ts +76 -0
  22. package/src/entry-actions/index.ts +10 -0
  23. package/src/flow/FlowPage.tsx +29 -2
  24. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  25. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  26. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  27. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +80 -20
  28. package/src/flow/actions/dataScopeFilter.ts +10 -1
  29. package/src/flow/actions/openView.tsx +21 -1
  30. package/src/flow/admin-shell/BaseLayoutModel.tsx +111 -0
  31. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +184 -42
  32. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1016 -119
  33. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +41 -4
  34. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  35. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  36. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +359 -2
  37. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  38. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  39. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  40. package/src/flow/common/Markdown/Display.tsx +14 -3
  41. package/src/flow/common/Markdown/Edit.tsx +19 -7
  42. package/src/flow/components/FlowRoute.tsx +128 -16
  43. package/src/flow/internal/components/Markdown/util.ts +2 -1
  44. package/src/flow/models/base/ActionModel.tsx +10 -8
  45. package/src/flow/models/base/ActionModelCore.tsx +5 -0
  46. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  47. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  48. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  49. package/src/flow/routeTransientInputArgs.ts +27 -0
  50. package/src/index.ts +4 -0
  51. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  52. 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: value ?? '',
108
+ value: safeValue,
107
109
  lang,
108
110
  cache: { enable: false },
109
111
  undoDelay: 0,
110
112
  mode: props.mode || 'ir',
111
- preview: { math: { engine: 'KaTeX' } },
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(value) {
135
- onChange(value);
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
- if (value !== editor.getValue()) {
233
- editor.setValue(value ?? '');
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 { resolveAdminRouteRuntimeTarget } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
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 routeRepository = flowEngine.context.routeRepository;
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?.getRouteBySchemaUid?.(pageUid);
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
- }, [app, flowEngine, legacyPageBehavior, pageUid, routeLayout, routeRepository, skipRouteRepositoryCheck]);
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
- }, [active, getLayoutModel, guardState.allowBridge, guardState.notFound, guardState.pending, pageUid]);
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: ctx.model.enableEditIcon
63
- ? {
64
- 'x-decorator': 'FormItem',
65
- 'x-component': 'Switch',
66
- title: tExpr('Icon only'),
67
- }
68
- : undefined,
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 type { TextAreaRef } from 'antd/es/input/TextArea';
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 textAreaRef = useRef<TextAreaRef>(null);
20
- const previousValueRef = useRef(value);
21
- const defaultValue = typeof value === 'bigint' ? String(value) : value;
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
- if (Object.is(previousValueRef.current, value)) {
25
- return;
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
- const textArea = textAreaRef.current?.resizableTextArea?.textArea;
30
- if (textArea) {
31
- const nextValue = value == null ? '' : String(value);
32
- if (textArea.value !== nextValue) {
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
- ref={textAreaRef}
45
- defaultValue={defaultValue}
62
+ value={textAreaValue}
46
63
  onChange={(event) => {
47
- previousValueRef.current = getEventValue(event);
64
+ recordLocalValue(event);
48
65
  onChange?.(event);
49
66
  }}
50
67
  onCompositionStart={(event) => {
51
- previousValueRef.current = getEventValue(event);
68
+ recordLocalValue(event);
52
69
  onCompositionStart?.(event);
53
70
  }}
54
71
  onCompositionEnd={(event) => {
55
- previousValueRef.current = getEventValue(event);
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<BaseApplication<any>, 'router' | 'getPublicPath'>;
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(currentPath)) {
141
- return <Link to={targetPath}>{props.title}</Link>;
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
+ };