@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.46
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/FlowContextProvider.d.ts +5 -1
- package/lib/FlowContextProvider.js +9 -2
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +84 -32
- package/lib/components/subModel/LazyDropdown.js +208 -16
- package/lib/components/subModel/utils.d.ts +1 -0
- package/lib/components/subModel/utils.js +6 -2
- package/lib/data-source/index.d.ts +9 -0
- package/lib/data-source/index.js +12 -0
- package/lib/executor/FlowExecutor.js +0 -3
- package/lib/flowContext.d.ts +6 -1
- package/lib/flowContext.js +38 -6
- package/lib/flowEngine.d.ts +4 -3
- package/lib/flowEngine.js +72 -40
- package/lib/models/flowModel.js +48 -16
- package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
- package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
- package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
- package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
- package/lib/runjs-context/contexts/base.js +464 -29
- package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
- package/lib/runjs-context/contexts/elementDoc.js +152 -0
- package/lib/utils/loadedPageCache.d.ts +24 -0
- package/lib/utils/loadedPageCache.js +139 -0
- package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
- package/lib/utils/parsePathnameToViewParams.js +28 -4
- package/lib/views/ViewNavigation.d.ts +12 -2
- package/lib/views/ViewNavigation.js +22 -7
- package/lib/views/createViewMeta.js +114 -50
- package/lib/views/inheritLayoutContext.d.ts +10 -0
- package/lib/views/inheritLayoutContext.js +50 -0
- package/lib/views/useDialog.js +2 -0
- package/lib/views/useDrawer.js +2 -0
- package/lib/views/usePage.js +2 -0
- package/package.json +4 -4
- package/src/FlowContextProvider.tsx +9 -1
- package/src/__tests__/createViewMeta.popup.test.ts +115 -1
- package/src/__tests__/flowContext.test.ts +23 -0
- package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
- package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
- package/src/__tests__/runjsContext.test.ts +18 -0
- package/src/__tests__/runjsContextImplementations.test.ts +9 -2
- package/src/__tests__/runjsLocales.test.ts +6 -5
- package/src/__tests__/viewScopedFlowEngine.test.ts +133 -0
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +90 -38
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +155 -5
- package/src/components/subModel/LazyDropdown.tsx +237 -16
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +254 -1
- package/src/components/subModel/utils.ts +6 -1
- package/src/data-source/index.ts +18 -0
- package/src/executor/FlowExecutor.ts +0 -3
- package/src/executor/__tests__/flowExecutor.test.ts +26 -0
- package/src/flowContext.ts +43 -6
- package/src/flowEngine.ts +75 -38
- package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
- package/src/models/__tests__/flowModel.test.ts +46 -62
- package/src/models/flowModel.tsx +65 -32
- package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
- package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
- package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
- package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
- package/src/runjs-context/contexts/base.ts +467 -31
- package/src/runjs-context/contexts/elementDoc.ts +130 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
- package/src/utils/loadedPageCache.ts +147 -0
- package/src/utils/parsePathnameToViewParams.ts +45 -5
- package/src/views/ViewNavigation.ts +40 -7
- package/src/views/__tests__/ViewNavigation.test.ts +52 -0
- package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
- package/src/views/createViewMeta.ts +106 -34
- package/src/views/inheritLayoutContext.ts +26 -0
- package/src/views/useDialog.tsx +2 -0
- package/src/views/useDrawer.tsx +2 -0
- package/src/views/usePage.tsx +2 -0
|
@@ -8,9 +8,30 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { reaction } from '@nocobase/flow-engine';
|
|
11
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
12
|
import { FlowEngine } from '../flowEngine';
|
|
13
13
|
import { FlowModel } from '../models';
|
|
14
|
+
import type { IFlowModelRepository } from '../types';
|
|
15
|
+
|
|
16
|
+
class MoveRepository implements IFlowModelRepository {
|
|
17
|
+
move = vi.fn(async (_sourceId: string, _targetId: string, _position: 'before' | 'after'): Promise<void> => {});
|
|
18
|
+
|
|
19
|
+
async findOne(): Promise<Record<string, unknown> | null> {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async save(): Promise<Record<string, unknown>> {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async destroy(): Promise<boolean> {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async duplicate(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
14
35
|
|
|
15
36
|
describe('FlowEngine moveModel', () => {
|
|
16
37
|
let engine: FlowEngine;
|
|
@@ -20,6 +41,14 @@ describe('FlowEngine moveModel', () => {
|
|
|
20
41
|
engine.registerModels({ FlowModel });
|
|
21
42
|
});
|
|
22
43
|
|
|
44
|
+
const createParentWithChildren = () => {
|
|
45
|
+
const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
|
|
46
|
+
parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
|
|
47
|
+
parent.addSubModel('items', { uid: 'child-b', use: 'FlowModel' });
|
|
48
|
+
parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
|
|
49
|
+
return parent;
|
|
50
|
+
};
|
|
51
|
+
|
|
23
52
|
it('keeps subModels array reactive after move so later additions trigger reactions', async () => {
|
|
24
53
|
const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
|
|
25
54
|
parent.addSubModel('items', { uid: 'child-a', use: 'FlowModel' });
|
|
@@ -40,4 +69,55 @@ describe('FlowEngine moveModel', () => {
|
|
|
40
69
|
dispose();
|
|
41
70
|
expect(seen).toEqual([3]);
|
|
42
71
|
});
|
|
72
|
+
|
|
73
|
+
it('persists an after move when dragging forward', async () => {
|
|
74
|
+
const repository = new MoveRepository();
|
|
75
|
+
engine.setModelRepository(repository);
|
|
76
|
+
const parent = createParentWithChildren();
|
|
77
|
+
|
|
78
|
+
await engine.moveModel('child-a', 'child-c');
|
|
79
|
+
|
|
80
|
+
expect(repository.move).toHaveBeenCalledWith('child-a', 'child-c', 'after');
|
|
81
|
+
expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-b', 'child-c', 'child-a']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('persists a before move when dragging backward', async () => {
|
|
85
|
+
const repository = new MoveRepository();
|
|
86
|
+
engine.setModelRepository(repository);
|
|
87
|
+
const parent = createParentWithChildren();
|
|
88
|
+
|
|
89
|
+
await engine.moveModel('child-c', 'child-a');
|
|
90
|
+
|
|
91
|
+
expect(repository.move).toHaveBeenCalledWith('child-c', 'child-a', 'before');
|
|
92
|
+
expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-c', 'child-a', 'child-b']);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does not persist self-drop', async () => {
|
|
96
|
+
const repository = new MoveRepository();
|
|
97
|
+
engine.setModelRepository(repository);
|
|
98
|
+
const parent = createParentWithChildren();
|
|
99
|
+
|
|
100
|
+
await engine.moveModel('child-a', 'child-a');
|
|
101
|
+
|
|
102
|
+
expect(repository.move).not.toHaveBeenCalled();
|
|
103
|
+
expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('keeps null sortIndex subModels in stable order', () => {
|
|
107
|
+
const parent = engine.createModel({
|
|
108
|
+
uid: 'parent',
|
|
109
|
+
use: 'FlowModel',
|
|
110
|
+
subModels: {
|
|
111
|
+
items: [
|
|
112
|
+
{ uid: 'child-a', use: 'FlowModel', sortIndex: null as unknown as number },
|
|
113
|
+
{ uid: 'child-b', use: 'FlowModel', sortIndex: null as unknown as number },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
parent.addSubModel('items', { uid: 'child-c', use: 'FlowModel' });
|
|
119
|
+
|
|
120
|
+
expect((parent.subModels.items as FlowModel[]).map((item) => item.uid)).toEqual(['child-a', 'child-b', 'child-c']);
|
|
121
|
+
expect((parent.subModels.items as FlowModel[]).map((item) => item.sortIndex)).toEqual([1, 2, 3]);
|
|
122
|
+
});
|
|
43
123
|
});
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
11
|
import { FlowEngine } from '../flowEngine';
|
|
12
12
|
import { FlowModel } from '../models';
|
|
13
13
|
|
|
@@ -20,6 +20,7 @@ describe('FlowEngine removeModel', () => {
|
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
it('removeModel should remove model but keep sub-models in cache (current behavior)', () => {
|
|
23
|
+
const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
|
|
23
24
|
const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
|
|
24
25
|
const child = engine.createModel({
|
|
25
26
|
uid: 'child',
|
|
@@ -32,14 +33,53 @@ describe('FlowEngine removeModel', () => {
|
|
|
32
33
|
expect(engine.getModel('parent')).toBe(parent);
|
|
33
34
|
expect(engine.getModel('child')).toBe(child);
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
try {
|
|
37
|
+
engine.removeModel('parent');
|
|
38
|
+
} finally {
|
|
39
|
+
loggerSpy.mockRestore();
|
|
40
|
+
}
|
|
36
41
|
|
|
37
42
|
expect(engine.getModel('parent')).toBeUndefined();
|
|
38
43
|
// Current behavior: child is still in cache
|
|
39
44
|
expect(engine.getModel('child')).toBeDefined();
|
|
40
45
|
});
|
|
41
46
|
|
|
47
|
+
it('removeModel should log missing models at debug level', () => {
|
|
48
|
+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
49
|
+
const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
expect(engine.removeModel('missing')).toBe(false);
|
|
53
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
54
|
+
expect(loggerSpy).toHaveBeenCalledWith("FlowEngine: Model with UID 'missing' does not exist.");
|
|
55
|
+
} finally {
|
|
56
|
+
consoleWarnSpy.mockRestore();
|
|
57
|
+
loggerSpy.mockRestore();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should reduce default logger verbosity in production', () => {
|
|
62
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
process.env.NODE_ENV = 'production';
|
|
66
|
+
|
|
67
|
+
const productionEngine = new FlowEngine();
|
|
68
|
+
|
|
69
|
+
expect(productionEngine.logger.level).toBe('warn');
|
|
70
|
+
expect(productionEngine.logger.isLevelEnabled('debug')).toBe(false);
|
|
71
|
+
expect(productionEngine.logger.isLevelEnabled('warn')).toBe(true);
|
|
72
|
+
} finally {
|
|
73
|
+
if (originalNodeEnv === undefined) {
|
|
74
|
+
delete process.env.NODE_ENV;
|
|
75
|
+
} else {
|
|
76
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
42
81
|
it('removeModelWithSubModels should remove model and all sub-models from cache', () => {
|
|
82
|
+
const loggerSpy = vi.spyOn(engine.logger, 'debug').mockImplementation(() => {});
|
|
43
83
|
const parent = engine.createModel({ uid: 'parent', use: 'FlowModel' });
|
|
44
84
|
const child = engine.createModel({
|
|
45
85
|
uid: 'child',
|
|
@@ -63,7 +103,11 @@ describe('FlowEngine removeModel', () => {
|
|
|
63
103
|
expect(engine.getModel('child')).toBe(child);
|
|
64
104
|
expect(engine.getModel('grandChild')).toBe(grandChild);
|
|
65
105
|
|
|
66
|
-
|
|
106
|
+
try {
|
|
107
|
+
engine.removeModelWithSubModels('parent');
|
|
108
|
+
} finally {
|
|
109
|
+
loggerSpy.mockRestore();
|
|
110
|
+
}
|
|
67
111
|
|
|
68
112
|
expect(engine.getModel('parent')).toBeUndefined();
|
|
69
113
|
expect(engine.getModel('child')).toBeUndefined();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
});
|
|
@@ -237,6 +237,15 @@ const getToolbarPopupContainer = (triggerNode?: HTMLElement | null) => {
|
|
|
237
237
|
);
|
|
238
238
|
};
|
|
239
239
|
|
|
240
|
+
const removeExtraMenuItemClickHandlers = (item: FlowModelExtraMenuItem): FlowModelExtraMenuItem => {
|
|
241
|
+
const { onClick: _onClick, children, ...rest } = item;
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
...rest,
|
|
245
|
+
children: children?.length ? children.map(removeExtraMenuItemClickHandlers) : undefined,
|
|
246
|
+
};
|
|
247
|
+
};
|
|
248
|
+
|
|
240
249
|
export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
241
250
|
model,
|
|
242
251
|
showDeleteButton = true,
|
|
@@ -254,8 +263,18 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
254
263
|
// 当模型发生子模型替换/增删等变化时,强制刷新菜单数据
|
|
255
264
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
256
265
|
const [extraMenuItems, setExtraMenuItems] = useState<FlowModelExtraMenuItem[]>([]);
|
|
266
|
+
const [extraMenuItemsLoaded, setExtraMenuItemsLoaded] = useState(false);
|
|
257
267
|
const [configurableFlowsAndSteps, setConfigurableFlowsAndSteps] = useState<FlowInfo[]>([]);
|
|
258
268
|
const [isLoading, setIsLoading] = useState(true);
|
|
269
|
+
const commonExtras = useMemo(
|
|
270
|
+
() => extraMenuItems.filter((it) => it.group === 'common-actions').sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)),
|
|
271
|
+
[extraMenuItems],
|
|
272
|
+
);
|
|
273
|
+
const hasCommonActions = showCopyUidButton || showDeleteButton || commonExtras.length > 0;
|
|
274
|
+
const shouldDeferConfigLoading = flattenSubMenus && menuLevels > 1 && hasCommonActions;
|
|
275
|
+
const shouldWaitForCommonActionProbe =
|
|
276
|
+
flattenSubMenus && menuLevels > 1 && !showCopyUidButton && !showDeleteButton && !extraMenuItemsLoaded;
|
|
277
|
+
const canRenderIcon = hasCommonActions || (!isLoading && configurableFlowsAndSteps.length > 0);
|
|
259
278
|
const closeDropdown = useCallback(() => {
|
|
260
279
|
setVisible(false);
|
|
261
280
|
onDropdownVisibleChange?.(false);
|
|
@@ -294,26 +313,30 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
294
313
|
useEffect(() => {
|
|
295
314
|
let mounted = true;
|
|
296
315
|
const loadExtras = async () => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
modelsToProcess
|
|
301
|
-
|
|
316
|
+
setExtraMenuItemsLoaded(false);
|
|
317
|
+
try {
|
|
318
|
+
const allExtras: FlowModelExtraMenuItem[] = [];
|
|
319
|
+
const modelsToProcess: Array<{ model: FlowModel; modelKey?: string }> = [];
|
|
320
|
+
walkSubModels(model, { maxDepth: menuLevels, arrayLimit: 50, mode: 'stack' }, (targetModel, { modelKey }) => {
|
|
321
|
+
modelsToProcess.push({ model: targetModel, modelKey });
|
|
322
|
+
});
|
|
302
323
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
324
|
+
for (const { model: targetModel, modelKey } of modelsToProcess) {
|
|
325
|
+
const Cls = targetModel.constructor as typeof FlowModel;
|
|
326
|
+
const extras = await Cls.getExtraMenuItems?.(targetModel, t);
|
|
327
|
+
if (extras?.length) {
|
|
328
|
+
allExtras.push(
|
|
329
|
+
...extras.map((item) => ({
|
|
330
|
+
...item,
|
|
331
|
+
key: modelKey ? `${modelKey}:${item.key}` : item.key,
|
|
332
|
+
})),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
313
335
|
}
|
|
314
|
-
}
|
|
315
336
|
|
|
316
|
-
|
|
337
|
+
if (!mounted) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
317
340
|
const seen = new Set<string>();
|
|
318
341
|
const dedupedExtras = allExtras.filter((item) => {
|
|
319
342
|
if (seen.has(`${item.key}`)) {
|
|
@@ -323,16 +346,22 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
323
346
|
return true;
|
|
324
347
|
});
|
|
325
348
|
setExtraMenuItems(dedupedExtras);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
console.error('Failed to load extra menu items:', error);
|
|
351
|
+
if (mounted) {
|
|
352
|
+
setExtraMenuItems([]);
|
|
353
|
+
}
|
|
354
|
+
} finally {
|
|
355
|
+
if (mounted) {
|
|
356
|
+
setExtraMenuItemsLoaded(true);
|
|
357
|
+
}
|
|
326
358
|
}
|
|
327
359
|
};
|
|
328
|
-
|
|
329
|
-
if (visible) {
|
|
330
|
-
loadExtras();
|
|
331
|
-
}
|
|
360
|
+
loadExtras();
|
|
332
361
|
return () => {
|
|
333
362
|
mounted = false;
|
|
334
363
|
};
|
|
335
|
-
}, [model, menuLevels, t, refreshTick
|
|
364
|
+
}, [model, menuLevels, t, refreshTick]);
|
|
336
365
|
|
|
337
366
|
// 统一的复制 UID 方法
|
|
338
367
|
const copyUidToClipboard = useCallback(
|
|
@@ -623,7 +652,7 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
623
652
|
return [];
|
|
624
653
|
}
|
|
625
654
|
},
|
|
626
|
-
[],
|
|
655
|
+
[t],
|
|
627
656
|
);
|
|
628
657
|
|
|
629
658
|
// 获取可配置的flows和steps
|
|
@@ -666,21 +695,50 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
666
695
|
}, [model, menuLevels, refreshTick]);
|
|
667
696
|
|
|
668
697
|
useEffect(() => {
|
|
698
|
+
let mounted = true;
|
|
669
699
|
const loadConfigurableFlowsAndSteps = async () => {
|
|
670
700
|
setIsLoading(true);
|
|
701
|
+
if (shouldDeferConfigLoading) {
|
|
702
|
+
setConfigurableFlowsAndSteps([]);
|
|
703
|
+
}
|
|
671
704
|
try {
|
|
672
705
|
const flows = await getConfigurableFlowsAndSteps();
|
|
673
|
-
|
|
706
|
+
if (mounted) {
|
|
707
|
+
setConfigurableFlowsAndSteps(flows);
|
|
708
|
+
}
|
|
674
709
|
} catch (error) {
|
|
675
710
|
console.error('Failed to load configurable flows and steps:', error);
|
|
676
|
-
|
|
711
|
+
if (mounted) {
|
|
712
|
+
setConfigurableFlowsAndSteps([]);
|
|
713
|
+
}
|
|
677
714
|
} finally {
|
|
678
|
-
|
|
715
|
+
if (mounted) {
|
|
716
|
+
setIsLoading(false);
|
|
717
|
+
}
|
|
679
718
|
}
|
|
680
719
|
};
|
|
681
720
|
|
|
721
|
+
if (shouldWaitForCommonActionProbe) {
|
|
722
|
+
setConfigurableFlowsAndSteps([]);
|
|
723
|
+
setIsLoading(false);
|
|
724
|
+
return () => {
|
|
725
|
+
mounted = false;
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!visible && shouldDeferConfigLoading) {
|
|
730
|
+
setConfigurableFlowsAndSteps([]);
|
|
731
|
+
setIsLoading(false);
|
|
732
|
+
return () => {
|
|
733
|
+
mounted = false;
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
682
737
|
loadConfigurableFlowsAndSteps();
|
|
683
|
-
|
|
738
|
+
return () => {
|
|
739
|
+
mounted = false;
|
|
740
|
+
};
|
|
741
|
+
}, [getConfigurableFlowsAndSteps, refreshTick, shouldDeferConfigLoading, shouldWaitForCommonActionProbe, visible]);
|
|
684
742
|
|
|
685
743
|
// 构建菜单项,包含错误处理和记忆化
|
|
686
744
|
const menuItems = useMemo((): NonNullable<MenuProps['items']> => {
|
|
@@ -847,16 +905,12 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
847
905
|
}
|
|
848
906
|
|
|
849
907
|
return items;
|
|
850
|
-
}, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, t]);
|
|
908
|
+
}, [configurableFlowsAndSteps, disabledIconColor, flattenSubMenus, message, model, t]);
|
|
851
909
|
|
|
852
910
|
// 向菜单项添加额外按钮
|
|
853
911
|
const finalMenuItems = useMemo((): NonNullable<MenuProps['items']> => {
|
|
854
912
|
const items = [...menuItems];
|
|
855
913
|
|
|
856
|
-
const commonExtras = extraMenuItems
|
|
857
|
-
.filter((it) => it.group === 'common-actions')
|
|
858
|
-
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
|
859
|
-
|
|
860
914
|
if (showCopyUidButton || showDeleteButton || commonExtras.length > 0) {
|
|
861
915
|
items.push({
|
|
862
916
|
type: 'divider',
|
|
@@ -870,7 +924,8 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
870
924
|
// });
|
|
871
925
|
|
|
872
926
|
if (commonExtras.length > 0) {
|
|
873
|
-
|
|
927
|
+
// Antd Menu 会同时触发 item.onClick 和 menu.onClick,这里统一交给 handleMenuClick 执行。
|
|
928
|
+
items.push(...(commonExtras.map(removeExtraMenuItemClickHandlers) as MenuProps['items']));
|
|
874
929
|
}
|
|
875
930
|
|
|
876
931
|
// 添加复制uid按钮
|
|
@@ -891,12 +946,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
891
946
|
}
|
|
892
947
|
|
|
893
948
|
return items;
|
|
894
|
-
}, [menuItems, showCopyUidButton, showDeleteButton, model.uid, model.destroy, t
|
|
895
|
-
|
|
896
|
-
// 如果正在加载或没有可配置的flows且不显示删除按钮和复制UID按钮,不显示菜单
|
|
897
|
-
const hasExtras = extraMenuItems.some((it) => it.group === 'common-actions');
|
|
949
|
+
}, [menuItems, showCopyUidButton, showDeleteButton, commonExtras, model.uid, model.destroy, t]);
|
|
898
950
|
|
|
899
|
-
if (
|
|
951
|
+
if (!canRenderIcon) {
|
|
900
952
|
return null;
|
|
901
953
|
}
|
|
902
954
|
|