@nocobase/flow-engine 2.1.0-beta.43 → 2.1.0-beta.45

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.
@@ -207,8 +207,17 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
207
207
  const [visible, setVisible] = (0, import_react.useState)(false);
208
208
  const [refreshTick, setRefreshTick] = (0, import_react.useState)(0);
209
209
  const [extraMenuItems, setExtraMenuItems] = (0, import_react.useState)([]);
210
+ const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = (0, import_react.useState)(false);
210
211
  const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = (0, import_react.useState)([]);
211
212
  const [isLoading, setIsLoading] = (0, import_react.useState)(true);
213
+ const commonExtras = (0, import_react.useMemo)(
214
+ () => extraMenuItems.filter((it) => it.group === "common-actions").sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
215
+ [extraMenuItems]
216
+ );
217
+ const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
218
+ const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
219
+ const shouldWaitForCommonActionProbe = flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
220
+ const canRenderIcon = hasCommonActions || !isLoading && configurableFlowsAndSteps.length > 0;
212
221
  const closeDropdown = (0, import_react.useCallback)(() => {
213
222
  setVisible(false);
214
223
  onDropdownVisibleChange == null ? void 0 : onDropdownVisibleChange(false);
@@ -240,24 +249,28 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
240
249
  let mounted = true;
241
250
  const loadExtras = /* @__PURE__ */ __name(async () => {
242
251
  var _a;
243
- const allExtras = [];
244
- const modelsToProcess = [];
245
- walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: "stack" }, (targetModel, { modelKey }) => {
246
- modelsToProcess.push({ model: targetModel, modelKey });
247
- });
248
- for (const { model: targetModel, modelKey } of modelsToProcess) {
249
- const Cls = targetModel.constructor;
250
- const extras = await ((_a = Cls.getExtraMenuItems) == null ? void 0 : _a.call(Cls, targetModel, t));
251
- if (extras == null ? void 0 : extras.length) {
252
- allExtras.push(
253
- ...extras.map((item) => ({
254
- ...item,
255
- key: modelKey ? `${modelKey}:${item.key}` : item.key
256
- }))
257
- );
252
+ setExtraMenuItemsLoaded(false);
253
+ try {
254
+ const allExtras = [];
255
+ const modelsToProcess = [];
256
+ walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: "stack" }, (targetModel, { modelKey }) => {
257
+ modelsToProcess.push({ model: targetModel, modelKey });
258
+ });
259
+ for (const { model: targetModel, modelKey } of modelsToProcess) {
260
+ const Cls = targetModel.constructor;
261
+ const extras = await ((_a = Cls.getExtraMenuItems) == null ? void 0 : _a.call(Cls, targetModel, t));
262
+ if (extras == null ? void 0 : extras.length) {
263
+ allExtras.push(
264
+ ...extras.map((item) => ({
265
+ ...item,
266
+ key: modelKey ? `${modelKey}:${item.key}` : item.key
267
+ }))
268
+ );
269
+ }
270
+ }
271
+ if (!mounted) {
272
+ return;
258
273
  }
259
- }
260
- if (mounted) {
261
274
  const seen = /* @__PURE__ */ new Set();
262
275
  const dedupedExtras = allExtras.filter((item) => {
263
276
  if (seen.has(`${item.key}`)) {
@@ -267,15 +280,22 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
267
280
  return true;
268
281
  });
269
282
  setExtraMenuItems(dedupedExtras);
283
+ } catch (error) {
284
+ console.error("Failed to load extra menu items:", error);
285
+ if (mounted) {
286
+ setExtraMenuItems([]);
287
+ }
288
+ } finally {
289
+ if (mounted) {
290
+ setExtraMenuItemsLoaded(true);
291
+ }
270
292
  }
271
293
  }, "loadExtras");
272
- if (visible) {
273
- loadExtras();
274
- }
294
+ loadExtras();
275
295
  return () => {
276
296
  mounted = false;
277
297
  };
278
- }, [model, menuLevels, t, refreshTick, visible]);
298
+ }, [model, menuLevels, t, refreshTick]);
279
299
  const copyUidToClipboard = (0, import_react.useCallback)(
280
300
  async (uid) => {
281
301
  var _a;
@@ -520,7 +540,7 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
520
540
  return [];
521
541
  }
522
542
  },
523
- []
543
+ [t]
524
544
  );
525
545
  const getConfigurableFlowsAndSteps = (0, import_react.useCallback)(async () => {
526
546
  const result = [];
@@ -558,20 +578,47 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
558
578
  };
559
579
  }, [model, menuLevels, refreshTick]);
560
580
  (0, import_react.useEffect)(() => {
581
+ let mounted = true;
561
582
  const loadConfigurableFlowsAndSteps = /* @__PURE__ */ __name(async () => {
562
583
  setIsLoading(true);
584
+ if (shouldDeferConfigLoading) {
585
+ setConfigurableFlowsAndSteps([]);
586
+ }
563
587
  try {
564
588
  const flows = await getConfigurableFlowsAndSteps();
565
- setConfigurableFlowsAndSteps(flows);
589
+ if (mounted) {
590
+ setConfigurableFlowsAndSteps(flows);
591
+ }
566
592
  } catch (error) {
567
593
  console.error("Failed to load configurable flows and steps:", error);
568
- setConfigurableFlowsAndSteps([]);
594
+ if (mounted) {
595
+ setConfigurableFlowsAndSteps([]);
596
+ }
569
597
  } finally {
570
- setIsLoading(false);
598
+ if (mounted) {
599
+ setIsLoading(false);
600
+ }
571
601
  }
572
602
  }, "loadConfigurableFlowsAndSteps");
603
+ if (shouldWaitForCommonActionProbe) {
604
+ setConfigurableFlowsAndSteps([]);
605
+ setIsLoading(false);
606
+ return () => {
607
+ mounted = false;
608
+ };
609
+ }
610
+ if (!visible && shouldDeferConfigLoading) {
611
+ setConfigurableFlowsAndSteps([]);
612
+ setIsLoading(false);
613
+ return () => {
614
+ mounted = false;
615
+ };
616
+ }
573
617
  loadConfigurableFlowsAndSteps();
574
- }, [getConfigurableFlowsAndSteps, refreshTick]);
618
+ return () => {
619
+ mounted = false;
620
+ };
621
+ }, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
575
622
  const menuItems = (0, import_react.useMemo)(() => {
576
623
  const items = [];
577
624
  const keyCounter = /* @__PURE__ */ new Map();
@@ -696,10 +743,9 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
696
743
  }
697
744
  }
698
745
  return items;
699
- }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
746
+ }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
700
747
  const finalMenuItems = (0, import_react.useMemo)(() => {
701
748
  const items = [...menuItems];
702
- const commonExtras = extraMenuItems.filter((it) => it.group === "common-actions").sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
703
749
  if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
704
750
  items.push({
705
751
  type: "divider"
@@ -721,9 +767,8 @@ const DefaultSettingsIcon = /* @__PURE__ */ __name(({
721
767
  }
722
768
  }
723
769
  return items;
724
- }, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t, extraMenuItems]);
725
- const hasExtras = extraMenuItems.some((it) => it.group === "common-actions");
726
- if (isLoading || configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton && !hasExtras) {
770
+ }, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
771
+ if (!canRenderIcon) {
727
772
  return null;
728
773
  }
729
774
  if (!model || !model.uid) {
@@ -19,6 +19,7 @@ export interface BuildFieldChildrenOptions {
19
19
  fieldUseModel?: string | ((field: any) => string);
20
20
  collection?: Collection;
21
21
  associationPathName?: string;
22
+ maxAssociationFieldDepth?: number;
22
23
  /**
23
24
  * 点击这些子项后,除自身路径外,还需要联动刷新的其他菜单路径前缀
24
25
  */
@@ -231,14 +231,18 @@ function buildSubModelGroups(subModelBaseClasses = []) {
231
231
  }
232
232
  __name(buildSubModelGroups, "buildSubModelGroups");
233
233
  function buildWrapperFieldChildren(ctx, options) {
234
- var _a;
235
- const { useModel, fieldUseModel, associationPathName, refreshTargets } = options;
234
+ var _a, _b;
235
+ const { useModel, fieldUseModel, associationPathName, refreshTargets, maxAssociationFieldDepth = 2 } = options;
236
236
  const collection = options.collection || ctx.model["collection"] || ctx.collection;
237
237
  const fields = collection.getFields();
238
238
  const defaultItemKeys = ["fieldSettings", "init"];
239
239
  const children = [];
240
+ const associationDepth = associationPathName ? associationPathName.split(".").filter(Boolean).length : 0;
240
241
  for (const f of fields) {
241
242
  if (!((_a = f == null ? void 0 : f.options) == null ? void 0 : _a.interface)) continue;
243
+ if (associationDepth >= maxAssociationFieldDepth && (((_b = f.isAssociationField) == null ? void 0 : _b.call(f)) || f.target || f.targetCollection)) {
244
+ continue;
245
+ }
242
246
  const fieldPath = associationPathName ? `${associationPathName}.${f.name}` : f.name;
243
247
  const childUse = typeof fieldUseModel === "function" ? fieldUseModel(f) : fieldUseModel ?? "FieldModel";
244
248
  if (childUse) {
@@ -153,9 +153,6 @@ const _FlowExecutor = class _FlowExecutor {
153
153
  const stepDefaultParams = await (0, import_utils.resolveDefaultParams)(step.defaultParams, runtimeCtx);
154
154
  combinedParams = { ...stepDefaultParams };
155
155
  } else {
156
- flowContext.logger.warn(
157
- `BaseModel.applyFlow: Step '${stepKey}' in flow '${flowKey}' has neither 'use' nor 'handler'. Skipping.`
158
- );
159
156
  continue;
160
157
  }
161
158
  const modelStepParams = model.getStepParams(flowKey, stepKey);
@@ -538,11 +538,11 @@ export declare class FlowEngine {
538
538
  replaceModel<T extends FlowModel = FlowModel>(uid: string, optionsOrFn?: Partial<FlowModelOptions> | ((currentOptions: FlowModelOptions) => Partial<FlowModelOptions>)): Promise<T | null>;
539
539
  /**
540
540
  * Move a model instance within its parent model.
541
- * @param {any} sourceId Source model UID
542
- * @param {any} targetId Target model UID
541
+ * @param {string | number} sourceId Source model UID
542
+ * @param {string | number} targetId Target model UID
543
543
  * @returns {Promise<void>} No return value
544
544
  */
545
- moveModel(sourceId: any, targetId: any, options?: PersistOptions): Promise<void>;
545
+ moveModel(sourceId: string | number, targetId: string | number, options?: PersistOptions): Promise<void>;
546
546
  /**
547
547
  * Filter model classes by parent class (supports multi-level inheritance).
548
548
  * @param {string | ModelConstructor} parentClass Parent class name or constructor
package/lib/flowEngine.js CHANGED
@@ -1376,18 +1376,24 @@ const _FlowEngine = class _FlowEngine {
1376
1376
  }
1377
1377
  /**
1378
1378
  * Move a model instance within its parent model.
1379
- * @param {any} sourceId Source model UID
1380
- * @param {any} targetId Target model UID
1379
+ * @param {string | number} sourceId Source model UID
1380
+ * @param {string | number} targetId Target model UID
1381
1381
  * @returns {Promise<void>} No return value
1382
1382
  */
1383
1383
  async moveModel(sourceId, targetId, options) {
1384
1384
  var _a, _b;
1385
- const sourceModel = this.getModel(sourceId);
1386
- const targetModel = this.getModel(targetId);
1385
+ const sourceUid = String(sourceId);
1386
+ const targetUid = String(targetId);
1387
+ if (!sourceUid || !targetUid || sourceUid === targetUid) {
1388
+ return;
1389
+ }
1390
+ const sourceModel = this.getModel(sourceUid);
1391
+ const targetModel = this.getModel(targetUid);
1387
1392
  if (!sourceModel || !targetModel) {
1388
1393
  console.warn(`FlowEngine: Cannot move model. Source or target model not found.`);
1389
1394
  return;
1390
1395
  }
1396
+ let position = "after";
1391
1397
  const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(sourceModel);
1392
1398
  const move = /* @__PURE__ */ __name((sourceModel2, targetModel2) => {
1393
1399
  if (!sourceModel2.parent || !targetModel2.parent || sourceModel2.parent !== targetModel2.parent) {
@@ -1411,6 +1417,7 @@ const _FlowEngine = class _FlowEngine {
1411
1417
  console.warn("FlowModel.moveTo: Current model is already at the target position. No action taken.");
1412
1418
  return false;
1413
1419
  }
1420
+ position = currentIndex < targetIndex ? "after" : "before";
1414
1421
  const [movedModel] = subModelsCopy.splice(currentIndex, 1);
1415
1422
  subModelsCopy.splice(targetIndex, 0, movedModel);
1416
1423
  subModelsCopy.forEach((model, index) => {
@@ -1419,10 +1426,12 @@ const _FlowEngine = class _FlowEngine {
1419
1426
  subModels.splice(0, subModels.length, ...subModelsCopy);
1420
1427
  return true;
1421
1428
  }, "move");
1422
- move(sourceModel, targetModel);
1429
+ const moved = move(sourceModel, targetModel);
1430
+ if (!moved) {
1431
+ return;
1432
+ }
1423
1433
  if ((options == null ? void 0 : options.persist) !== false && this.ensureModelRepository()) {
1424
- const position = sourceModel.sortIndex - targetModel.sortIndex > 0 ? "after" : "before";
1425
- await this._modelRepository.move(sourceId, targetId, position);
1434
+ await this._modelRepository.move(sourceUid, targetUid, position);
1426
1435
  this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1427
1436
  }
1428
1437
  sourceModel.parent.emitter.emit("onSubModelMoved", { source: sourceModel, target: targetModel });
@@ -71,6 +71,18 @@ var _flowContext;
71
71
  const classActionRegistries = /* @__PURE__ */ new WeakMap();
72
72
  const classEventRegistries = /* @__PURE__ */ new WeakMap();
73
73
  const modelMetas = /* @__PURE__ */ new WeakMap();
74
+ function getStableSortIndex(item, fallbackIndex) {
75
+ return typeof (item == null ? void 0 : item.sortIndex) === "number" && Number.isFinite(item.sortIndex) ? item.sortIndex : fallbackIndex + 1;
76
+ }
77
+ __name(getStableSortIndex, "getStableSortIndex");
78
+ function sortByStableSortIndex(items) {
79
+ return items.map((item, index) => ({
80
+ item,
81
+ index,
82
+ sortIndex: getStableSortIndex(item, index)
83
+ })).sort((a, b) => a.sortIndex - b.sortIndex || a.index - b.index).map(({ item }) => item);
84
+ }
85
+ __name(sortByStableSortIndex, "sortByStableSortIndex");
74
86
  const modelGlobalRegistries = /* @__PURE__ */ new WeakMap();
75
87
  const classMenuExtensions = /* @__PURE__ */ new WeakMap();
76
88
  const sortExtraMenuItems = /* @__PURE__ */ __name((items) => {
@@ -204,7 +216,7 @@ const _FlowModel = class _FlowModel {
204
216
  };
205
217
  this.stepParams = options.stepParams || {};
206
218
  this.subModels = {};
207
- this.sortIndex = options.sortIndex || 0;
219
+ this.sortIndex = getStableSortIndex({ sortIndex: options.sortIndex }, -1);
208
220
  this._options = options;
209
221
  this._title = "";
210
222
  this._extraTitle = "";
@@ -416,7 +428,7 @@ const _FlowModel = class _FlowModel {
416
428
  }
417
429
  Object.entries(mergedSubModels || {}).forEach(([key, value]) => {
418
430
  if (Array.isArray(value)) {
419
- value.sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0)).forEach((item) => {
431
+ sortByStableSortIndex(value).forEach((item) => {
420
432
  this.addSubModel(key, item);
421
433
  });
422
434
  } else {
@@ -628,26 +640,43 @@ const _FlowModel = class _FlowModel {
628
640
  return this.props;
629
641
  }
630
642
  setStepParams(flowKeyOrAllParams, stepKeyOrStepsParams, params) {
643
+ var _a;
644
+ let hasChanged = false;
631
645
  if (typeof flowKeyOrAllParams === "string") {
632
646
  const flowKey = flowKeyOrAllParams;
633
647
  if (typeof stepKeyOrStepsParams === "string" && params !== void 0) {
634
- if (!this.stepParams[flowKey]) {
635
- this.stepParams[flowKey] = {};
648
+ const currentStepParams = ((_a = this.stepParams[flowKey]) == null ? void 0 : _a[stepKeyOrStepsParams]) || {};
649
+ const nextStepParams = { ...currentStepParams, ...params };
650
+ if (!import_lodash.default.isEqual(currentStepParams, nextStepParams)) {
651
+ if (!this.stepParams[flowKey]) {
652
+ this.stepParams[flowKey] = {};
653
+ }
654
+ this.stepParams[flowKey][stepKeyOrStepsParams] = nextStepParams;
655
+ hasChanged = true;
636
656
  }
637
- this.stepParams[flowKey][stepKeyOrStepsParams] = {
638
- ...this.stepParams[flowKey][stepKeyOrStepsParams],
639
- ...params
640
- };
641
657
  } else if (typeof stepKeyOrStepsParams === "object" && stepKeyOrStepsParams !== null) {
642
- this.stepParams[flowKey] = { ...this.stepParams[flowKey] || {}, ...stepKeyOrStepsParams };
658
+ const currentFlowParams = this.stepParams[flowKey] || {};
659
+ const nextFlowParams = { ...currentFlowParams, ...stepKeyOrStepsParams };
660
+ if (!import_lodash.default.isEqual(currentFlowParams, nextFlowParams)) {
661
+ this.stepParams[flowKey] = nextFlowParams;
662
+ hasChanged = true;
663
+ }
643
664
  }
644
665
  } else if (typeof flowKeyOrAllParams === "object" && flowKeyOrAllParams !== null) {
645
666
  for (const fk in flowKeyOrAllParams) {
646
667
  if (Object.prototype.hasOwnProperty.call(flowKeyOrAllParams, fk)) {
647
- this.stepParams[fk] = { ...this.stepParams[fk] || {}, ...flowKeyOrAllParams[fk] };
668
+ const currentFlowParams = this.stepParams[fk] || {};
669
+ const nextFlowParams = { ...currentFlowParams, ...flowKeyOrAllParams[fk] };
670
+ if (!import_lodash.default.isEqual(currentFlowParams, nextFlowParams)) {
671
+ this.stepParams[fk] = nextFlowParams;
672
+ hasChanged = true;
673
+ }
648
674
  }
649
675
  }
650
676
  }
677
+ if (!hasChanged) {
678
+ return;
679
+ }
651
680
  this.emitter.emit("onStepParamsChanged");
652
681
  }
653
682
  getStepParams(flowKey, stepKey) {
@@ -933,7 +962,10 @@ const _FlowModel = class _FlowModel {
933
962
  if (!Array.isArray(subModels[subKey])) {
934
963
  subModels[subKey] = import_reactive.observable.shallow([]);
935
964
  }
936
- const maxSortIndex = Math.max(...subModels[subKey].map((item) => item.sortIndex || 0), 0);
965
+ const maxSortIndex = Math.max(
966
+ ...subModels[subKey].map((item, index) => getStableSortIndex(item, index)),
967
+ 0
968
+ );
937
969
  model.sortIndex = maxSortIndex + 1;
938
970
  subModels[subKey].push(model);
939
971
  actualParent.emitter.emit("onSubModelAdded", model);
@@ -981,7 +1013,7 @@ const _FlowModel = class _FlowModel {
981
1013
  return [];
982
1014
  }
983
1015
  const results = [];
984
- import_lodash.default.castArray(model).sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0)).forEach((item, index) => {
1016
+ sortByStableSortIndex(import_lodash.default.castArray(model)).forEach((item, index) => {
985
1017
  const result = callback(item, index);
986
1018
  if (result) {
987
1019
  results.push(item);
@@ -995,7 +1027,7 @@ const _FlowModel = class _FlowModel {
995
1027
  return [];
996
1028
  }
997
1029
  const results = [];
998
- import_lodash.default.castArray(model).sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0)).forEach((item, index) => {
1030
+ sortByStableSortIndex(import_lodash.default.castArray(model)).forEach((item, index) => {
999
1031
  const result = callback(item, index);
1000
1032
  results.push(result);
1001
1033
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.43",
3
+ "version": "2.1.0-beta.45",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.43",
12
- "@nocobase/shared": "2.1.0-beta.43",
11
+ "@nocobase/sdk": "2.1.0-beta.45",
12
+ "@nocobase/shared": "2.1.0-beta.45",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "6d7750e2373bf2451d246de88cc1f62491685e18"
40
+ "gitHead": "42587115fc34c3eb01ef2b2549f1c998e5708318"
41
41
  }
@@ -8,9 +8,30 @@
8
8
  */
9
9
 
10
10
  import { reaction } from '@nocobase/flow-engine';
11
- import { beforeEach, describe, expect, it } from 'vitest';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
  import { FlowEngine } from '../flowEngine';
13
13
  import { FlowModel } from '../models';
14
+ import type { IFlowModelRepository } from '../types';
15
+
16
+ class MoveRepository implements IFlowModelRepository {
17
+ move = vi.fn(async (_sourceId: string, _targetId: string, _position: 'before' | 'after'): Promise<void> => {});
18
+
19
+ async findOne(): Promise<Record<string, unknown> | null> {
20
+ return null;
21
+ }
22
+
23
+ async save(): Promise<Record<string, unknown>> {
24
+ return {};
25
+ }
26
+
27
+ async destroy(): Promise<boolean> {
28
+ return true;
29
+ }
30
+
31
+ async duplicate(): Promise<Record<string, unknown> | null> {
32
+ return null;
33
+ }
34
+ }
14
35
 
15
36
  describe('FlowEngine moveModel', () => {
16
37
  let engine: FlowEngine;
@@ -20,6 +41,14 @@ describe('FlowEngine moveModel', () => {
20
41
  engine.registerModels({ FlowModel });
21
42
  });
22
43
 
44
+ const createParentWithChildren = () => {
45
+ const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
46
+ parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
47
+ parent.addSubModel('items', { uid: 'child-b', use: 'FlowModel' });
48
+ parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
49
+ return parent;
50
+ };
51
+
23
52
  it('keeps subModels array reactive after move so later additions trigger reactions', async () => {
24
53
  const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
25
54
  parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
@@ -40,4 +69,55 @@ describe('FlowEngine moveModel', () => {
40
69
  dispose();
41
70
  expect(seen).toEqual([3]);
42
71
  });
72
+
73
+ it('persists an after move when dragging forward', async () => {
74
+ const repository = new MoveRepository();
75
+ engine.setModelRepository(repository);
76
+ const parent = createParentWithChildren();
77
+
78
+ await engine.moveModel('child-a', 'child-c');
79
+
80
+ expect(repository.move).toHaveBeenCalledWith('child-a', 'child-c', 'after');
81
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-b', 'child-c', 'child-a']);
82
+ });
83
+
84
+ it('persists a before move when dragging backward', async () => {
85
+ const repository = new MoveRepository();
86
+ engine.setModelRepository(repository);
87
+ const parent = createParentWithChildren();
88
+
89
+ await engine.moveModel('child-c', 'child-a');
90
+
91
+ expect(repository.move).toHaveBeenCalledWith('child-c', 'child-a', 'before');
92
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-c', 'child-a', 'child-b']);
93
+ });
94
+
95
+ it('does not persist self-drop', async () => {
96
+ const repository = new MoveRepository();
97
+ engine.setModelRepository(repository);
98
+ const parent = createParentWithChildren();
99
+
100
+ await engine.moveModel('child-a', 'child-a');
101
+
102
+ expect(repository.move).not.toHaveBeenCalled();
103
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
104
+ });
105
+
106
+ it('keeps null sortIndex subModels in stable order', () => {
107
+ const parent = engine.createModel({
108
+ uid: 'parent',
109
+ use: 'FlowModel',
110
+ subModels: {
111
+ items: [
112
+ { uid: 'child-a', use: 'FlowModel', sortIndex: null as unknown as number },
113
+ { uid: 'child-b', use: 'FlowModel', sortIndex: null as unknown as number },
114
+ ],
115
+ },
116
+ });
117
+
118
+ parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
119
+
120
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
121
+ expect((parent.subModels.items as FlowModel[]).map((item) => item.sortIndex)).toEqual([1, 2, 3]);
122
+ });
43
123
  });
@@ -263,8 +263,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
263
263
  // 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
264
264
  const [refreshTick, setRefreshTick] = useState(0);
265
265
  const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
266
+ const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
266
267
  const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
267
268
  const [isLoading, setIsLoading] = useState(true);
269
+ const commonExtras = useMemo(
270
+ () => extraMenuItems.filter((it) => it.group === 'common-actions').sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
271
+ [extraMenuItems],
272
+ );
273
+ const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
274
+ const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
275
+ const shouldWaitForCommonActionProbe =
276
+ flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
277
+ const canRenderIcon = hasCommonActions || (!isLoading && configurableFlowsAndSteps.length > 0);
268
278
  const closeDropdown = useCallback(() => {
269
279
  setVisible(false);
270
280
  onDropdownVisibleChange?.(false);
@@ -303,26 +313,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
303
313
  useEffect(() => {
304
314
  let mounted = true;
305
315
  const loadExtras = async () => {
306
- const allExtras: FlowModelExtraMenuItem[] = [];
307
- const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
308
- walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
309
- modelsToProcess.push({ model: targetModel, modelKey });
310
- });
316
+ setExtraMenuItemsLoaded(false);
317
+ try {
318
+ const allExtras: FlowModelExtraMenuItem[] = [];
319
+ const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
320
+ walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
321
+ modelsToProcess.push({ model: targetModel, modelKey });
322
+ });
311
323
 
312
- for (const { model: targetModel, modelKey } of modelsToProcess) {
313
- const Cls = targetModel.constructor as typeof FlowModel;
314
- const extras = await Cls.getExtraMenuItems?.(targetModel, t);
315
- if (extras?.length) {
316
- allExtras.push(
317
- ...extras.map((item) => ({
318
- ...item,
319
- key: modelKey ? `${modelKey}:${item.key}` : item.key,
320
- })),
321
- );
324
+ for (const { model: targetModel, modelKey } of modelsToProcess) {
325
+ const Cls = targetModel.constructor as typeof FlowModel;
326
+ const extras = await Cls.getExtraMenuItems?.(targetModel, t);
327
+ if (extras?.length) {
328
+ allExtras.push(
329
+ ...extras.map((item) => ({
330
+ ...item,
331
+ key: modelKey ? `${modelKey}:${item.key}` : item.key,
332
+ })),
333
+ );
334
+ }
322
335
  }
323
- }
324
336
 
325
- if (mounted) {
337
+ if (!mounted) {
338
+ return;
339
+ }
326
340
  const seen = new Set<string>();
327
341
  const dedupedExtras = allExtras.filter((item) => {
328
342
  if (seen.has(`${item.key}`)) {
@@ -332,16 +346,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
332
346
  return true;
333
347
  });
334
348
  setExtraMenuItems(dedupedExtras);
349
+ } catch (error) {
350
+ console.error('Failed to load extra menu items:', error);
351
+ if (mounted) {
352
+ setExtraMenuItems([]);
353
+ }
354
+ } finally {
355
+ if (mounted) {
356
+ setExtraMenuItemsLoaded(true);
357
+ }
335
358
  }
336
359
  };
337
- // 避免 effect 触发 setState 导致循环:仅在 visible 打开时加载一次,关闭后仍保留结果
338
- if (visible) {
339
- loadExtras();
340
- }
360
+ loadExtras();
341
361
  return () => {
342
362
  mounted = false;
343
363
  };
344
- }, [model, menuLevels, t, refreshTick, visible]);
364
+ }, [model, menuLevels, t, refreshTick]);
345
365
 
346
366
  // 统一的复制 UID 方法
347
367
  const copyUidToClipboard = useCallback(
@@ -632,7 +652,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
632
652
  return [];
633
653
  }
634
654
  },
635
- [],
655
+ [t],
636
656
  );
637
657
 
638
658
  // 获取可配置的flows和steps
@@ -675,21 +695,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
675
695
  }, [model, menuLevels, refreshTick]);
676
696
 
677
697
  useEffect(() => {
698
+ let mounted = true;
678
699
  const loadConfigurableFlowsAndSteps = async () => {
679
700
  setIsLoading(true);
701
+ if (shouldDeferConfigLoading) {
702
+ setConfigurableFlowsAndSteps([]);
703
+ }
680
704
  try {
681
705
  const flows = await getConfigurableFlowsAndSteps();
682
- setConfigurableFlowsAndSteps(flows);
706
+ if (mounted) {
707
+ setConfigurableFlowsAndSteps(flows);
708
+ }
683
709
  } catch (error) {
684
710
  console.error('Failed to load configurable flows and steps:', error);
685
- setConfigurableFlowsAndSteps([]);
711
+ if (mounted) {
712
+ setConfigurableFlowsAndSteps([]);
713
+ }
686
714
  } finally {
687
- setIsLoading(false);
715
+ if (mounted) {
716
+ setIsLoading(false);
717
+ }
688
718
  }
689
719
  };
690
720
 
721
+ if (shouldWaitForCommonActionProbe) {
722
+ setConfigurableFlowsAndSteps([]);
723
+ setIsLoading(false);
724
+ return () => {
725
+ mounted = false;
726
+ };
727
+ }
728
+
729
+ if (!visible && shouldDeferConfigLoading) {
730
+ setConfigurableFlowsAndSteps([]);
731
+ setIsLoading(false);
732
+ return () => {
733
+ mounted = false;
734
+ };
735
+ }
736
+
691
737
  loadConfigurableFlowsAndSteps();
692
- }, [getConfigurableFlowsAndSteps, refreshTick]);
738
+ return () => {
739
+ mounted = false;
740
+ };
741
+ }, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
693
742
 
694
743
  // 构建菜单项,包含错误处理和记忆化
695
744
  const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
@@ -856,16 +905,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
856
905
  }
857
906
 
858
907
  return items;
859
- }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
908
+ }, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
860
909
 
861
910
  // 向菜单项添加额外按钮
862
911
  const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
863
912
  const items = [...menuItems];
864
913
 
865
- const commonExtras = extraMenuItems
866
- .filter((it) => it.group === 'common-actions')
867
- .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
868
-
869
914
  if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
870
915
  items.push({
871
916
  type: 'divider',
@@ -901,12 +946,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
901
946
  }
902
947
 
903
948
  return items;
904
- }, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t, extraMenuItems]);
905
-
906
- // 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
907
- const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
949
+ }, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
908
950
 
909
- if (isLoading || (configurableFlowsAndSteps.length === 0 && !showDeleteButton && !showCopyUidButton && !hasExtras)) {
951
+ if (!canRenderIcon) {
910
952
  return null;
911
953
  }
912
954
 
@@ -7,10 +7,10 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import React from 'react';
11
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
- import { render, cleanup, waitFor, act } from '@testing-library/react';
10
+ import { act, cleanup, render, waitFor } from '@testing-library/react';
13
11
  import { App, ConfigProvider } from 'antd';
12
+ import React from 'react';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
14
 
15
15
  import { FlowEngine } from '../../../../../flowEngine';
16
16
  import { FlowModel } from '../../../../../models/flowModel';
@@ -141,6 +141,86 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
141
141
  vi.clearAllMocks();
142
142
  });
143
143
 
144
+ it('defers nested configurable step resolution and clears stale config while closed', async () => {
145
+ class TestFlowModel extends FlowModel {}
146
+
147
+ const engine = new FlowEngine();
148
+ const model = new TestFlowModel({ uid: 'model-lazy-settings', flowEngine: engine });
149
+ const hideInSettings = vi.fn((ctx) => !!ctx.getStepParams('general')?.hidden);
150
+ const uiSchema = vi.fn(() => ({
151
+ field: { type: 'string', 'x-component': 'Input' },
152
+ }));
153
+
154
+ TestFlowModel.registerFlow({
155
+ key: 'lazyFlow',
156
+ title: 'Lazy Flow',
157
+ steps: {
158
+ general: {
159
+ title: 'General',
160
+ hideInSettings,
161
+ uiSchema,
162
+ },
163
+ },
164
+ });
165
+
166
+ const { getByLabelText } = render(
167
+ React.createElement(
168
+ ConfigProvider as any,
169
+ null,
170
+ React.createElement(
171
+ App as any,
172
+ null,
173
+ React.createElement(DefaultSettingsIcon as any, { model, menuLevels: 2 }),
174
+ ),
175
+ ),
176
+ );
177
+
178
+ expect(getByLabelText('flows-settings')).toBeTruthy();
179
+ expect(hideInSettings).not.toHaveBeenCalled();
180
+ expect(uiSchema).not.toHaveBeenCalled();
181
+
182
+ await act(async () => {
183
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
184
+ });
185
+
186
+ await waitFor(() => {
187
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
188
+ expect(uiSchema).toHaveBeenCalledTimes(1);
189
+ const menu = (globalThis as any).__lastDropdownMenu;
190
+ const items = (menu?.items || []) as any[];
191
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(true);
192
+ });
193
+
194
+ await act(async () => {
195
+ (globalThis as any).__lastDropdownOnOpenChange?.(false, { source: 'trigger' });
196
+ });
197
+
198
+ await waitFor(() => {
199
+ const menu = (globalThis as any).__lastDropdownMenu;
200
+ const items = (menu?.items || []) as any[];
201
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
202
+ });
203
+
204
+ await act(async () => {
205
+ model.setStepParams('lazyFlow', 'general', { hidden: true });
206
+ });
207
+
208
+ expect(hideInSettings).toHaveBeenCalledTimes(1);
209
+ expect(uiSchema).toHaveBeenCalledTimes(1);
210
+
211
+ await act(async () => {
212
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
213
+ });
214
+
215
+ await waitFor(() => {
216
+ expect(hideInSettings).toHaveBeenCalledTimes(2);
217
+ const menu = (globalThis as any).__lastDropdownMenu;
218
+ const items = (menu?.items || []) as any[];
219
+ expect(items.some((it) => String(it.key || '') === 'lazyFlow:general')).toBe(false);
220
+ });
221
+ expect(uiSchema).toHaveBeenCalledTimes(1);
222
+ });
223
+
144
224
  it('excludes instance (dynamic) flows from the settings menu', async () => {
145
225
  class TestFlowModel extends FlowModel {}
146
226
 
@@ -720,6 +800,10 @@ describe('DefaultSettingsIcon - only static flows are shown', () => {
720
800
  ),
721
801
  );
722
802
 
803
+ await act(async () => {
804
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
805
+ });
806
+
723
807
  await waitFor(() => {
724
808
  const menu = (globalThis as any).__lastDropdownMenu;
725
809
  expect(menu).toBeTruthy();
@@ -903,4 +987,67 @@ describe('DefaultSettingsIcon - extra menu items', () => {
903
987
  dispose?.();
904
988
  }
905
989
  });
990
+
991
+ it('uses common extra actions to defer nested configurable step resolution', async () => {
992
+ const onClick = vi.fn();
993
+
994
+ class TestFlowModel extends FlowModel {}
995
+ const dispose = TestFlowModel.registerExtraMenuItems({
996
+ group: 'common-actions',
997
+ sort: 10,
998
+ items: [{ key: 'extra-action', label: 'Extra Action', onClick }],
999
+ });
1000
+
1001
+ const engine = new FlowEngine();
1002
+ const model = new TestFlowModel({ uid: 'm-extra-lazy', flowEngine: engine });
1003
+ const uiSchema = vi.fn(() => ({
1004
+ f: { type: 'string', 'x-component': 'Input' },
1005
+ }));
1006
+
1007
+ TestFlowModel.registerFlow({
1008
+ key: 'flow',
1009
+ title: 'Flow',
1010
+ steps: { s: { title: 'S', uiSchema } },
1011
+ });
1012
+
1013
+ try {
1014
+ const { getByLabelText } = render(
1015
+ React.createElement(
1016
+ ConfigProvider as any,
1017
+ null,
1018
+ React.createElement(
1019
+ App as any,
1020
+ null,
1021
+ React.createElement(DefaultSettingsIcon as any, {
1022
+ model,
1023
+ menuLevels: 2,
1024
+ showCopyUidButton: false,
1025
+ showDeleteButton: false,
1026
+ }),
1027
+ ),
1028
+ ),
1029
+ );
1030
+
1031
+ await waitFor(() => {
1032
+ expect(getByLabelText('flows-settings')).toBeTruthy();
1033
+ const menu = (globalThis as any).__lastDropdownMenu;
1034
+ const items = (menu?.items || []) as any[];
1035
+ expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
1036
+ });
1037
+ expect(uiSchema).not.toHaveBeenCalled();
1038
+
1039
+ await act(async () => {
1040
+ (globalThis as any).__lastDropdownOnOpenChange?.(true, { source: 'trigger' });
1041
+ });
1042
+
1043
+ await waitFor(() => {
1044
+ expect(uiSchema).toHaveBeenCalledTimes(1);
1045
+ const menu = (globalThis as any).__lastDropdownMenu;
1046
+ const items = (menu?.items || []) as any[];
1047
+ expect(items.some((it) => String(it.key || '') === 'flow:s')).toBe(true);
1048
+ });
1049
+ } finally {
1050
+ dispose?.();
1051
+ }
1052
+ });
906
1053
  });
@@ -232,6 +232,7 @@ export interface BuildFieldChildrenOptions {
232
232
  fieldUseModel?: string | ((field: any) => string);
233
233
  collection?: Collection;
234
234
  associationPathName?: string;
235
+ maxAssociationFieldDepth?: number;
235
236
  /**
236
237
  * 点击这些子项后,除自身路径外,还需要联动刷新的其他菜单路径前缀
237
238
  */
@@ -239,13 +240,17 @@ export interface BuildFieldChildrenOptions {
239
240
  }
240
241
 
241
242
  export function buildWrapperFieldChildren(ctx: FlowModelContext, options: BuildFieldChildrenOptions) {
242
- const { useModel, fieldUseModel, associationPathName, refreshTargets } = options;
243
+ const { useModel, fieldUseModel, associationPathName, refreshTargets, maxAssociationFieldDepth = 2 } = options;
243
244
  const collection: Collection = options.collection || ctx.model['collection'] || ctx.collection;
244
245
  const fields = collection.getFields();
245
246
  const defaultItemKeys = ['fieldSettings', 'init'];
246
247
  const children: SubModelItem[] = [];
248
+ const associationDepth = associationPathName ? associationPathName.split('.').filter(Boolean).length : 0;
247
249
  for (const f of fields) {
248
250
  if (!f?.options?.interface) continue;
251
+ if (associationDepth >= maxAssociationFieldDepth && (f.isAssociationField?.() || f.target || f.targetCollection)) {
252
+ continue;
253
+ }
249
254
  const fieldPath = associationPathName ? `${associationPathName}.${f.name}` : f.name;
250
255
 
251
256
  const childUse = typeof fieldUseModel === 'function' ? fieldUseModel(f) : fieldUseModel ?? 'FieldModel';
@@ -158,9 +158,6 @@ export class FlowExecutor {
158
158
  const stepDefaultParams = await resolveDefaultParams(step.defaultParams, runtimeCtx);
159
159
  combinedParams = { ...stepDefaultParams };
160
160
  } else {
161
- flowContext.logger.warn(
162
- `BaseModel.applyFlow: Step '${stepKey}' in flow '${flowKey}' has neither 'use' nor 'handler'. Skipping.`,
163
- );
164
161
  continue;
165
162
  }
166
163
 
@@ -81,7 +81,7 @@ describe('FlowExecutor', () => {
81
81
  expect(result.step2).toBe('step2-ok');
82
82
  });
83
83
 
84
- it('runFlow warns and skips steps without use or handler', async () => {
84
+ it('runFlow silently skips steps without use or handler', async () => {
85
85
  const flows = {
86
86
  referenceSettings: {
87
87
  steps: {
@@ -98,9 +98,7 @@ describe('FlowExecutor', () => {
98
98
  const result = await engine.executor.runFlow(model, 'referenceSettings');
99
99
 
100
100
  expect(result).toEqual({});
101
- expect(loggerWarnSpy).toHaveBeenCalledWith(
102
- "BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
103
- );
101
+ expect(loggerWarnSpy).not.toHaveBeenCalled();
104
102
  expect(loggerErrorSpy).not.toHaveBeenCalled();
105
103
  } finally {
106
104
  loggerChildSpy.mockRestore();
package/src/flowEngine.ts CHANGED
@@ -1631,17 +1631,24 @@ export class FlowEngine {
1631
1631
 
1632
1632
  /**
1633
1633
  * Move a model instance within its parent model.
1634
- * @param {any} sourceId Source model UID
1635
- * @param {any} targetId Target model UID
1634
+ * @param {string | number} sourceId Source model UID
1635
+ * @param {string | number} targetId Target model UID
1636
1636
  * @returns {Promise<void>} No return value
1637
1637
  */
1638
- async moveModel(sourceId: any, targetId: any, options?: PersistOptions): Promise<void> {
1639
- const sourceModel = this.getModel(sourceId);
1640
- const targetModel = this.getModel(targetId);
1638
+ async moveModel(sourceId: string | number, targetId: string | number, options?: PersistOptions): Promise<void> {
1639
+ const sourceUid = String(sourceId);
1640
+ const targetUid = String(targetId);
1641
+ if (!sourceUid || !targetUid || sourceUid === targetUid) {
1642
+ return;
1643
+ }
1644
+
1645
+ const sourceModel = this.getModel(sourceUid);
1646
+ const targetModel = this.getModel(targetUid);
1641
1647
  if (!sourceModel || !targetModel) {
1642
1648
  console.warn(`FlowEngine: Cannot move model. Source or target model not found.`);
1643
1649
  return;
1644
1650
  }
1651
+ let position: 'before' | 'after' = 'after';
1645
1652
  const dirtyLoadedPageKey = this._loadedPageCache.getDirtyKeyForModel(sourceModel);
1646
1653
  const move = (sourceModel: FlowModel, targetModel: FlowModel) => {
1647
1654
  if (!sourceModel.parent || !targetModel.parent || sourceModel.parent !== targetModel.parent) {
@@ -1672,6 +1679,8 @@ export class FlowEngine {
1672
1679
  return false;
1673
1680
  }
1674
1681
 
1682
+ position = currentIndex < targetIndex ? 'after' : 'before';
1683
+
1675
1684
  // 使用splice直接移动数组元素(O(n)比排序O(n log n)更快)
1676
1685
  const [movedModel] = subModelsCopy.splice(currentIndex, 1);
1677
1686
  subModelsCopy.splice(targetIndex, 0, movedModel);
@@ -1686,10 +1695,13 @@ export class FlowEngine {
1686
1695
 
1687
1696
  return true;
1688
1697
  };
1689
- move(sourceModel, targetModel);
1698
+ const moved = move(sourceModel, targetModel);
1699
+ if (!moved) {
1700
+ return;
1701
+ }
1702
+
1690
1703
  if (options?.persist !== false && this.ensureModelRepository()) {
1691
- const position = sourceModel.sortIndex - targetModel.sortIndex > 0 ? 'after' : 'before';
1692
- await this._modelRepository.move(sourceId, targetId, position);
1704
+ await this._modelRepository.move(sourceUid, targetUid, position);
1693
1705
  this._loadedPageCache.markDirty(dirtyLoadedPageKey);
1694
1706
  }
1695
1707
  // 触发事件以通知其他部分模型已移动
@@ -313,6 +313,19 @@ describe('FlowModel', () => {
313
313
 
314
314
  model.emitter.off('onStepParamsChanged', listener);
315
315
  });
316
+
317
+ test('should not emit onStepParamsChanged when params are unchanged', () => {
318
+ const listener = vi.fn();
319
+ model.emitter.on('onStepParamsChanged', listener);
320
+
321
+ model.setStepParams('testFlow', 'step1', { param1: 'value1' });
322
+ model.setStepParams('testFlow', { step1: { param1: 'value1' } });
323
+ model.setStepParams({ testFlow: { step1: { param1: 'value1' } } });
324
+
325
+ expect(listener).not.toHaveBeenCalled();
326
+
327
+ model.emitter.off('onStepParamsChanged', listener);
328
+ });
316
329
  });
317
330
  });
318
331
 
@@ -550,34 +563,6 @@ describe('FlowModel', () => {
550
563
 
551
564
  loggerSpy.mockRestore();
552
565
  });
553
-
554
- test('should warn and skip step when use and handler are both missing', async () => {
555
- const warnSpy = vi.spyOn(model.context.logger, 'warn').mockImplementation(() => {});
556
- const errorSpy = vi.spyOn(model.context.logger, 'error').mockImplementation(() => {});
557
-
558
- TestFlowModel.registerFlow({
559
- key: 'settingsOnlyFlow',
560
- steps: {
561
- edit: {
562
- title: 'Edit',
563
- uiSchema: {},
564
- },
565
- },
566
- });
567
-
568
- const result = await model.applyFlow('settingsOnlyFlow');
569
-
570
- expect(result).toEqual({});
571
- expect(warnSpy).toHaveBeenCalledWith(
572
- expect.stringContaining("Step 'edit' in flow 'settingsOnlyFlow' has neither 'use' nor 'handler'"),
573
- );
574
- expect(errorSpy).not.toHaveBeenCalledWith(
575
- expect.stringContaining("Step 'edit' in flow 'settingsOnlyFlow' has neither 'use' nor 'handler'"),
576
- );
577
-
578
- warnSpy.mockRestore();
579
- errorSpy.mockRestore();
580
- });
581
566
  });
582
567
 
583
568
  describe('beforeRender flows', () => {
@@ -56,6 +56,25 @@ const classEventRegistries = new WeakMap<typeof FlowModel, ModelEventRegistry>()
56
56
  // 使用WeakMap存储每个类的meta
57
57
  const modelMetas = new WeakMap<typeof FlowModel, FlowModelMeta>();
58
58
 
59
+ type SortableModelLike = {
60
+ sortIndex?: number | null;
61
+ };
62
+
63
+ function getStableSortIndex(item: SortableModelLike, fallbackIndex: number) {
64
+ return typeof item?.sortIndex === 'number' && Number.isFinite(item.sortIndex) ? item.sortIndex : fallbackIndex + 1;
65
+ }
66
+
67
+ function sortByStableSortIndex<T extends SortableModelLike>(items: T[]) {
68
+ return items
69
+ .map((item, index) => ({
70
+ item,
71
+ index,
72
+ sortIndex: getStableSortIndex(item, index),
73
+ }))
74
+ .sort((a, b) => a.sortIndex - b.sortIndex || a.index - b.index)
75
+ .map(({ item }) => item);
76
+ }
77
+
59
78
  // 使用WeakMap存储每个类的 GlobalFlowRegistry
60
79
  const modelGlobalRegistries = new WeakMap<typeof FlowModel, GlobalFlowRegistry>();
61
80
 
@@ -242,7 +261,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
242
261
  };
243
262
  this.stepParams = options.stepParams || {};
244
263
  this.subModels = {};
245
- this.sortIndex = options.sortIndex || 0;
264
+ this.sortIndex = getStableSortIndex({ sortIndex: options.sortIndex }, -1);
246
265
  this._options = options;
247
266
  this._title = '';
248
267
  this._extraTitle = '';
@@ -511,11 +530,9 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
511
530
 
512
531
  Object.entries(mergedSubModels || {}).forEach(([key, value]) => {
513
532
  if (Array.isArray(value)) {
514
- value
515
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
516
- .forEach((item) => {
517
- this.addSubModel(key, item);
518
- });
533
+ sortByStableSortIndex(value).forEach((item) => {
534
+ this.addSubModel(key, item);
535
+ });
519
536
  } else {
520
537
  this.setSubModel(key, value);
521
538
  }
@@ -778,26 +795,43 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
778
795
  stepKeyOrStepsParams?: string | Record<string, ParamObject>,
779
796
  params?: ParamObject,
780
797
  ): void {
798
+ let hasChanged = false;
799
+
781
800
  if (typeof flowKeyOrAllParams === 'string') {
782
801
  const flowKey = flowKeyOrAllParams;
783
802
  if (typeof stepKeyOrStepsParams === 'string' && params !== undefined) {
784
- if (!this.stepParams[flowKey]) {
785
- this.stepParams[flowKey] = {};
803
+ const currentStepParams = this.stepParams[flowKey]?.[stepKeyOrStepsParams] || {};
804
+ const nextStepParams = { ...currentStepParams, ...params };
805
+ if (!_.isEqual(currentStepParams, nextStepParams)) {
806
+ if (!this.stepParams[flowKey]) {
807
+ this.stepParams[flowKey] = {};
808
+ }
809
+ this.stepParams[flowKey][stepKeyOrStepsParams] = nextStepParams;
810
+ hasChanged = true;
786
811
  }
787
- this.stepParams[flowKey][stepKeyOrStepsParams] = {
788
- ...this.stepParams[flowKey][stepKeyOrStepsParams],
789
- ...params,
790
- };
791
812
  } else if (typeof stepKeyOrStepsParams === 'object' && stepKeyOrStepsParams !== null) {
792
- this.stepParams[flowKey] = { ...(this.stepParams[flowKey] || {}), ...stepKeyOrStepsParams };
813
+ const currentFlowParams = this.stepParams[flowKey] || {};
814
+ const nextFlowParams = { ...currentFlowParams, ...stepKeyOrStepsParams };
815
+ if (!_.isEqual(currentFlowParams, nextFlowParams)) {
816
+ this.stepParams[flowKey] = nextFlowParams;
817
+ hasChanged = true;
818
+ }
793
819
  }
794
820
  } else if (typeof flowKeyOrAllParams === 'object' && flowKeyOrAllParams !== null) {
795
821
  for (const fk in flowKeyOrAllParams) {
796
822
  if (Object.prototype.hasOwnProperty.call(flowKeyOrAllParams, fk)) {
797
- this.stepParams[fk] = { ...(this.stepParams[fk] || {}), ...flowKeyOrAllParams[fk] };
823
+ const currentFlowParams = this.stepParams[fk] || {};
824
+ const nextFlowParams = { ...currentFlowParams, ...flowKeyOrAllParams[fk] };
825
+ if (!_.isEqual(currentFlowParams, nextFlowParams)) {
826
+ this.stepParams[fk] = nextFlowParams;
827
+ hasChanged = true;
828
+ }
798
829
  }
799
830
  }
800
831
  }
832
+ if (!hasChanged) {
833
+ return;
834
+ }
801
835
  // 发起配置修改事件
802
836
  this.emitter.emit('onStepParamsChanged');
803
837
  }
@@ -1206,7 +1240,10 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1206
1240
  if (!Array.isArray(subModels[subKey])) {
1207
1241
  subModels[subKey] = observable.shallow([]);
1208
1242
  }
1209
- const maxSortIndex = Math.max(...(subModels[subKey] as FlowModel[]).map((item) => item.sortIndex || 0), 0);
1243
+ const maxSortIndex = Math.max(
1244
+ ...(subModels[subKey] as FlowModel[]).map((item, index) => getStableSortIndex(item, index)),
1245
+ 0,
1246
+ );
1210
1247
  model.sortIndex = maxSortIndex + 1;
1211
1248
  subModels[subKey].push(model);
1212
1249
  actualParent.emitter.emit('onSubModelAdded', model);
@@ -1264,14 +1301,12 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1264
1301
 
1265
1302
  const results: ArrayElementType<NonNullable<Structure['subModels']>[K]>[] = [];
1266
1303
 
1267
- _.castArray(model)
1268
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
1269
- .forEach((item, index) => {
1270
- const result = (callback as (model: any, index: number) => boolean)(item, index);
1271
- if (result) {
1272
- results.push(item);
1273
- }
1274
- });
1304
+ sortByStableSortIndex(_.castArray(model)).forEach((item, index) => {
1305
+ const result = (callback as (model: any, index: number) => boolean)(item, index);
1306
+ if (result) {
1307
+ results.push(item);
1308
+ }
1309
+ });
1275
1310
 
1276
1311
  return results;
1277
1312
  }
@@ -1288,12 +1323,10 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
1288
1323
 
1289
1324
  const results: R[] = [];
1290
1325
 
1291
- _.castArray(model)
1292
- .sort((a, b) => (a.sortIndex || 0) - (b.sortIndex || 0))
1293
- .forEach((item, index) => {
1294
- const result = (callback as (model: any, index: number) => R)(item, index);
1295
- results.push(result);
1296
- });
1326
+ sortByStableSortIndex(_.castArray(model)).forEach((item, index) => {
1327
+ const result = (callback as (model: any, index: number) => R)(item, index);
1328
+ results.push(result);
1329
+ });
1297
1330
 
1298
1331
  return results;
1299
1332
  }