@nocobase/client-v2 2.1.0-beta.32 → 2.1.0-beta.34

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 (94) hide show
  1. package/es/APIClient.d.ts +16 -0
  2. package/es/Application.d.ts +2 -1
  3. package/es/authRedirect.d.ts +9 -16
  4. package/es/components/form/EnvVariableInput.d.ts +8 -6
  5. package/es/components/form/VariableInput.d.ts +73 -0
  6. package/es/components/form/index.d.ts +1 -0
  7. package/es/components/form/table/RowOverlayPreview.d.ts +27 -0
  8. package/es/components/form/table/SelectionCell.d.ts +36 -0
  9. package/es/components/form/table/Table.d.ts +82 -0
  10. package/es/components/form/table/constants.d.ts +15 -0
  11. package/es/components/form/table/dnd/SortableRow.d.ts +40 -0
  12. package/es/components/form/table/dnd/index.d.ts +9 -0
  13. package/es/components/form/table/index.d.ts +9 -0
  14. package/es/components/form/table/styles.d.ts +41 -0
  15. package/es/components/form/table/utils.d.ts +44 -0
  16. package/es/components/index.d.ts +2 -0
  17. package/es/flow/components/TextAreaWithContextSelector.d.ts +15 -0
  18. package/es/flow/components/code-editor/index.d.ts +1 -0
  19. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  20. package/es/flow/models/blocks/table/dragSort/dragSortComponents.d.ts +1 -6
  21. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +5 -1
  22. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  23. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  24. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  25. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  26. package/es/flow-compat/passwordUtils.d.ts +1 -1
  27. package/es/index.d.ts +1 -0
  28. package/es/index.mjs +166 -99
  29. package/es/theme/globalStyles.d.ts +9 -0
  30. package/es/theme/index.d.ts +1 -0
  31. package/lib/index.js +171 -104
  32. package/package.json +10 -5
  33. package/src/APIClient.ts +68 -0
  34. package/src/Application.tsx +6 -2
  35. package/src/__tests__/authRedirect.test.ts +170 -64
  36. package/src/__tests__/globalDeps.test.ts +3 -0
  37. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +6 -6
  38. package/src/authRedirect.ts +23 -84
  39. package/src/components/form/EnvVariableInput.tsx +11 -46
  40. package/src/components/form/VariableInput.tsx +177 -0
  41. package/src/components/form/__tests__/EnvVariableInput.test.tsx +175 -0
  42. package/src/components/form/index.tsx +1 -0
  43. package/src/components/form/table/RowOverlayPreview.tsx +51 -0
  44. package/src/components/form/table/SelectionCell.tsx +72 -0
  45. package/src/components/form/table/Table.tsx +279 -0
  46. package/src/components/form/table/__tests__/Table.pagination.test.tsx +80 -0
  47. package/src/components/form/table/constants.ts +16 -0
  48. package/src/components/form/table/dnd/SortableRow.tsx +106 -0
  49. package/src/components/form/table/dnd/index.ts +10 -0
  50. package/src/components/form/table/index.tsx +13 -0
  51. package/src/components/form/table/styles.ts +110 -0
  52. package/src/components/form/table/utils.ts +75 -0
  53. package/src/components/index.ts +2 -0
  54. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  55. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  56. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  57. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  58. package/src/flow/actions/formAssignRules.tsx +24 -9
  59. package/src/flow/actions/pattern.tsx +41 -6
  60. package/src/flow/actions/titleField.tsx +4 -2
  61. package/src/flow/actions/validation.tsx +1 -1
  62. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +2 -4
  63. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  64. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +111 -0
  65. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +2 -1
  66. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  67. package/src/flow/components/TextAreaWithContextSelector.tsx +30 -6
  68. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  69. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +81 -0
  70. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +34 -2
  71. package/src/flow/components/code-editor/index.tsx +12 -8
  72. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  73. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  74. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  75. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  76. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  77. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  78. package/src/flow/models/blocks/table/TableColumnModel.tsx +5 -2
  79. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +36 -0
  80. package/src/flow/models/blocks/table/dragSort/dragSortComponents.tsx +1 -81
  81. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  82. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  83. package/src/flow/models/fields/ClickableFieldModel.tsx +46 -2
  84. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  85. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -15
  86. package/src/flow/models/fields/JSEditableFieldModel.tsx +107 -7
  87. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +180 -2
  88. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  89. package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +97 -0
  90. package/src/index.ts +1 -0
  91. package/src/nocobase-buildin-plugin/index.tsx +4 -4
  92. package/src/theme/globalStyles.ts +21 -0
  93. package/src/theme/index.tsx +1 -0
  94. package/src/utils/globalDeps.ts +14 -1
@@ -7,6 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import { uid } from '@formily/shared';
10
11
  import { defineAction, tExpr } from '@nocobase/flow-engine';
11
12
  import { DetailsItemModel } from '../models/blocks/details/DetailsItemModel';
12
13
  import { getFieldBindingUse, rebuildFieldSubModel } from '../internal/utils/rebuildFieldSubModel';
@@ -19,6 +20,14 @@ type PatternAwareFieldModel = {
19
20
  scheduleApplyJsSettings?: () => void;
20
21
  };
21
22
 
23
+ function resolveAssociationTitleField(ctx: any) {
24
+ return (
25
+ ctx.model.subModels?.field?.props?.fieldNames?.label ||
26
+ ctx.model.props.titleField ||
27
+ ctx.collectionField?.targetCollectionTitleFieldName
28
+ );
29
+ }
30
+
22
31
  function shouldPreserveFieldModelOnPatternChange(ctx: any) {
23
32
  const fieldModel = ctx.model.subModels.field;
24
33
  const fieldUse = getFieldBindingUse(fieldModel) ?? fieldModel?.use;
@@ -27,6 +36,22 @@ function shouldPreserveFieldModelOnPatternChange(ctx: any) {
27
36
  return ((ModelClass?.meta as PatternAwareFieldModelMeta | undefined)?.preserveOnPatternChange ?? false) === true;
28
37
  }
29
38
 
39
+ async function refreshPatternRuntime(ctx: any) {
40
+ ctx.model.invalidateFlowCache?.('beforeRender', true);
41
+ await ctx.model.rerender?.();
42
+
43
+ const parent = ctx.model.parent;
44
+ if (!parent) {
45
+ return;
46
+ }
47
+
48
+ parent.invalidateFlowCache?.('beforeRender', true);
49
+ parent.setProps?.({
50
+ __patternRefreshKey: uid(),
51
+ });
52
+ await parent.rerender?.();
53
+ }
54
+
30
55
  export const pattern = defineAction({
31
56
  name: 'pattern',
32
57
  title: tExpr('Display mode'),
@@ -76,17 +101,25 @@ export const pattern = defineAction({
76
101
  if (params.pattern !== previousParams.pattern) {
77
102
  (ctx.model.subModels.field as PatternAwareFieldModel | undefined)?.scheduleApplyJsSettings?.();
78
103
  }
104
+ await refreshPatternRuntime(ctx);
79
105
  return;
80
106
  }
81
107
 
82
108
  const targetCollection = ctx.collectionField.targetCollection;
83
- const targetCollectionTitleField = targetCollection?.getField(
84
- ctx.model.subModels.field.props?.fieldNames?.label || ctx.model.props.titleField,
85
- );
109
+ const associationTitleField = resolveAssociationTitleField(ctx);
110
+ const targetCollectionTitleField = targetCollection?.getField(associationTitleField);
86
111
  const { model } = ctx;
87
112
  const resolveDefaultProps = (binding, field = ctx.collectionField) => {
88
113
  if (!binding) return undefined;
89
- return typeof binding.defaultProps === 'function' ? binding.defaultProps(ctx, field) : binding.defaultProps;
114
+ const defaultProps =
115
+ typeof binding.defaultProps === 'function' ? binding.defaultProps(ctx, field) : binding.defaultProps;
116
+ if (!ctx.collectionField?.isAssociationField?.() || !associationTitleField) {
117
+ return defaultProps;
118
+ }
119
+ return {
120
+ ...(defaultProps || {}),
121
+ titleField: associationTitleField,
122
+ };
90
123
  };
91
124
  if (params.pattern === 'readPretty') {
92
125
  const binding = DetailsItemModel.getDefaultBindingByField(ctx, ctx.collectionField, {
@@ -111,17 +144,19 @@ export const pattern = defineAction({
111
144
  });
112
145
  }
113
146
  }
147
+ await refreshPatternRuntime(ctx);
114
148
  },
115
149
  async beforeParamsSave(ctx, params, previousParams) {
116
150
  if (params.pattern === 'readPretty') {
151
+ const titleField = resolveAssociationTitleField(ctx);
117
152
  ctx.model.setProps({
118
153
  pattern: 'readPretty',
119
154
  disabled: false,
120
- titleField: (ctx.model.subModels?.field as any)?.props.fieldNames?.label || ctx.model.props.titleField,
155
+ titleField,
121
156
  });
122
157
  if (ctx.collectionField.isAssociationField())
123
158
  await ctx.model.setStepParams('editItemSettings', 'titleField', {
124
- titleField: (ctx.model.subModels.field as any).props?.fieldNames?.label || ctx.model.props.titleField,
159
+ titleField,
125
160
  });
126
161
  } else {
127
162
  ctx.model.setProps({
@@ -43,7 +43,9 @@ export const titleField = defineAction({
43
43
  const options = targetFields
44
44
  .filter((field) =>
45
45
  isTitleFieldInterface(
46
- getFlowFieldInterfaceOptions(field.options?.interface || field.interface, dataSourceManager),
46
+ typeof field.getInterfaceOptions === 'function'
47
+ ? field.getInterfaceOptions()
48
+ : getFlowFieldInterfaceOptions(field.options?.interface || field.interface, dataSourceManager),
47
49
  ),
48
50
  )
49
51
  .map((field) => ({
@@ -59,7 +61,7 @@ export const titleField = defineAction({
59
61
  };
60
62
  },
61
63
  defaultParams: (ctx: any) => {
62
- const titleField = ctx.model.context.collectionField.targetCollectionTitleFieldName;
64
+ const titleField = ctx.model?.context?.collectionField?.targetCollectionTitleFieldName;
63
65
  return {
64
66
  label: ctx.model.parent?.props?.titleField || ctx.model.props.titleField || titleField,
65
67
  };
@@ -18,7 +18,7 @@ export const validation = defineAction({
18
18
  return;
19
19
  }
20
20
  const targetInterface = ctx.model.collectionField.getInterfaceOptions();
21
- if (!targetInterface.validationType) {
21
+ if (!targetInterface?.validationType) {
22
22
  return null;
23
23
  }
24
24
  return {
@@ -628,10 +628,6 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
628
628
  )
629
629
  .filter(Boolean) || [];
630
630
 
631
- if (isV2AdminRuntime(this.context.app) && children.length === 0) {
632
- return null;
633
- }
634
-
635
631
  if (options.designable && depth === 0) {
636
632
  children.push(getAdminLayoutMenuInitializerButton('schema-initializer-Menu-side', this, route));
637
633
  }
@@ -685,6 +681,7 @@ export class AdminLayoutMenuItemModel extends FlowModel<AdminLayoutMenuItemStruc
685
681
  AdminLayoutMenuItemModel.registerFlow({
686
682
  key: 'menuCreation',
687
683
  title: 'Add menu item',
684
+ manual: true,
688
685
  steps: {
689
686
  basic: {
690
687
  title: 'Add menu item',
@@ -701,6 +698,7 @@ AdminLayoutMenuItemModel.registerFlow({
701
698
  AdminLayoutMenuItemModel.registerFlow({
702
699
  key: 'menuSettings',
703
700
  title: 'Menu settings',
701
+ manual: true,
704
702
  steps: {
705
703
  edit: {
706
704
  title: 'Edit',
@@ -311,7 +311,7 @@ describe('AdminLayoutModel menu items', () => {
311
311
  expect(route.children[1]._model).toBe(adminLayoutModel.subModels.menuItems?.[1]);
312
312
  });
313
313
 
314
- it('should filter legacy page menu routes in v2 admin layout', () => {
314
+ it('should filter legacy page menu routes but keep empty groups in v2 admin layout', () => {
315
315
  const adminLayoutModel = engine.createModel<AdminLayoutModel>({
316
316
  uid: 'admin-layout-model',
317
317
  use: AdminLayoutModel,
@@ -369,22 +369,30 @@ describe('AdminLayoutModel menu items', () => {
369
369
  t: (title) => title,
370
370
  });
371
371
 
372
- expect(route.children).toHaveLength(2);
372
+ expect(route.children).toHaveLength(3);
373
373
  expect(route.children[0]).toMatchObject({
374
+ path: '/admin/2',
375
+ redirect: '/admin/2',
376
+ _runtimePath: null,
377
+ _navigationMode: 'spa',
378
+ _isLegacy: false,
379
+ });
380
+ expect(route.children[0].routes).toBeUndefined();
381
+ expect(route.children[1]).toMatchObject({
374
382
  path: '/admin/3',
375
383
  redirect: '/admin/nested-flow-page',
376
384
  _runtimePath: '/apps/demo/v2/admin/nested-flow-page',
377
385
  _navigationMode: 'spa',
378
386
  _isLegacy: false,
379
387
  });
380
- expect(route.children[0].routes).toHaveLength(1);
381
- expect(route.children[0].routes?.[0]).toMatchObject({
388
+ expect(route.children[1].routes).toHaveLength(1);
389
+ expect(route.children[1].routes?.[0]).toMatchObject({
382
390
  path: '/admin/nested-flow-page',
383
391
  _runtimePath: '/apps/demo/v2/admin/nested-flow-page',
384
392
  _navigationMode: 'spa',
385
393
  _isLegacy: false,
386
394
  });
387
- expect(route.children[1]).toMatchObject({
395
+ expect(route.children[2]).toMatchObject({
388
396
  path: '/admin/__admin_layout__/link/4',
389
397
  });
390
398
  });
@@ -249,4 +249,115 @@ describe('resolveAdminRouteRuntimeTarget', () => {
249
249
  expect(toRouterNavigationPath('/nocobase/v2/admin/page-1', '/nocobase/v2')).toBe('/admin/page-1');
250
250
  expect(toRouterNavigationPath('/admin/page-1', '/nocobase/v2')).toBe('/admin/page-1');
251
251
  });
252
+
253
+ describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
254
+ const subApp = {
255
+ getPublicPath: () => '/nocobase/v2/',
256
+ router: {
257
+ getBasename: () => '/nocobase/v2/apps/test-app/',
258
+ },
259
+ } as any;
260
+
261
+ it('should resolve flowPage runtime path under sub-app basename', () => {
262
+ expect(
263
+ resolveAdminRouteRuntimeTarget({
264
+ app: subApp,
265
+ route: {
266
+ type: NocoBaseDesktopRouteType.flowPage,
267
+ schemaUid: 'fp1',
268
+ },
269
+ }),
270
+ ).toEqual({
271
+ runtimePath: '/nocobase/v2/apps/test-app/admin/fp1',
272
+ navigationMode: 'spa',
273
+ isLegacy: false,
274
+ reason: 'ok',
275
+ });
276
+ });
277
+
278
+ it('should resolve group DFS to first flowPage under sub-app basename', () => {
279
+ const route: NocoBaseDesktopRoute = {
280
+ type: NocoBaseDesktopRouteType.group,
281
+ children: [
282
+ {
283
+ type: NocoBaseDesktopRouteType.tabs,
284
+ schemaUid: 'tabs-1',
285
+ },
286
+ {
287
+ type: NocoBaseDesktopRouteType.group,
288
+ children: [
289
+ {
290
+ type: NocoBaseDesktopRouteType.page,
291
+ schemaUid: 'legacy-2',
292
+ },
293
+ {
294
+ type: NocoBaseDesktopRouteType.flowPage,
295
+ schemaUid: 'nested-fp',
296
+ },
297
+ ],
298
+ },
299
+ ],
300
+ };
301
+
302
+ expect(resolveAdminRouteRuntimeTarget({ app: subApp, route })).toEqual({
303
+ runtimePath: '/nocobase/v2/apps/test-app/admin/nested-fp',
304
+ navigationMode: 'spa',
305
+ isLegacy: false,
306
+ reason: 'ok',
307
+ });
308
+ });
309
+
310
+ it('should strip sub-app basename when converting to router internal path', () => {
311
+ expect(toRouterNavigationPath('/nocobase/v2/apps/test-app/admin/page-1', '/nocobase/v2/apps/test-app')).toBe(
312
+ '/admin/page-1',
313
+ );
314
+ expect(toRouterNavigationPath('/nocobase/v2/apps/test-app/admin/page-1', '/nocobase/v2/apps/test-app/')).toBe(
315
+ '/admin/page-1',
316
+ );
317
+ });
318
+ });
319
+
320
+ describe('fallback when router basename is missing', () => {
321
+ it('should fall back to publicPath when router is undefined', () => {
322
+ const appNoRouter = {
323
+ getPublicPath: () => '/nocobase/v2/',
324
+ router: undefined,
325
+ } as any;
326
+
327
+ expect(
328
+ resolveAdminRouteRuntimeTarget({
329
+ app: appNoRouter,
330
+ route: {
331
+ type: NocoBaseDesktopRouteType.flowPage,
332
+ schemaUid: 'fp1',
333
+ },
334
+ }),
335
+ ).toMatchObject({
336
+ runtimePath: '/nocobase/v2/admin/fp1',
337
+ reason: 'ok',
338
+ });
339
+ });
340
+
341
+ it('should fall back to publicPath when getBasename returns undefined', () => {
342
+ const appNoBasename = {
343
+ getPublicPath: () => '/nocobase/v2/',
344
+ router: {
345
+ getBasename: () => undefined,
346
+ },
347
+ } as any;
348
+
349
+ expect(
350
+ resolveAdminRouteRuntimeTarget({
351
+ app: appNoBasename,
352
+ route: {
353
+ type: NocoBaseDesktopRouteType.flowPage,
354
+ schemaUid: 'fp1',
355
+ },
356
+ }),
357
+ ).toMatchObject({
358
+ runtimePath: '/nocobase/v2/admin/fp1',
359
+ reason: 'ok',
360
+ });
361
+ });
362
+ });
252
363
  });
@@ -7,6 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import { getV2EffectiveBasePath } from '../../../authRedirect';
10
11
  import type { BaseApplication } from '../../../BaseApplication';
11
12
  import { NocoBaseDesktopRouteType, type NocoBaseDesktopRoute } from '../../../flow-compat';
12
13
 
@@ -106,7 +107,7 @@ function joinRootRelativePath(basePath: string, pathname: string) {
106
107
  }
107
108
 
108
109
  function getV2AdminPath(app: ResolveAdminRouteRuntimeTargetOptions['app'], schemaUid: string) {
109
- return joinRootRelativePath(app.getPublicPath(), `/admin/${schemaUid}`);
110
+ return joinRootRelativePath(getV2EffectiveBasePath(app), `/admin/${schemaUid}`);
110
111
  }
111
112
 
112
113
  function appendLocationState(pathname: string, location?: LocationLike) {
@@ -28,6 +28,9 @@ import {
28
28
  GLOBAL_EMBED_CONTAINER_ID,
29
29
  EMBED_REPLACING_DATA_KEY,
30
30
  shouldHideEventInSettings,
31
+ DetachedFlowRegistry,
32
+ replaceFlowRegistry,
33
+ serializeFlowRegistry,
31
34
  } from '@nocobase/flow-engine';
32
35
  import { Collapse, Input, Button, Space, Tooltip, Empty, Dropdown, Select } from 'antd';
33
36
  import { uid } from '@formily/shared';
@@ -35,6 +38,9 @@ import { useUpdate } from 'ahooks';
35
38
  import _ from 'lodash';
36
39
 
37
40
  type FlowOnObject = Exclude<FlowDefinition['on'], string | undefined>;
41
+ type FlowRegistryAvailability = {
42
+ hasFlow(flowKey: string): boolean;
43
+ };
38
44
 
39
45
  function isFlowOnObject(on: FlowDefinition['on']): on is FlowOnObject {
40
46
  return !!on && typeof on === 'object';
@@ -93,7 +99,7 @@ function validateFlowOnPhase(onObj: FlowOnObject): 'flowKey' | 'stepKey' | undef
93
99
 
94
100
  export const DynamicFlowsIcon: React.FC<{ model: FlowModel }> = (props) => {
95
101
  const { model } = props;
96
- const t = model.translate.bind(model);
102
+ const t = React.useMemo(() => model.translate.bind(model), [model]);
97
103
 
98
104
  const handleClick = () => {
99
105
  const target = document.querySelector<HTMLDivElement>(`#${GLOBAL_EMBED_CONTAINER_ID}`);
@@ -190,7 +196,17 @@ const FieldLabel = ({
190
196
 
191
197
  // 事件配置组件 - 独立的 observer 组件确保响应式更新
192
198
  const EventConfigSection = observer(
193
- ({ flow, model, flowEngine }: { flow: FlowDefinition; model: FlowModel; flowEngine: any }) => {
199
+ ({
200
+ flow,
201
+ model,
202
+ flowEngine,
203
+ flowRegistry,
204
+ }: {
205
+ flow: FlowDefinition;
206
+ model: FlowModel;
207
+ flowEngine: any;
208
+ flowRegistry: FlowRegistryAvailability;
209
+ }) => {
194
210
  const ctx = useFlowContext<FlowEngineContext>();
195
211
  const t = model.translate.bind(model);
196
212
  const refresh = useUpdate();
@@ -270,9 +286,9 @@ const EventConfigSection = observer(
270
286
  return staticFlows.map((f) => ({
271
287
  value: f.key,
272
288
  label: formatKeyWithTitle(String(f.key), f.title),
273
- disabled: model.flowRegistry.hasFlow(f.key),
289
+ disabled: flowRegistry.hasFlow(f.key),
274
290
  }));
275
- }, [formatKeyWithTitle, model.flowRegistry, staticFlows]);
291
+ }, [flowRegistry, formatKeyWithTitle, staticFlows]);
276
292
 
277
293
  const stepOptions = React.useMemo(() => {
278
294
  if (!flowKeyValue) return [];
@@ -446,12 +462,58 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
446
462
  const { model } = props;
447
463
  const ctx = useFlowContext<FlowEngineContext>();
448
464
  const flowEngine = model.flowEngine;
465
+ const latestFlows = serializeFlowRegistry(model.flowRegistry);
466
+ const initialFlowsRef = React.useRef(latestFlows);
467
+ const [draftFlowRegistry] = React.useState(() => new DetachedFlowRegistry(latestFlows));
449
468
  const [submitLoading, setSubmitLoading] = React.useState(false);
450
- const t = model.translate.bind(model);
469
+ const t = React.useMemo(() => model.translate.bind(model), [model]);
470
+ const hasUnsavedChanges = React.useCallback(() => {
471
+ return !_.isEqual(initialFlowsRef.current, serializeFlowRegistry(draftFlowRegistry));
472
+ }, [draftFlowRegistry]);
473
+
474
+ React.useEffect(() => {
475
+ if (_.isEqual(initialFlowsRef.current, latestFlows) || hasUnsavedChanges()) {
476
+ return;
477
+ }
478
+
479
+ replaceFlowRegistry(draftFlowRegistry, latestFlows);
480
+ initialFlowsRef.current = latestFlows;
481
+ }, [draftFlowRegistry, hasUnsavedChanges, latestFlows]);
482
+
483
+ React.useEffect(() => {
484
+ const view = ctx.view;
485
+ const previousBeforeClose = view.beforeClose;
486
+ const beforeClose = async (payload) => {
487
+ if (hasUnsavedChanges()) {
488
+ const confirmed =
489
+ (await ctx.modal?.confirm?.({
490
+ title: t('Unsaved changes'),
491
+ content: t("Are you sure you don't want to save?"),
492
+ okText: t('Confirm'),
493
+ cancelText: t('Cancel'),
494
+ })) ?? true;
495
+
496
+ if (!confirmed) {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ const result = await previousBeforeClose?.(payload);
502
+ return result !== false;
503
+ };
504
+
505
+ view.beforeClose = beforeClose;
506
+
507
+ return () => {
508
+ if (view.beforeClose === beforeClose) {
509
+ view.beforeClose = previousBeforeClose;
510
+ }
511
+ };
512
+ }, [ctx, hasUnsavedChanges, t]);
451
513
 
452
514
  // 添加新流
453
515
  const handleAddFlow = () => {
454
- model.flowRegistry.addFlow(uid(), {
516
+ draftFlowRegistry.addFlow(uid(), {
455
517
  title: t('Event flow'),
456
518
  steps: {},
457
519
  });
@@ -516,7 +578,7 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
516
578
  );
517
579
 
518
580
  // 生成折叠面板项
519
- const collapseItems = model.flowRegistry.mapFlows((flow) => {
581
+ const collapseItems = draftFlowRegistry.mapFlows((flow) => {
520
582
  return {
521
583
  key: flow.key,
522
584
  label: renderPanelHeader(flow),
@@ -529,7 +591,7 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
529
591
  children: (
530
592
  <div>
531
593
  {/* 事件部分 */}
532
- <EventConfigSection flow={flow} model={model} flowEngine={flowEngine} />
594
+ <EventConfigSection flow={flow} model={model} flowEngine={flowEngine} flowRegistry={draftFlowRegistry} />
533
595
 
534
596
  {/* 步骤部分 */}
535
597
  <div>
@@ -670,13 +732,13 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
670
732
  flexShrink: 0,
671
733
  }}
672
734
  >
673
- <Button onClick={() => ctx.view.destroy()}>{t('Cancel')}</Button>
735
+ <Button onClick={() => ctx.view.close()}>{t('Cancel')}</Button>
674
736
  <Button
675
737
  type="primary"
676
738
  loading={submitLoading}
677
739
  onClick={async () => {
678
740
  setSubmitLoading(true);
679
- const invalid = model.flowRegistry
741
+ const invalid = draftFlowRegistry
680
742
  .mapFlows((flow) => {
681
743
  if (!isFlowOnObject(flow.on)) return;
682
744
  normalizeFlowOnPhase(flow.on);
@@ -695,10 +757,11 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
695
757
  setSubmitLoading(false);
696
758
  return;
697
759
  }
698
- try {
699
- const afterSaves: Array<() => Promise<void>> = [];
760
+ const previousFlows = serializeFlowRegistry(model.flowRegistry);
761
+ const afterSaves: Array<() => Promise<void>> = [];
700
762
 
701
- for (const flow of model.flowRegistry.mapFlows((it) => it)) {
763
+ try {
764
+ for (const flow of draftFlowRegistry.mapFlows((it) => it)) {
702
765
  for (const step of flow.mapSteps((it) => it)) {
703
766
  const serialized = step.serialize();
704
767
  const actionDef = step.use ? model.getAction(step.use) : undefined;
@@ -731,8 +794,19 @@ const DynamicFlowsEditor = observer((props: { model: FlowModel }) => {
731
794
  }
732
795
  }
733
796
 
797
+ replaceFlowRegistry(model.flowRegistry, serializeFlowRegistry(draftFlowRegistry));
734
798
  await model.flowRegistry.save();
799
+ } catch (error) {
800
+ replaceFlowRegistry(model.flowRegistry, previousFlows);
801
+ setSubmitLoading(false);
802
+ model.context?.message?.error?.('Steps post-save hooks failed to run');
803
+ console.error(error);
804
+ return;
805
+ }
735
806
 
807
+ initialFlowsRef.current = serializeFlowRegistry(model.flowRegistry);
808
+
809
+ try {
736
810
  for (const runAfterSave of afterSaves) {
737
811
  await runAfterSave();
738
812
  }
@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
20
20
  import { Button, Input } from 'antd';
21
21
  import { css } from '@emotion/css';
22
22
  import type { TextAreaRef } from 'antd/es/input/TextArea';
23
- import { FlowContextSelector, useFlowContext } from '@nocobase/flow-engine';
23
+ import { FlowContextSelector, useFlowContext, type MetaTreeNode } from '@nocobase/flow-engine';
24
24
 
25
25
  export interface TextAreaWithContextSelectorProps {
26
26
  value?: string;
@@ -29,6 +29,20 @@ export interface TextAreaWithContextSelectorProps {
29
29
  rows?: number;
30
30
  maxRows?: number;
31
31
  style?: React.CSSProperties;
32
+ disabled?: boolean;
33
+ /**
34
+ * Custom meta tree for the variable picker. Accepts an array, a sync getter,
35
+ * or an async getter — same shape as `FlowContextSelector`'s `metaTree`. If
36
+ * omitted, the full `ctx.getPropertyMetaTree()` is used (legacy default).
37
+ */
38
+ metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
39
+ /**
40
+ * Format a picked meta node into the string inserted at the caret. When
41
+ * omitted, the FlowContextSelector default (`{{ ctx.X.Y }}`) is used.
42
+ * Override to match a different storage convention — e.g. NocoBase server
43
+ * templates use `{{$X.Y}}` without the `ctx.` prefix.
44
+ */
45
+ formatPathToValue?: (meta: MetaTreeNode) => string;
32
46
  }
33
47
 
34
48
  /**
@@ -41,6 +55,9 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
41
55
  rows = 3,
42
56
  maxRows = 24,
43
57
  style,
58
+ disabled,
59
+ metaTree,
60
+ formatPathToValue,
44
61
  }) => {
45
62
  const flowCtx = useFlowContext();
46
63
  const [innerValue, setInnerValue] = useState<string>(value || '');
@@ -76,10 +93,11 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
76
93
  const next = prev.slice(0, start) + toInsert + prev.slice(end);
77
94
  setInnerValue(next);
78
95
  onChange?.(next);
79
- // 恢复光标位置并聚焦
96
+ // 插入后选中刚插入的变量文本,与 v1 RawTextArea 行为一致:
97
+ // 用户可立即按删除键移除整段变量,或继续输入直接替换。
80
98
  requestAnimationFrame(() => {
81
99
  const pos = start + (toInsert?.length || 0);
82
- el.setSelectionRange(pos, pos);
100
+ el.setSelectionRange(start, pos);
83
101
  el.focus();
84
102
  });
85
103
  },
@@ -94,8 +112,8 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
94
112
  [insertAtCaret],
95
113
  );
96
114
 
97
- // 使用函数形式提供变量树,保证与运行时上下文一致
98
- const metaTree = useMemo(() => () => flowCtx.getPropertyMetaTree?.(), [flowCtx]);
115
+ // 使用函数形式提供变量树,保证与运行时上下文一致;当外部传入则尊重外部值。
116
+ const resolvedMetaTree = useMemo(() => metaTree ?? (() => flowCtx.getPropertyMetaTree?.()), [flowCtx, metaTree]);
99
117
 
100
118
  return (
101
119
  <div style={{ position: 'relative', width: '100%', ...style }}>
@@ -105,6 +123,7 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
105
123
  onChange={handleTextChange}
106
124
  autoSize={{ minRows: rows, maxRows }}
107
125
  placeholder={placeholder}
126
+ disabled={disabled}
108
127
  style={{ width: '100%' }}
109
128
  />
110
129
  <div
@@ -116,7 +135,12 @@ export const TextAreaWithContextSelector: React.FC<TextAreaWithContextSelectorPr
116
135
  lineHeight: 0,
117
136
  }}
118
137
  >
119
- <FlowContextSelector metaTree={metaTree} onChange={(val) => handleVariableSelected(val)}>
138
+ <FlowContextSelector
139
+ metaTree={resolvedMetaTree}
140
+ disabled={disabled}
141
+ formatPathToValue={formatPathToValue}
142
+ onChange={(val) => handleVariableSelected(val)}
143
+ >
120
144
  <Button
121
145
  type="default"
122
146
  style={{ fontStyle: 'italic', fontFamily: 'New York, Times New Roman, Times, serif' }}