@nocobase/flow-engine 2.0.0-beta.2 → 2.0.0-beta.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.
Files changed (124) hide show
  1. package/lib/BlockScopedFlowEngine.js +0 -1
  2. package/lib/JSRunner.d.ts +6 -0
  3. package/lib/JSRunner.js +2 -1
  4. package/lib/ViewScopedFlowEngine.js +3 -0
  5. package/lib/acl/Acl.js +13 -3
  6. package/lib/components/dnd/gridDragPlanner.d.ts +1 -0
  7. package/lib/components/dnd/gridDragPlanner.js +53 -1
  8. package/lib/components/settings/wrappers/component/SwitchWithTitle.js +2 -1
  9. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +11 -3
  10. package/lib/components/variables/VariableInput.js +8 -2
  11. package/lib/data-source/index.js +6 -0
  12. package/lib/executor/FlowExecutor.d.ts +2 -1
  13. package/lib/executor/FlowExecutor.js +156 -22
  14. package/lib/flowContext.d.ts +4 -1
  15. package/lib/flowContext.js +176 -107
  16. package/lib/flowEngine.d.ts +21 -0
  17. package/lib/flowEngine.js +38 -0
  18. package/lib/flowSettings.js +12 -10
  19. package/lib/index.d.ts +3 -0
  20. package/lib/index.js +16 -0
  21. package/lib/models/CollectionFieldModel.d.ts +1 -0
  22. package/lib/models/CollectionFieldModel.js +3 -2
  23. package/lib/models/flowModel.d.ts +7 -0
  24. package/lib/models/flowModel.js +66 -1
  25. package/lib/provider.js +7 -6
  26. package/lib/resources/baseRecordResource.d.ts +5 -0
  27. package/lib/resources/baseRecordResource.js +24 -0
  28. package/lib/resources/multiRecordResource.d.ts +1 -0
  29. package/lib/resources/multiRecordResource.js +11 -4
  30. package/lib/resources/singleRecordResource.js +2 -0
  31. package/lib/resources/sqlResource.d.ts +1 -0
  32. package/lib/resources/sqlResource.js +8 -3
  33. package/lib/runjs-context/contexts/base.js +10 -4
  34. package/lib/runjsLibs.d.ts +28 -0
  35. package/lib/runjsLibs.js +532 -0
  36. package/lib/scheduler/ModelOperationScheduler.d.ts +2 -0
  37. package/lib/scheduler/ModelOperationScheduler.js +21 -21
  38. package/lib/types.d.ts +15 -0
  39. package/lib/utils/createCollectionContextMeta.js +1 -0
  40. package/lib/utils/index.d.ts +2 -0
  41. package/lib/utils/index.js +10 -0
  42. package/lib/utils/params-resolvers.js +16 -9
  43. package/lib/utils/resolveModuleUrl.d.ts +58 -0
  44. package/lib/utils/resolveModuleUrl.js +65 -0
  45. package/lib/utils/runjsModuleLoader.d.ts +58 -0
  46. package/lib/utils/runjsModuleLoader.js +422 -0
  47. package/lib/utils/runjsTemplateCompat.d.ts +35 -0
  48. package/lib/utils/runjsTemplateCompat.js +743 -0
  49. package/lib/utils/safeGlobals.d.ts +5 -9
  50. package/lib/utils/safeGlobals.js +129 -17
  51. package/lib/views/createViewMeta.d.ts +0 -7
  52. package/lib/views/createViewMeta.js +19 -70
  53. package/lib/views/index.d.ts +1 -2
  54. package/lib/views/index.js +4 -3
  55. package/lib/views/useDialog.js +8 -3
  56. package/lib/views/useDrawer.js +7 -2
  57. package/lib/views/usePage.d.ts +4 -0
  58. package/lib/views/usePage.js +43 -6
  59. package/lib/views/usePopover.js +4 -1
  60. package/lib/views/viewEvents.d.ts +17 -0
  61. package/lib/views/viewEvents.js +90 -0
  62. package/package.json +4 -4
  63. package/src/BlockScopedFlowEngine.ts +2 -5
  64. package/src/JSRunner.ts +8 -1
  65. package/src/ViewScopedFlowEngine.ts +4 -0
  66. package/src/__tests__/createViewMeta.popup.test.ts +62 -1
  67. package/src/__tests__/flowEngine.dataSourceDirty.test.ts +63 -0
  68. package/src/__tests__/flowSettings.open.test.tsx +69 -15
  69. package/src/__tests__/provider.test.tsx +0 -5
  70. package/src/__tests__/runjsExternalLibs.test.ts +242 -0
  71. package/src/__tests__/runjsLibsLazyLoading.test.ts +44 -0
  72. package/src/__tests__/runjsPreprocessDefault.test.ts +49 -0
  73. package/src/acl/Acl.tsx +3 -3
  74. package/src/components/__tests__/gridDragPlanner.test.ts +141 -1
  75. package/src/components/dnd/gridDragPlanner.ts +60 -0
  76. package/src/components/settings/wrappers/component/SwitchWithTitle.tsx +2 -1
  77. package/src/components/settings/wrappers/component/__tests__/InlineControls.test.tsx +74 -0
  78. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -3
  79. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +63 -4
  80. package/src/components/variables/VariableInput.tsx +8 -2
  81. package/src/data-source/index.ts +6 -0
  82. package/src/executor/FlowExecutor.ts +193 -23
  83. package/src/executor/__tests__/flowExecutor.test.ts +66 -0
  84. package/src/flowContext.ts +234 -118
  85. package/src/flowEngine.ts +41 -0
  86. package/src/flowSettings.ts +12 -11
  87. package/src/index.ts +10 -0
  88. package/src/models/CollectionFieldModel.tsx +3 -1
  89. package/src/models/__tests__/dispatchEvent.when.test.ts +356 -0
  90. package/src/models/__tests__/flowModel.clone.test.ts +416 -0
  91. package/src/models/__tests__/flowModel.test.ts +16 -0
  92. package/src/models/flowModel.tsx +94 -1
  93. package/src/provider.tsx +9 -7
  94. package/src/resources/__tests__/multiRecordResource.test.ts +44 -0
  95. package/src/resources/__tests__/sqlResource.test.ts +60 -0
  96. package/src/resources/baseRecordResource.ts +31 -0
  97. package/src/resources/multiRecordResource.ts +11 -4
  98. package/src/resources/singleRecordResource.ts +3 -0
  99. package/src/resources/sqlResource.ts +8 -3
  100. package/src/runjs-context/contexts/base.ts +9 -2
  101. package/src/runjsLibs.ts +622 -0
  102. package/src/scheduler/ModelOperationScheduler.ts +23 -21
  103. package/src/types.ts +26 -1
  104. package/src/utils/__tests__/params-resolvers.test.ts +40 -0
  105. package/src/utils/__tests__/runjsRequireAsyncAutoWhitelist.test.ts +38 -0
  106. package/src/utils/__tests__/runjsTemplateCompat.test.ts +159 -0
  107. package/src/utils/__tests__/safeGlobals.test.ts +49 -2
  108. package/src/utils/createCollectionContextMeta.ts +1 -0
  109. package/src/utils/index.ts +6 -0
  110. package/src/utils/params-resolvers.ts +23 -9
  111. package/src/utils/resolveModuleUrl.ts +91 -0
  112. package/src/utils/runjsModuleLoader.ts +553 -0
  113. package/src/utils/runjsTemplateCompat.ts +828 -0
  114. package/src/utils/safeGlobals.ts +133 -16
  115. package/src/views/__tests__/FlowView.usePage.test.tsx +54 -1
  116. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +35 -8
  117. package/src/views/__tests__/viewEvents.resolveOpenerEngine.test.ts +28 -0
  118. package/src/views/createViewMeta.ts +22 -75
  119. package/src/views/index.tsx +1 -2
  120. package/src/views/useDialog.tsx +9 -2
  121. package/src/views/useDrawer.tsx +8 -1
  122. package/src/views/usePage.tsx +51 -5
  123. package/src/views/usePopover.tsx +4 -1
  124. package/src/views/viewEvents.ts +55 -0
@@ -0,0 +1,242 @@
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
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { observable } from '@formily/reactive';
12
+
13
+ vi.mock('../utils/runjsModuleLoader', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import('../utils/runjsModuleLoader')>();
15
+ return {
16
+ ...actual,
17
+ runjsImportAsync: vi.fn(),
18
+ runjsRequireAsync: vi.fn(),
19
+ };
20
+ });
21
+
22
+ import { runjsImportAsync } from '../utils/runjsModuleLoader';
23
+ import { FlowEngine, FlowRunJSContext } from '..';
24
+ import { externalReactRender } from '../runjsLibs';
25
+
26
+ function newEngine(): FlowEngine {
27
+ const engine = new FlowEngine();
28
+ // 提供最小 api,避免 ctx.auth getter 在打印对象时抛错
29
+ engine.context.defineProperty('api', { value: { auth: { role: 'guest', locale: 'zh-CN', token: '' } } });
30
+ return engine;
31
+ }
32
+
33
+ beforeEach(() => {
34
+ (globalThis as any).__nocobaseImportAsyncCache = undefined;
35
+ (globalThis as any).__nbRunjsRoots = undefined;
36
+ (runjsImportAsync as any).mockReset();
37
+
38
+ if (typeof window !== 'undefined') {
39
+ (window as any).__esm_cdn_base_url__ = 'https://esm.sh';
40
+ (window as any).__esm_cdn_suffix__ = '';
41
+ }
42
+ });
43
+
44
+ describe('RunJS external libs', () => {
45
+ it('should override ctx.React/ReactDOM when importing external react', async () => {
46
+ const engine = newEngine();
47
+ const ctx = new FlowRunJSContext(engine.context);
48
+
49
+ const fakeReact = { createElement: vi.fn(), Fragment: Symbol('Fragment') };
50
+ const fakeReactDOM = { createRoot: vi.fn(() => ({ render: vi.fn(), unmount: vi.fn() })) };
51
+
52
+ (runjsImportAsync as any).mockImplementation(async (url: string) => {
53
+ if (url === 'https://esm.sh/react@18.2.0') return fakeReact;
54
+ if (url === 'https://esm.sh/react-dom@18.2.0/client') return fakeReactDOM;
55
+ throw new Error(`unexpected import url: ${url}`);
56
+ });
57
+
58
+ await ctx.importAsync('react@18.2.0');
59
+
60
+ expect(ctx.React).toBe(fakeReact);
61
+ expect(ctx.libs.React).toBe(fakeReact);
62
+ expect(ctx.ReactDOM).toBe(fakeReactDOM);
63
+ expect(ctx.libs.ReactDOM).toBe(fakeReactDOM);
64
+
65
+ expect(runjsImportAsync).toHaveBeenNthCalledWith(1, 'https://esm.sh/react@18.2.0');
66
+ expect(runjsImportAsync).toHaveBeenNthCalledWith(2, 'https://esm.sh/react-dom@18.2.0/client');
67
+ });
68
+
69
+ it('should override ctx.antd and ctx.libs.antd when importing antd', async () => {
70
+ const engine = newEngine();
71
+ const ctx = new FlowRunJSContext(engine.context);
72
+
73
+ const fakeAntd = { Button: 'Button' };
74
+
75
+ (runjsImportAsync as any).mockImplementation(async (url: string) => {
76
+ if (url === 'https://esm.sh/antd@5.29.3?bundle=1') return fakeAntd;
77
+ throw new Error(`unexpected import url: ${url}`);
78
+ });
79
+
80
+ await ctx.importAsync('antd@5.29.3');
81
+
82
+ expect(ctx.antd).toBe(fakeAntd);
83
+ expect(ctx.libs.antd).toBe(fakeAntd);
84
+ });
85
+
86
+ it('should isolate roots by rendererKey and unmount on ReactDOM switch', () => {
87
+ const engine = newEngine();
88
+ const ctx = new FlowRunJSContext(engine.context);
89
+
90
+ const root1 = { render: vi.fn(), unmount: vi.fn() };
91
+ const root2 = { render: vi.fn(), unmount: vi.fn() };
92
+
93
+ const renderer1 = { createRoot: vi.fn(() => root1) };
94
+ const renderer2 = { createRoot: vi.fn(() => root2) };
95
+
96
+ ctx.defineProperty('ReactDOM', { value: renderer1 });
97
+
98
+ const container = document.createElement('div');
99
+
100
+ ctx.render({ step: 1 }, container);
101
+ ctx.render({ step: 2 }, container);
102
+
103
+ expect(renderer1.createRoot).toHaveBeenCalledTimes(1);
104
+ expect(root1.render).toHaveBeenCalledTimes(2);
105
+
106
+ ctx.defineProperty('ReactDOM', { value: renderer2 });
107
+ ctx.render({ step: 3 }, container);
108
+
109
+ expect(root1.unmount).toHaveBeenCalledTimes(1);
110
+ expect(renderer2.createRoot).toHaveBeenCalledTimes(1);
111
+ expect(root2.render).toHaveBeenCalledTimes(1);
112
+ });
113
+
114
+ it('should not reuse roots across different ctx owners when rendererKey is the same', () => {
115
+ const engine1 = newEngine();
116
+ const ctx1 = new FlowRunJSContext(engine1.context);
117
+
118
+ const engine2 = newEngine();
119
+ const ctx2 = new FlowRunJSContext(engine2.context);
120
+
121
+ const root1 = { render: vi.fn(), unmount: vi.fn() };
122
+ const root2 = { render: vi.fn(), unmount: vi.fn() };
123
+
124
+ // 两个 ctx 共享同一个 ReactDOM 实例引用(rendererKey 相同),但 owner 不同;
125
+ // 若错误复用 entry,会导致旧 ctx 的 autorun 闭包继续持有并驱动新渲染,进而泄漏。
126
+ const renderer = {
127
+ createRoot: vi.fn(() => root1),
128
+ };
129
+ (renderer.createRoot as any).mockImplementationOnce(() => root1).mockImplementationOnce(() => root2);
130
+
131
+ ctx1.defineProperty('ReactDOM', { value: renderer });
132
+ ctx2.defineProperty('ReactDOM', { value: renderer });
133
+
134
+ const container = document.createElement('div');
135
+
136
+ ctx1.render({ step: 1 } as any, container);
137
+ ctx2.render({ step: 2 } as any, container);
138
+
139
+ expect(renderer.createRoot).toHaveBeenCalledTimes(2);
140
+ expect(root1.unmount).toHaveBeenCalledTimes(1);
141
+ expect(root2.render).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it('should wrap external antd ConfigProvider and rerender on themeToken change', async () => {
145
+ const engine = newEngine();
146
+
147
+ const themeState = observable.shallow({ token: { colorPrimary: 'red' } });
148
+ engine.context.defineProperty('themeToken', {
149
+ get: () => themeState.token,
150
+ cache: false,
151
+ });
152
+ engine.context.defineProperty('antdConfig', {
153
+ value: {
154
+ theme: { token: { colorPrimary: 'red' } },
155
+ prefixCls: 'ant',
156
+ },
157
+ });
158
+
159
+ const ctx = new FlowRunJSContext(engine.context);
160
+
161
+ const createElement = vi.fn((type: any, props: any, ...children: any[]) => ({ type, props, children }));
162
+ const fakeReact = { createElement };
163
+ const fakeAntdConfigProvider = function ConfigProvider() {};
164
+ const fakeAntdApp = function App() {};
165
+ const fakeAntd = { ConfigProvider: fakeAntdConfigProvider, App: fakeAntdApp };
166
+
167
+ const root = { render: vi.fn(), unmount: vi.fn() };
168
+ const fakeReactDOM = { createRoot: vi.fn(() => root) };
169
+
170
+ ctx.defineProperty('React', { value: fakeReact });
171
+ ctx.defineProperty('ReactDOM', { value: fakeReactDOM });
172
+ ctx.defineProperty('antd', { value: fakeAntd });
173
+
174
+ const container = document.createElement('div');
175
+ const vnode = { v: 1 };
176
+
177
+ ctx.render(vnode as any, container);
178
+
179
+ expect(root.render).toHaveBeenCalledTimes(1);
180
+ // createElement is called twice: App first, then ConfigProvider
181
+ expect(createElement).toHaveBeenNthCalledWith(
182
+ 2,
183
+ fakeAntdConfigProvider,
184
+ expect.objectContaining({ prefixCls: 'ant' }),
185
+ expect.anything(),
186
+ );
187
+
188
+ themeState.token = { colorPrimary: 'blue' };
189
+ await new Promise((resolve) => setTimeout(resolve, 0));
190
+
191
+ expect(root.render).toHaveBeenCalledTimes(2);
192
+
193
+ // cleanup (dispose autorun + unmount root)
194
+ ctx.render('', container);
195
+ });
196
+
197
+ it('should enhance hooks dispatcher-null TypeError with a helpful hint', () => {
198
+ const original = new TypeError(`Cannot read properties of null (reading 'useMemo')`);
199
+ // Mimic a real browser stack from ESM CDN where a dependency brings its own React.
200
+ (original as any).stack = [
201
+ `TypeError: Cannot read properties of null (reading 'useMemo')`,
202
+ ` at u.useMemo (https://esm.sh/react@19.2.4/es2022/react.mjs:2:7636)`,
203
+ ` at to (https://esm.sh/@dnd-kit/core@6.1.0/es2022/core.mjs:6:1574)`,
204
+ ].join('\n');
205
+
206
+ const root = {
207
+ render: vi.fn(() => {
208
+ throw original;
209
+ }),
210
+ unmount: vi.fn(),
211
+ };
212
+
213
+ const internalReact = {};
214
+ const internalAntd = {};
215
+ const ctx: any = {
216
+ React: internalReact,
217
+ ReactDOM: { __nbRunjsInternalShim: true },
218
+ antd: internalAntd,
219
+ };
220
+
221
+ const entry: any = { root };
222
+ const containerEl = document.createElement('div');
223
+
224
+ try {
225
+ externalReactRender({
226
+ ctx,
227
+ entry,
228
+ vnode: { v: 1 },
229
+ containerEl,
230
+ rootMap: new WeakMap(),
231
+ unmountContainerRoot: vi.fn(),
232
+ internalReact,
233
+ internalAntd,
234
+ });
235
+ expect.fail('expected externalReactRender to throw');
236
+ } catch (e: any) {
237
+ expect(String(e?.message || '')).toContain('[RunJS Hint]');
238
+ expect(String(e?.message || '')).toContain('await ctx.importAsync("react@19.2.4")');
239
+ expect(e?.cause).toBe(original);
240
+ }
241
+ });
242
+ });
@@ -0,0 +1,44 @@
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
+ import { describe, it, expect } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { registerRunJSLib } from '../runjsLibs';
13
+
14
+ describe('RunJS ctx.libs lazy loading', () => {
15
+ it('preloads member access via prepareRunJsCode injection', async () => {
16
+ registerRunJSLib('testLib', async () => ({ foo: 123 }));
17
+
18
+ const engine = new FlowEngine();
19
+ const ctx: any = engine.context;
20
+ const r = await ctx.runjs(`return ctx.libs.testLib.foo;`);
21
+ expect(r.success).toBe(true);
22
+ expect(r.value).toBe(123);
23
+ });
24
+
25
+ it('preloads bracket access via prepareRunJsCode injection', async () => {
26
+ registerRunJSLib('testLib', async () => ({ foo: 456 }));
27
+
28
+ const engine = new FlowEngine();
29
+ const ctx: any = engine.context;
30
+ const r = await ctx.runjs(`return ctx.libs['testLib'].foo;`);
31
+ expect(r.success).toBe(true);
32
+ expect(r.value).toBe(456);
33
+ });
34
+
35
+ it('preloads object destructuring via prepareRunJsCode injection', async () => {
36
+ registerRunJSLib('testLib', async () => ({ foo: 789 }));
37
+
38
+ const engine = new FlowEngine();
39
+ const ctx: any = engine.context;
40
+ const r = await ctx.runjs(`const { testLib } = ctx.libs; return testLib.foo;`);
41
+ expect(r.success).toBe(true);
42
+ expect(r.value).toBe(789);
43
+ });
44
+ });
@@ -0,0 +1,49 @@
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
+ import { describe, it, expect } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { prepareRunJsCode } from '../utils/runjsTemplateCompat';
13
+
14
+ describe('ctx.runjs preprocessTemplates default', () => {
15
+ it('enables template preprocess by default', async () => {
16
+ const engine = new FlowEngine();
17
+ const ctx = engine.context as any;
18
+ ctx.defineProperty('user', { value: { id: 123 } });
19
+
20
+ const r1 = await ctx.runjs('return {{ctx.user.id}};');
21
+ expect(r1.success).toBe(true);
22
+ expect(r1.value).toBe(123);
23
+
24
+ const r2 = await ctx.runjs('return "{{ctx.user.id}}";');
25
+ expect(r2.success).toBe(true);
26
+ expect(r2.value).toBe('123');
27
+ });
28
+
29
+ it('can disable template preprocess explicitly', async () => {
30
+ const engine = new FlowEngine();
31
+ const ctx = engine.context as any;
32
+ ctx.defineProperty('user', { value: { id: 123 } });
33
+
34
+ const r = await ctx.runjs('return "{{ctx.user.id}}";', undefined, { preprocessTemplates: false });
35
+ expect(r.success).toBe(true);
36
+ expect(r.value).toBe('{{ctx.user.id}}');
37
+ });
38
+
39
+ it('does not double-preprocess already prepared code', async () => {
40
+ const engine = new FlowEngine();
41
+ const ctx = engine.context as any;
42
+ ctx.defineProperty('user', { value: { id: 123 } });
43
+
44
+ const prepared = await prepareRunJsCode('return "{{ctx.user.id}}";', { preprocessTemplates: true });
45
+ const r = await ctx.runjs(prepared);
46
+ expect(r.success).toBe(true);
47
+ expect(r.value).toBe('123');
48
+ });
49
+ });
package/src/acl/Acl.tsx CHANGED
@@ -6,7 +6,7 @@
6
6
  * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
- import { omit } from 'lodash';
9
+ import _, { omit } from 'lodash';
10
10
  import { FlowEngine } from '../flowEngine';
11
11
  import { FlowModel } from '../models/flowModel';
12
12
 
@@ -31,11 +31,11 @@ export class ACL {
31
31
  constructor(private flowEngine: FlowEngine) {}
32
32
 
33
33
  setData(data: Record<string, any>) {
34
- this.data = data;
34
+ this.data = _.cloneDeep(data);
35
35
  }
36
36
 
37
37
  setMeta(data: Record<string, any>) {
38
- this.meta = data;
38
+ this.meta = _.cloneDeep(data);
39
39
  }
40
40
 
41
41
  async load() {
@@ -19,9 +19,14 @@ import {
19
19
 
20
20
  const rect = { top: 0, left: 0, width: 100, height: 100 };
21
21
 
22
- const createLayout = (rows: Record<string, string[][]>, sizes: Record<string, number[]>): GridLayoutData => ({
22
+ const createLayout = (
23
+ rows: Record<string, string[][]>,
24
+ sizes: Record<string, number[]>,
25
+ rowOrder?: string[],
26
+ ): GridLayoutData => ({
23
27
  rows,
24
28
  sizes,
29
+ rowOrder,
25
30
  });
26
31
 
27
32
  describe('getSlotKey', () => {
@@ -275,6 +280,7 @@ describe('simulateLayoutForSlot', () => {
275
280
  rowA: [24],
276
281
  rowB: [24],
277
282
  },
283
+ ['rowA', 'rowB'],
278
284
  );
279
285
 
280
286
  const slot: LayoutSlot = {
@@ -315,6 +321,33 @@ describe('simulateLayoutForSlot', () => {
315
321
  expect(result.sizes['row-new']).toEqual([24]);
316
322
  });
317
323
 
324
+ it('removes empty source row when moving item into empty container slot', () => {
325
+ const layout = createLayout(
326
+ {
327
+ rowA: [['block-x']],
328
+ },
329
+ {
330
+ rowA: [24],
331
+ },
332
+ );
333
+
334
+ const slot: LayoutSlot = {
335
+ type: 'empty-row',
336
+ rect,
337
+ };
338
+
339
+ const result = simulateLayoutForSlot({
340
+ slot,
341
+ sourceUid: 'block-x',
342
+ layout,
343
+ generateRowId: () => 'row-new',
344
+ });
345
+
346
+ expect(result.rows['row-new']).toEqual([['block-x']]);
347
+ expect(result.rows.rowA).toBeUndefined();
348
+ expect(result.sizes.rowA).toBeUndefined();
349
+ });
350
+
318
351
  it('handles column slot with after position', () => {
319
352
  const layout = createLayout(
320
353
  {
@@ -373,6 +406,7 @@ describe('simulateLayoutForSlot', () => {
373
406
  rowA: [24],
374
407
  rowB: [24],
375
408
  },
409
+ ['rowA', 'rowB'],
376
410
  );
377
411
 
378
412
  const slot: LayoutSlot = {
@@ -392,6 +426,112 @@ describe('simulateLayoutForSlot', () => {
392
426
  expect(Object.keys(result.rows)).toEqual(['rowA', 'row-inserted', 'rowB']);
393
427
  });
394
428
 
429
+ it('inserts row into rowOrder when dropping below target row', () => {
430
+ const layout = createLayout(
431
+ {
432
+ rowA: [['a']],
433
+ rowB: [['b']],
434
+ },
435
+ {
436
+ rowA: [24],
437
+ rowB: [24],
438
+ },
439
+ ['rowA', 'rowB'],
440
+ );
441
+
442
+ const slot: LayoutSlot = {
443
+ type: 'row-gap',
444
+ targetRowId: 'rowA',
445
+ position: 'below',
446
+ rect,
447
+ };
448
+
449
+ const result = simulateLayoutForSlot({
450
+ slot,
451
+ sourceUid: 'c',
452
+ layout,
453
+ generateRowId: () => 'row-new',
454
+ });
455
+
456
+ expect(result.rowOrder).toEqual(['rowA', 'row-new', 'rowB']);
457
+ });
458
+
459
+ it('maintains rowOrder and inserts new row before target when provided', () => {
460
+ const layout = createLayout(
461
+ {
462
+ rowA: [['a']],
463
+ rowB: [['b']],
464
+ },
465
+ {
466
+ rowA: [24],
467
+ rowB: [24],
468
+ },
469
+ ['rowA', 'rowB'],
470
+ );
471
+
472
+ const slot: LayoutSlot = {
473
+ type: 'row-gap',
474
+ targetRowId: 'rowB',
475
+ position: 'above',
476
+ rect,
477
+ };
478
+
479
+ const result = simulateLayoutForSlot({
480
+ slot,
481
+ sourceUid: 'c',
482
+ layout,
483
+ generateRowId: () => 'row-new',
484
+ });
485
+
486
+ expect(result.rowOrder).toEqual(['rowA', 'row-new', 'rowB']);
487
+ expect(result.rows).toEqual({
488
+ rowA: [['a']],
489
+ 'row-new': [['c']],
490
+ rowB: [['b']],
491
+ });
492
+ expect(result.sizes).toEqual({
493
+ rowA: [24],
494
+ 'row-new': [24],
495
+ rowB: [24],
496
+ });
497
+ });
498
+
499
+ it('derives rowOrder from rows when missing and removes empty rows from order', () => {
500
+ const layout = createLayout(
501
+ {
502
+ row1: [['a']],
503
+ row2: [['b']],
504
+ row3: [['c']],
505
+ },
506
+ {
507
+ row1: [24],
508
+ row2: [24],
509
+ row3: [24],
510
+ },
511
+ );
512
+
513
+ const slot: LayoutSlot = {
514
+ type: 'column',
515
+ rowId: 'row1',
516
+ columnIndex: 0,
517
+ insertIndex: 0,
518
+ position: 'before',
519
+ rect,
520
+ };
521
+
522
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'b', layout });
523
+
524
+ expect(result.rowOrder).toEqual(['row1', 'row3']);
525
+ expect(result.rows).toEqual({
526
+ row1: [['b', 'a']],
527
+ row3: [['c']],
528
+ });
529
+ expect(result.sizes).toEqual({
530
+ row1: [24],
531
+ row3: [24],
532
+ });
533
+ });
534
+
395
535
  it('handles empty-column slot by replacing empty column', () => {
396
536
  const layout = createLayout(
397
537
  {
@@ -46,6 +46,7 @@ export interface Point {
46
46
  export interface GridLayoutData {
47
47
  rows: Record<string, string[][]>;
48
48
  sizes: Record<string, number[]>;
49
+ rowOrder?: string[];
49
50
  }
50
51
 
51
52
  export interface ColumnSlot {
@@ -142,6 +143,49 @@ export interface LayoutSnapshot {
142
143
  containerRect: Rect;
143
144
  }
144
145
 
146
+ const deriveRowOrder = (rows: Record<string, string[][]>, provided?: string[]) => {
147
+ const order: string[] = [];
148
+ const used = new Set<string>();
149
+
150
+ (provided || Object.keys(rows)).forEach((rowId) => {
151
+ if (rows[rowId] && !used.has(rowId)) {
152
+ order.push(rowId);
153
+ used.add(rowId);
154
+ }
155
+ });
156
+
157
+ Object.keys(rows).forEach((rowId) => {
158
+ if (!used.has(rowId)) {
159
+ order.push(rowId);
160
+ used.add(rowId);
161
+ }
162
+ });
163
+
164
+ return order;
165
+ };
166
+
167
+ const normalizeRowsWithOrder = (rows: Record<string, string[][]>, order: string[]) => {
168
+ const next: Record<string, string[][]> = {};
169
+ order.forEach((rowId) => {
170
+ if (rows[rowId]) {
171
+ next[rowId] = rows[rowId];
172
+ }
173
+ });
174
+ Object.keys(rows).forEach((rowId) => {
175
+ if (!next[rowId]) {
176
+ next[rowId] = rows[rowId];
177
+ }
178
+ });
179
+ return next;
180
+ };
181
+
182
+ const ensureRowOrder = (layout: GridLayoutData) => {
183
+ const order = deriveRowOrder(layout.rows, layout.rowOrder);
184
+ layout.rowOrder = order;
185
+ layout.rows = normalizeRowsWithOrder(layout.rows, order);
186
+ return order;
187
+ };
188
+
145
189
  export interface BuildLayoutSnapshotOptions {
146
190
  container: HTMLElement | null;
147
191
  }
@@ -465,10 +509,12 @@ const removeItemFromLayout = (layout: GridLayoutData, uidValue: string) => {
465
509
  if (columns.length === 0) {
466
510
  delete layout.rows[rowId];
467
511
  delete layout.sizes[rowId];
512
+ ensureRowOrder(layout);
468
513
  return;
469
514
  }
470
515
 
471
516
  normalizeRowSizes(rowId, layout);
517
+ ensureRowOrder(layout);
472
518
  };
473
519
 
474
520
  const toIntSizes = (weights: number[], count: number): number[] => {
@@ -592,8 +638,10 @@ export const simulateLayoutForSlot = ({
592
638
  const cloned: GridLayoutData = {
593
639
  rows: _.cloneDeep(layout.rows),
594
640
  sizes: _.cloneDeep(layout.sizes),
641
+ rowOrder: layout.rowOrder ? [...layout.rowOrder] : undefined,
595
642
  };
596
643
 
644
+ ensureRowOrder(cloned);
597
645
  removeItemFromLayout(cloned, sourceUid);
598
646
 
599
647
  const createRowId = generateRowId ?? uid;
@@ -638,8 +686,16 @@ export const simulateLayoutForSlot = ({
638
686
  case 'row-gap': {
639
687
  const newRowId = createRowId();
640
688
  const rowPosition: 'before' | 'after' = slot.position === 'above' ? 'before' : 'after';
689
+ const currentOrder = deriveRowOrder(cloned.rows, cloned.rowOrder);
641
690
  cloned.rows = insertRow(cloned.rows, slot.targetRowId, newRowId, rowPosition, [[sourceUid]]);
642
691
  cloned.sizes[newRowId] = [DEFAULT_GRID_COLUMNS];
692
+ const targetIndex = currentOrder.indexOf(slot.targetRowId);
693
+ const insertIndex =
694
+ targetIndex === -1 ? currentOrder.length : rowPosition === 'before' ? targetIndex : targetIndex + 1;
695
+ const nextOrder = [...currentOrder];
696
+ nextOrder.splice(insertIndex, 0, newRowId);
697
+ cloned.rowOrder = nextOrder;
698
+ cloned.rows = normalizeRowsWithOrder(cloned.rows, nextOrder);
643
699
  break;
644
700
  }
645
701
  case 'empty-row': {
@@ -649,11 +705,15 @@ export const simulateLayoutForSlot = ({
649
705
  [newRowId]: [[sourceUid]],
650
706
  };
651
707
  cloned.sizes[newRowId] = [DEFAULT_GRID_COLUMNS];
708
+ const currentOrder = deriveRowOrder(cloned.rows, cloned.rowOrder);
709
+ cloned.rowOrder = [...currentOrder.filter((id) => id !== newRowId), newRowId];
710
+ cloned.rows = normalizeRowsWithOrder(cloned.rows, cloned.rowOrder);
652
711
  break;
653
712
  }
654
713
  default:
655
714
  break;
656
715
  }
657
716
 
717
+ ensureRowOrder(cloned);
658
718
  return cloned;
659
719
  };
@@ -52,7 +52,8 @@ export const SwitchWithTitle: FC = observer(
52
52
  };
53
53
 
54
54
  // 点击整个容器时触发
55
- const handleWrapperClick = () => {
55
+ const handleWrapperClick = (e: React.MouseEvent) => {
56
+ e.stopPropagation();
56
57
  if (disabled) return;
57
58
  handleChange(!checked);
58
59
  };