@nocobase/flow-engine 2.1.0-beta.13 → 2.1.0-beta.15
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/FlowModelRenderer.js +10 -6
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +13 -5
- package/lib/data-source/index.js +6 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/models/flowModel.d.ts +2 -1
- package/lib/models/flowModel.js +11 -2
- package/lib/reactive/observer.js +46 -16
- package/lib/utils/runjsValue.js +41 -11
- package/package.json +4 -4
- package/src/components/FlowModelRenderer.tsx +9 -5
- package/src/components/__tests__/FlowModelRenderer.test.tsx +43 -2
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +15 -4
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +62 -0
- package/src/data-source/index.ts +6 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/models/flowModel.tsx +13 -4
- package/src/reactive/__tests__/observer.test.tsx +82 -0
- package/src/reactive/observer.tsx +87 -25
- package/src/utils/__tests__/runjsValue.test.ts +11 -0
- package/src/utils/runjsValue.ts +50 -11
|
@@ -61,11 +61,12 @@ const FlowModelRendererWithAutoFlows = (0, import_reactive.observer)(
|
|
|
61
61
|
showErrorFallback,
|
|
62
62
|
settingsMenuLevel,
|
|
63
63
|
extraToolbarItems,
|
|
64
|
-
fallback
|
|
64
|
+
fallback,
|
|
65
|
+
useCache
|
|
65
66
|
}) => {
|
|
66
67
|
const { loading: pending, error: autoFlowsError } = (0, import_hooks.useApplyAutoFlows)(model, inputArgs, {
|
|
67
68
|
throwOnError: false,
|
|
68
|
-
useCache
|
|
69
|
+
useCache
|
|
69
70
|
});
|
|
70
71
|
(0, import_utils.setAutoFlowError)(model, autoFlowsError || null);
|
|
71
72
|
if (pending) {
|
|
@@ -195,13 +196,15 @@ const FlowModelRenderer = (0, import_reactive.observer)(
|
|
|
195
196
|
extraToolbarItems,
|
|
196
197
|
useCache
|
|
197
198
|
}) => {
|
|
199
|
+
var _a;
|
|
200
|
+
const resolvedUseCache = typeof useCache === "boolean" ? useCache : (_a = model == null ? void 0 : model.context) == null ? void 0 : _a.useCache;
|
|
198
201
|
(0, import_react.useEffect)(() => {
|
|
199
|
-
if (model == null ? void 0 : model.context) {
|
|
202
|
+
if ((model == null ? void 0 : model.context) && typeof resolvedUseCache !== "undefined") {
|
|
200
203
|
model.context.defineProperty("useCache", {
|
|
201
|
-
value:
|
|
204
|
+
value: resolvedUseCache
|
|
202
205
|
});
|
|
203
206
|
}
|
|
204
|
-
}, [model == null ? void 0 : model.context,
|
|
207
|
+
}, [model == null ? void 0 : model.context, resolvedUseCache]);
|
|
205
208
|
if (!model || typeof model.render !== "function") {
|
|
206
209
|
console.warn("FlowModelRenderer: Invalid model or render method not found.", model);
|
|
207
210
|
return null;
|
|
@@ -218,7 +221,8 @@ const FlowModelRenderer = (0, import_reactive.observer)(
|
|
|
218
221
|
showErrorFallback,
|
|
219
222
|
settingsMenuLevel,
|
|
220
223
|
extraToolbarItems,
|
|
221
|
-
fallback
|
|
224
|
+
fallback,
|
|
225
|
+
useCache: resolvedUseCache
|
|
222
226
|
}
|
|
223
227
|
);
|
|
224
228
|
if (showErrorFallback) {
|
|
@@ -52,6 +52,13 @@ var import_reactive = require("../../../../reactive");
|
|
|
52
52
|
var import_useFloatToolbarPortal = require("./useFloatToolbarPortal");
|
|
53
53
|
var import_useFloatToolbarVisibility = require("./useFloatToolbarVisibility");
|
|
54
54
|
const TOOLBAR_Z_INDEX = 999;
|
|
55
|
+
const getFloatMenuInstanceId = /* @__PURE__ */ __name((model) => {
|
|
56
|
+
if (!model) {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
const forkId = (model == null ? void 0 : model.isFork) ? model == null ? void 0 : model.forkId : void 0;
|
|
60
|
+
return forkId == null || forkId === "" ? String(model.uid || "") : `${String(model.uid || "")}::${String(forkId)}`;
|
|
61
|
+
}, "getFloatMenuInstanceId");
|
|
55
62
|
const hostContainerStyles = import_css.css`
|
|
56
63
|
position: relative;
|
|
57
64
|
|
|
@@ -247,7 +254,7 @@ const detectButtonInDOM = /* @__PURE__ */ __name((container) => {
|
|
|
247
254
|
}
|
|
248
255
|
return false;
|
|
249
256
|
}, "detectButtonInDOM");
|
|
250
|
-
const renderToolbarItems = /* @__PURE__ */ __name((model, showDeleteButton, showCopyUidButton, flowEngine, settingsMenuLevel, extraToolbarItems, onSettingsMenuOpenChange, getPopupContainer) => {
|
|
257
|
+
const renderToolbarItems = /* @__PURE__ */ __name((model, modelInstanceId, showDeleteButton, showCopyUidButton, flowEngine, settingsMenuLevel, extraToolbarItems, onSettingsMenuOpenChange, getPopupContainer) => {
|
|
251
258
|
var _a, _b;
|
|
252
259
|
const toolbarItems = ((_b = (_a = flowEngine == null ? void 0 : flowEngine.flowSettings) == null ? void 0 : _a.getToolbarItems) == null ? void 0 : _b.call(_a)) || [];
|
|
253
260
|
const allToolbarItems = [...toolbarItems, ...extraToolbarItems || []];
|
|
@@ -262,7 +269,7 @@ const renderToolbarItems = /* @__PURE__ */ __name((model, showDeleteButton, show
|
|
|
262
269
|
{
|
|
263
270
|
key: itemConfig.key,
|
|
264
271
|
model,
|
|
265
|
-
id:
|
|
272
|
+
id: modelInstanceId,
|
|
266
273
|
showDeleteButton,
|
|
267
274
|
showCopyUidButton,
|
|
268
275
|
menuLevels: settingsMenuLevel,
|
|
@@ -397,7 +404,7 @@ const FlowsFloatContextMenuWithModel = (0, import_reactive.observer)(
|
|
|
397
404
|
schedulePortalRectUpdate: /* @__PURE__ */ __name(() => {
|
|
398
405
|
}, "schedulePortalRectUpdate")
|
|
399
406
|
});
|
|
400
|
-
const modelUid = (model
|
|
407
|
+
const modelUid = getFloatMenuInstanceId(model);
|
|
401
408
|
const flowEngine = (0, import_provider.useFlowEngine)();
|
|
402
409
|
const updatePortalRectProxy = (0, import_react.useCallback)(() => {
|
|
403
410
|
portalActionsRef.current.updatePortalRect();
|
|
@@ -434,6 +441,7 @@ const FlowsFloatContextMenuWithModel = (0, import_reactive.observer)(
|
|
|
434
441
|
const toolbarItems = (0, import_react.useMemo)(
|
|
435
442
|
() => model ? renderToolbarItems(
|
|
436
443
|
model,
|
|
444
|
+
modelUid,
|
|
437
445
|
showDeleteButton,
|
|
438
446
|
showCopyUidButton,
|
|
439
447
|
flowEngine,
|
|
@@ -495,7 +503,7 @@ const FlowsFloatContextMenuWithModel = (0, import_reactive.observer)(
|
|
|
495
503
|
ref: toolbarContainerRef,
|
|
496
504
|
className: `nb-toolbar-container ${toolbarContainerClassName}`,
|
|
497
505
|
style: toolbarContainerStyle,
|
|
498
|
-
"data-model-uid":
|
|
506
|
+
"data-model-uid": modelUid
|
|
499
507
|
},
|
|
500
508
|
showTitle && (model.title || model.extraTitle) && /* @__PURE__ */ import_react.default.createElement("div", { className: "nb-toolbar-container-title" }, model.title && /* @__PURE__ */ import_react.default.createElement("span", { className: "title-tag" }, model.title), model.extraTitle && /* @__PURE__ */ import_react.default.createElement("span", { className: "title-tag" }, model.extraTitle)),
|
|
501
509
|
/* @__PURE__ */ import_react.default.createElement(
|
|
@@ -528,7 +536,7 @@ const FlowsFloatContextMenuWithModel = (0, import_reactive.observer)(
|
|
|
528
536
|
className: `${hostContainerStyles} ${hasButton ? "has-button-child" : ""} ${className || ""}`,
|
|
529
537
|
style: containerStyle,
|
|
530
538
|
"data-has-float-menu": "true",
|
|
531
|
-
"data-float-menu-model-uid":
|
|
539
|
+
"data-float-menu-model-uid": modelUid,
|
|
532
540
|
onMouseMove: handleChildHover,
|
|
533
541
|
onMouseEnter: handleHostMouseEnter,
|
|
534
542
|
onMouseLeave: handleHostMouseLeave
|
package/lib/data-source/index.js
CHANGED
|
@@ -461,6 +461,12 @@ const _Collection = class _Collection {
|
|
|
461
461
|
}
|
|
462
462
|
get titleCollectionField() {
|
|
463
463
|
const titleFieldName = this.options.titleField || this.filterTargetKey;
|
|
464
|
+
if (Array.isArray(titleFieldName)) {
|
|
465
|
+
if (titleFieldName.length !== 1) {
|
|
466
|
+
return void 0;
|
|
467
|
+
}
|
|
468
|
+
return this.getField(titleFieldName[0]);
|
|
469
|
+
}
|
|
464
470
|
const titleCollectionField = this.getField(titleFieldName);
|
|
465
471
|
return titleCollectionField;
|
|
466
472
|
}
|
package/lib/locale/en-US.json
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"Failed to destroy model after creation error": "Failed to destroy model after creation error",
|
|
35
35
|
"Failed to get action {{action}}": "Failed to get action {{action}}",
|
|
36
36
|
"Failed to get configurable flows for model {{model}}": "Failed to get configurable flows for model {{model}}",
|
|
37
|
+
"Attributes are unavailable before selecting a record": "Attributes are unavailable before selecting a record",
|
|
37
38
|
"Failed to import FormDialog": "Failed to import FormDialog",
|
|
38
39
|
"Failed to import FormDialog or FormStep": "Failed to import FormDialog or FormStep",
|
|
39
40
|
"Failed to import Formily components": "Failed to import Formily components",
|
package/lib/locale/index.d.ts
CHANGED
|
@@ -43,6 +43,7 @@ export declare const locales: {
|
|
|
43
43
|
"Failed to destroy model after creation error": string;
|
|
44
44
|
"Failed to get action {{action}}": string;
|
|
45
45
|
"Failed to get configurable flows for model {{model}}": string;
|
|
46
|
+
"Attributes are unavailable before selecting a record": string;
|
|
46
47
|
"Failed to import FormDialog": string;
|
|
47
48
|
"Failed to import FormDialog or FormStep": string;
|
|
48
49
|
"Failed to import Formily components": string;
|
|
@@ -120,6 +121,7 @@ export declare const locales: {
|
|
|
120
121
|
"Failed to destroy model after creation error": string;
|
|
121
122
|
"Failed to get action {{action}}": string;
|
|
122
123
|
"Failed to get configurable flows for model {{model}}": string;
|
|
124
|
+
"Attributes are unavailable before selecting a record": string;
|
|
123
125
|
"Failed to import FormDialog": string;
|
|
124
126
|
"Failed to import FormDialog or FormStep": string;
|
|
125
127
|
"Failed to import Formily components": string;
|
package/lib/locale/zh-CN.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"Failed to destroy model after creation error": "创建错误后销毁模型失败",
|
|
32
32
|
"Failed to get action {{action}}": "获取 action '{{action}}' 失败",
|
|
33
33
|
"Failed to get configurable flows for model {{model}}": "获取模型 '{{model}}' 的可配置 flows 失败",
|
|
34
|
+
"Attributes are unavailable before selecting a record": "选择记录之前,当前项属性不可用",
|
|
34
35
|
"Failed to import FormDialog": "导入 FormDialog 失败",
|
|
35
36
|
"Failed to import FormDialog or FormStep": "导入 FormDialog 或 FormStep 失败",
|
|
36
37
|
"Failed to import Formily components": "导入 Formily 组件失败",
|
|
@@ -13,6 +13,7 @@ import { FlowContext, FlowModelContext, FlowRuntimeContext } from '../flowContex
|
|
|
13
13
|
import { FlowEngine } from '../flowEngine';
|
|
14
14
|
import type { ActionDefinition, ArrayElementType, CreateModelOptions, CreateSubModelOptions, DefaultStructure, FlowDefinitionOptions, FlowModelMeta, FlowModelOptions, ModelConstructor, ParamObject, ParentFlowModel, PersistOptions, ResolveUseResult, StepParams } from '../types';
|
|
15
15
|
import { IModelComponentProps, ReadonlyModelProps } from '../types';
|
|
16
|
+
import type { MenuProps } from 'antd';
|
|
16
17
|
import { ModelActionRegistry } from '../action-registry/ModelActionRegistry';
|
|
17
18
|
import { ModelEventRegistry } from '../event-registry/ModelEventRegistry';
|
|
18
19
|
import { GlobalFlowRegistry } from '../flow-registry/GlobalFlowRegistry';
|
|
@@ -21,7 +22,6 @@ import { FlowSettingsOpenOptions } from '../flowSettings';
|
|
|
21
22
|
import type { ScheduleOptions } from '../scheduler/ModelOperationScheduler';
|
|
22
23
|
import type { DispatchEventOptions, EventDefinition } from '../types';
|
|
23
24
|
import { ForkFlowModel } from './forkFlowModel';
|
|
24
|
-
import type { MenuProps } from 'antd';
|
|
25
25
|
type BaseMenuItem = NonNullable<MenuProps['items']>[number];
|
|
26
26
|
type MenuLeafItem = Exclude<BaseMenuItem, {
|
|
27
27
|
children: MenuProps['items'];
|
|
@@ -239,6 +239,7 @@ export declare class FlowModel<Structure extends DefaultStructure = DefaultStruc
|
|
|
239
239
|
* 使用 lodash debounce 避免频繁调用
|
|
240
240
|
*/
|
|
241
241
|
private _rerunLastAutoRun;
|
|
242
|
+
private resetAutoRunState;
|
|
242
243
|
/**
|
|
243
244
|
* 通用事件分发钩子:开始
|
|
244
245
|
* 子类可覆盖;beforeRender 事件可通过抛出 FlowExitException 提前终止。
|
package/lib/models/flowModel.js
CHANGED
|
@@ -63,12 +63,12 @@ var import_InstanceFlowRegistry = require("../flow-registry/InstanceFlowRegistry
|
|
|
63
63
|
var import_flowContext = require("../flowContext");
|
|
64
64
|
var import_utils = require("../utils");
|
|
65
65
|
var import_lib = require("antd/lib");
|
|
66
|
+
var import__ = require("..");
|
|
66
67
|
var import_ModelActionRegistry = require("../action-registry/ModelActionRegistry");
|
|
67
68
|
var import_utils2 = require("../components/subModel/utils");
|
|
68
69
|
var import_ModelEventRegistry = require("../event-registry/ModelEventRegistry");
|
|
69
70
|
var import_GlobalFlowRegistry = require("../flow-registry/GlobalFlowRegistry");
|
|
70
71
|
var import_forkFlowModel = require("./forkFlowModel");
|
|
71
|
-
var import__ = require("..");
|
|
72
72
|
var _flowContext;
|
|
73
73
|
const classActionRegistries = /* @__PURE__ */ new WeakMap();
|
|
74
74
|
const classEventRegistries = /* @__PURE__ */ new WeakMap();
|
|
@@ -183,10 +183,13 @@ const _FlowModel = class _FlowModel {
|
|
|
183
183
|
if (changed.type === "set" && import_lodash.default.isEqual(changed.value, changed.oldValue)) {
|
|
184
184
|
return;
|
|
185
185
|
}
|
|
186
|
+
const hasLastAutoRun = !!this._lastAutoRunParams;
|
|
186
187
|
if (this.flowEngine) {
|
|
187
188
|
this.invalidateFlowCache("beforeRender");
|
|
188
189
|
}
|
|
189
|
-
|
|
190
|
+
if (hasLastAutoRun) {
|
|
191
|
+
this._rerunLastAutoRun();
|
|
192
|
+
}
|
|
190
193
|
this.forks.forEach((fork) => {
|
|
191
194
|
fork.rerender();
|
|
192
195
|
});
|
|
@@ -674,6 +677,11 @@ const _FlowModel = class _FlowModel {
|
|
|
674
677
|
}, "isMatch");
|
|
675
678
|
return Array.from(allFlows.values()).filter(isMatch);
|
|
676
679
|
}
|
|
680
|
+
resetAutoRunState() {
|
|
681
|
+
var _a, _b;
|
|
682
|
+
(_b = (_a = this._rerunLastAutoRun) == null ? void 0 : _a.cancel) == null ? void 0 : _b.call(_a);
|
|
683
|
+
this._lastAutoRunParams = null;
|
|
684
|
+
}
|
|
677
685
|
/**
|
|
678
686
|
* 通用事件分发钩子:开始
|
|
679
687
|
* 子类可覆盖;beforeRender 事件可通过抛出 FlowExitException 提前终止。
|
|
@@ -763,6 +771,7 @@ const _FlowModel = class _FlowModel {
|
|
|
763
771
|
}));
|
|
764
772
|
return () => {
|
|
765
773
|
var _a3, _b3;
|
|
774
|
+
renderTarget.resetAutoRunState();
|
|
766
775
|
if (typeof renderTarget.onUnmount === "function") {
|
|
767
776
|
renderTarget.onUnmount();
|
|
768
777
|
}
|
package/lib/reactive/observer.js
CHANGED
|
@@ -51,8 +51,31 @@ const observer = /* @__PURE__ */ __name((Component, options) => {
|
|
|
51
51
|
const ctxRef = (0, import_react.useRef)(ctx);
|
|
52
52
|
ctxRef.current = ctx;
|
|
53
53
|
const pendingDisposerRef = (0, import_react.useRef)(null);
|
|
54
|
+
const pendingTimerRef = (0, import_react.useRef)(null);
|
|
55
|
+
const clearPendingDisposer = /* @__PURE__ */ __name(() => {
|
|
56
|
+
if (pendingDisposerRef.current) {
|
|
57
|
+
pendingDisposerRef.current();
|
|
58
|
+
pendingDisposerRef.current = null;
|
|
59
|
+
}
|
|
60
|
+
}, "clearPendingDisposer");
|
|
61
|
+
const clearPendingTimer = /* @__PURE__ */ __name(() => {
|
|
62
|
+
if (pendingTimerRef.current) {
|
|
63
|
+
clearTimeout(pendingTimerRef.current);
|
|
64
|
+
pendingTimerRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
}, "clearPendingTimer");
|
|
67
|
+
const isContextActive = /* @__PURE__ */ __name(() => {
|
|
68
|
+
var _a, _b;
|
|
69
|
+
const pageActive = getPageActive(ctxRef.current);
|
|
70
|
+
const tabActive = (_b = (_a = ctxRef.current) == null ? void 0 : _a.tabActive) == null ? void 0 : _b.value;
|
|
71
|
+
return pageActive !== false && tabActive !== false;
|
|
72
|
+
}, "isContextActive");
|
|
54
73
|
(0, import_react.useEffect)(() => {
|
|
55
74
|
return () => {
|
|
75
|
+
if (pendingTimerRef.current) {
|
|
76
|
+
clearTimeout(pendingTimerRef.current);
|
|
77
|
+
pendingTimerRef.current = null;
|
|
78
|
+
}
|
|
56
79
|
if (pendingDisposerRef.current) {
|
|
57
80
|
pendingDisposerRef.current();
|
|
58
81
|
pendingDisposerRef.current = null;
|
|
@@ -62,30 +85,37 @@ const observer = /* @__PURE__ */ __name((Component, options) => {
|
|
|
62
85
|
const ObservedComponent = (0, import_react.useMemo)(
|
|
63
86
|
() => (0, import_reactive_react.observer)(Component, {
|
|
64
87
|
scheduler(updater) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
setTimeout(() => {
|
|
88
|
+
if (!isContextActive()) {
|
|
89
|
+
if (pendingTimerRef.current || pendingDisposerRef.current) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
93
|
+
pendingTimerRef.current = null;
|
|
70
94
|
if (pendingDisposerRef.current) {
|
|
71
95
|
return;
|
|
72
96
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
if (isContextActive()) {
|
|
98
|
+
updater();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
pendingDisposerRef.current = (0, import_reactive.reaction)(
|
|
102
|
+
() => isContextActive(),
|
|
103
|
+
(active) => {
|
|
104
|
+
if (!active) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
clearPendingDisposer();
|
|
76
108
|
updater();
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "FlowObserverPendingUpdate"
|
|
79
112
|
}
|
|
80
|
-
|
|
81
|
-
pendingDisposerRef.current = disposer;
|
|
113
|
+
);
|
|
82
114
|
});
|
|
83
115
|
return;
|
|
84
116
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
pendingDisposerRef.current = null;
|
|
88
|
-
}
|
|
117
|
+
clearPendingTimer();
|
|
118
|
+
clearPendingDisposer();
|
|
89
119
|
updater();
|
|
90
120
|
},
|
|
91
121
|
...options
|
package/lib/utils/runjsValue.js
CHANGED
|
@@ -231,6 +231,34 @@ function normalizeSubPath(raw) {
|
|
|
231
231
|
return { subPath: s, wildcard: false };
|
|
232
232
|
}
|
|
233
233
|
__name(normalizeSubPath, "normalizeSubPath");
|
|
234
|
+
function extractCtxRootUsage(expr) {
|
|
235
|
+
const raw = String(expr || "").trim();
|
|
236
|
+
if (!raw || raw === "ctx") return null;
|
|
237
|
+
const dotMatch = raw.match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*)([\s\S]*)$/);
|
|
238
|
+
if (dotMatch) {
|
|
239
|
+
const varName = dotMatch[1] || "";
|
|
240
|
+
const rest = dotMatch[2] || "";
|
|
241
|
+
const normalized = normalizeSubPath(rest);
|
|
242
|
+
return {
|
|
243
|
+
varName,
|
|
244
|
+
subPath: normalized.subPath,
|
|
245
|
+
wildcard: normalized.wildcard
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const bracketMatch = raw.match(/^ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]([\s\S]*)$/);
|
|
249
|
+
if (bracketMatch) {
|
|
250
|
+
const varName = bracketMatch[2] || "";
|
|
251
|
+
const rest = bracketMatch[3] || "";
|
|
252
|
+
const normalized = normalizeSubPath(rest);
|
|
253
|
+
return {
|
|
254
|
+
varName,
|
|
255
|
+
subPath: normalized.subPath,
|
|
256
|
+
wildcard: normalized.wildcard
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
__name(extractCtxRootUsage, "extractCtxRootUsage");
|
|
234
262
|
function extractUsedVariablePathsFromRunJS(code) {
|
|
235
263
|
if (typeof code !== "string" || !code.trim()) return {};
|
|
236
264
|
const src = stripStringsAndComments(code);
|
|
@@ -242,23 +270,25 @@ function extractUsedVariablePathsFromRunJS(code) {
|
|
|
242
270
|
set.add(subPath || "");
|
|
243
271
|
usage.set(varName, set);
|
|
244
272
|
}, "add");
|
|
273
|
+
const addCtxUsage = /* @__PURE__ */ __name((expr) => {
|
|
274
|
+
const hit = extractCtxRootUsage(expr);
|
|
275
|
+
if (!(hit == null ? void 0 : hit.varName)) return;
|
|
276
|
+
add(hit.varName, hit.wildcard ? "" : hit.subPath);
|
|
277
|
+
}, "addCtxUsage");
|
|
245
278
|
const dotRe = /ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*(?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
|
|
246
279
|
let match;
|
|
247
280
|
while (match = dotRe.exec(src)) {
|
|
248
|
-
|
|
249
|
-
const firstKeyMatch = pathAfterCtx.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
250
|
-
if (!firstKeyMatch) continue;
|
|
251
|
-
const firstKey = firstKeyMatch[1];
|
|
252
|
-
const rest = pathAfterCtx.slice(firstKey.length);
|
|
253
|
-
const { subPath, wildcard } = normalizeSubPath(rest);
|
|
254
|
-
add(firstKey, wildcard ? "" : subPath);
|
|
281
|
+
addCtxUsage(`ctx.${match[1] || ""}`);
|
|
255
282
|
}
|
|
256
283
|
const bracketRootRe = /ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]((?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
|
|
257
284
|
while (match = bracketRootRe.exec(srcWithStrings)) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
285
|
+
addCtxUsage(`ctx['${match[2] || ""}']${match[3] || ""}`);
|
|
286
|
+
}
|
|
287
|
+
const getVarRe = /ctx\.getVar\s*\(\s*(['"])((?:\\.|(?!\1)[\s\S])*)\1\s*\)/g;
|
|
288
|
+
while (match = getVarRe.exec(srcWithStrings)) {
|
|
289
|
+
const expr = String(match[2] || "").replace(/\\'/g, "'").replace(/\\"/g, '"').trim();
|
|
290
|
+
if (!expr.startsWith("ctx")) continue;
|
|
291
|
+
addCtxUsage(expr);
|
|
262
292
|
}
|
|
263
293
|
const out = {};
|
|
264
294
|
for (const [k, set] of usage.entries()) {
|
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.15",
|
|
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.15",
|
|
12
|
+
"@nocobase/shared": "2.1.0-beta.15",
|
|
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": "dc1aceea6357e6ab149976c2a236fc4b6bee1370"
|
|
41
41
|
}
|
|
@@ -127,6 +127,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
|
|
127
127
|
settingsMenuLevel?: number;
|
|
128
128
|
extraToolbarItems?: ToolbarItemConfig[];
|
|
129
129
|
fallback?: React.ReactNode;
|
|
130
|
+
useCache?: boolean;
|
|
130
131
|
}> = observer(
|
|
131
132
|
({
|
|
132
133
|
model,
|
|
@@ -139,12 +140,12 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
|
|
139
140
|
settingsMenuLevel,
|
|
140
141
|
extraToolbarItems,
|
|
141
142
|
fallback,
|
|
143
|
+
useCache,
|
|
142
144
|
}) => {
|
|
143
145
|
// hidden 占位由模型自身处理;无需在此注入
|
|
144
|
-
|
|
145
146
|
const { loading: pending, error: autoFlowsError } = useApplyAutoFlows(model, inputArgs, {
|
|
146
147
|
throwOnError: false,
|
|
147
|
-
useCache
|
|
148
|
+
useCache,
|
|
148
149
|
});
|
|
149
150
|
// 将错误下沉到 model 实例上,供内容层读取(类型安全的 WeakMap 存储)
|
|
150
151
|
setAutoFlowError(model, autoFlowsError || null);
|
|
@@ -348,13 +349,15 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
|
|
348
349
|
extraToolbarItems,
|
|
349
350
|
useCache,
|
|
350
351
|
}) => {
|
|
352
|
+
const resolvedUseCache = typeof useCache === 'boolean' ? useCache : model?.context?.useCache;
|
|
353
|
+
|
|
351
354
|
useEffect(() => {
|
|
352
|
-
if (model?.context) {
|
|
355
|
+
if (model?.context && typeof resolvedUseCache !== 'undefined') {
|
|
353
356
|
model.context.defineProperty('useCache', {
|
|
354
|
-
value:
|
|
357
|
+
value: resolvedUseCache,
|
|
355
358
|
});
|
|
356
359
|
}
|
|
357
|
-
}, [model?.context,
|
|
360
|
+
}, [model?.context, resolvedUseCache]);
|
|
358
361
|
|
|
359
362
|
if (!model || typeof model.render !== 'function') {
|
|
360
363
|
// 可以选择渲染 null 或者一个错误/提示信息
|
|
@@ -375,6 +378,7 @@ export const FlowModelRenderer: React.FC<FlowModelRendererProps> = observer(
|
|
|
375
378
|
settingsMenuLevel={settingsMenuLevel}
|
|
376
379
|
extraToolbarItems={extraToolbarItems}
|
|
377
380
|
fallback={fallback}
|
|
381
|
+
useCache={resolvedUseCache}
|
|
378
382
|
/>
|
|
379
383
|
);
|
|
380
384
|
|
|
@@ -38,9 +38,8 @@ describe('FlowModelRenderer', () => {
|
|
|
38
38
|
test('should pass useCache to useApplyAutoFlows and set it on context', async () => {
|
|
39
39
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={true} />);
|
|
40
40
|
|
|
41
|
-
// Check if dispatchEvent was called with useCache: true
|
|
42
|
-
// useApplyAutoFlows calls dispatchEvent('beforeRender', inputArgs, { useCache })
|
|
43
41
|
await waitFor(() => {
|
|
42
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
44
43
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
45
44
|
'beforeRender',
|
|
46
45
|
undefined,
|
|
@@ -58,6 +57,7 @@ describe('FlowModelRenderer', () => {
|
|
|
58
57
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} useCache={false} />);
|
|
59
58
|
|
|
60
59
|
await waitFor(() => {
|
|
60
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
61
61
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
62
62
|
'beforeRender',
|
|
63
63
|
undefined,
|
|
@@ -74,6 +74,7 @@ describe('FlowModelRenderer', () => {
|
|
|
74
74
|
const { unmount } = renderWithProvider(<FlowModelRenderer model={model} />);
|
|
75
75
|
|
|
76
76
|
await waitFor(() => {
|
|
77
|
+
expect(model.dispatchEvent).toHaveBeenCalledTimes(1);
|
|
77
78
|
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
|
78
79
|
'beforeRender',
|
|
79
80
|
undefined,
|
|
@@ -86,4 +87,44 @@ describe('FlowModelRenderer', () => {
|
|
|
86
87
|
|
|
87
88
|
unmount();
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
test('should clear stale beforeRender state after unmount when reusing the same model', async () => {
|
|
92
|
+
const statefulEngine = new FlowEngine();
|
|
93
|
+
|
|
94
|
+
class StatefulModel extends FlowModel {
|
|
95
|
+
render(): any {
|
|
96
|
+
return <div>Stateful Content</div>;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const statefulModel = new StatefulModel({
|
|
101
|
+
uid: 'stateful-model',
|
|
102
|
+
flowEngine: statefulEngine,
|
|
103
|
+
});
|
|
104
|
+
const executorSpy = vi.spyOn((statefulEngine as any).executor, 'dispatchEvent').mockResolvedValue([]);
|
|
105
|
+
|
|
106
|
+
const firstRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
|
107
|
+
await waitFor(() => {
|
|
108
|
+
expect(executorSpy).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
firstRender.unmount();
|
|
112
|
+
|
|
113
|
+
executorSpy.mockClear();
|
|
114
|
+
statefulModel.setStepParams('anyFlow', 'anyStep', { x: 1 });
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
116
|
+
expect(executorSpy.mock.calls.length).toBe(0);
|
|
117
|
+
|
|
118
|
+
const secondRender = renderWithProvider(<FlowModelRenderer model={statefulModel} />);
|
|
119
|
+
await waitFor(() => {
|
|
120
|
+
expect(executorSpy).toHaveBeenCalledTimes(1);
|
|
121
|
+
});
|
|
122
|
+
const [target, eventName, inputArgs, options] = executorSpy.mock.calls[0];
|
|
123
|
+
expect(target).toBe(statefulModel);
|
|
124
|
+
expect(eventName).toBe('beforeRender');
|
|
125
|
+
expect(inputArgs).toBeUndefined();
|
|
126
|
+
expect(options).toMatchObject({ useCache: true });
|
|
127
|
+
|
|
128
|
+
secondRender.unmount();
|
|
129
|
+
});
|
|
89
130
|
});
|
|
@@ -70,6 +70,15 @@ interface BaseFloatContextMenuProps {
|
|
|
70
70
|
toolbarPosition?: ToolbarPosition;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
const getFloatMenuInstanceId = (model?: FlowModel | null) => {
|
|
74
|
+
if (!model) {
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const forkId = (model as any)?.isFork ? (model as any)?.forkId : undefined;
|
|
79
|
+
return forkId == null || forkId === '' ? String(model.uid || '') : `${String(model.uid || '')}::${String(forkId)}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
73
82
|
const hostContainerStyles = css`
|
|
74
83
|
position: relative;
|
|
75
84
|
|
|
@@ -279,6 +288,7 @@ const detectButtonInDOM = (container: HTMLElement): boolean => {
|
|
|
279
288
|
// 渲染工具栏项目,并让设置菜单与工具栏共享同一个 popup 容器。
|
|
280
289
|
const renderToolbarItems = (
|
|
281
290
|
model: FlowModel,
|
|
291
|
+
modelInstanceId: string,
|
|
282
292
|
showDeleteButton: boolean,
|
|
283
293
|
showCopyUidButton: boolean,
|
|
284
294
|
flowEngine: FlowEngine,
|
|
@@ -304,7 +314,7 @@ const renderToolbarItems = (
|
|
|
304
314
|
<ItemComponent
|
|
305
315
|
key={itemConfig.key}
|
|
306
316
|
model={model}
|
|
307
|
-
id={
|
|
317
|
+
id={modelInstanceId}
|
|
308
318
|
showDeleteButton={showDeleteButton}
|
|
309
319
|
showCopyUidButton={showCopyUidButton}
|
|
310
320
|
menuLevels={settingsMenuLevel}
|
|
@@ -517,7 +527,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
517
527
|
updatePortalRect: () => {},
|
|
518
528
|
schedulePortalRectUpdate: () => {},
|
|
519
529
|
});
|
|
520
|
-
const modelUid = model
|
|
530
|
+
const modelUid = getFloatMenuInstanceId(model);
|
|
521
531
|
const flowEngine = useFlowEngine();
|
|
522
532
|
const updatePortalRectProxy = useCallback(() => {
|
|
523
533
|
portalActionsRef.current.updatePortalRect();
|
|
@@ -559,6 +569,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
559
569
|
model
|
|
560
570
|
? renderToolbarItems(
|
|
561
571
|
model,
|
|
572
|
+
modelUid,
|
|
562
573
|
showDeleteButton,
|
|
563
574
|
showCopyUidButton,
|
|
564
575
|
flowEngine,
|
|
@@ -629,7 +640,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
629
640
|
ref={toolbarContainerRef}
|
|
630
641
|
className={`nb-toolbar-container ${toolbarContainerClassName}`}
|
|
631
642
|
style={toolbarContainerStyle}
|
|
632
|
-
data-model-uid={
|
|
643
|
+
data-model-uid={modelUid}
|
|
633
644
|
>
|
|
634
645
|
{showTitle && (model.title || model.extraTitle) && (
|
|
635
646
|
<div className="nb-toolbar-container-title">
|
|
@@ -668,7 +679,7 @@ const FlowsFloatContextMenuWithModel: React.FC<ModelProvidedProps> = observer(
|
|
|
668
679
|
className={`${hostContainerStyles} ${hasButton ? 'has-button-child' : ''} ${className || ''}`}
|
|
669
680
|
style={containerStyle}
|
|
670
681
|
data-has-float-menu="true"
|
|
671
|
-
data-float-menu-model-uid={
|
|
682
|
+
data-float-menu-model-uid={modelUid}
|
|
672
683
|
onMouseMove={handleChildHover}
|
|
673
684
|
onMouseEnter={handleHostMouseEnter}
|
|
674
685
|
onMouseLeave={handleHostMouseLeave}
|
package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx
CHANGED
|
@@ -544,4 +544,66 @@ describe('FlowsFloatContextMenu', () => {
|
|
|
544
544
|
expect(parentOverlayAfterRestore?.className).toContain('nb-toolbar-visible');
|
|
545
545
|
});
|
|
546
546
|
});
|
|
547
|
+
|
|
548
|
+
it('treats forked models as distinct float menu instances even when they share the same uid', async () => {
|
|
549
|
+
const engine = new FlowEngine();
|
|
550
|
+
engine.flowSettings.forceEnable();
|
|
551
|
+
const masterModel = new FlowModel({ uid: 'forked-model', flowEngine: engine });
|
|
552
|
+
masterModel.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
553
|
+
masterModel.render = vi.fn(function (this: any) {
|
|
554
|
+
return <div data-testid={`content-${String(this.forkId || this.uid)}`}>{String(this.forkId || this.uid)}</div>;
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const firstFork = masterModel.createFork({}, 'card-1') as FlowModel & { forkId?: string };
|
|
558
|
+
const secondFork = masterModel.createFork({}, 'card-2') as FlowModel & { forkId?: string };
|
|
559
|
+
const firstInstanceId = `forked-model::${String((firstFork as any).forkId)}`;
|
|
560
|
+
const secondInstanceId = `forked-model::${String((secondFork as any).forkId)}`;
|
|
561
|
+
const appContainer = createAppContainer();
|
|
562
|
+
mockRect(appContainer, { top: 0, left: 0, width: 1280, height: 900 });
|
|
563
|
+
|
|
564
|
+
const { getByTestId } = renderWithProviders(
|
|
565
|
+
engine,
|
|
566
|
+
<>
|
|
567
|
+
<FlowsFloatContextMenu model={firstFork}>
|
|
568
|
+
<div data-testid="fork-host-1">first</div>
|
|
569
|
+
</FlowsFloatContextMenu>
|
|
570
|
+
<FlowsFloatContextMenu model={secondFork}>
|
|
571
|
+
<div data-testid="fork-host-2">second</div>
|
|
572
|
+
</FlowsFloatContextMenu>
|
|
573
|
+
</>,
|
|
574
|
+
{ container: appContainer },
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const firstHost = getHost(getByTestId('fork-host-1'));
|
|
578
|
+
const secondHost = getHost(getByTestId('fork-host-2'));
|
|
579
|
+
mockRect(firstHost, { top: 20, left: 20, width: 180, height: 72 });
|
|
580
|
+
mockRect(secondHost, { top: 120, left: 20, width: 180, height: 72 });
|
|
581
|
+
|
|
582
|
+
fireEvent.mouseEnter(firstHost);
|
|
583
|
+
|
|
584
|
+
const firstOverlay = await waitFor(() => {
|
|
585
|
+
const nextOverlay = queryOverlay(appContainer, firstInstanceId);
|
|
586
|
+
expect(nextOverlay).toBeTruthy();
|
|
587
|
+
return nextOverlay as HTMLDivElement;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await waitFor(() => {
|
|
591
|
+
expect(within(firstOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
fireEvent.mouseEnter(secondHost);
|
|
595
|
+
|
|
596
|
+
const secondOverlay = await waitFor(() => {
|
|
597
|
+
const nextOverlay = queryOverlay(appContainer, secondInstanceId);
|
|
598
|
+
expect(nextOverlay).toBeTruthy();
|
|
599
|
+
return nextOverlay as HTMLDivElement;
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await waitFor(() => {
|
|
603
|
+
expect(within(secondOverlay).getByLabelText('flows-settings')).toBeTruthy();
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
expect(firstOverlay.getAttribute('data-model-uid')).toBe(firstInstanceId);
|
|
607
|
+
expect(secondOverlay.getAttribute('data-model-uid')).toBe(secondInstanceId);
|
|
608
|
+
});
|
|
547
609
|
});
|
package/src/data-source/index.ts
CHANGED
|
@@ -501,6 +501,12 @@ export class Collection {
|
|
|
501
501
|
|
|
502
502
|
get titleCollectionField() {
|
|
503
503
|
const titleFieldName = this.options.titleField || this.filterTargetKey;
|
|
504
|
+
if (Array.isArray(titleFieldName)) {
|
|
505
|
+
if (titleFieldName.length !== 1) {
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
return this.getField(titleFieldName[0]);
|
|
509
|
+
}
|
|
504
510
|
const titleCollectionField = this.getField(titleFieldName);
|
|
505
511
|
return titleCollectionField;
|
|
506
512
|
}
|
package/src/locale/en-US.json
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"Failed to destroy model after creation error": "Failed to destroy model after creation error",
|
|
35
35
|
"Failed to get action {{action}}": "Failed to get action {{action}}",
|
|
36
36
|
"Failed to get configurable flows for model {{model}}": "Failed to get configurable flows for model {{model}}",
|
|
37
|
+
"Attributes are unavailable before selecting a record": "Attributes are unavailable before selecting a record",
|
|
37
38
|
"Failed to import FormDialog": "Failed to import FormDialog",
|
|
38
39
|
"Failed to import FormDialog or FormStep": "Failed to import FormDialog or FormStep",
|
|
39
40
|
"Failed to import Formily components": "Failed to import Formily components",
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"Failed to destroy model after creation error": "创建错误后销毁模型失败",
|
|
32
32
|
"Failed to get action {{action}}": "获取 action '{{action}}' 失败",
|
|
33
33
|
"Failed to get configurable flows for model {{model}}": "获取模型 '{{model}}' 的可配置 flows 失败",
|
|
34
|
+
"Attributes are unavailable before selecting a record": "选择记录之前,当前项属性不可用",
|
|
34
35
|
"Failed to import FormDialog": "导入 FormDialog 失败",
|
|
35
36
|
"Failed to import FormDialog or FormStep": "导入 FormDialog 或 FormStep 失败",
|
|
36
37
|
"Failed to import Formily components": "导入 Formily 组件失败",
|
package/src/models/flowModel.tsx
CHANGED
|
@@ -36,7 +36,9 @@ import type {
|
|
|
36
36
|
import { IModelComponentProps, ReadonlyModelProps } from '../types';
|
|
37
37
|
import { isInheritedFrom, setupRuntimeContextSteps } from '../utils';
|
|
38
38
|
// import { FlowExitAllException } from '../utils/exceptions';
|
|
39
|
+
import type { MenuProps } from 'antd';
|
|
39
40
|
import { Typography } from 'antd/lib';
|
|
41
|
+
import { observer } from '..';
|
|
40
42
|
import { ModelActionRegistry } from '../action-registry/ModelActionRegistry';
|
|
41
43
|
import { buildSubModelItem } from '../components/subModel/utils';
|
|
42
44
|
import { ModelEventRegistry } from '../event-registry/ModelEventRegistry';
|
|
@@ -46,8 +48,6 @@ import { FlowSettingsOpenOptions } from '../flowSettings';
|
|
|
46
48
|
import type { ScheduleOptions } from '../scheduler/ModelOperationScheduler';
|
|
47
49
|
import type { DispatchEventOptions, EventDefinition } from '../types';
|
|
48
50
|
import { ForkFlowModel } from './forkFlowModel';
|
|
49
|
-
import type { MenuProps } from 'antd';
|
|
50
|
-
import { observer } from '..';
|
|
51
51
|
|
|
52
52
|
// 使用 WeakMap 为每个类缓存一个 ModelActionRegistry 实例
|
|
53
53
|
const classActionRegistries = new WeakMap<typeof FlowModel, ModelActionRegistry>();
|
|
@@ -208,11 +208,14 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
208
208
|
if (changed.type === 'set' && _.isEqual(changed.value, changed.oldValue)) {
|
|
209
209
|
return;
|
|
210
210
|
}
|
|
211
|
+
const hasLastAutoRun = !!this._lastAutoRunParams;
|
|
211
212
|
|
|
212
213
|
if (this.flowEngine) {
|
|
213
214
|
this.invalidateFlowCache('beforeRender');
|
|
214
215
|
}
|
|
215
|
-
|
|
216
|
+
if (hasLastAutoRun) {
|
|
217
|
+
this._rerunLastAutoRun();
|
|
218
|
+
}
|
|
216
219
|
this.forks.forEach((fork) => {
|
|
217
220
|
fork.rerender();
|
|
218
221
|
});
|
|
@@ -858,6 +861,11 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
858
861
|
}
|
|
859
862
|
}, 100);
|
|
860
863
|
|
|
864
|
+
private resetAutoRunState(): void {
|
|
865
|
+
this._rerunLastAutoRun?.cancel?.();
|
|
866
|
+
this._lastAutoRunParams = null;
|
|
867
|
+
}
|
|
868
|
+
|
|
861
869
|
/**
|
|
862
870
|
* 通用事件分发钩子:开始
|
|
863
871
|
* 子类可覆盖;beforeRender 事件可通过抛出 FlowExitException 提前终止。
|
|
@@ -951,7 +959,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
951
959
|
}
|
|
952
960
|
|
|
953
961
|
// 创建缓存的响应式包装器组件工厂(只创建一次)
|
|
954
|
-
const createReactiveWrapper = (modelInstance:
|
|
962
|
+
const createReactiveWrapper = (modelInstance: FlowModel) => {
|
|
955
963
|
const ReactiveWrapper = observer(() => {
|
|
956
964
|
// 触发响应式更新的关键属性访问(读取 run/渲染目标的 props)
|
|
957
965
|
const renderTarget = modelInstance;
|
|
@@ -977,6 +985,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
977
985
|
model: renderTarget,
|
|
978
986
|
});
|
|
979
987
|
return () => {
|
|
988
|
+
renderTarget.resetAutoRunState();
|
|
980
989
|
if (typeof renderTarget.onUnmount === 'function') {
|
|
981
990
|
renderTarget.onUnmount();
|
|
982
991
|
}
|
|
@@ -208,4 +208,86 @@ describe('observer', () => {
|
|
|
208
208
|
expect(screen.getByText('Count: 0')).toBeInTheDocument();
|
|
209
209
|
expect(screen.queryByText('Count: 1')).not.toBeInTheDocument();
|
|
210
210
|
});
|
|
211
|
+
|
|
212
|
+
it('should flush pending update without TDZ error when context becomes active before timer callback runs', async () => {
|
|
213
|
+
vi.useFakeTimers();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const model = observable({ count: 0 });
|
|
217
|
+
const pageActive = observable.ref(false);
|
|
218
|
+
const tabActive = observable.ref(true);
|
|
219
|
+
|
|
220
|
+
const context = {
|
|
221
|
+
pageActive,
|
|
222
|
+
tabActive,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
(useFlowContext as any).mockReturnValue(context);
|
|
226
|
+
|
|
227
|
+
const Component = observer(() => <div>Count: {model.count}</div>);
|
|
228
|
+
|
|
229
|
+
render(<Component />);
|
|
230
|
+
|
|
231
|
+
expect(screen.getByText('Count: 0')).toBeInTheDocument();
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
model.count++;
|
|
235
|
+
pageActive.value = true;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await act(async () => {
|
|
239
|
+
await vi.runAllTimersAsync();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(screen.getByText('Count: 1')).toBeInTheDocument();
|
|
243
|
+
} finally {
|
|
244
|
+
vi.useRealTimers();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should cleanup pending timer and listener on unmount', async () => {
|
|
249
|
+
vi.useFakeTimers();
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const model = observable({ count: 0 });
|
|
253
|
+
const pageActive = observable.ref(false);
|
|
254
|
+
const tabActive = observable.ref(true);
|
|
255
|
+
const renderSpy = vi.fn();
|
|
256
|
+
|
|
257
|
+
const context = {
|
|
258
|
+
pageActive,
|
|
259
|
+
tabActive,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
(useFlowContext as any).mockReturnValue(context);
|
|
263
|
+
|
|
264
|
+
const Component = observer(() => {
|
|
265
|
+
renderSpy(model.count);
|
|
266
|
+
return <div>Count: {model.count}</div>;
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const { unmount } = render(<Component />);
|
|
270
|
+
|
|
271
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
272
|
+
expect(screen.getByText('Count: 0')).toBeInTheDocument();
|
|
273
|
+
|
|
274
|
+
act(() => {
|
|
275
|
+
model.count++;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
unmount();
|
|
279
|
+
|
|
280
|
+
act(() => {
|
|
281
|
+
pageActive.value = true;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await act(async () => {
|
|
285
|
+
await vi.runAllTimersAsync();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(renderSpy).toHaveBeenCalledTimes(1);
|
|
289
|
+
} finally {
|
|
290
|
+
vi.useRealTimers();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
211
293
|
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { observer as originalObserver, IObserverOptions, ReactFC } from '@formily/reactive-react';
|
|
11
11
|
import React, { useMemo, useEffect, useRef } from 'react';
|
|
12
12
|
import { useFlowContext } from '../FlowContextProvider';
|
|
13
|
-
import {
|
|
13
|
+
import { reaction } from '@formily/reactive';
|
|
14
14
|
import { FlowEngineContext } from '..';
|
|
15
15
|
|
|
16
16
|
type ObserverComponentProps<P, Options extends IObserverOptions> = Options extends {
|
|
@@ -30,12 +30,67 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
|
|
|
30
30
|
const ctxRef = useRef(ctx);
|
|
31
31
|
ctxRef.current = ctx;
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// 保存延迟更新的监听器,避免重复创建监听。
|
|
34
34
|
const pendingDisposerRef = useRef<(() => void) | null>(null);
|
|
35
|
+
// 保存延迟创建监听器的定时器,避免组件卸载后仍继续调度。
|
|
36
|
+
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
/**
|
|
39
|
+
* 清理挂起的可见性监听器。
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* clearPendingDisposer();
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
const clearPendingDisposer = () => {
|
|
47
|
+
if (pendingDisposerRef.current) {
|
|
48
|
+
pendingDisposerRef.current();
|
|
49
|
+
pendingDisposerRef.current = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 清理挂起的定时器。
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* clearPendingTimer();
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
const clearPendingTimer = () => {
|
|
62
|
+
if (pendingTimerRef.current) {
|
|
63
|
+
clearTimeout(pendingTimerRef.current);
|
|
64
|
+
pendingTimerRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 判断当前页面与标签页是否允许立即更新。
|
|
70
|
+
*
|
|
71
|
+
* @returns 当前上下文是否处于可更新状态。
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* if (isContextActive()) {
|
|
75
|
+
* updater();
|
|
76
|
+
* }
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
const isContextActive = () => {
|
|
80
|
+
const pageActive = getPageActive(ctxRef.current);
|
|
81
|
+
const tabActive = ctxRef.current?.tabActive?.value;
|
|
82
|
+
|
|
83
|
+
return pageActive !== false && tabActive !== false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 组件卸载时统一清理所有挂起任务,避免异步回调在卸载后继续运行。
|
|
37
87
|
useEffect(() => {
|
|
38
88
|
return () => {
|
|
89
|
+
if (pendingTimerRef.current) {
|
|
90
|
+
clearTimeout(pendingTimerRef.current);
|
|
91
|
+
pendingTimerRef.current = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
39
94
|
if (pendingDisposerRef.current) {
|
|
40
95
|
pendingDisposerRef.current();
|
|
41
96
|
pendingDisposerRef.current = null;
|
|
@@ -47,38 +102,45 @@ export const observer = <P, Options extends IObserverOptions = IObserverOptions>
|
|
|
47
102
|
() =>
|
|
48
103
|
originalObserver(Component, {
|
|
49
104
|
scheduler(updater) {
|
|
50
|
-
|
|
51
|
-
|
|
105
|
+
if (!isContextActive()) {
|
|
106
|
+
if (pendingTimerRef.current || pendingDisposerRef.current) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 通过异步任务打断同步调度,避免连续触发时形成递归更新。
|
|
111
|
+
pendingTimerRef.current = setTimeout(() => {
|
|
112
|
+
pendingTimerRef.current = null;
|
|
52
113
|
|
|
53
|
-
if (pageActive === false || tabActive === false) {
|
|
54
|
-
// Avoid stack overflow
|
|
55
|
-
setTimeout(() => {
|
|
56
|
-
// If there is already a pending updater, do nothing
|
|
57
114
|
if (pendingDisposerRef.current) {
|
|
58
115
|
return;
|
|
59
116
|
}
|
|
60
117
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
118
|
+
if (isContextActive()) {
|
|
119
|
+
updater();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 只监听组合后的“是否可更新”状态,条件恢复后执行一次并立即销毁。
|
|
124
|
+
pendingDisposerRef.current = reaction(
|
|
125
|
+
() => isContextActive(),
|
|
126
|
+
(active) => {
|
|
127
|
+
if (!active) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clearPendingDisposer();
|
|
67
132
|
updater();
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'FlowObserverPendingUpdate',
|
|
136
|
+
},
|
|
137
|
+
);
|
|
73
138
|
});
|
|
74
139
|
return;
|
|
75
140
|
}
|
|
76
141
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
pendingDisposerRef.current();
|
|
80
|
-
pendingDisposerRef.current = null;
|
|
81
|
-
}
|
|
142
|
+
clearPendingTimer();
|
|
143
|
+
clearPendingDisposer();
|
|
82
144
|
|
|
83
145
|
updater();
|
|
84
146
|
},
|
|
@@ -41,4 +41,15 @@ describe('runjsValue utils', () => {
|
|
|
41
41
|
expect(out.someVar).toContain('');
|
|
42
42
|
expect(out.user).toContain('name');
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
it('extractUsedVariablePathsFromRunJS: extracts ctx.getVar string paths', () => {
|
|
46
|
+
const code = `
|
|
47
|
+
const phone = await ctx.getVar('ctx.item.value.phone');
|
|
48
|
+
const assignee = await ctx.getVar("ctx.user.profile.name");
|
|
49
|
+
return [phone, assignee];
|
|
50
|
+
`;
|
|
51
|
+
const out = extractUsedVariablePathsFromRunJS(code);
|
|
52
|
+
expect(out.item).toContain('value.phone');
|
|
53
|
+
expect(out.user).toContain('profile.name');
|
|
54
|
+
});
|
|
44
55
|
});
|
package/src/utils/runjsValue.ts
CHANGED
|
@@ -236,6 +236,37 @@ function normalizeSubPath(raw: string): { subPath: string; wildcard: boolean } {
|
|
|
236
236
|
return { subPath: s, wildcard: false };
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
function extractCtxRootUsage(expr: string): { varName: string; subPath: string; wildcard: boolean } | null {
|
|
240
|
+
const raw = String(expr || '').trim();
|
|
241
|
+
if (!raw || raw === 'ctx') return null;
|
|
242
|
+
|
|
243
|
+
const dotMatch = raw.match(/^ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*)([\s\S]*)$/);
|
|
244
|
+
if (dotMatch) {
|
|
245
|
+
const varName = dotMatch[1] || '';
|
|
246
|
+
const rest = dotMatch[2] || '';
|
|
247
|
+
const normalized = normalizeSubPath(rest);
|
|
248
|
+
return {
|
|
249
|
+
varName,
|
|
250
|
+
subPath: normalized.subPath,
|
|
251
|
+
wildcard: normalized.wildcard,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const bracketMatch = raw.match(/^ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]([\s\S]*)$/);
|
|
256
|
+
if (bracketMatch) {
|
|
257
|
+
const varName = bracketMatch[2] || '';
|
|
258
|
+
const rest = bracketMatch[3] || '';
|
|
259
|
+
const normalized = normalizeSubPath(rest);
|
|
260
|
+
return {
|
|
261
|
+
varName,
|
|
262
|
+
subPath: normalized.subPath,
|
|
263
|
+
wildcard: normalized.wildcard,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
239
270
|
/**
|
|
240
271
|
* Heuristic extraction of ctx variable usage from RunJS code.
|
|
241
272
|
*
|
|
@@ -256,27 +287,35 @@ export function extractUsedVariablePathsFromRunJS(code: string): Record<string,
|
|
|
256
287
|
usage.set(varName, set);
|
|
257
288
|
};
|
|
258
289
|
|
|
290
|
+
const addCtxUsage = (expr: string) => {
|
|
291
|
+
const hit = extractCtxRootUsage(expr);
|
|
292
|
+
if (!hit?.varName) return;
|
|
293
|
+
add(hit.varName, hit.wildcard ? '' : hit.subPath);
|
|
294
|
+
};
|
|
295
|
+
|
|
259
296
|
// dot form: ctx.foo.bar / ctx.foo[0].bar (excluding ctx.method(...))
|
|
260
297
|
const dotRe = /ctx\.([a-zA-Z_$][a-zA-Z0-9_$]*(?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
|
|
261
298
|
let match: RegExpExecArray | null;
|
|
262
299
|
while ((match = dotRe.exec(src))) {
|
|
263
|
-
|
|
264
|
-
const firstKeyMatch = pathAfterCtx.match(/^([a-zA-Z_$][a-zA-Z0-9_$]*)/);
|
|
265
|
-
if (!firstKeyMatch) continue;
|
|
266
|
-
const firstKey = firstKeyMatch[1];
|
|
267
|
-
const rest = pathAfterCtx.slice(firstKey.length);
|
|
268
|
-
const { subPath, wildcard } = normalizeSubPath(rest);
|
|
269
|
-
add(firstKey, wildcard ? '' : subPath);
|
|
300
|
+
addCtxUsage(`ctx.${match[1] || ''}`);
|
|
270
301
|
}
|
|
271
302
|
|
|
272
303
|
// bracket root: ctx['foo'].bar / ctx["foo"][0] (excluding ctx['method'](...))
|
|
273
304
|
const bracketRootRe =
|
|
274
305
|
/ctx\s*\[\s*(['"])([a-zA-Z_$][a-zA-Z0-9_$]*)\1\s*\]((?:(?:\.[a-zA-Z_$][a-zA-Z0-9_$]*)|(?:\[[^\]]+\]))*)(?!\s*\()/g;
|
|
275
306
|
while ((match = bracketRootRe.exec(srcWithStrings))) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
307
|
+
addCtxUsage(`ctx['${match[2] || ''}']${match[3] || ''}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// async-safe helper form: await ctx.getVar('ctx.foo.bar')
|
|
311
|
+
const getVarRe = /ctx\.getVar\s*\(\s*(['"])((?:\\.|(?!\1)[\s\S])*)\1\s*\)/g;
|
|
312
|
+
while ((match = getVarRe.exec(srcWithStrings))) {
|
|
313
|
+
const expr = String(match[2] || '')
|
|
314
|
+
.replace(/\\'/g, "'")
|
|
315
|
+
.replace(/\\"/g, '"')
|
|
316
|
+
.trim();
|
|
317
|
+
if (!expr.startsWith('ctx')) continue;
|
|
318
|
+
addCtxUsage(expr);
|
|
280
319
|
}
|
|
281
320
|
|
|
282
321
|
const out: Record<string, string[]> = {};
|