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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/lib/components/subModel/LazyDropdown.js +17 -9
  2. package/lib/flowContext.d.ts +6 -1
  3. package/lib/flowContext.js +35 -6
  4. package/lib/flowEngine.d.ts +1 -0
  5. package/lib/flowEngine.js +53 -30
  6. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  7. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  8. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  9. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  10. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  11. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  12. package/lib/runjs-context/contexts/base.js +464 -29
  13. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  14. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  15. package/lib/utils/loadedPageCache.d.ts +24 -0
  16. package/lib/utils/loadedPageCache.js +139 -0
  17. package/package.json +4 -4
  18. package/src/__tests__/flowContext.test.ts +23 -0
  19. package/src/__tests__/runjsContext.test.ts +18 -0
  20. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  21. package/src/__tests__/runjsLocales.test.ts +6 -5
  22. package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
  23. package/src/components/subModel/LazyDropdown.tsx +16 -7
  24. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +51 -0
  25. package/src/flowContext.ts +40 -6
  26. package/src/flowEngine.ts +51 -27
  27. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  28. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  29. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  30. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  31. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  32. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  33. package/src/runjs-context/contexts/base.ts +467 -31
  34. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  35. package/src/utils/loadedPageCache.ts +147 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var elementDoc_exports = {};
29
+ __export(elementDoc_exports, {
30
+ createElementPropertyDoc: () => createElementPropertyDoc,
31
+ createZhCNElementPropertyDoc: () => createZhCNElementPropertyDoc
32
+ });
33
+ module.exports = __toCommonJS(elementDoc_exports);
34
+ const elementProperties = {
35
+ innerHTML: "Sanitized inner HTML string.",
36
+ outerHTML: "Sanitized outer HTML string.",
37
+ textContent: "Text content.",
38
+ style: "Inline style declaration.",
39
+ classList: "DOMTokenList for CSS classes.",
40
+ appendChild: {
41
+ type: "function",
42
+ description: "Append a DOM node or text.",
43
+ detail: "(child: Node | string) => void",
44
+ completion: { insertText: `ctx.element.appendChild('text')` }
45
+ },
46
+ setAttribute: {
47
+ type: "function",
48
+ description: "Set an HTML attribute.",
49
+ detail: "(name: string, value: string) => void",
50
+ completion: { insertText: `ctx.element.setAttribute('data-key', 'value')` }
51
+ },
52
+ getAttribute: {
53
+ type: "function",
54
+ description: "Read an HTML attribute.",
55
+ detail: "(name: string) => string | null",
56
+ completion: { insertText: `ctx.element.getAttribute('data-key')` }
57
+ },
58
+ querySelector: {
59
+ type: "function",
60
+ description: "Find the first matching descendant.",
61
+ detail: "(selectors: string) => Element | null",
62
+ completion: { insertText: `ctx.element.querySelector('.selector')` }
63
+ },
64
+ querySelectorAll: {
65
+ type: "function",
66
+ description: "Find all matching descendants.",
67
+ detail: "(selectors: string) => NodeListOf<Element>",
68
+ completion: { insertText: `ctx.element.querySelectorAll('.selector')` }
69
+ },
70
+ addEventListener: {
71
+ type: "function",
72
+ description: "Attach an event listener to the element.",
73
+ detail: "(type: string, listener: EventListener) => void",
74
+ completion: { insertText: `ctx.element.addEventListener('click', (event) => {})` }
75
+ },
76
+ removeEventListener: {
77
+ type: "function",
78
+ description: "Remove an event listener from the element.",
79
+ detail: "(type: string, listener: EventListener) => void",
80
+ completion: { insertText: `ctx.element.removeEventListener('click', handler)` }
81
+ }
82
+ };
83
+ const zhCNElementProperties = {
84
+ innerHTML: "\u5DF2\u6D88\u6BD2\u7684 innerHTML \u5B57\u7B26\u4E32\u3002",
85
+ outerHTML: "\u5DF2\u6D88\u6BD2\u7684 outerHTML \u5B57\u7B26\u4E32\u3002",
86
+ textContent: "\u6587\u672C\u5185\u5BB9\u3002",
87
+ style: "\u5185\u8054\u6837\u5F0F\u58F0\u660E\u3002",
88
+ classList: "CSS class \u5217\u8868\u3002",
89
+ appendChild: {
90
+ type: "function",
91
+ description: "\u8FFD\u52A0 DOM \u8282\u70B9\u6216\u6587\u672C\u3002",
92
+ detail: "(child: Node | string) => void",
93
+ completion: { insertText: `ctx.element.appendChild('text')` }
94
+ },
95
+ setAttribute: {
96
+ type: "function",
97
+ description: "\u8BBE\u7F6E HTML \u5C5E\u6027\u3002",
98
+ detail: "(name: string, value: string) => void",
99
+ completion: { insertText: `ctx.element.setAttribute('data-key', 'value')` }
100
+ },
101
+ getAttribute: {
102
+ type: "function",
103
+ description: "\u8BFB\u53D6 HTML \u5C5E\u6027\u3002",
104
+ detail: "(name: string) => string | null",
105
+ completion: { insertText: `ctx.element.getAttribute('data-key')` }
106
+ },
107
+ querySelector: {
108
+ type: "function",
109
+ description: "\u67E5\u8BE2\u7B2C\u4E00\u4E2A\u5339\u914D\u7684\u540E\u4EE3\u5143\u7D20\u3002",
110
+ detail: "(selectors: string) => Element | null",
111
+ completion: { insertText: `ctx.element.querySelector('.selector')` }
112
+ },
113
+ querySelectorAll: {
114
+ type: "function",
115
+ description: "\u67E5\u8BE2\u6240\u6709\u5339\u914D\u7684\u540E\u4EE3\u5143\u7D20\u3002",
116
+ detail: "(selectors: string) => NodeListOf<Element>",
117
+ completion: { insertText: `ctx.element.querySelectorAll('.selector')` }
118
+ },
119
+ addEventListener: {
120
+ type: "function",
121
+ description: "\u7ED9\u5143\u7D20\u6DFB\u52A0\u4E8B\u4EF6\u76D1\u542C\u3002",
122
+ detail: "(type: string, listener: EventListener) => void",
123
+ completion: { insertText: `ctx.element.addEventListener('click', (event) => {})` }
124
+ },
125
+ removeEventListener: {
126
+ type: "function",
127
+ description: "\u79FB\u9664\u5143\u7D20\u4E8B\u4EF6\u76D1\u542C\u3002",
128
+ detail: "(type: string, listener: EventListener) => void",
129
+ completion: { insertText: `ctx.element.removeEventListener('click', handler)` }
130
+ }
131
+ };
132
+ function createElementPropertyDoc(description = "Current DOM container for this RunJS context. Usually an ElementProxy.") {
133
+ return {
134
+ description,
135
+ detail: "HTMLElement | ElementProxy",
136
+ properties: elementProperties
137
+ };
138
+ }
139
+ __name(createElementPropertyDoc, "createElementPropertyDoc");
140
+ function createZhCNElementPropertyDoc(description = "\u5F53\u524D RunJS \u4E0A\u4E0B\u6587\u7684 DOM \u5BB9\u5668\uFF0C\u901A\u5E38\u4E3A ElementProxy\u3002") {
141
+ return {
142
+ description,
143
+ detail: "HTMLElement | ElementProxy",
144
+ properties: zhCNElementProperties
145
+ };
146
+ }
147
+ __name(createZhCNElementPropertyDoc, "createZhCNElementPropertyDoc");
148
+ // Annotate the CommonJS export names for ESM import in node:
149
+ 0 && (module.exports = {
150
+ createElementPropertyDoc,
151
+ createZhCNElementPropertyDoc
152
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ import type { FlowModel } from '../models';
10
+ type LoadedPageOptions = {
11
+ parentId?: string;
12
+ subKey?: string;
13
+ };
14
+ type DirtyKeyOptions = {
15
+ force?: boolean;
16
+ };
17
+ export declare const createLoadedPageCache: () => {
18
+ getDirtyKeyForModel(model?: FlowModel | null, options?: DirtyKeyOptions): string | undefined;
19
+ markDirty(key?: string): void;
20
+ shouldBypass(options?: LoadedPageOptions, isFlowSettingsEnabled?: () => boolean): boolean;
21
+ clear(options?: LoadedPageOptions): void;
22
+ mountModelToParent: <T extends FlowModel<import("..").DefaultStructure> = FlowModel<import("..").DefaultStructure>>(model: T, forceReplace?: boolean) => T;
23
+ };
24
+ export {};
@@ -0,0 +1,139 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+ var __copyProps = (to, from, except, desc) => {
20
+ if (from && typeof from === "object" || typeof from === "function") {
21
+ for (let key of __getOwnPropNames(from))
22
+ if (!__hasOwnProp.call(to, key) && key !== except)
23
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
24
+ }
25
+ return to;
26
+ };
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+ var loadedPageCache_exports = {};
29
+ __export(loadedPageCache_exports, {
30
+ createLoadedPageCache: () => createLoadedPageCache
31
+ });
32
+ module.exports = __toCommonJS(loadedPageCache_exports);
33
+ const getLoadedPageKey = /* @__PURE__ */ __name((options) => {
34
+ const parentId = options == null ? void 0 : options.parentId;
35
+ const subKey = options == null ? void 0 : options.subKey;
36
+ if (!parentId || subKey !== "page") {
37
+ return void 0;
38
+ }
39
+ return `${parentId}::${subKey}`;
40
+ }, "getLoadedPageKey");
41
+ const getLoadedPageKeyFromModel = /* @__PURE__ */ __name((model) => {
42
+ var _a;
43
+ let current = model;
44
+ while (current) {
45
+ if (current.subKey === "page" && ((_a = current.parent) == null ? void 0 : _a.uid)) {
46
+ return getLoadedPageKey({ parentId: current.parent.uid, subKey: current.subKey });
47
+ }
48
+ current = current.parent;
49
+ }
50
+ return void 0;
51
+ }, "getLoadedPageKeyFromModel");
52
+ const isFlowSettingsEnabledForContext = /* @__PURE__ */ __name((context) => {
53
+ try {
54
+ return !!(context == null ? void 0 : context.flowSettingsEnabled);
55
+ } catch (error) {
56
+ return false;
57
+ }
58
+ }, "isFlowSettingsEnabledForContext");
59
+ const isFlowSettingsEnabledForModel = /* @__PURE__ */ __name((model) => {
60
+ if (isFlowSettingsEnabledForContext(model == null ? void 0 : model.context)) {
61
+ return true;
62
+ }
63
+ const visited = /* @__PURE__ */ new Set();
64
+ let engine = model == null ? void 0 : model.flowEngine;
65
+ while (engine && !visited.has(engine)) {
66
+ visited.add(engine);
67
+ if (isFlowSettingsEnabledForContext(engine.context)) {
68
+ return true;
69
+ }
70
+ engine = engine.previousEngine;
71
+ }
72
+ return false;
73
+ }, "isFlowSettingsEnabledForModel");
74
+ const removeLoadedModelTree = /* @__PURE__ */ __name((model) => {
75
+ if (!(model == null ? void 0 : model.uid) || !model.flowEngine) {
76
+ return;
77
+ }
78
+ if (model.flowEngine.getModel(model.uid) === model) {
79
+ model.flowEngine.removeModelWithSubModels(model.uid);
80
+ }
81
+ }, "removeLoadedModelTree");
82
+ const mountLoadedModelToParent = /* @__PURE__ */ __name((model, forceReplace = false) => {
83
+ var _a;
84
+ if (!(model == null ? void 0 : model.parent) || !model.subKey) {
85
+ return model;
86
+ }
87
+ const mounted = (_a = model.parent.subModels) == null ? void 0 : _a[model.subKey];
88
+ const existing = forceReplace && model.subType !== "array" && mounted && !Array.isArray(mounted) ? mounted : model.parent.findSubModel(model.subKey, (m) => m.uid === model.uid);
89
+ if (existing) {
90
+ if (!forceReplace || existing === model) {
91
+ return model;
92
+ }
93
+ removeLoadedModelTree(existing);
94
+ }
95
+ if (model.subType === "array") {
96
+ model.parent.addSubModel(model.subKey, model);
97
+ } else {
98
+ model.parent.setSubModel(model.subKey, model);
99
+ }
100
+ return model;
101
+ }, "mountLoadedModelToParent");
102
+ const createLoadedPageCache = /* @__PURE__ */ __name(() => {
103
+ const dirtyKeys = /* @__PURE__ */ new Set();
104
+ return {
105
+ getDirtyKeyForModel(model, options) {
106
+ if (!(options == null ? void 0 : options.force) && !isFlowSettingsEnabledForModel(model)) {
107
+ return void 0;
108
+ }
109
+ return getLoadedPageKeyFromModel(model);
110
+ },
111
+ markDirty(key) {
112
+ if (key) {
113
+ dirtyKeys.add(key);
114
+ }
115
+ },
116
+ shouldBypass(options, isFlowSettingsEnabled) {
117
+ const key = getLoadedPageKey(options);
118
+ if (!key || !dirtyKeys.has(key)) {
119
+ return false;
120
+ }
121
+ try {
122
+ return !(isFlowSettingsEnabled == null ? void 0 : isFlowSettingsEnabled());
123
+ } catch (error) {
124
+ return true;
125
+ }
126
+ },
127
+ clear(options) {
128
+ const key = getLoadedPageKey(options);
129
+ if (key) {
130
+ dirtyKeys.delete(key);
131
+ }
132
+ },
133
+ mountModelToParent: mountLoadedModelToParent
134
+ };
135
+ }, "createLoadedPageCache");
136
+ // Annotate the CommonJS export names for ESM import in node:
137
+ 0 && (module.exports = {
138
+ createLoadedPageCache
139
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.42",
3
+ "version": "2.1.0-beta.43",
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.42",
12
- "@nocobase/shared": "2.1.0-beta.42",
11
+ "@nocobase/sdk": "2.1.0-beta.43",
12
+ "@nocobase/shared": "2.1.0-beta.43",
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": "619b4d06c934bf3266ba7f388438db018217c87d"
40
+ "gitHead": "6d7750e2373bf2451d246de88cc1f62491685e18"
41
41
  }
@@ -796,6 +796,29 @@ describe('FlowContext.getApiInfos', () => {
796
796
  expect((infos.bar?.ref as any)?.url).toBe('https://example.com');
797
797
  });
798
798
 
799
+ it('should include completion only when requested by getApiInfos()', async () => {
800
+ const ctx = new FlowContext();
801
+ ctx.defineMethod('bar', () => 2, {
802
+ description: 'Bar',
803
+ completion: { insertText: 'ctx.bar()' },
804
+ });
805
+ ctx.defineProperty('token', {
806
+ value: 't',
807
+ info: {
808
+ description: 'Token string',
809
+ completion: { insertText: 'ctx.token' },
810
+ },
811
+ });
812
+
813
+ const compact = await ctx.getApiInfos();
814
+ expect((compact.bar as any)?.completion).toBeUndefined();
815
+ expect((compact.token as any)?.completion).toBeUndefined();
816
+
817
+ const editorInfos = await ctx.getApiInfos({ includeCompletion: true });
818
+ expect((editorInfos.bar as any)?.completion?.insertText).toBe('ctx.bar()');
819
+ expect((editorInfos.token as any)?.completion?.insertText).toBe('ctx.token');
820
+ });
821
+
799
822
  it('should return property infos with completion/ref/examples', async () => {
800
823
  const ctx = new FlowContext();
801
824
  ctx.defineProperty('token', {
@@ -88,6 +88,19 @@ describe('flowRunJSContext registry and doc', () => {
88
88
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
89
89
  expect(doc).toBeTruthy();
90
90
  expect(doc?.label).toMatch(/RunJS base/);
91
+ expect(doc?.properties?.element).toBeUndefined();
92
+ });
93
+
94
+ it('should mark element-dependent base completions with element requirement', () => {
95
+ const ctx: any = { model: { constructor: { name: 'UnknownModel' } } };
96
+ const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
97
+
98
+ expect((doc?.methods?.render as any)?.completion?.requires).toContain('element');
99
+ expect((doc?.properties?.viewer as any)?.properties?.popover?.completion?.requires).toContain('element');
100
+ expect((doc?.properties?.viewer as any)?.properties?.embed?.completion?.requires).toContain('element');
101
+ expect(
102
+ (doc?.properties?.libs as any)?.properties?.ReactDOM?.properties?.createRoot?.completion?.requires,
103
+ ).toContain('element');
91
104
  });
92
105
 
93
106
  it('should support locale-specific doc', () => {
@@ -99,6 +112,11 @@ describe('flowRunJSContext registry and doc', () => {
99
112
  const messageText =
100
113
  typeof message === 'string' ? message : (message as any)?.description ?? (message as any)?.detail ?? '';
101
114
  expect(String(messageText)).toMatch(/Ant Design 全局消息/);
115
+ expect((doc?.methods?.render as any)?.completion?.requires).toContain('element');
116
+ expect((doc?.properties?.viewer as any)?.properties?.popover?.completion?.requires).toContain('element');
117
+ expect(
118
+ (doc?.properties?.libs as any)?.properties?.ReactDOM?.properties?.createRoot?.completion?.requires,
119
+ ).toContain('element');
102
120
  });
103
121
 
104
122
  it('should fallback to English when locale is not found', () => {
@@ -27,7 +27,10 @@ describe('Specific RunJSContext implementations', () => {
27
27
  const ctx: any = { model: { constructor: { name: 'JSColumnModel' } } };
28
28
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
29
29
  expect(doc?.properties?.element).toBeTruthy();
30
- expect(doc?.properties?.element).toContain('ElementProxy');
30
+ const elementDoc: any = doc?.properties?.element;
31
+ expect(elementDoc?.detail).toContain('ElementProxy');
32
+ expect(elementDoc?.properties?.setAttribute).toBeTruthy();
33
+ expect(elementDoc?.properties?.querySelector).toBeTruthy();
31
34
  });
32
35
 
33
36
  it('should have record property in doc', () => {
@@ -68,7 +71,9 @@ describe('Specific RunJSContext implementations', () => {
68
71
  (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } });
69
72
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
70
73
  expect(doc?.label).toMatch(/JS 列/);
71
- expect(doc?.properties?.element).toContain('表格单元格');
74
+ const elementDoc: any = doc?.properties?.element;
75
+ expect(elementDoc?.description).toContain('表格单元格');
76
+ expect(elementDoc?.properties?.addEventListener).toBeTruthy();
72
77
  });
73
78
 
74
79
  it('should create instance successfully', () => {
@@ -162,6 +167,7 @@ describe('Specific RunJSContext implementations', () => {
162
167
  const ctx: any = { model: { constructor: { name: 'JSRecordActionModel' } } };
163
168
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
164
169
  expect(doc?.properties?.record).toBeTruthy();
170
+ expect(doc?.properties?.element).toBeUndefined();
165
171
  });
166
172
 
167
173
  it('should have filterByTk property', () => {
@@ -184,6 +190,7 @@ describe('Specific RunJSContext implementations', () => {
184
190
  const ctx: any = { model: { constructor: { name: 'JSCollectionActionModel' } } };
185
191
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
186
192
  expect(doc?.properties?.resource).toBeTruthy();
193
+ expect(doc?.properties?.element).toBeUndefined();
187
194
  });
188
195
 
189
196
  it('should support zh-CN locale', () => {
@@ -12,6 +12,10 @@ import { getRunJSDocFor } from '..';
12
12
  import { setupRunJSContexts } from '../runjs-context/setup';
13
13
  import { FlowContext } from '../flowContext';
14
14
 
15
+ function getRunJSDocText(doc: unknown) {
16
+ return typeof doc === 'string' ? doc : (doc as any)?.description ?? (doc as any)?.detail ?? '';
17
+ }
18
+
15
19
  describe('RunJS locales patch (engine doc)', () => {
16
20
  beforeAll(async () => {
17
21
  await setupRunJSContexts();
@@ -30,10 +34,7 @@ describe('RunJS locales patch (engine doc)', () => {
30
34
  (ctx as any).defineProperty('model', { value: { constructor: { name: 'JSBlockModel' } } });
31
35
  (ctx as any).defineProperty('api', { value: { auth: { locale: 'zh-CN' } } });
32
36
  const doc = getRunJSDocFor(ctx as any, { version: 'v1' });
33
- const message = doc?.properties?.message;
34
- const messageText =
35
- typeof message === 'string' ? message : (message as any)?.description ?? (message as any)?.detail ?? '';
36
- expect(String(messageText)).toMatch(/Ant Design 全局消息 API/);
37
- expect(String(doc?.methods?.t || '')).toMatch(/国际化函数/);
37
+ expect(String(getRunJSDocText(doc?.properties?.message))).toMatch(/Ant Design 全局消息 API/);
38
+ expect(String(getRunJSDocText(doc?.methods?.t))).toMatch(/国际化函数/);
38
39
  });
39
40
  });
@@ -18,6 +18,33 @@ import { APIClient as SDKApiClient } from '@nocobase/sdk';
18
18
  import { FlowEngine } from '../flowEngine';
19
19
  import { createViewScopedEngine } from '../ViewScopedFlowEngine';
20
20
  import { FlowModel } from '../models';
21
+ import type { IFlowModelRepository } from '../types';
22
+
23
+ const clone = <T>(value: T): T => (value == null ? value : JSON.parse(JSON.stringify(value)));
24
+
25
+ class DirtyPageRepository implements IFlowModelRepository<FlowModel> {
26
+ public findOneCalls = 0;
27
+ public data: Record<string, any> | null = null;
28
+
29
+ async findOne(): Promise<Record<string, any> | null> {
30
+ this.findOneCalls += 1;
31
+ return clone(this.data);
32
+ }
33
+
34
+ async save(model: FlowModel): Promise<Record<string, any>> {
35
+ return model.serialize();
36
+ }
37
+
38
+ async destroy(): Promise<boolean> {
39
+ return true;
40
+ }
41
+
42
+ async move(): Promise<void> {}
43
+
44
+ async duplicate(): Promise<Record<string, any> | null> {
45
+ return null;
46
+ }
47
+ }
21
48
 
22
49
  describe('ViewScopedFlowEngine', () => {
23
50
  it('shares global actions/events and model classes with parent', async () => {
@@ -307,4 +334,110 @@ describe('ViewScopedFlowEngine', () => {
307
334
  expect(result).not.toBeNull();
308
335
  expect(result?.uid).toBe('child-normal');
309
336
  });
337
+
338
+ it('reloads a dirty loaded page from repository and replaces stale parent reference', async () => {
339
+ const root = new FlowEngine();
340
+ const repository = new DirtyPageRepository();
341
+ root.setModelRepository(repository);
342
+
343
+ class ParentModel extends FlowModel {}
344
+ class PageModel extends FlowModel {}
345
+ class BlockModel extends FlowModel {}
346
+ root.registerModels({ ParentModel, PageModel, BlockModel });
347
+
348
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'popup-action' });
349
+ const oldScoped = createViewScopedEngine(root);
350
+ const stalePage = oldScoped.createModel<PageModel>({
351
+ use: 'PageModel',
352
+ uid: 'popup-page',
353
+ parentId: parent.uid,
354
+ subKey: 'page',
355
+ subType: 'object',
356
+ subModels: {
357
+ items: [{ use: 'BlockModel', uid: 'stale-block' }],
358
+ },
359
+ });
360
+ const staleBlock = stalePage.findSubModel('items' as any, (item) => item.uid === 'stale-block') as FlowModel;
361
+ parent.setSubModel('page', stalePage);
362
+ oldScoped.unlinkFromStack();
363
+
364
+ repository.data = {
365
+ use: 'PageModel',
366
+ uid: 'popup-page',
367
+ parentId: parent.uid,
368
+ subKey: 'page',
369
+ subType: 'object',
370
+ subModels: {
371
+ items: [{ use: 'BlockModel', uid: 'fresh-block' }],
372
+ },
373
+ };
374
+
375
+ root.flowSettings.enable();
376
+ await staleBlock.saveStepParams();
377
+ root.flowSettings.disable();
378
+ repository.findOneCalls = 0;
379
+
380
+ const runtimeScoped = createViewScopedEngine(root);
381
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
382
+ async: true,
383
+ parentId: parent.uid,
384
+ subKey: 'page',
385
+ subType: 'object',
386
+ use: 'PageModel',
387
+ });
388
+
389
+ expect(repository.findOneCalls).toBe(1);
390
+ expect(loaded).not.toBe(stalePage);
391
+ expect((parent.subModels as any).page).toBe(loaded);
392
+ expect(loaded?.mapSubModels('items' as any, (item) => item.uid)).toEqual(['fresh-block']);
393
+
394
+ repository.findOneCalls = 0;
395
+ const nextRuntimeScoped = createViewScopedEngine(root);
396
+ const loadedAgain = await nextRuntimeScoped.loadOrCreateModel<PageModel>({
397
+ async: true,
398
+ parentId: parent.uid,
399
+ subKey: 'page',
400
+ subType: 'object',
401
+ use: 'PageModel',
402
+ });
403
+
404
+ expect(repository.findOneCalls).toBe(0);
405
+ expect(loadedAgain?.uid).toBe('popup-page');
406
+ });
407
+
408
+ it('does not bypass loaded page cache after a non-config save', async () => {
409
+ const root = new FlowEngine();
410
+ const repository = new DirtyPageRepository();
411
+ root.setModelRepository(repository);
412
+
413
+ class ParentModel extends FlowModel {}
414
+ class PageModel extends FlowModel {}
415
+ root.registerModels({ ParentModel, PageModel });
416
+
417
+ const parent = root.createModel<ParentModel>({ use: 'ParentModel', uid: 'normal-parent' });
418
+ const stalePage = root.createModel<PageModel>({
419
+ use: 'PageModel',
420
+ uid: 'normal-page',
421
+ parentId: parent.uid,
422
+ subKey: 'page',
423
+ subType: 'object',
424
+ });
425
+ parent.setSubModel('page', stalePage);
426
+
427
+ root.flowSettings.disable();
428
+ await root.saveModel(stalePage);
429
+ repository.findOneCalls = 0;
430
+
431
+ const runtimeScoped = createViewScopedEngine(root);
432
+ const loaded = await runtimeScoped.loadOrCreateModel<PageModel>({
433
+ async: true,
434
+ parentId: parent.uid,
435
+ subKey: 'page',
436
+ subType: 'object',
437
+ use: 'PageModel',
438
+ });
439
+
440
+ expect(repository.findOneCalls).toBe(0);
441
+ expect(loaded?.uid).toBe('normal-page');
442
+ });
310
443
  });
@@ -463,6 +463,7 @@ const createSearchItem = (
463
463
  },
464
464
  activateSearchSubmenu: (key: string) => void,
465
465
  deactivateSearchSubmenu: (key: string) => void,
466
+ shouldActivateSearchSubmenu: boolean,
466
467
  ) => ({
467
468
  key: `${searchKey}-search`,
468
469
  type: 'group' as const,
@@ -480,28 +481,34 @@ const createSearchItem = (
480
481
  onChange={(e) => {
481
482
  e.stopPropagation();
482
483
  const value = e.target.value;
483
- activateSearchSubmenu(searchKey);
484
+ if (shouldActivateSearchSubmenu) {
485
+ activateSearchSubmenu(searchKey);
486
+ }
484
487
  if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
485
488
  searchHandlers.updateInputValue(searchKey, value);
486
489
  return;
487
490
  }
488
- if (!value) {
491
+ if (!value && shouldActivateSearchSubmenu) {
489
492
  deactivateSearchSubmenu(searchKey);
490
493
  }
491
494
  searchHandlers.updateSearchValue(searchKey, value);
492
495
  }}
493
496
  onCompositionStart={(e) => {
494
497
  e.stopPropagation();
495
- activateSearchSubmenu(searchKey);
498
+ if (shouldActivateSearchSubmenu) {
499
+ activateSearchSubmenu(searchKey);
500
+ }
496
501
  searchHandlers.startComposition(searchKey);
497
502
  }}
498
503
  onCompositionEnd={(e) => {
499
504
  e.stopPropagation();
500
505
  const value = e.currentTarget.value;
501
- if (value) {
502
- activateSearchSubmenu(searchKey);
503
- } else {
504
- deactivateSearchSubmenu(searchKey);
506
+ if (shouldActivateSearchSubmenu) {
507
+ if (value) {
508
+ activateSearchSubmenu(searchKey);
509
+ } else {
510
+ deactivateSearchSubmenu(searchKey);
511
+ }
505
512
  }
506
513
  searchHandlers.endComposition(searchKey, value);
507
514
  }}
@@ -737,6 +744,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
737
744
  const searchKey = keyPath;
738
745
  const currentSearchValue = searchValues[searchKey] || '';
739
746
  const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
747
+ const shouldActivateSearchSubmenu = !(item.type === 'group' && path.length === 0);
740
748
 
741
749
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
742
750
  // 仅保留自身匹配或存在匹配子项的分组。
@@ -770,6 +778,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
770
778
  searchHandlers,
771
779
  activateSearchSubmenu,
772
780
  deactivateSearchSubmenu,
781
+ shouldActivateSearchSubmenu,
773
782
  );
774
783
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
775
784