@nocobase/flow-engine 2.1.0-beta.43 → 2.1.0-beta.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -31
- package/lib/executor/FlowExecutor.js +0 -3
- package/lib/flowEngine.d.ts +3 -3
- package/lib/flowEngine.js +16 -7
- package/lib/models/flowModel.js +45 -13
- package/package.json +4 -4
- package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +79 -37
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +150 -3
- package/src/executor/FlowExecutor.ts +0 -3
- package/src/executor/__tests__/flowExecutor.test.ts +2 -4
- package/src/flowEngine.ts +20 -8
- package/src/models/__tests__/flowModel.test.ts +13 -28
- package/src/models/flowModel.tsx +62 -29
|
@@ -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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
modelsToProcess
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
273
|
-
loadExtras();
|
|
274
|
-
}
|
|
294
|
+
loadExtras();
|
|
275
295
|
return () => {
|
|
276
296
|
mounted = false;
|
|
277
297
|
};
|
|
278
|
-
}, [model, menuLevels, t, refreshTick
|
|
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
|
-
|
|
589
|
+
if (mounted) {
|
|
590
|
+
setConfigurableFlowsAndSteps(flows);
|
|
591
|
+
}
|
|
566
592
|
} catch (error) {
|
|
567
593
|
console.error("Failed to load configurable flows and steps:", error);
|
|
568
|
-
|
|
594
|
+
if (mounted) {
|
|
595
|
+
setConfigurableFlowsAndSteps([]);
|
|
596
|
+
}
|
|
569
597
|
} finally {
|
|
570
|
-
|
|
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
|
-
|
|
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
|
|
725
|
-
|
|
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) {
|
|
@@ -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);
|
package/lib/flowEngine.d.ts
CHANGED
|
@@ -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 {
|
|
542
|
-
* @param {
|
|
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:
|
|
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 {
|
|
1380
|
-
* @param {
|
|
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
|
|
1386
|
-
const
|
|
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
|
-
|
|
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 });
|
package/lib/models/flowModel.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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)
|
|
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.
|
|
3
|
+
"version": "2.1.0-beta.44",
|
|
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.
|
|
12
|
-
"@nocobase/shared": "2.1.0-beta.
|
|
11
|
+
"@nocobase/sdk": "2.1.0-beta.44",
|
|
12
|
+
"@nocobase/shared": "2.1.0-beta.44",
|
|
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": "
|
|
40
|
+
"gitHead": "540d4897b1696cc24c0754521b0b766978b4933c"
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
modelsToProcess
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
if (visible) {
|
|
339
|
-
loadExtras();
|
|
340
|
-
}
|
|
360
|
+
loadExtras();
|
|
341
361
|
return () => {
|
|
342
362
|
mounted = false;
|
|
343
363
|
};
|
|
344
|
-
}, [model, menuLevels, t, refreshTick
|
|
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
|
-
|
|
706
|
+
if (mounted) {
|
|
707
|
+
setConfigurableFlowsAndSteps(flows);
|
|
708
|
+
}
|
|
683
709
|
} catch (error) {
|
|
684
710
|
console.error('Failed to load configurable flows and steps:', error);
|
|
685
|
-
|
|
711
|
+
if (mounted) {
|
|
712
|
+
setConfigurableFlowsAndSteps([]);
|
|
713
|
+
}
|
|
686
714
|
} finally {
|
|
687
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
});
|
|
@@ -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
|
|
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).
|
|
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 {
|
|
1635
|
-
* @param {
|
|
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:
|
|
1639
|
-
const
|
|
1640
|
-
const
|
|
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
|
-
|
|
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', () => {
|
package/src/models/flowModel.tsx
CHANGED
|
@@ -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
|
|
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
|
-
.
|
|
516
|
-
|
|
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
|
-
|
|
785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
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
|
-
|
|
1293
|
-
.
|
|
1294
|
-
|
|
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
|
}
|