@nocobase/client-v2 2.2.0-beta.7 → 2.2.0-beta.9

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 (113) hide show
  1. package/es/BaseApplication.d.ts +2 -0
  2. package/es/RouteRepository.d.ts +8 -0
  3. package/es/RouterManager.d.ts +15 -0
  4. package/es/authRedirect.d.ts +1 -0
  5. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  6. package/es/entry-actions/index.d.ts +9 -0
  7. package/es/flow/actions/afterSuccess.d.ts +8 -1
  8. package/es/flow/actions/index.d.ts +1 -1
  9. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  10. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
  12. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  14. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  15. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  16. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  17. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  18. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
  19. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  20. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  23. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  25. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  26. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  27. package/es/index.d.ts +2 -0
  28. package/es/index.mjs +243 -141
  29. package/es/utils/markdownSanitize.d.ts +11 -0
  30. package/lib/index.js +232 -130
  31. package/package.json +7 -7
  32. package/src/BaseApplication.tsx +2 -0
  33. package/src/RouteRepository.ts +25 -0
  34. package/src/RouterManager.tsx +142 -1
  35. package/src/__tests__/RouteRepository.test.ts +23 -0
  36. package/src/__tests__/RouterManager.test.ts +125 -0
  37. package/src/__tests__/authRedirect.test.ts +17 -0
  38. package/src/authRedirect.ts +38 -0
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/entry-actions/EntryActionManager.ts +76 -0
  50. package/src/entry-actions/index.ts +10 -0
  51. package/src/flow/FlowPage.tsx +29 -2
  52. package/src/flow/__tests__/FlowPage.test.tsx +152 -25
  53. package/src/flow/__tests__/FlowRoute.test.tsx +547 -2
  54. package/src/flow/__tests__/getKey.test.ts +7 -0
  55. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  56. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  57. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +62 -0
  58. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +164 -0
  59. package/src/flow/actions/afterSuccess.tsx +142 -3
  60. package/src/flow/actions/dataScopeFilter.ts +10 -1
  61. package/src/flow/actions/dateTimeFormat.tsx +2 -2
  62. package/src/flow/actions/index.ts +1 -1
  63. package/src/flow/actions/openView.tsx +59 -5
  64. package/src/flow/admin-shell/BaseLayoutModel.tsx +149 -3
  65. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +187 -42
  66. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1020 -110
  67. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +42 -4
  68. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +177 -1
  69. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +20 -0
  70. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
  71. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  72. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  73. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +518 -2
  74. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +48 -0
  75. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  76. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  77. package/src/flow/common/Markdown/Display.tsx +14 -3
  78. package/src/flow/common/Markdown/Edit.tsx +19 -7
  79. package/src/flow/components/FlowRoute.tsx +128 -16
  80. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  81. package/src/flow/internal/components/Markdown/util.ts +2 -1
  82. package/src/flow/models/base/ActionModel.tsx +10 -8
  83. package/src/flow/models/base/ActionModelCore.tsx +11 -4
  84. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  85. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  86. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  87. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  88. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  89. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
  90. package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
  91. package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
  92. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  93. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
  94. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
  95. package/src/flow/models/blocks/table/TableColumnModel.tsx +6 -2
  96. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
  97. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  98. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  99. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +51 -0
  100. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  101. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  102. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  103. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +45 -13
  104. package/src/flow/models/fields/TextareaFieldModel.tsx +42 -19
  105. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +32 -1
  106. package/src/flow/models/topbar/TopbarActionModel.tsx +78 -3
  107. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  108. package/src/flow/routeTransientInputArgs.ts +27 -0
  109. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +42 -0
  110. package/src/index.ts +2 -0
  111. package/src/nocobase-buildin-plugin/index.tsx +7 -1
  112. package/src/settings-center/SystemSettingsPage.tsx +1 -1
  113. package/src/utils/markdownSanitize.ts +88 -0
@@ -8,41 +8,110 @@
8
8
  */
9
9
 
10
10
  import { useApp } from '../../../flow-compat';
11
+ import type { AppListProps } from '@ant-design/pro-layout/es/components/AppsLogoComponents/types';
11
12
  import React from 'react';
13
+ import type { AdminLayoutModel } from './AdminLayoutModel';
14
+ import { ADMIN_LAYOUT_MODEL_UID } from './constants';
15
+ import type { AppSwitcherActionPanelModel } from './AppSwitcherActionPanelModel';
12
16
 
13
- export const useApplications = () => {
17
+ export const APP_SWITCHER_ACTION_PANEL_MODEL_UID = `${ADMIN_LAYOUT_MODEL_UID}-app-switcher-actions`;
18
+
19
+ function getErrorStatus(error: unknown) {
20
+ if (!error || typeof error !== 'object') {
21
+ return;
22
+ }
23
+ const errorLike = error as { response?: { status?: unknown }; status?: unknown };
24
+ const status = errorLike.response?.status ?? errorLike.status;
25
+ return typeof status === 'number' ? status : undefined;
26
+ }
27
+
28
+ export const useApplications = (adminLayoutModel?: AdminLayoutModel) => {
14
29
  const app = useApp();
15
30
  const loadAppList = app.apps.loadAppList;
16
- const [appList, setAppList] = React.useState([]);
31
+ const [legacyAppList, setLegacyAppList] = React.useState<AppListProps>([]);
32
+ const [appSwitcherModel, setAppSwitcherModel] = React.useState<AppSwitcherActionPanelModel>();
33
+
34
+ React.useEffect(() => {
35
+ let canceled = false;
36
+ let modelWithLayoutContext: AppSwitcherActionPanelModel | undefined;
37
+
38
+ if (!adminLayoutModel) {
39
+ setAppSwitcherModel(undefined);
40
+ return;
41
+ }
42
+
43
+ const load = async () => {
44
+ try {
45
+ const model = await adminLayoutModel.flowEngine.loadOrCreateModel<AppSwitcherActionPanelModel>({
46
+ uid: APP_SWITCHER_ACTION_PANEL_MODEL_UID,
47
+ use: 'AppSwitcherActionPanelModel',
48
+ parentId: adminLayoutModel.uid,
49
+ subKey: 'appSwitcher',
50
+ subType: 'object',
51
+ });
52
+ if (canceled || !model) {
53
+ return;
54
+ }
55
+ model.context.addDelegate(adminLayoutModel.context);
56
+ modelWithLayoutContext = model;
57
+ setAppSwitcherModel(model);
58
+ } catch (error) {
59
+ if (canceled) {
60
+ return;
61
+ }
62
+ if (getErrorStatus(error) !== 404) {
63
+ throw error;
64
+ }
65
+ setAppSwitcherModel(undefined);
66
+ }
67
+ };
68
+ load();
69
+
70
+ return () => {
71
+ canceled = true;
72
+ modelWithLayoutContext?.context.removeDelegate(adminLayoutModel.context);
73
+ };
74
+ }, [adminLayoutModel]);
17
75
 
18
76
  React.useEffect(() => {
19
77
  let canceled = false;
20
78
 
79
+ if (adminLayoutModel) {
80
+ setLegacyAppList([]);
81
+ return;
82
+ }
83
+
21
84
  if (!loadAppList) {
22
- setAppList([]);
85
+ setLegacyAppList([]);
23
86
  return;
24
87
  }
25
88
 
26
- void Promise.resolve(loadAppList(app))
27
- .then((list) => {
89
+ const load = async () => {
90
+ try {
91
+ const list = await Promise.resolve(loadAppList(app));
28
92
  if (!canceled) {
29
- setAppList(Array.isArray(list) ? list : []);
93
+ setLegacyAppList(Array.isArray(list) ? list : []);
30
94
  }
31
- })
32
- .catch((error) => {
95
+ } catch (error) {
33
96
  console.error('[NocoBase] Failed to load application switcher list.', error);
34
97
  if (!canceled) {
35
- setAppList([]);
98
+ setLegacyAppList([]);
36
99
  }
37
- });
100
+ }
101
+ };
102
+ load();
38
103
 
39
104
  return () => {
40
105
  canceled = true;
41
106
  };
42
- }, [app, loadAppList]);
107
+ }, [adminLayoutModel, app, loadAppList]);
108
+
109
+ const shouldRenderConfiguredSwitcher =
110
+ !!appSwitcherModel && (appSwitcherModel.hasActions() || app.flowEngine.context.flowSettingsEnabled);
43
111
 
44
112
  return {
45
113
  Component: app.apps.Component,
46
- appList,
114
+ appList: shouldRenderConfiguredSwitcher ? [{ title: '', url: '#' }] : legacyAppList,
115
+ appSwitcherModel,
47
116
  };
48
117
  };
@@ -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: 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
  };
@@ -33,6 +33,7 @@ export const getKey = (viewItem: ViewItem) => {
33
33
  stableStringify(params.viewUid),
34
34
  stableStringify(params.sourceId),
35
35
  stableStringify(params.filterByTk),
36
+ stableStringify(params.openViewRouteState),
36
37
  String(index),
37
38
  ];
38
39
 
@@ -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',
@@ -25,7 +25,7 @@ export function ActionWithoutPermission(props) {
25
25
  const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
26
26
  const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} ` : '';
27
27
  return `${dataSourcePrefix}${collectionPrefix}`;
28
- }, []);
28
+ }, [collection, dataSource.displayName, dataSource.key, t]);
29
29
  const { actionName } = props?.forbidden || model.forbidden;
30
30
  const messageValue = useMemo(() => {
31
31
  return t(
@@ -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
  }
@@ -143,10 +148,11 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
143
148
  renderButton() {
144
149
  const { iconOnly, ...props } = this.props;
145
150
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
151
+ const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
146
152
 
147
153
  return (
148
154
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon}>
149
- {iconOnly ? null : props.children || this.getTitle()}
155
+ {titleContent}
150
156
  </Button>
151
157
  );
152
158
  }
@@ -162,11 +168,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
162
168
  renderHiddenInConfig(): React.ReactNode | undefined {
163
169
  const { iconOnly, ...props } = this.props;
164
170
  const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
171
+ const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
165
172
  if (this.forbidden) {
166
173
  return (
167
174
  <ActionWithoutPermission>
168
175
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
169
- {iconOnly ? null : props.children || this.getTitle()}
176
+ {titleContent}
170
177
  </Button>
171
178
  </ActionWithoutPermission>
172
179
  );
@@ -174,7 +181,7 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
174
181
  return (
175
182
  <Tooltip title={this.context.t('The button is hidden and only visible when the UI Editor is active')}>
176
183
  <Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
177
- {iconOnly ? null : props.children || this.getTitle()}
184
+ {titleContent}
178
185
  </Button>
179
186
  </Tooltip>
180
187
  );
@@ -0,0 +1,37 @@
1
+ import { render, screen } from '@nocobase/test/client';
2
+ import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
3
+ import { App, ConfigProvider } from 'antd';
4
+ import React from 'react';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { ActionModel } from '../ActionModel';
7
+
8
+ class NoIconActionModel extends ActionModel {
9
+ defaultProps = {
10
+ type: 'link' as const,
11
+ title: 'Open details',
12
+ };
13
+ }
14
+
15
+ describe('ActionModel rendering', () => {
16
+ it('shows the title when iconOnly is true but no icon is configured', () => {
17
+ const engine = new FlowEngine();
18
+ engine.registerModels({ NoIconActionModel });
19
+ const model = engine.createModel<NoIconActionModel>({
20
+ use: 'NoIconActionModel',
21
+ props: {
22
+ title: 'Open details',
23
+ iconOnly: true,
24
+ },
25
+ });
26
+
27
+ render(
28
+ <FlowEngineProvider engine={engine}>
29
+ <ConfigProvider>
30
+ <App>{model.render()}</App>
31
+ </ConfigProvider>
32
+ </FlowEngineProvider>,
33
+ );
34
+
35
+ expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
36
+ });
37
+ });
@@ -210,7 +210,7 @@ FilterFormCustomFieldModel.registerFlow({
210
210
  'x-decorator': 'FormItem',
211
211
  required: true,
212
212
  description:
213
- '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.")}}',
213
+ '{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.")}}',
214
214
  },
215
215
  source: {
216
216
  type: 'array',
@@ -117,7 +117,7 @@ FormSubmitActionModel.registerFlow({
117
117
  try {
118
118
  ctx.model.setProps('loading', true);
119
119
  const { submitHandler } = await import('./submitHandler');
120
- await submitHandler(ctx, params);
120
+ return await submitHandler(ctx, params);
121
121
  } catch (error) {
122
122
  ctx.model.setProps('loading', false);
123
123
  if (error instanceof FlowExitAllException) {