@nocobase/flow-engine 2.1.0-alpha.14 → 2.1.0-alpha.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/data-source/index.js +6 -0
- package/lib/flowContext.js +27 -0
- package/lib/flowEngine.d.ts +15 -3
- package/lib/flowEngine.js +61 -2
- 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/reactive/observer.js +46 -16
- package/lib/types.d.ts +15 -4
- package/package.json +5 -4
- package/src/__tests__/flow-engine.test.ts +166 -0
- package/src/__tests__/flowContext.test.ts +65 -1
- package/src/data-source/index.ts +6 -0
- package/src/flowContext.ts +30 -0
- package/src/flowEngine.ts +78 -4
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/reactive/__tests__/observer.test.tsx +82 -0
- package/src/reactive/observer.tsx +87 -25
- package/src/types.ts +17 -5
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/flowContext.js
CHANGED
|
@@ -57,6 +57,7 @@ __export(flowContext_exports, {
|
|
|
57
57
|
});
|
|
58
58
|
module.exports = __toCommonJS(flowContext_exports);
|
|
59
59
|
var import_reactive = require("@formily/reactive");
|
|
60
|
+
var import_axios = __toESM(require("axios"));
|
|
60
61
|
var antd = __toESM(require("antd"));
|
|
61
62
|
var import_lodash = __toESM(require("lodash"));
|
|
62
63
|
var import_qs = __toESM(require("qs"));
|
|
@@ -81,6 +82,28 @@ var import_dayjs = __toESM(require("dayjs"));
|
|
|
81
82
|
var import_runjsLibs = require("./runjsLibs");
|
|
82
83
|
var import_runjsModuleLoader = require("./utils/runjsModuleLoader");
|
|
83
84
|
var _proxy, _FlowContext_instances, createChildNodes_fn, findMetaByPath_fn, findMetaInDelegatesDeep_fn, findMetaInProperty_fn, resolvePathInMeta_fn, resolvePathInMetaAsync_fn, buildParentTitles_fn, toTreeNode_fn;
|
|
85
|
+
function normalizePathname(pathname) {
|
|
86
|
+
return pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
87
|
+
}
|
|
88
|
+
__name(normalizePathname, "normalizePathname");
|
|
89
|
+
function shouldBypassApiClient(url, app) {
|
|
90
|
+
try {
|
|
91
|
+
const requestUrl = new URL(url);
|
|
92
|
+
if (!["http:", "https:"].includes(requestUrl.protocol)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
if (!(app == null ? void 0 : app.getApiUrl)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const apiUrl = new URL(app.getApiUrl());
|
|
99
|
+
const apiPath = normalizePathname(apiUrl.pathname);
|
|
100
|
+
const requestPath = normalizePathname(requestUrl.pathname);
|
|
101
|
+
return requestUrl.origin !== apiUrl.origin || !requestPath.startsWith(apiPath);
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
__name(shouldBypassApiClient, "shouldBypassApiClient");
|
|
84
107
|
function isRecordRefLike(val) {
|
|
85
108
|
return !!(val && typeof val === "object" && "collection" in val && "filterByTk" in val);
|
|
86
109
|
}
|
|
@@ -2211,6 +2234,10 @@ const _BaseFlowEngineContext = class _BaseFlowEngineContext extends FlowContext
|
|
|
2211
2234
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
2212
2235
|
});
|
|
2213
2236
|
this.defineMethod("request", (options) => {
|
|
2237
|
+
const app = this.app;
|
|
2238
|
+
if (typeof (options == null ? void 0 : options.url) === "string" && shouldBypassApiClient(options.url, app)) {
|
|
2239
|
+
return import_axios.default.request(options);
|
|
2240
|
+
}
|
|
2214
2241
|
return this.api.request(options);
|
|
2215
2242
|
});
|
|
2216
2243
|
this.defineMethod(
|
package/lib/flowEngine.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { FlowResource } from './resources';
|
|
|
8
8
|
import { Emitter } from './emitter';
|
|
9
9
|
import ModelOperationScheduler from './scheduler/ModelOperationScheduler';
|
|
10
10
|
import type { ScheduleOptions, ScheduledCancel } from './scheduler/ModelOperationScheduler';
|
|
11
|
-
import type { ActionDefinition, ApplyFlowCacheEntry, CreateModelOptions, EnsureBatchResult, EventDefinition,
|
|
11
|
+
import type { ActionDefinition, ApplyFlowCacheEntry, CreateModelOptions, EnsureBatchResult, EventDefinition, FlowModelLoaderInputMap, FlowModelOptions, IFlowModelRepository, ModelConstructor, PersistOptions, ResourceType } from './types';
|
|
12
12
|
/**
|
|
13
13
|
* FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
|
|
14
14
|
* It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
|
|
@@ -262,16 +262,19 @@ export declare class FlowEngine {
|
|
|
262
262
|
registerModels(models: Record<string, ModelConstructor | typeof FlowModel<any>>): void;
|
|
263
263
|
/**
|
|
264
264
|
* Register multiple model loader entries.
|
|
265
|
-
*
|
|
265
|
+
* The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
|
|
266
|
+
* It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
|
|
267
|
+
* @param {FlowModelLoaderInputMap} loaders Model loader input map
|
|
266
268
|
* @returns {void}
|
|
267
269
|
* @example
|
|
268
270
|
* flowEngine.registerModelLoaders({
|
|
269
271
|
* DemoModel: {
|
|
272
|
+
* extends: 'BaseModel',
|
|
270
273
|
* loader: () => import('./models/DemoModel'),
|
|
271
274
|
* },
|
|
272
275
|
* });
|
|
273
276
|
*/
|
|
274
|
-
registerModelLoaders(loaders:
|
|
277
|
+
registerModelLoaders(loaders: FlowModelLoaderInputMap): void;
|
|
275
278
|
/**
|
|
276
279
|
* Get a registered model class (constructor) asynchronously.
|
|
277
280
|
* This will first ensure the model loader entry is resolved.
|
|
@@ -381,6 +384,15 @@ export declare class FlowEngine {
|
|
|
381
384
|
* @returns {Map<string, ModelConstructor>} Model classes inherited from base class and passed the filter
|
|
382
385
|
*/
|
|
383
386
|
getSubclassesOf(baseClass: string | ModelConstructor, filter?: (ModelClass: ModelConstructor, className: string) => boolean): Map<string, ModelConstructor>;
|
|
387
|
+
/**
|
|
388
|
+
* Asynchronously get all subclasses of a base class, including those registered via model loaders.
|
|
389
|
+
* Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
|
|
390
|
+
* Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
|
|
391
|
+
* @param {string | ModelConstructor} baseClass Base class name or constructor
|
|
392
|
+
* @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
|
|
393
|
+
* @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
|
|
394
|
+
*/
|
|
395
|
+
getSubclassesOfAsync(baseClass: string | ModelConstructor, filter?: (ModelClass: ModelConstructor, className: string) => boolean): Promise<Map<string, ModelConstructor>>;
|
|
384
396
|
/**
|
|
385
397
|
* Create and register a model instance.
|
|
386
398
|
* If an instance with the same UID exists, returns the existing instance.
|
package/lib/flowEngine.js
CHANGED
|
@@ -418,21 +418,31 @@ const _FlowEngine = class _FlowEngine {
|
|
|
418
418
|
}
|
|
419
419
|
/**
|
|
420
420
|
* Register multiple model loader entries.
|
|
421
|
-
*
|
|
421
|
+
* The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
|
|
422
|
+
* It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
|
|
423
|
+
* @param {FlowModelLoaderInputMap} loaders Model loader input map
|
|
422
424
|
* @returns {void}
|
|
423
425
|
* @example
|
|
424
426
|
* flowEngine.registerModelLoaders({
|
|
425
427
|
* DemoModel: {
|
|
428
|
+
* extends: 'BaseModel',
|
|
426
429
|
* loader: () => import('./models/DemoModel'),
|
|
427
430
|
* },
|
|
428
431
|
* });
|
|
429
432
|
*/
|
|
430
433
|
registerModelLoaders(loaders) {
|
|
431
434
|
let changed = false;
|
|
432
|
-
for (const [name,
|
|
435
|
+
for (const [name, input] of Object.entries(loaders)) {
|
|
433
436
|
if (this._modelLoaders.has(name)) {
|
|
434
437
|
console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
|
|
435
438
|
}
|
|
439
|
+
const entry = {
|
|
440
|
+
loader: input.loader
|
|
441
|
+
};
|
|
442
|
+
if (input.extends != null) {
|
|
443
|
+
const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
|
|
444
|
+
entry.extends = raw.map((item) => typeof item === "string" ? item : item.name);
|
|
445
|
+
}
|
|
436
446
|
this._modelLoaders.set(name, entry);
|
|
437
447
|
changed = true;
|
|
438
448
|
}
|
|
@@ -737,6 +747,55 @@ const _FlowEngine = class _FlowEngine {
|
|
|
737
747
|
}
|
|
738
748
|
return result;
|
|
739
749
|
}
|
|
750
|
+
/**
|
|
751
|
+
* Asynchronously get all subclasses of a base class, including those registered via model loaders.
|
|
752
|
+
* Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
|
|
753
|
+
* Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
|
|
754
|
+
* @param {string | ModelConstructor} baseClass Base class name or constructor
|
|
755
|
+
* @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
|
|
756
|
+
* @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
|
|
757
|
+
*/
|
|
758
|
+
async getSubclassesOfAsync(baseClass, filter) {
|
|
759
|
+
var _a;
|
|
760
|
+
const baseClassName = typeof baseClass === "string" ? baseClass : baseClass.name;
|
|
761
|
+
let parentModelClass;
|
|
762
|
+
if (typeof baseClass === "string") {
|
|
763
|
+
if (!this.getModelClass(baseClass)) {
|
|
764
|
+
await this.ensureModel(baseClass);
|
|
765
|
+
}
|
|
766
|
+
parentModelClass = this.getModelClass(baseClass);
|
|
767
|
+
} else {
|
|
768
|
+
parentModelClass = baseClass;
|
|
769
|
+
}
|
|
770
|
+
if (!parentModelClass) {
|
|
771
|
+
return /* @__PURE__ */ new Map();
|
|
772
|
+
}
|
|
773
|
+
const result = this.getSubclassesOf(parentModelClass, filter);
|
|
774
|
+
const loaderCandidates = [];
|
|
775
|
+
for (const [name, entry] of this._modelLoaders) {
|
|
776
|
+
if (result.has(name) || this._modelClasses.has(name)) continue;
|
|
777
|
+
if ((_a = entry.extends) == null ? void 0 : _a.includes(baseClassName)) {
|
|
778
|
+
loaderCandidates.push(name);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (loaderCandidates.length > 0) {
|
|
782
|
+
await this.ensureModels(loaderCandidates);
|
|
783
|
+
}
|
|
784
|
+
for (const name of loaderCandidates) {
|
|
785
|
+
const ModelClass = this._modelClasses.get(name);
|
|
786
|
+
if (!ModelClass) continue;
|
|
787
|
+
if (!(0, import_utils.isInheritedFrom)(ModelClass, parentModelClass)) {
|
|
788
|
+
console.warn(
|
|
789
|
+
`FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`
|
|
790
|
+
);
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (!filter || filter(ModelClass, name)) {
|
|
794
|
+
result.set(name, ModelClass);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
740
799
|
/**
|
|
741
800
|
* Create and register a model instance.
|
|
742
801
|
* If an instance with the same UID exists, returns the existing instance.
|
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 组件失败",
|
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/types.d.ts
CHANGED
|
@@ -316,17 +316,28 @@ export type FlowModelLoaderResult = ModelConstructor | {
|
|
|
316
316
|
*/
|
|
317
317
|
export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
|
|
318
318
|
/**
|
|
319
|
-
* FlowModel loader entry.
|
|
320
|
-
* Future contribution-style fields are intentionally kept as commented placeholders in phase A
|
|
321
|
-
* and are not consumed by current runtime logic.
|
|
319
|
+
* FlowModel loader entry (normalized internal form).
|
|
322
320
|
*/
|
|
323
321
|
export interface FlowModelLoaderEntry {
|
|
324
322
|
loader: FlowModelLoader;
|
|
323
|
+
extends?: string[];
|
|
325
324
|
}
|
|
326
325
|
/**
|
|
327
|
-
* FlowModel loader
|
|
326
|
+
* FlowModel loader input (user-facing form for registerModelLoaders).
|
|
327
|
+
* The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
|
|
328
|
+
*/
|
|
329
|
+
export interface FlowModelLoaderInput {
|
|
330
|
+
loader: FlowModelLoader;
|
|
331
|
+
extends?: string | ModelConstructor | (string | ModelConstructor)[];
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* FlowModel loader entry map (normalized internal form).
|
|
328
335
|
*/
|
|
329
336
|
export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
|
|
337
|
+
/**
|
|
338
|
+
* FlowModel loader input map (user-facing form for registerModelLoaders).
|
|
339
|
+
*/
|
|
340
|
+
export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
|
|
330
341
|
/**
|
|
331
342
|
* Batch ensure result.
|
|
332
343
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "2.1.0-alpha.
|
|
3
|
+
"version": "2.1.0-alpha.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,9 +8,10 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "2.1.0-alpha.
|
|
12
|
-
"@nocobase/shared": "2.1.0-alpha.
|
|
11
|
+
"@nocobase/sdk": "2.1.0-alpha.15",
|
|
12
|
+
"@nocobase/shared": "2.1.0-alpha.15",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
|
+
"axios": "^1.7.0",
|
|
14
15
|
"dayjs": "^1.11.9",
|
|
15
16
|
"dompurify": "^3.0.2",
|
|
16
17
|
"lodash": "^4.x",
|
|
@@ -36,5 +37,5 @@
|
|
|
36
37
|
],
|
|
37
38
|
"author": "NocoBase Team",
|
|
38
39
|
"license": "Apache-2.0",
|
|
39
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "7c86e75b0af4b9f532c8ebf5ef96a7423b0ab60e"
|
|
40
41
|
}
|
|
@@ -189,4 +189,170 @@ describe('FlowEngine', () => {
|
|
|
189
189
|
expect(mounted?.uid).toBe('c3');
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
|
+
|
|
193
|
+
describe('getSubclassesOfAsync', () => {
|
|
194
|
+
it('should return async-loaded subclasses matching extends declaration', async () => {
|
|
195
|
+
class AsyncSubModelD extends BaseModel {}
|
|
196
|
+
class AsyncSubModelE extends BaseModel {}
|
|
197
|
+
|
|
198
|
+
engine.registerModelLoaders({
|
|
199
|
+
AsyncSubModelD: {
|
|
200
|
+
extends: 'BaseModel',
|
|
201
|
+
loader: async () => ({ AsyncSubModelD }),
|
|
202
|
+
},
|
|
203
|
+
AsyncSubModelE: {
|
|
204
|
+
extends: 'BaseModel',
|
|
205
|
+
loader: async () => ({ AsyncSubModelE }),
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await engine.getSubclassesOfAsync(BaseModel);
|
|
210
|
+
|
|
211
|
+
// Sync-registered subclasses
|
|
212
|
+
expect(result.has('SubModelA')).toBe(true);
|
|
213
|
+
expect(result.has('SubModelB')).toBe(true);
|
|
214
|
+
expect(result.has('SubModelC')).toBe(true);
|
|
215
|
+
// Async-loaded subclasses
|
|
216
|
+
expect(result.has('AsyncSubModelD')).toBe(true);
|
|
217
|
+
expect(result.has('AsyncSubModelE')).toBe(true);
|
|
218
|
+
// Base class excluded
|
|
219
|
+
expect(result.has('BaseModel')).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should merge sync-registered and async-loaded subclasses', async () => {
|
|
223
|
+
class AsyncSubModel extends BaseModel {}
|
|
224
|
+
|
|
225
|
+
engine.registerModelLoaders({
|
|
226
|
+
AsyncSubModel: {
|
|
227
|
+
extends: 'BaseModel',
|
|
228
|
+
loader: async () => ({ AsyncSubModel }),
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = await engine.getSubclassesOfAsync('BaseModel');
|
|
233
|
+
|
|
234
|
+
// Sync: SubModelA, SubModelB, SubModelC
|
|
235
|
+
expect(result.has('SubModelA')).toBe(true);
|
|
236
|
+
expect(result.has('SubModelB')).toBe(true);
|
|
237
|
+
expect(result.has('SubModelC')).toBe(true);
|
|
238
|
+
// Async
|
|
239
|
+
expect(result.has('AsyncSubModel')).toBe(true);
|
|
240
|
+
expect(result.size).toBe(4);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should support extends as string array (multiple parents)', async () => {
|
|
244
|
+
class AnotherBase extends FlowModel {}
|
|
245
|
+
class MultiParentModel extends BaseModel {}
|
|
246
|
+
|
|
247
|
+
engine.registerModels({ AnotherBase });
|
|
248
|
+
engine.registerModelLoaders({
|
|
249
|
+
MultiParentModel: {
|
|
250
|
+
extends: ['BaseModel', 'AnotherBase'],
|
|
251
|
+
loader: async () => ({ MultiParentModel }),
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const resultBase = await engine.getSubclassesOfAsync(BaseModel);
|
|
256
|
+
expect(resultBase.has('MultiParentModel')).toBe(true);
|
|
257
|
+
|
|
258
|
+
// Also found by AnotherBase (even though actual inheritance is from BaseModel, not AnotherBase)
|
|
259
|
+
// The extends declaration triggers loading, but isInheritedFrom validation will exclude it from AnotherBase results
|
|
260
|
+
const resultAnother = await engine.getSubclassesOfAsync(AnotherBase);
|
|
261
|
+
expect(resultAnother.has('MultiParentModel')).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should support extends as ModelConstructor', async () => {
|
|
265
|
+
class AsyncCtorSubModel extends BaseModel {}
|
|
266
|
+
|
|
267
|
+
engine.registerModelLoaders({
|
|
268
|
+
AsyncCtorSubModel: {
|
|
269
|
+
extends: BaseModel,
|
|
270
|
+
loader: async () => ({ AsyncCtorSubModel }),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const result = await engine.getSubclassesOfAsync(BaseModel);
|
|
275
|
+
expect(result.has('AsyncCtorSubModel')).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should validate actual inheritance and warn on mismatch', async () => {
|
|
279
|
+
class UnrelatedModel extends FlowModel {}
|
|
280
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
281
|
+
|
|
282
|
+
engine.registerModelLoaders({
|
|
283
|
+
UnrelatedModel: {
|
|
284
|
+
extends: 'BaseModel',
|
|
285
|
+
loader: async () => ({ UnrelatedModel }),
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const result = await engine.getSubclassesOfAsync(BaseModel);
|
|
290
|
+
expect(result.has('UnrelatedModel')).toBe(false);
|
|
291
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
292
|
+
expect.stringContaining("declares extends 'BaseModel' but does not actually inherit from it"),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
warnSpy.mockRestore();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should resolve base class from loaders if not in _modelClasses', async () => {
|
|
299
|
+
const freshEngine = new FlowEngine();
|
|
300
|
+
|
|
301
|
+
class LazyBase extends FlowModel {}
|
|
302
|
+
class LazySub extends LazyBase {}
|
|
303
|
+
|
|
304
|
+
freshEngine.registerModelLoaders({
|
|
305
|
+
LazyBase: {
|
|
306
|
+
loader: async () => ({ LazyBase }),
|
|
307
|
+
},
|
|
308
|
+
LazySub: {
|
|
309
|
+
extends: 'LazyBase',
|
|
310
|
+
loader: async () => ({ LazySub }),
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const result = await freshEngine.getSubclassesOfAsync('LazyBase');
|
|
315
|
+
expect(result.has('LazySub')).toBe(true);
|
|
316
|
+
expect(result.size).toBe(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should return empty Map when base class cannot be found', async () => {
|
|
320
|
+
const result = await engine.getSubclassesOfAsync('NonExistentModel');
|
|
321
|
+
expect(result.size).toBe(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should support filter parameter on both sync and async sources', async () => {
|
|
325
|
+
class FilteredAsyncModel extends BaseModel {}
|
|
326
|
+
|
|
327
|
+
engine.registerModelLoaders({
|
|
328
|
+
FilteredAsyncModel: {
|
|
329
|
+
extends: 'BaseModel',
|
|
330
|
+
loader: async () => ({ FilteredAsyncModel }),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const result = await engine.getSubclassesOfAsync(BaseModel, (_ModelClass, name) => name.startsWith('SubModelA'));
|
|
335
|
+
|
|
336
|
+
// Only SubModelA passes the filter (SubModelB, SubModelC, FilteredAsyncModel excluded)
|
|
337
|
+
expect(result.has('SubModelA')).toBe(true);
|
|
338
|
+
expect(result.has('SubModelB')).toBe(false);
|
|
339
|
+
expect(result.has('SubModelC')).toBe(false);
|
|
340
|
+
expect(result.has('FilteredAsyncModel')).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should not include loaders without extends declaration', async () => {
|
|
344
|
+
class NoExtendsModel extends BaseModel {}
|
|
345
|
+
|
|
346
|
+
engine.registerModelLoaders({
|
|
347
|
+
NoExtendsModel: {
|
|
348
|
+
loader: async () => ({ NoExtendsModel }),
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const result = await engine.getSubclassesOfAsync(BaseModel);
|
|
353
|
+
// Only sync-registered subclasses; NoExtendsModel has no extends, so not discovered
|
|
354
|
+
expect(result.has('NoExtendsModel')).toBe(false);
|
|
355
|
+
expect(result.has('SubModelA')).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
192
358
|
});
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import axios from 'axios';
|
|
11
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
11
12
|
import { FlowContext, FlowRuntimeContext, FlowRunJSContext, type PropertyMetaFactory } from '../flowContext';
|
|
12
13
|
import { FlowEngine } from '../flowEngine';
|
|
13
14
|
import { FlowModel } from '../models/flowModel';
|
|
@@ -1630,6 +1631,69 @@ describe('runAction delegation from runtime context', () => {
|
|
|
1630
1631
|
});
|
|
1631
1632
|
});
|
|
1632
1633
|
|
|
1634
|
+
describe('FlowContext request defaults', () => {
|
|
1635
|
+
class RequestModel extends FlowModel {}
|
|
1636
|
+
|
|
1637
|
+
afterEach(() => {
|
|
1638
|
+
vi.restoreAllMocks();
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
const createRequestContext = () => {
|
|
1642
|
+
const engine = new FlowEngine();
|
|
1643
|
+
engine.registerModels({ RequestModel });
|
|
1644
|
+
|
|
1645
|
+
const apiRequest = vi.fn(async (options) => options);
|
|
1646
|
+
const app = {
|
|
1647
|
+
getApiUrl(pathname = '') {
|
|
1648
|
+
return 'https://app.example.com/api/'.replace(/\/$/g, '') + '/' + pathname.replace(/^\//g, '');
|
|
1649
|
+
},
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
engine.context.defineProperty('api', { value: { request: apiRequest } as any });
|
|
1653
|
+
engine.context.defineProperty('app', { value: app });
|
|
1654
|
+
|
|
1655
|
+
const model = engine.createModel({ use: 'RequestModel' });
|
|
1656
|
+
const ctx = new FlowRuntimeContext(model, 'flow');
|
|
1657
|
+
const directAxiosRequest = vi.spyOn(axios, 'request').mockResolvedValue({ data: {} } as any);
|
|
1658
|
+
|
|
1659
|
+
return { ctx, apiRequest, directAxiosRequest };
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
it.each([
|
|
1663
|
+
['apiClient', 'users:list', 'api'],
|
|
1664
|
+
['apiClient', '/api/users:list', 'api'],
|
|
1665
|
+
['apiClient', 'https://app.example.com/api/users:list', 'api'],
|
|
1666
|
+
['direct axios', 'https://app.example.com/custom-api/users', 'axios'],
|
|
1667
|
+
])('should use %s for %s', async (_target, url, expected) => {
|
|
1668
|
+
const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
|
|
1669
|
+
|
|
1670
|
+
await ctx.request({ url, method: 'get' });
|
|
1671
|
+
|
|
1672
|
+
if (expected === 'api') {
|
|
1673
|
+
expect(apiRequest).toHaveBeenCalledTimes(1);
|
|
1674
|
+
expect(directAxiosRequest).not.toHaveBeenCalled();
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
expect(directAxiosRequest).toHaveBeenCalledTimes(1);
|
|
1679
|
+
expect(apiRequest).not.toHaveBeenCalled();
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
it('should use direct axios for cross-origin absolute urls', async () => {
|
|
1683
|
+
const { ctx, apiRequest, directAxiosRequest } = createRequestContext();
|
|
1684
|
+
|
|
1685
|
+
await ctx.request({ url: 'https://api.example.com/users', method: 'get', skipAuth: false });
|
|
1686
|
+
|
|
1687
|
+
expect(directAxiosRequest).toHaveBeenCalledTimes(1);
|
|
1688
|
+
expect(apiRequest).not.toHaveBeenCalled();
|
|
1689
|
+
expect(directAxiosRequest.mock.calls[0][0]).toMatchObject({
|
|
1690
|
+
url: 'https://api.example.com/users',
|
|
1691
|
+
method: 'get',
|
|
1692
|
+
skipAuth: false,
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1633
1697
|
describe('FlowContext delayed meta loading', () => {
|
|
1634
1698
|
// 测试场景:属性定义时 meta 为异步函数,首次访问时延迟加载
|
|
1635
1699
|
// 输入:属性带有异步 meta 函数
|
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/flowContext.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { ISchema } from '@formily/json-schema';
|
|
|
11
11
|
import { observable } from '@formily/reactive';
|
|
12
12
|
import { APIClient, RequestOptions } from '@nocobase/sdk';
|
|
13
13
|
import type { Router } from '@remix-run/router';
|
|
14
|
+
import axios from 'axios';
|
|
14
15
|
import { MessageInstance } from 'antd/es/message/interface';
|
|
15
16
|
import * as antd from 'antd';
|
|
16
17
|
import type { HookAPI } from 'antd/es/modal/useModal';
|
|
@@ -58,6 +59,31 @@ import dayjs from 'dayjs';
|
|
|
58
59
|
import { externalReactRender, setupRunJSLibs } from './runjsLibs';
|
|
59
60
|
import { runjsImportAsync, runjsImportModule, runjsRequireAsync } from './utils/runjsModuleLoader';
|
|
60
61
|
|
|
62
|
+
function normalizePathname(pathname: string) {
|
|
63
|
+
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldBypassApiClient(url: string, app?: { getApiUrl?: (pathname?: string) => string }) {
|
|
67
|
+
try {
|
|
68
|
+
const requestUrl = new URL(url);
|
|
69
|
+
if (!['http:', 'https:'].includes(requestUrl.protocol)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!app?.getApiUrl) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const apiUrl = new URL(app.getApiUrl());
|
|
78
|
+
const apiPath = normalizePathname(apiUrl.pathname);
|
|
79
|
+
const requestPath = normalizePathname(requestUrl.pathname);
|
|
80
|
+
|
|
81
|
+
return requestUrl.origin !== apiUrl.origin || !requestPath.startsWith(apiPath);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
// Helper: detect a RecordRef-like object
|
|
62
88
|
function isRecordRefLike(val: any): boolean {
|
|
63
89
|
return !!(val && typeof val === 'object' && 'collection' in val && 'filterByTk' in val);
|
|
@@ -3024,6 +3050,10 @@ class BaseFlowEngineContext extends FlowContext {
|
|
|
3024
3050
|
return this.engine.getModel(modelName, searchInPreviousEngines);
|
|
3025
3051
|
});
|
|
3026
3052
|
this.defineMethod('request', (options: RequestOptions) => {
|
|
3053
|
+
const app = this.app as { getApiUrl?: (pathname?: string) => string } | undefined;
|
|
3054
|
+
if (typeof options?.url === 'string' && shouldBypassApiClient(options.url, app)) {
|
|
3055
|
+
return axios.request(options);
|
|
3056
|
+
}
|
|
3027
3057
|
return this.api.request(options);
|
|
3028
3058
|
});
|
|
3029
3059
|
this.defineMethod(
|
package/src/flowEngine.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type {
|
|
|
27
27
|
EnsureBatchResult,
|
|
28
28
|
EventDefinition,
|
|
29
29
|
FlowModelLoaderEntry,
|
|
30
|
-
|
|
30
|
+
FlowModelLoaderInputMap,
|
|
31
31
|
FlowModelLoaderResult,
|
|
32
32
|
FlowModelOptions,
|
|
33
33
|
IFlowModelRepository,
|
|
@@ -483,21 +483,31 @@ export class FlowEngine {
|
|
|
483
483
|
|
|
484
484
|
/**
|
|
485
485
|
* Register multiple model loader entries.
|
|
486
|
-
*
|
|
486
|
+
* The `extends` field declares parent class(es) for async subclass discovery via `getSubclassesOfAsync`.
|
|
487
|
+
* It accepts `string | ModelConstructor | (string | ModelConstructor)[]` and is normalized to `string[]` internally.
|
|
488
|
+
* @param {FlowModelLoaderInputMap} loaders Model loader input map
|
|
487
489
|
* @returns {void}
|
|
488
490
|
* @example
|
|
489
491
|
* flowEngine.registerModelLoaders({
|
|
490
492
|
* DemoModel: {
|
|
493
|
+
* extends: 'BaseModel',
|
|
491
494
|
* loader: () => import('./models/DemoModel'),
|
|
492
495
|
* },
|
|
493
496
|
* });
|
|
494
497
|
*/
|
|
495
|
-
public registerModelLoaders(loaders:
|
|
498
|
+
public registerModelLoaders(loaders: FlowModelLoaderInputMap): void {
|
|
496
499
|
let changed = false;
|
|
497
|
-
for (const [name,
|
|
500
|
+
for (const [name, input] of Object.entries(loaders)) {
|
|
498
501
|
if (this._modelLoaders.has(name)) {
|
|
499
502
|
console.warn(`FlowEngine: Model loader with name '${name}' is already registered and will be overwritten.`);
|
|
500
503
|
}
|
|
504
|
+
const entry: FlowModelLoaderEntry = {
|
|
505
|
+
loader: input.loader,
|
|
506
|
+
};
|
|
507
|
+
if (input.extends != null) {
|
|
508
|
+
const raw = Array.isArray(input.extends) ? input.extends : [input.extends];
|
|
509
|
+
entry.extends = raw.map((item) => (typeof item === 'string' ? item : item.name));
|
|
510
|
+
}
|
|
501
511
|
this._modelLoaders.set(name, entry);
|
|
502
512
|
changed = true;
|
|
503
513
|
}
|
|
@@ -845,6 +855,70 @@ export class FlowEngine {
|
|
|
845
855
|
return result;
|
|
846
856
|
}
|
|
847
857
|
|
|
858
|
+
/**
|
|
859
|
+
* Asynchronously get all subclasses of a base class, including those registered via model loaders.
|
|
860
|
+
* Merges results from already-loaded classes (_modelClasses) and async loader entries with matching `extends` declarations.
|
|
861
|
+
* Loader-resolved classes are validated with `isInheritedFrom`; mismatches are warned and excluded.
|
|
862
|
+
* @param {string | ModelConstructor} baseClass Base class name or constructor
|
|
863
|
+
* @param {(ModelClass: ModelConstructor, className: string) => boolean} [filter] Optional filter function
|
|
864
|
+
* @returns {Promise<Map<string, ModelConstructor>>} Model classes that are subclasses of the base class
|
|
865
|
+
*/
|
|
866
|
+
public async getSubclassesOfAsync(
|
|
867
|
+
baseClass: string | ModelConstructor,
|
|
868
|
+
filter?: (ModelClass: ModelConstructor, className: string) => boolean,
|
|
869
|
+
): Promise<Map<string, ModelConstructor>> {
|
|
870
|
+
const baseClassName = typeof baseClass === 'string' ? baseClass : baseClass.name;
|
|
871
|
+
|
|
872
|
+
// If baseClass is a string and not yet loaded, try to resolve it first
|
|
873
|
+
let parentModelClass: ModelConstructor | undefined;
|
|
874
|
+
if (typeof baseClass === 'string') {
|
|
875
|
+
if (!this.getModelClass(baseClass)) {
|
|
876
|
+
await this.ensureModel(baseClass);
|
|
877
|
+
}
|
|
878
|
+
parentModelClass = this.getModelClass(baseClass);
|
|
879
|
+
} else {
|
|
880
|
+
parentModelClass = baseClass;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (!parentModelClass) {
|
|
884
|
+
return new Map();
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Step 1: Collect already-loaded subclasses from _modelClasses
|
|
888
|
+
const result = this.getSubclassesOf(parentModelClass, filter);
|
|
889
|
+
|
|
890
|
+
// Step 2: Find unloaded loaders whose extends includes baseClassName
|
|
891
|
+
const loaderCandidates: string[] = [];
|
|
892
|
+
for (const [name, entry] of this._modelLoaders) {
|
|
893
|
+
if (result.has(name) || this._modelClasses.has(name)) continue;
|
|
894
|
+
if (entry.extends?.includes(baseClassName)) {
|
|
895
|
+
loaderCandidates.push(name);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Step 3: Resolve all matching loaders
|
|
900
|
+
if (loaderCandidates.length > 0) {
|
|
901
|
+
await this.ensureModels(loaderCandidates);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Step 4: Validate resolved classes and add to result
|
|
905
|
+
for (const name of loaderCandidates) {
|
|
906
|
+
const ModelClass = this._modelClasses.get(name);
|
|
907
|
+
if (!ModelClass) continue;
|
|
908
|
+
if (!isInheritedFrom(ModelClass, parentModelClass)) {
|
|
909
|
+
console.warn(
|
|
910
|
+
`FlowEngine: Model '${name}' declares extends '${baseClassName}' but does not actually inherit from it. Skipping.`,
|
|
911
|
+
);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (!filter || filter(ModelClass, name)) {
|
|
915
|
+
result.set(name, ModelClass);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
|
|
848
922
|
/**
|
|
849
923
|
* Create and register a model instance.
|
|
850
924
|
* If an instance with the same UID exists, returns the existing instance.
|
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 组件失败",
|
|
@@ -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
|
},
|
package/src/types.ts
CHANGED
|
@@ -407,22 +407,34 @@ export type FlowModelLoaderResult =
|
|
|
407
407
|
export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
|
|
408
408
|
|
|
409
409
|
/**
|
|
410
|
-
* FlowModel loader entry.
|
|
411
|
-
* Future contribution-style fields are intentionally kept as commented placeholders in phase A
|
|
412
|
-
* and are not consumed by current runtime logic.
|
|
410
|
+
* FlowModel loader entry (normalized internal form).
|
|
413
411
|
*/
|
|
414
412
|
export interface FlowModelLoaderEntry {
|
|
415
413
|
loader: FlowModelLoader;
|
|
416
|
-
|
|
414
|
+
extends?: string[];
|
|
417
415
|
// meta?: Partial<FlowModelMeta>;
|
|
418
416
|
// scenes?: string[];
|
|
419
417
|
}
|
|
420
418
|
|
|
421
419
|
/**
|
|
422
|
-
* FlowModel loader
|
|
420
|
+
* FlowModel loader input (user-facing form for registerModelLoaders).
|
|
421
|
+
* The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
|
|
422
|
+
*/
|
|
423
|
+
export interface FlowModelLoaderInput {
|
|
424
|
+
loader: FlowModelLoader;
|
|
425
|
+
extends?: string | ModelConstructor | (string | ModelConstructor)[];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* FlowModel loader entry map (normalized internal form).
|
|
423
430
|
*/
|
|
424
431
|
export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
|
|
425
432
|
|
|
433
|
+
/**
|
|
434
|
+
* FlowModel loader input map (user-facing form for registerModelLoaders).
|
|
435
|
+
*/
|
|
436
|
+
export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
|
|
437
|
+
|
|
426
438
|
/**
|
|
427
439
|
* Batch ensure result.
|
|
428
440
|
*/
|