@nocobase/flow-engine 2.1.0-alpha.2 → 2.1.0-alpha.20
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/LICENSE +201 -661
- package/README.md +79 -10
- package/lib/JSRunner.d.ts +10 -1
- package/lib/JSRunner.js +50 -5
- package/lib/ViewScopedFlowEngine.js +5 -1
- package/lib/components/FlowModelRenderer.d.ts +1 -1
- package/lib/components/MobilePopup.js +6 -5
- package/lib/components/dnd/gridDragPlanner.js +6 -2
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +48 -9
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +19 -43
- package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +332 -296
- package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +272 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
- package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +247 -0
- package/lib/components/subModel/AddSubModelButton.js +27 -1
- package/lib/components/subModel/utils.js +2 -2
- package/lib/data-source/index.js +6 -0
- package/lib/executor/FlowExecutor.js +31 -8
- package/lib/flowContext.js +31 -1
- package/lib/flowEngine.d.ts +151 -1
- package/lib/flowEngine.js +389 -15
- package/lib/flowSettings.d.ts +14 -6
- package/lib/flowSettings.js +34 -6
- package/lib/lazy-helper.d.ts +14 -0
- package/lib/lazy-helper.js +71 -0
- package/lib/locale/en-US.json +1 -0
- package/lib/locale/index.d.ts +2 -0
- package/lib/locale/zh-CN.json +1 -0
- package/lib/models/flowModel.js +17 -7
- package/lib/reactive/observer.js +46 -16
- package/lib/runjs-context/registry.d.ts +1 -1
- package/lib/runjs-context/setup.js +20 -12
- package/lib/runjs-context/snippets/index.js +13 -2
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
- package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
- package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
- package/lib/scheduler/ModelOperationScheduler.js +3 -2
- package/lib/types.d.ts +47 -1
- package/lib/utils/index.d.ts +2 -2
- package/lib/utils/index.js +4 -0
- package/lib/utils/parsePathnameToViewParams.js +1 -1
- package/lib/utils/runjsTemplateCompat.js +1 -1
- package/lib/utils/runjsValue.js +41 -11
- package/lib/utils/schema-utils.d.ts +7 -1
- package/lib/utils/schema-utils.js +19 -0
- package/lib/views/FlowView.d.ts +7 -1
- package/lib/views/runViewBeforeClose.d.ts +10 -0
- package/lib/views/runViewBeforeClose.js +45 -0
- package/lib/views/useDialog.d.ts +2 -1
- package/lib/views/useDialog.js +20 -3
- package/lib/views/useDrawer.d.ts +2 -1
- package/lib/views/useDrawer.js +20 -3
- package/lib/views/usePage.d.ts +2 -1
- package/lib/views/usePage.js +10 -3
- package/package.json +6 -5
- package/src/JSRunner.ts +68 -4
- package/src/ViewScopedFlowEngine.ts +4 -0
- package/src/__tests__/JSRunner.test.ts +27 -1
- package/src/__tests__/flow-engine.test.ts +166 -0
- package/src/__tests__/flowContext.test.ts +65 -1
- package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
- package/src/__tests__/flowSettings.test.ts +94 -15
- package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
- package/src/__tests__/runjsContext.test.ts +16 -0
- package/src/__tests__/runjsContextRuntime.test.ts +2 -0
- package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
- package/src/__tests__/runjsSnippets.test.ts +21 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
- package/src/components/FlowModelRenderer.tsx +3 -1
- package/src/components/MobilePopup.tsx +4 -2
- package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
- package/src/components/__tests__/gridDragPlanner.test.ts +88 -0
- package/src/components/dnd/gridDragPlanner.ts +8 -2
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +63 -9
- package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +457 -440
- package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +95 -0
- package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +547 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +358 -0
- package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +281 -0
- package/src/components/subModel/AddSubModelButton.tsx +32 -2
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
- package/src/components/subModel/utils.ts +1 -1
- package/src/data-source/index.ts +6 -0
- package/src/executor/FlowExecutor.ts +34 -9
- package/src/executor/__tests__/flowExecutor.test.ts +57 -0
- package/src/flowContext.ts +35 -3
- package/src/flowEngine.ts +445 -11
- package/src/flowSettings.ts +40 -6
- package/src/lazy-helper.tsx +57 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
- package/src/models/flowModel.tsx +18 -6
- package/src/reactive/__tests__/observer.test.tsx +82 -0
- package/src/reactive/observer.tsx +87 -25
- package/src/runjs-context/registry.ts +1 -1
- package/src/runjs-context/setup.ts +22 -12
- package/src/runjs-context/snippets/index.ts +12 -1
- package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
- package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
- package/src/scheduler/ModelOperationScheduler.ts +14 -3
- package/src/types.ts +60 -0
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
- package/src/utils/__tests__/runjsValue.test.ts +11 -0
- package/src/utils/__tests__/utils.test.ts +62 -0
- package/src/utils/index.ts +2 -1
- package/src/utils/parsePathnameToViewParams.ts +2 -2
- package/src/utils/runjsTemplateCompat.ts +1 -1
- package/src/utils/runjsValue.ts +50 -11
- package/src/utils/schema-utils.ts +30 -1
- package/src/views/FlowView.tsx +11 -1
- package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
- package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
- package/src/views/runViewBeforeClose.ts +19 -0
- package/src/views/useDialog.tsx +25 -3
- package/src/views/useDrawer.tsx +25 -3
- package/src/views/usePage.tsx +12 -3
|
@@ -14,7 +14,7 @@ import { FlowEngine } from '../flowEngine';
|
|
|
14
14
|
import { FlowModel, ModelRenderMode } from '../models/flowModel';
|
|
15
15
|
|
|
16
16
|
describe('FlowModel.renderHiddenInConfig', () => {
|
|
17
|
-
it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', () => {
|
|
17
|
+
it('renders via renderHiddenInConfig when hidden and config enabled (React element mode, mounted)', async () => {
|
|
18
18
|
class ElemModel extends FlowModel {
|
|
19
19
|
render() {
|
|
20
20
|
return <div data-testid="content">Content</div>;
|
|
@@ -28,14 +28,14 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
28
28
|
const model = new ElemModel({ uid: 'elem-1', flowEngine: engine });
|
|
29
29
|
|
|
30
30
|
// runtime hidden => mounted result should be empty (no content/hidden)
|
|
31
|
-
engine.flowSettings.disable();
|
|
31
|
+
await engine.flowSettings.disable();
|
|
32
32
|
model.setHidden(true);
|
|
33
33
|
const { container, unmount, rerender } = render(model.render() as React.ReactElement);
|
|
34
34
|
expect(screen.queryByTestId('content')).toBeNull();
|
|
35
35
|
expect(screen.queryByTestId('hidden')).toBeNull();
|
|
36
36
|
|
|
37
37
|
// config enabled + hidden => should show renderHiddenInConfig result
|
|
38
|
-
engine.flowSettings.enable();
|
|
38
|
+
await engine.flowSettings.enable();
|
|
39
39
|
rerender(model.render() as React.ReactElement);
|
|
40
40
|
expect(screen.getByTestId('hidden').textContent).toBe('HiddenViaAPI');
|
|
41
41
|
|
|
@@ -46,7 +46,7 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
46
46
|
unmount();
|
|
47
47
|
cleanup();
|
|
48
48
|
});
|
|
49
|
-
it('returns a render function when hidden and config enabled (RenderFunction mode)', () => {
|
|
49
|
+
it('returns a render function when hidden and config enabled (RenderFunction mode)', async () => {
|
|
50
50
|
class FuncModel extends FlowModel {
|
|
51
51
|
static override renderMode = ModelRenderMode.RenderFunction;
|
|
52
52
|
render() {
|
|
@@ -63,13 +63,13 @@ describe('FlowModel.renderHiddenInConfig', () => {
|
|
|
63
63
|
const model = engine.createModel({ use: 'FuncModel' }) as FuncModel;
|
|
64
64
|
|
|
65
65
|
// runtime hidden => null
|
|
66
|
-
engine.flowSettings.disable();
|
|
66
|
+
await engine.flowSettings.disable();
|
|
67
67
|
model.setHidden(true);
|
|
68
68
|
const runtimeHidden = model.render();
|
|
69
69
|
expect(runtimeHidden).toBeNull();
|
|
70
70
|
|
|
71
71
|
// config enabled + hidden => renderHiddenInConfig (function)
|
|
72
|
-
engine.flowSettings.enable();
|
|
72
|
+
await engine.flowSettings.enable();
|
|
73
73
|
const cfgHidden = model.render();
|
|
74
74
|
expect(typeof cfgHidden).toBe('function');
|
|
75
75
|
const cellNode = (cfgHidden as any)();
|
|
@@ -29,11 +29,16 @@ describe('flowRunJSContext registry and doc', () => {
|
|
|
29
29
|
expect(RunJSContextRegistry['resolve']('v1' as any, '*')).toBeTruthy();
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it('should register v2 mapping', () => {
|
|
33
|
+
expect(RunJSContextRegistry['resolve']('v2' as any, '*')).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
32
36
|
it('should register all context types', () => {
|
|
33
37
|
const contextTypes = [
|
|
34
38
|
'JSBlockModel',
|
|
35
39
|
'JSFieldModel',
|
|
36
40
|
'JSItemModel',
|
|
41
|
+
'JSItemActionModel',
|
|
37
42
|
'JSColumnModel',
|
|
38
43
|
'FormJSFieldItemModel',
|
|
39
44
|
'JSRecordActionModel',
|
|
@@ -44,12 +49,22 @@ describe('flowRunJSContext registry and doc', () => {
|
|
|
44
49
|
const ctor = RunJSContextRegistry['resolve']('v1' as any, modelClass);
|
|
45
50
|
expect(ctor).toBeTruthy();
|
|
46
51
|
});
|
|
52
|
+
|
|
53
|
+
contextTypes.forEach((modelClass) => {
|
|
54
|
+
const ctor = RunJSContextRegistry['resolve']('v2' as any, modelClass);
|
|
55
|
+
expect(ctor).toBeTruthy();
|
|
56
|
+
});
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
it('should expose scene metadata for contexts', () => {
|
|
50
60
|
expect(getRunJSScenesForModel('JSBlockModel', 'v1')).toEqual(['block']);
|
|
51
61
|
expect(getRunJSScenesForModel('JSFieldModel', 'v1')).toEqual(['detail']);
|
|
62
|
+
expect(getRunJSScenesForModel('JSItemActionModel', 'v1')).toEqual(['table']);
|
|
63
|
+
expect(getRunJSScenesForModel('JSBlockModel', 'v2')).toEqual(['block']);
|
|
64
|
+
expect(getRunJSScenesForModel('JSFieldModel', 'v2')).toEqual(['detail']);
|
|
65
|
+
expect(getRunJSScenesForModel('JSItemActionModel', 'v2')).toEqual(['table']);
|
|
52
66
|
expect(getRunJSScenesForModel('UnknownModel', 'v1')).toEqual([]);
|
|
67
|
+
expect(getRunJSScenesForModel('UnknownModel', 'v2')).toEqual([]);
|
|
53
68
|
});
|
|
54
69
|
|
|
55
70
|
it('should only execute once (idempotent)', async () => {
|
|
@@ -175,6 +190,7 @@ describe('flowRunJSContext registry and doc', () => {
|
|
|
175
190
|
const ctx = new FlowContext();
|
|
176
191
|
ctx.defineProperty('model', { value: { constructor: { name: 'JSColumnModel' } } });
|
|
177
192
|
expect(getRunJSScenesForContext(ctx as any, { version: 'v1' })).toEqual(['table']);
|
|
193
|
+
expect(getRunJSScenesForContext(ctx as any, { version: 'v2' })).toEqual(['table']);
|
|
178
194
|
});
|
|
179
195
|
|
|
180
196
|
it('JSBlockModel context should have element property in doc', () => {
|
|
@@ -186,6 +186,7 @@ describe('RunJS Context Runtime Behavior', () => {
|
|
|
186
186
|
'JSBlockModel',
|
|
187
187
|
'JSFieldModel',
|
|
188
188
|
'JSItemModel',
|
|
189
|
+
'JSItemActionModel',
|
|
189
190
|
'JSColumnModel',
|
|
190
191
|
'FormJSFieldItemModel',
|
|
191
192
|
'JSRecordActionModel',
|
|
@@ -237,6 +238,7 @@ describe('RunJS Context Runtime Behavior', () => {
|
|
|
237
238
|
'JSBlockModel',
|
|
238
239
|
'JSFieldModel',
|
|
239
240
|
'JSItemModel',
|
|
241
|
+
'JSItemActionModel',
|
|
240
242
|
'JSColumnModel',
|
|
241
243
|
'FormJSFieldItemModel',
|
|
242
244
|
'JSRecordActionModel',
|
|
@@ -36,6 +36,29 @@ describe('ctx.runjs preprocessTemplates default', () => {
|
|
|
36
36
|
expect(r.value).toBe('{{ctx.user.id}}');
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
+
it('disables template preprocess by default for version v2', async () => {
|
|
40
|
+
const engine = new FlowEngine();
|
|
41
|
+
const ctx = engine.context as any;
|
|
42
|
+
ctx.defineProperty('user', { value: { id: 123 } });
|
|
43
|
+
|
|
44
|
+
const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, { version: 'v2' });
|
|
45
|
+
expect(r.success).toBe(true);
|
|
46
|
+
expect(r.value).toBe('{{ctx.user.id}}');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps explicit preprocessTemplates override higher priority than version', async () => {
|
|
50
|
+
const engine = new FlowEngine();
|
|
51
|
+
const ctx = engine.context as any;
|
|
52
|
+
ctx.defineProperty('user', { value: { id: 123 } });
|
|
53
|
+
|
|
54
|
+
const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, {
|
|
55
|
+
version: 'v2',
|
|
56
|
+
preprocessTemplates: true,
|
|
57
|
+
});
|
|
58
|
+
expect(r.success).toBe(true);
|
|
59
|
+
expect(r.value).toBe('123');
|
|
60
|
+
});
|
|
61
|
+
|
|
39
62
|
it('does not double-preprocess already prepared code', async () => {
|
|
40
63
|
const engine = new FlowEngine();
|
|
41
64
|
const ctx = engine.context as any;
|
|
@@ -112,6 +112,27 @@ describe('RunJS Snippets', () => {
|
|
|
112
112
|
expect(multiScene?.scenes).toEqual(expect.arrayContaining(['detail', 'table']));
|
|
113
113
|
expect(multiScene?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/table']));
|
|
114
114
|
});
|
|
115
|
+
|
|
116
|
+
it('should expose new style snippets for matching contexts', async () => {
|
|
117
|
+
const tableSnippets = await listSnippetsForContext('JSColumnRunJSContext', 'v1', 'zh-CN');
|
|
118
|
+
const fieldSnippets = await listSnippetsForContext('FormJSFieldItemRunJSContext', 'v1', 'zh-CN');
|
|
119
|
+
const detailEventSnippets = await listSnippetsForContext('DetailsItemModel', 'v1', 'zh-CN');
|
|
120
|
+
const tableEventSnippets = await listSnippetsForContext('TableColumnModel', 'v1', 'zh-CN');
|
|
121
|
+
|
|
122
|
+
const tableStyle = tableSnippets.find((s) => s.ref === 'scene/table/set-cell-style');
|
|
123
|
+
expect(tableStyle?.name).toBe('表格字段样式设置');
|
|
124
|
+
expect(tableStyle?.body).toContain('ctx.model.props.onCell');
|
|
125
|
+
expect(tableStyle?.scenes).toEqual(['tableFieldEvent']);
|
|
126
|
+
|
|
127
|
+
const fieldStyle = fieldSnippets.find((s) => s.ref === 'scene/detail/set-field-style');
|
|
128
|
+
expect(fieldStyle?.name).toBe('设置表单项/详情项样式');
|
|
129
|
+
expect(fieldStyle?.body).toContain('ctx.model.props.style');
|
|
130
|
+
expect(fieldStyle?.scenes).toEqual(expect.arrayContaining(['detailFieldEvent', 'formFieldEvent']));
|
|
131
|
+
expect(fieldStyle?.groups).toEqual(expect.arrayContaining(['scene/detail', 'scene/form']));
|
|
132
|
+
|
|
133
|
+
expect(detailEventSnippets.some((s) => s.ref === 'scene/detail/set-field-style')).toBe(true);
|
|
134
|
+
expect(tableEventSnippets.some((s) => s.ref === 'scene/table/set-cell-style')).toBe(true);
|
|
135
|
+
});
|
|
115
136
|
});
|
|
116
137
|
|
|
117
138
|
describe('New snippets', () => {
|
|
@@ -256,11 +256,11 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
256
256
|
|
|
257
257
|
// Both children should return null from hydration because parent has flowSettingsEnabled
|
|
258
258
|
// This is the bug fix: previously only children with their own flowSettingsEnabled would return null
|
|
259
|
-
const result1 = (scoped as any).hydrateModelFromPreviousEngines({
|
|
259
|
+
const result1 = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
260
260
|
parentId: 'parent-with-settings',
|
|
261
261
|
subKey: 'popup',
|
|
262
262
|
});
|
|
263
|
-
const result2 = (scoped as any).hydrateModelFromPreviousEngines({
|
|
263
|
+
const result2 = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
264
264
|
parentId: 'parent-with-settings',
|
|
265
265
|
subKey: 'items',
|
|
266
266
|
});
|
|
@@ -298,7 +298,7 @@ describe('ViewScopedFlowEngine', () => {
|
|
|
298
298
|
const scoped = createViewScopedEngine(root);
|
|
299
299
|
|
|
300
300
|
// Call the private method hydrateModelFromPreviousEngines directly
|
|
301
|
-
const result = (scoped as any).hydrateModelFromPreviousEngines({
|
|
301
|
+
const result = await (scoped as any).hydrateModelFromPreviousEngines({
|
|
302
302
|
parentId: 'parent-normal',
|
|
303
303
|
subKey: 'content',
|
|
304
304
|
});
|
|
@@ -69,7 +69,7 @@ export interface FlowModelRendererProps {
|
|
|
69
69
|
showBackground?: boolean;
|
|
70
70
|
showBorder?: boolean;
|
|
71
71
|
showDragHandle?: boolean;
|
|
72
|
-
/**
|
|
72
|
+
/** 自定义工具栏样式,`top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
|
|
73
73
|
style?: React.CSSProperties;
|
|
74
74
|
/**
|
|
75
75
|
* @default 'inside'
|
|
@@ -112,6 +112,7 @@ const FlowModelRendererWithAutoFlows: React.FC<{
|
|
|
112
112
|
showBackground?: boolean;
|
|
113
113
|
showBorder?: boolean;
|
|
114
114
|
showDragHandle?: boolean;
|
|
115
|
+
/** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
|
|
115
116
|
style?: React.CSSProperties;
|
|
116
117
|
/**
|
|
117
118
|
* @default 'inside'
|
|
@@ -182,6 +183,7 @@ const FlowModelRendererCore: React.FC<{
|
|
|
182
183
|
showBackground?: boolean;
|
|
183
184
|
showBorder?: boolean;
|
|
184
185
|
showDragHandle?: boolean;
|
|
186
|
+
/** `top/left/right/bottom` 会作为 portal overlay 的 inset 使用 */
|
|
185
187
|
style?: React.CSSProperties;
|
|
186
188
|
/**
|
|
187
189
|
* @default 'inside'
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ConfigProvider } from 'antd';
|
|
11
|
-
import { Popup } from 'antd-mobile';
|
|
12
11
|
import React, { FC, ReactNode, useMemo } from 'react';
|
|
13
|
-
import { CloseOutline } from 'antd-mobile-icons';
|
|
14
12
|
import { useMobileActionDrawerStyle } from './MobilePopup.style';
|
|
15
13
|
import { useTranslation } from 'react-i18next';
|
|
14
|
+
import { lazy } from '../lazy-helper';
|
|
15
|
+
|
|
16
|
+
const { Popup } = lazy(() => import('antd-mobile'), 'Popup');
|
|
17
|
+
const { CloseOutline } = lazy(() => import('antd-mobile-icons'), 'CloseOutline');
|
|
16
18
|
|
|
17
19
|
interface MobilePopupProps {
|
|
18
20
|
title?: string;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
-
import { render, cleanup, waitFor } from '@testing-library/react';
|
|
12
|
+
import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
|
13
13
|
import { App, ConfigProvider } from 'antd';
|
|
14
14
|
import { FlowEngine } from '../../flowEngine';
|
|
15
15
|
import { FlowModel, ModelRenderMode } from '../../models/flowModel';
|
|
@@ -94,6 +94,16 @@ const clickDeleteFromLastDropdown = async () => {
|
|
|
94
94
|
menu.onClick?.({ key: 'delete' });
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
+
const getHost = (element: HTMLElement) => element.closest('[data-has-float-menu="true"]') as HTMLDivElement;
|
|
98
|
+
|
|
99
|
+
const hoverHostAndClickDelete = async (element: HTMLElement) => {
|
|
100
|
+
const host = getHost(element);
|
|
101
|
+
if (host) {
|
|
102
|
+
fireEvent.mouseEnter(host);
|
|
103
|
+
}
|
|
104
|
+
await clickDeleteFromLastDropdown();
|
|
105
|
+
};
|
|
106
|
+
|
|
97
107
|
// ---------------- Tests ----------------
|
|
98
108
|
describe('Delete problematic model via FlowSettings menu', () => {
|
|
99
109
|
beforeEach(() => {
|
|
@@ -114,13 +124,13 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
const engine = new FlowEngine();
|
|
117
|
-
engine.flowSettings.forceEnable();
|
|
127
|
+
await engine.flowSettings.forceEnable();
|
|
118
128
|
engine.registerModels({ BrokenModel });
|
|
119
129
|
const model = engine.createModel({ use: 'BrokenModel', uid: 'broken-top-2' }) as BrokenModel;
|
|
120
130
|
// satisfy FlowsFloatContextMenu styles
|
|
121
131
|
model.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
122
132
|
|
|
123
|
-
render(
|
|
133
|
+
const { findByTestId } = render(
|
|
124
134
|
<ConfigProvider>
|
|
125
135
|
<App>
|
|
126
136
|
<FlowEngineProvider engine={engine}>
|
|
@@ -130,7 +140,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
130
140
|
</ConfigProvider>,
|
|
131
141
|
);
|
|
132
142
|
|
|
133
|
-
await
|
|
143
|
+
await hoverHostAndClickDelete(await findByTestId('result'));
|
|
134
144
|
expect(engine.getModel(model.uid)).toBeUndefined();
|
|
135
145
|
});
|
|
136
146
|
|
|
@@ -154,7 +164,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
const engine = new FlowEngine();
|
|
157
|
-
engine.flowSettings.forceEnable();
|
|
167
|
+
await engine.flowSettings.forceEnable();
|
|
158
168
|
engine.registerModels({ ParentModel, BrokenChild });
|
|
159
169
|
const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-3' }) as ParentModel;
|
|
160
170
|
const child = engine.createModel({ use: 'BrokenChild', uid: 'child-3' }) as BrokenChild;
|
|
@@ -163,7 +173,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
163
173
|
parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
164
174
|
child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
165
175
|
|
|
166
|
-
render(
|
|
176
|
+
const { findByTestId } = render(
|
|
167
177
|
<ConfigProvider>
|
|
168
178
|
<App>
|
|
169
179
|
<FlowEngineProvider engine={engine}>
|
|
@@ -173,7 +183,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
173
183
|
</ConfigProvider>,
|
|
174
184
|
);
|
|
175
185
|
|
|
176
|
-
await
|
|
186
|
+
await hoverHostAndClickDelete(await findByTestId('result'));
|
|
177
187
|
expect(engine.getModel(child.uid)).toBeUndefined();
|
|
178
188
|
const remain = (parent.subModels as any).items || [];
|
|
179
189
|
expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();
|
|
@@ -200,7 +210,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
200
210
|
}
|
|
201
211
|
|
|
202
212
|
const engine = new FlowEngine();
|
|
203
|
-
engine.flowSettings.forceEnable();
|
|
213
|
+
await engine.flowSettings.forceEnable();
|
|
204
214
|
engine.registerModels({ ParentModel, RenderFnChild });
|
|
205
215
|
const parent = engine.createModel({ use: 'ParentModel', uid: 'parent-4' }) as ParentModel;
|
|
206
216
|
const child = engine.createModel({ use: 'RenderFnChild', uid: 'cell-4' }) as RenderFnChild;
|
|
@@ -208,7 +218,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
208
218
|
parent.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
209
219
|
child.context.defineProperty('themeToken', { value: { borderRadiusLG: 8 } });
|
|
210
220
|
|
|
211
|
-
render(
|
|
221
|
+
const { findByTestId } = render(
|
|
212
222
|
<ConfigProvider>
|
|
213
223
|
<App>
|
|
214
224
|
<FlowEngineProvider engine={engine}>
|
|
@@ -218,7 +228,7 @@ describe('Delete problematic model via FlowSettings menu', () => {
|
|
|
218
228
|
</ConfigProvider>,
|
|
219
229
|
);
|
|
220
230
|
|
|
221
|
-
await
|
|
231
|
+
await hoverHostAndClickDelete(await findByTestId('result'));
|
|
222
232
|
expect(engine.getModel(child.uid)).toBeUndefined();
|
|
223
233
|
const remain = (parent.subModels as any).cells || [];
|
|
224
234
|
expect(remain.find((m: FlowModel) => m.uid === child.uid)).toBeUndefined();
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getSlotKey,
|
|
16
16
|
resolveDropIntent,
|
|
17
17
|
Point,
|
|
18
|
+
buildLayoutSnapshot,
|
|
18
19
|
} from '../dnd/gridDragPlanner';
|
|
19
20
|
|
|
20
21
|
const rect = { top: 0, left: 0, width: 100, height: 100 };
|
|
@@ -29,6 +30,93 @@ const createLayout = (
|
|
|
29
30
|
rowOrder,
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
const createDomRect = ({ top, left, width, height }: { top: number; left: number; width: number; height: number }) => {
|
|
34
|
+
return {
|
|
35
|
+
top,
|
|
36
|
+
left,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
right: left + width,
|
|
40
|
+
bottom: top + height,
|
|
41
|
+
x: left,
|
|
42
|
+
y: top,
|
|
43
|
+
toJSON: () => ({}),
|
|
44
|
+
} as DOMRect;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const mockRect = (
|
|
48
|
+
element: Element,
|
|
49
|
+
rect: {
|
|
50
|
+
top: number;
|
|
51
|
+
left: number;
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
},
|
|
55
|
+
) => {
|
|
56
|
+
Object.defineProperty(element, 'getBoundingClientRect', {
|
|
57
|
+
configurable: true,
|
|
58
|
+
value: () => createDomRect(rect),
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe('buildLayoutSnapshot', () => {
|
|
63
|
+
it('should ignore nested grid columns/items even when rowId is duplicated', () => {
|
|
64
|
+
const container = document.createElement('div');
|
|
65
|
+
const row = document.createElement('div');
|
|
66
|
+
row.setAttribute('data-grid-row-id', 'row-1');
|
|
67
|
+
container.appendChild(row);
|
|
68
|
+
|
|
69
|
+
const column = document.createElement('div');
|
|
70
|
+
column.setAttribute('data-grid-column-row-id', 'row-1');
|
|
71
|
+
column.setAttribute('data-grid-column-index', '0');
|
|
72
|
+
row.appendChild(column);
|
|
73
|
+
|
|
74
|
+
const item = document.createElement('div');
|
|
75
|
+
item.setAttribute('data-grid-item-row-id', 'row-1');
|
|
76
|
+
item.setAttribute('data-grid-column-index', '0');
|
|
77
|
+
item.setAttribute('data-grid-item-index', '0');
|
|
78
|
+
column.appendChild(item);
|
|
79
|
+
|
|
80
|
+
// 在外层 item 内构建一个嵌套 grid,并复用相同 rowId/columnIndex
|
|
81
|
+
const nestedRow = document.createElement('div');
|
|
82
|
+
nestedRow.setAttribute('data-grid-row-id', 'row-1');
|
|
83
|
+
item.appendChild(nestedRow);
|
|
84
|
+
|
|
85
|
+
const nestedColumn = document.createElement('div');
|
|
86
|
+
nestedColumn.setAttribute('data-grid-column-row-id', 'row-1');
|
|
87
|
+
nestedColumn.setAttribute('data-grid-column-index', '0');
|
|
88
|
+
nestedRow.appendChild(nestedColumn);
|
|
89
|
+
|
|
90
|
+
const nestedItem = document.createElement('div');
|
|
91
|
+
nestedItem.setAttribute('data-grid-item-row-id', 'row-1');
|
|
92
|
+
nestedItem.setAttribute('data-grid-column-index', '0');
|
|
93
|
+
nestedItem.setAttribute('data-grid-item-index', '0');
|
|
94
|
+
nestedColumn.appendChild(nestedItem);
|
|
95
|
+
|
|
96
|
+
mockRect(container, { top: 0, left: 0, width: 600, height: 600 });
|
|
97
|
+
mockRect(row, { top: 10, left: 10, width: 320, height: 120 });
|
|
98
|
+
mockRect(column, { top: 10, left: 10, width: 320, height: 120 });
|
|
99
|
+
mockRect(item, { top: 20, left: 20, width: 300, height: 80 });
|
|
100
|
+
|
|
101
|
+
// 嵌套 grid 给一个明显偏离的位置,用于判断是否被错误命中
|
|
102
|
+
mockRect(nestedRow, { top: 360, left: 360, width: 200, height: 120 });
|
|
103
|
+
mockRect(nestedColumn, { top: 360, left: 360, width: 200, height: 120 });
|
|
104
|
+
mockRect(nestedItem, { top: 370, left: 370, width: 180, height: 90 });
|
|
105
|
+
|
|
106
|
+
const snapshot = buildLayoutSnapshot({ container });
|
|
107
|
+
const columnEdgeSlots = snapshot.slots.filter((slot) => slot.type === 'column-edge');
|
|
108
|
+
const columnSlots = snapshot.slots.filter((slot) => slot.type === 'column');
|
|
109
|
+
|
|
110
|
+
// 外层单行单列单项应只有 6 个 slot:上/下 row-gap + 左/右 column-edge + before/after column
|
|
111
|
+
expect(snapshot.slots).toHaveLength(6);
|
|
112
|
+
expect(columnEdgeSlots).toHaveLength(2);
|
|
113
|
+
expect(columnSlots).toHaveLength(2);
|
|
114
|
+
|
|
115
|
+
// 不应混入嵌套 grid(其 top >= 360)
|
|
116
|
+
expect(snapshot.slots.every((slot) => slot.rect.top < 300)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
32
120
|
describe('getSlotKey', () => {
|
|
33
121
|
it('should generate unique key for column slot', () => {
|
|
34
122
|
const slot: LayoutSlot = {
|
|
@@ -333,7 +333,10 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
|
|
|
333
333
|
|
|
334
334
|
const columnElements = Array.from(
|
|
335
335
|
container.querySelectorAll(`[data-grid-column-row-id="${rowId}"][data-grid-column-index]`),
|
|
336
|
-
)
|
|
336
|
+
).filter((el) => {
|
|
337
|
+
// 只保留当前 row 下的直接列,避免嵌套 Grid 中相同 rowId 的列混入
|
|
338
|
+
return (el as HTMLElement).closest('[data-grid-row-id]') === rowElement;
|
|
339
|
+
}) as HTMLElement[];
|
|
337
340
|
|
|
338
341
|
const sortedColumns = columnElements.sort((a, b) => {
|
|
339
342
|
const indexA = Number(a.dataset.gridColumnIndex || 0);
|
|
@@ -363,7 +366,10 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
|
|
|
363
366
|
|
|
364
367
|
const itemElements = Array.from(
|
|
365
368
|
columnElement.querySelectorAll(`[data-grid-item-row-id="${rowId}"][data-grid-column-index="${columnIndex}"]`),
|
|
366
|
-
)
|
|
369
|
+
).filter((el) => {
|
|
370
|
+
// 只保留当前 column 下的直接 item,避免命中更深层嵌套 column 的 item
|
|
371
|
+
return (el as HTMLElement).closest('[data-grid-column-row-id][data-grid-column-index]') === columnElement;
|
|
372
|
+
}) as HTMLElement[];
|
|
367
373
|
|
|
368
374
|
const sortedItems = itemElements.sort((a, b) => {
|
|
369
375
|
const indexA = Number(a.dataset.gridItemIndex || 0);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ExclamationCircleOutlined, MenuOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
|
11
|
+
import { css } from '@emotion/css';
|
|
11
12
|
import type { DropdownProps, MenuProps } from 'antd';
|
|
12
13
|
import { App, Dropdown, Modal, Tooltip, theme } from 'antd';
|
|
13
14
|
import React, { startTransition, useCallback, useEffect, useMemo, useState, FC } from 'react';
|
|
@@ -188,15 +189,42 @@ interface DefaultSettingsIconProps {
|
|
|
188
189
|
showCopyUidButton?: boolean;
|
|
189
190
|
menuLevels?: number; // Menu levels: 1=current model only (default), 2=include sub-models
|
|
190
191
|
flattenSubMenus?: boolean; // Whether to flatten sub-menus: false=group by model (default), true=flatten all
|
|
192
|
+
onDropdownVisibleChange?: (open: boolean) => void;
|
|
193
|
+
getPopupContainer?: DropdownProps['getPopupContainer'];
|
|
191
194
|
[key: string]: any; // Allow additional props
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
const TOOLBAR_ICONS_SELECTOR = '.nb-toolbar-container-icons';
|
|
198
|
+
const TOOLBAR_CONTAINER_SELECTOR = '.nb-toolbar-container';
|
|
199
|
+
const TOOLBAR_DROPDOWN_OVERLAY_CLASS = css`
|
|
200
|
+
width: max-content;
|
|
201
|
+
min-width: max-content;
|
|
202
|
+
|
|
203
|
+
.ant-dropdown-menu {
|
|
204
|
+
width: max-content;
|
|
205
|
+
min-width: max-content;
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const getToolbarPopupContainer = (triggerNode?: HTMLElement | null) => {
|
|
210
|
+
if (!triggerNode) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
(triggerNode.closest(TOOLBAR_ICONS_SELECTOR) as HTMLElement | null) ||
|
|
216
|
+
(triggerNode.closest(TOOLBAR_CONTAINER_SELECTOR) as HTMLElement | null)
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
194
220
|
export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
195
221
|
model,
|
|
196
222
|
showDeleteButton = true,
|
|
197
223
|
showCopyUidButton = true,
|
|
198
224
|
menuLevels = 1, // 默认一级菜单
|
|
199
225
|
flattenSubMenus = true,
|
|
226
|
+
onDropdownVisibleChange,
|
|
227
|
+
getPopupContainer,
|
|
200
228
|
}) => {
|
|
201
229
|
const { message } = App.useApp();
|
|
202
230
|
const t = useMemo(() => getT(model), [model]);
|
|
@@ -210,15 +238,38 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
210
238
|
const [isLoading, setIsLoading] = useState(true);
|
|
211
239
|
const closeDropdown = useCallback(() => {
|
|
212
240
|
setVisible(false);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
241
|
+
onDropdownVisibleChange?.(false);
|
|
242
|
+
}, [onDropdownVisibleChange]);
|
|
243
|
+
const resolvePopupContainer = useCallback<NonNullable<DropdownProps['getPopupContainer']>>(
|
|
244
|
+
(triggerNode) => {
|
|
245
|
+
// 工具栏自身容器必须优先,保证鼠标从 icon 移到菜单时仍处于同一 hover 树。
|
|
246
|
+
// 弹窗场景的裁剪问题由 useFloatToolbarPortal 负责把 toolbar 挂到正确的 popup host。
|
|
247
|
+
return (
|
|
248
|
+
getToolbarPopupContainer(triggerNode) ||
|
|
249
|
+
getPopupContainer?.(triggerNode) ||
|
|
250
|
+
triggerNode?.parentElement ||
|
|
251
|
+
document.body
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
[getPopupContainer],
|
|
255
|
+
);
|
|
256
|
+
const handleOpenChange: DropdownProps['onOpenChange'] = useCallback(
|
|
257
|
+
(nextOpen: boolean, info) => {
|
|
258
|
+
if (info.source === 'trigger' || nextOpen) {
|
|
259
|
+
// 当鼠标快速滑过时,终止菜单的渲染,防止卡顿
|
|
260
|
+
startTransition(() => {
|
|
261
|
+
setVisible(nextOpen);
|
|
262
|
+
});
|
|
263
|
+
onDropdownVisibleChange?.(nextOpen);
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
[onDropdownVisibleChange],
|
|
267
|
+
);
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
return () => {
|
|
270
|
+
onDropdownVisibleChange?.(false);
|
|
271
|
+
};
|
|
272
|
+
}, [onDropdownVisibleChange]);
|
|
222
273
|
const dropdownMaxHeight = useNiceDropdownMaxHeight([visible]);
|
|
223
274
|
useEffect(() => {
|
|
224
275
|
let mounted = true;
|
|
@@ -833,6 +884,9 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
|
|
|
833
884
|
|
|
834
885
|
return (
|
|
835
886
|
<Dropdown
|
|
887
|
+
getPopupContainer={resolvePopupContainer}
|
|
888
|
+
overlayClassName={TOOLBAR_DROPDOWN_OVERLAY_CLASS}
|
|
889
|
+
overlayStyle={{ width: 'max-content', minWidth: 'max-content' }}
|
|
836
890
|
onOpenChange={handleOpenChange}
|
|
837
891
|
open={visible}
|
|
838
892
|
menu={{
|