@nocobase/flow-engine 2.1.0-beta.9 → 2.2.0-alpha.1

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 (215) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/FieldModelRenderer.js +2 -2
  4. package/lib/components/FlowModelRenderer.d.ts +3 -1
  5. package/lib/components/FlowModelRenderer.js +12 -6
  6. package/lib/components/FormItem.d.ts +6 -0
  7. package/lib/components/FormItem.js +11 -3
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  10. package/lib/components/dnd/gridDragPlanner.js +607 -19
  11. package/lib/components/dnd/index.d.ts +31 -2
  12. package/lib/components/dnd/index.js +244 -23
  13. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  15. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +152 -42
  17. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  19. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  20. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  23. package/lib/components/subModel/AddSubModelButton.js +12 -1
  24. package/lib/components/subModel/LazyDropdown.js +301 -52
  25. package/lib/components/subModel/index.d.ts +1 -0
  26. package/lib/components/subModel/index.js +19 -0
  27. package/lib/components/subModel/utils.d.ts +2 -1
  28. package/lib/components/subModel/utils.js +15 -5
  29. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  30. package/lib/components/variables/VariableHybridInput.js +499 -0
  31. package/lib/components/variables/index.d.ts +2 -0
  32. package/lib/components/variables/index.js +3 -0
  33. package/lib/data-source/index.d.ts +84 -0
  34. package/lib/data-source/index.js +269 -7
  35. package/lib/executor/FlowExecutor.js +6 -3
  36. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  37. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  38. package/lib/flow-registry/index.d.ts +1 -0
  39. package/lib/flow-registry/index.js +3 -1
  40. package/lib/flowContext.d.ts +9 -1
  41. package/lib/flowContext.js +77 -6
  42. package/lib/flowEngine.d.ts +136 -4
  43. package/lib/flowEngine.js +429 -51
  44. package/lib/flowI18n.js +2 -1
  45. package/lib/flowSettings.d.ts +14 -6
  46. package/lib/flowSettings.js +34 -6
  47. package/lib/index.d.ts +2 -0
  48. package/lib/index.js +7 -0
  49. package/lib/lazy-helper.d.ts +14 -0
  50. package/lib/lazy-helper.js +71 -0
  51. package/lib/locale/en-US.json +1 -0
  52. package/lib/locale/index.d.ts +2 -0
  53. package/lib/locale/zh-CN.json +1 -0
  54. package/lib/models/DisplayItemModel.d.ts +1 -1
  55. package/lib/models/EditableItemModel.d.ts +1 -1
  56. package/lib/models/FilterableItemModel.d.ts +1 -1
  57. package/lib/models/flowModel.d.ts +13 -10
  58. package/lib/models/flowModel.js +126 -34
  59. package/lib/provider.js +38 -23
  60. package/lib/reactive/observer.js +46 -16
  61. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  62. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  63. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  64. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  65. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  66. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  67. package/lib/runjs-context/contexts/base.js +464 -29
  68. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  69. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  70. package/lib/runjs-context/setup.js +1 -0
  71. package/lib/runjs-context/snippets/index.js +13 -2
  72. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  74. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  75. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/loadedPageCache.d.ts +24 -0
  82. package/lib/utils/loadedPageCache.js +139 -0
  83. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  84. package/lib/utils/parsePathnameToViewParams.js +28 -4
  85. package/lib/utils/randomId.d.ts +39 -0
  86. package/lib/utils/randomId.js +45 -0
  87. package/lib/utils/runjsTemplateCompat.js +1 -1
  88. package/lib/utils/runjsValue.js +41 -11
  89. package/lib/utils/schema-utils.d.ts +7 -1
  90. package/lib/utils/schema-utils.js +19 -0
  91. package/lib/views/FlowView.d.ts +7 -1
  92. package/lib/views/FlowView.js +11 -1
  93. package/lib/views/PageComponent.js +8 -6
  94. package/lib/views/ViewNavigation.d.ts +12 -2
  95. package/lib/views/ViewNavigation.js +28 -9
  96. package/lib/views/createViewMeta.js +114 -50
  97. package/lib/views/inheritLayoutContext.d.ts +10 -0
  98. package/lib/views/inheritLayoutContext.js +50 -0
  99. package/lib/views/runViewBeforeClose.d.ts +10 -0
  100. package/lib/views/runViewBeforeClose.js +45 -0
  101. package/lib/views/useDialog.d.ts +2 -1
  102. package/lib/views/useDialog.js +12 -3
  103. package/lib/views/useDrawer.d.ts +2 -1
  104. package/lib/views/useDrawer.js +12 -3
  105. package/lib/views/usePage.d.ts +5 -11
  106. package/lib/views/usePage.js +304 -144
  107. package/package.json +5 -4
  108. package/src/FlowContextProvider.tsx +9 -1
  109. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  110. package/src/__tests__/flow-engine.test.ts +166 -0
  111. package/src/__tests__/flowContext.test.ts +105 -1
  112. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  113. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +21 -0
  120. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  121. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  122. package/src/__tests__/runjsLocales.test.ts +6 -5
  123. package/src/__tests__/runjsSnippets.test.ts +21 -0
  124. package/src/__tests__/viewScopedFlowEngine.test.ts +136 -3
  125. package/src/components/FieldModelRenderer.tsx +2 -1
  126. package/src/components/FlowModelRenderer.tsx +18 -6
  127. package/src/components/FormItem.tsx +7 -1
  128. package/src/components/MobilePopup.tsx +4 -2
  129. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  130. package/src/components/__tests__/FormItem.test.tsx +25 -0
  131. package/src/components/__tests__/dnd.test.ts +44 -0
  132. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  133. package/src/components/__tests__/gridDragPlanner.test.ts +472 -5
  134. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  135. package/src/components/dnd/gridDragPlanner.ts +750 -17
  136. package/src/components/dnd/index.tsx +305 -28
  137. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  138. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +178 -48
  139. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +344 -8
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +16 -2
  145. package/src/components/subModel/LazyDropdown.tsx +341 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +524 -38
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +13 -2
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +69 -2
  154. package/src/data-source/index.ts +332 -8
  155. package/src/executor/FlowExecutor.ts +6 -3
  156. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +85 -6
  161. package/src/flowEngine.ts +484 -45
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  172. package/src/models/__tests__/flowModel.test.ts +65 -37
  173. package/src/models/flowModel.tsx +184 -65
  174. package/src/provider.tsx +41 -25
  175. package/src/reactive/__tests__/observer.test.tsx +82 -0
  176. package/src/reactive/observer.tsx +87 -25
  177. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  178. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  179. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  180. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  181. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  182. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  183. package/src/runjs-context/contexts/base.ts +467 -31
  184. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  185. package/src/runjs-context/setup.ts +1 -0
  186. package/src/runjs-context/snippets/index.ts +12 -1
  187. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  188. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  189. package/src/types.ts +62 -0
  190. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  191. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  192. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  193. package/src/utils/__tests__/utils.test.ts +62 -0
  194. package/src/utils/createCollectionContextMeta.ts +6 -2
  195. package/src/utils/index.ts +5 -1
  196. package/src/utils/loadedPageCache.ts +147 -0
  197. package/src/utils/parsePathnameToViewParams.ts +45 -5
  198. package/src/utils/randomId.ts +48 -0
  199. package/src/utils/runjsTemplateCompat.ts +1 -1
  200. package/src/utils/runjsValue.ts +50 -11
  201. package/src/utils/schema-utils.ts +30 -1
  202. package/src/views/FlowView.tsx +22 -2
  203. package/src/views/PageComponent.tsx +7 -4
  204. package/src/views/ViewNavigation.ts +46 -9
  205. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  206. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  207. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  208. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  209. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +12 -12
  210. package/src/views/createViewMeta.ts +106 -34
  211. package/src/views/inheritLayoutContext.ts +26 -0
  212. package/src/views/runViewBeforeClose.ts +19 -0
  213. package/src/views/useDialog.tsx +13 -3
  214. package/src/views/useDrawer.tsx +13 -3
  215. package/src/views/usePage.tsx +367 -180
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import React from 'react';
11
- import { act, render, screen, userEvent, waitFor } from '@nocobase/test/client';
11
+ import { act, fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/client';
12
12
  import { vi, beforeEach } from 'vitest';
13
13
  import {
14
14
  AddSubModelButton,
@@ -21,11 +21,13 @@ import {
21
21
  import { SubModelItem, mergeSubModelItems, transformItems } from '../AddSubModelButton';
22
22
  import { App, ConfigProvider } from 'antd';
23
23
 
24
+ const getSubmenuTitle = (label: string) => screen.getByText(label).closest('.ant-dropdown-menu-submenu-title');
25
+
24
26
  describe('AddSubModelButton - preset settings open on add', () => {
25
27
  test('calls openFlowSettings with preset=true for subModel with preset steps', async () => {
26
28
  // Arrange: set up engine and models
27
29
  const engine = new FlowEngine();
28
- engine.flowSettings.forceEnable();
30
+ await engine.flowSettings.forceEnable();
29
31
 
30
32
  class ParentModel extends FlowModel {}
31
33
 
@@ -99,12 +101,70 @@ describe('AddSubModelButton - preset settings open on add', () => {
99
101
  });
100
102
  });
101
103
 
104
+ describe('AddSubModelButton - model loader integration', () => {
105
+ test('resolves model loaders before creating sub models', async () => {
106
+ const engine = new FlowEngine();
107
+ await engine.flowSettings.forceEnable();
108
+
109
+ class ParentModel extends FlowModel {}
110
+ class ChildModel extends FlowModel {}
111
+
112
+ const childLoader = vi.fn(async () => ({ ChildModel }));
113
+
114
+ engine.registerModels({ ParentModel });
115
+ engine.registerModelLoaders({
116
+ ChildModel: {
117
+ loader: childLoader,
118
+ },
119
+ });
120
+
121
+ const parent = engine.createModel<ParentModel>({ use: 'ParentModel', uid: 'parent-loader' });
122
+
123
+ render(
124
+ <FlowEngineProvider engine={engine}>
125
+ <ConfigProvider>
126
+ <App>
127
+ <AddSubModelButton
128
+ model={parent}
129
+ subModelKey="items"
130
+ items={[
131
+ {
132
+ key: 'child',
133
+ label: 'Add Child',
134
+ createModelOptions: { use: 'ChildModel' },
135
+ },
136
+ ]}
137
+ >
138
+ Add SubModel
139
+ </AddSubModelButton>
140
+ </App>
141
+ </ConfigProvider>
142
+ </FlowEngineProvider>,
143
+ );
144
+
145
+ await act(async () => {
146
+ await userEvent.click(screen.getByText('Add SubModel'));
147
+ });
148
+
149
+ await waitFor(() => expect(screen.getByText('Add Child')).toBeInTheDocument());
150
+
151
+ await act(async () => {
152
+ await userEvent.click(screen.getByText('Add Child'));
153
+ });
154
+
155
+ await waitFor(() => expect(childLoader).toHaveBeenCalledTimes(1));
156
+ const items = parent.subModels.items as FlowModel[];
157
+ expect(Array.isArray(items)).toBe(true);
158
+ expect(items[0]).toBeInstanceOf(ChildModel);
159
+ });
160
+ });
161
+
102
162
  describe('AddSubModelButton - async group children (nested)', () => {
103
163
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
104
164
 
105
165
  it('renders group and nested async group leaf items', async () => {
106
166
  const engine = new FlowEngine();
107
- engine.flowSettings.forceEnable();
167
+ await engine.flowSettings.forceEnable();
108
168
  class Parent extends FlowModel {}
109
169
  engine.registerModels({ Parent });
110
170
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p1' });
@@ -157,12 +217,74 @@ describe('AddSubModelButton - async group children (nested)', () => {
157
217
  await waitFor(() => expect(screen.getByText('Nested-Leaf-1')).toBeInTheDocument());
158
218
  await waitFor(() => expect(screen.getByText('Nested-Leaf-2')).toBeInTheDocument());
159
219
  });
220
+
221
+ it('keeps root dropdown open while only the current nested group stays expanded', async () => {
222
+ const engine = new FlowEngine();
223
+ await engine.flowSettings.forceEnable();
224
+ class Parent extends FlowModel {}
225
+ engine.registerModels({ Parent });
226
+ const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-multi-open' });
227
+
228
+ const items = [
229
+ {
230
+ key: 'group-a',
231
+ label: 'Group A',
232
+ children: [
233
+ { key: 'a-1', label: 'A-1', createModelOptions: { use: 'Parent' } },
234
+ { key: 'a-2', label: 'A-2', createModelOptions: { use: 'Parent' } },
235
+ ],
236
+ },
237
+ {
238
+ key: 'group-b',
239
+ label: 'Group B',
240
+ children: [
241
+ { key: 'b-1', label: 'B-1', createModelOptions: { use: 'Parent' } },
242
+ { key: 'b-2', label: 'B-2', createModelOptions: { use: 'Parent' } },
243
+ ],
244
+ },
245
+ ];
246
+
247
+ render(
248
+ <FlowEngineProvider engine={engine}>
249
+ <ConfigProvider>
250
+ <App>
251
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
252
+ Open Menu
253
+ </AddSubModelButton>
254
+ </App>
255
+ </ConfigProvider>
256
+ </FlowEngineProvider>,
257
+ );
258
+
259
+ await act(async () => {
260
+ await userEvent.click(screen.getByText('Open Menu'));
261
+ });
262
+
263
+ await waitFor(() => expect(screen.getByText('Group A')).toBeInTheDocument());
264
+ await waitFor(() => expect(screen.getByText('Group B')).toBeInTheDocument());
265
+
266
+ await act(async () => {
267
+ await userEvent.hover(screen.getByText('Group A'));
268
+ });
269
+ await waitFor(() => expect(screen.getByText('A-1')).toBeInTheDocument());
270
+ await waitFor(() => expect(getSubmenuTitle('Group A')).toHaveAttribute('aria-expanded', 'true'));
271
+ expect(getSubmenuTitle('Group B')).toHaveAttribute('aria-expanded', 'false');
272
+
273
+ await act(async () => {
274
+ await userEvent.hover(screen.getByText('Group B'));
275
+ });
276
+ await waitFor(() => expect(screen.getByText('B-1')).toBeInTheDocument());
277
+ await waitFor(() => expect(getSubmenuTitle('Group B')).toHaveAttribute('aria-expanded', 'true'));
278
+ expect(getSubmenuTitle('Group A')).toHaveAttribute('aria-expanded', 'false');
279
+ expect(screen.getByText('Group A')).toBeInTheDocument();
280
+ expect(screen.getByText('Group B')).toBeInTheDocument();
281
+ });
160
282
  });
161
283
 
162
284
  describe('transformItems - searchable flags', () => {
163
285
  it('preserves searchable + placeholder on non-group submenu items', async () => {
164
286
  const engine = new FlowEngine();
165
- engine.flowSettings.forceEnable();
287
+ await engine.flowSettings.forceEnable();
166
288
  class Parent extends FlowModel {}
167
289
  engine.registerModels({ Parent });
168
290
  const parent = engine.createModel<FlowModel>({ use: 'Parent' });
@@ -188,12 +310,309 @@ describe('transformItems - searchable flags', () => {
188
310
  expect(submenu.searchPlaceholder).toBe('Search blocks');
189
311
  expect(Array.isArray(submenu.children)).toBe(true);
190
312
  });
313
+
314
+ it('filters searchable field menus by display label instead of item key', async () => {
315
+ const engine = new FlowEngine();
316
+ await engine.flowSettings.forceEnable();
317
+ const parent = engine.createModel<FlowModel>({ use: FlowModel });
318
+ const user = userEvent.setup();
319
+
320
+ const items = [
321
+ {
322
+ key: 'fields',
323
+ label: '',
324
+ type: 'group' as const,
325
+ searchable: true,
326
+ searchPlaceholder: 'Search fields',
327
+ children: [
328
+ { key: 'field_name', label: 'Field display name' },
329
+ { key: 'other_field', label: 'Other field' },
330
+ ],
331
+ },
332
+ ];
333
+
334
+ render(
335
+ <FlowEngineProvider engine={engine}>
336
+ <ConfigProvider>
337
+ <App>
338
+ <AddSubModelButton model={parent} items={items as any} subModelKey="items">
339
+ Open
340
+ </AddSubModelButton>
341
+ </App>
342
+ </ConfigProvider>
343
+ </FlowEngineProvider>,
344
+ );
345
+
346
+ await user.click(screen.getByText('Open'));
347
+ const searchInput = await screen.findByPlaceholderText('Search fields');
348
+ expect(screen.getByText('Field display name')).toBeInTheDocument();
349
+
350
+ await user.type(searchInput, 'field_name');
351
+ await waitFor(() => expect(screen.queryByText('Field display name')).not.toBeInTheDocument());
352
+
353
+ await user.clear(searchInput);
354
+ await user.type(searchInput, 'display');
355
+ await waitFor(() => expect(screen.getByText('Field display name')).toBeInTheDocument());
356
+ });
357
+
358
+ it('keeps searchable submenu children during IME composition', async () => {
359
+ const engine = new FlowEngine();
360
+ await engine.flowSettings.forceEnable();
361
+ class Parent extends FlowModel {}
362
+ engine.registerModels({ Parent });
363
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
364
+
365
+ const items = [
366
+ {
367
+ key: 'fields',
368
+ label: 'Fields',
369
+ searchable: true,
370
+ children: [
371
+ { key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
372
+ { key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
373
+ ],
374
+ },
375
+ ];
376
+
377
+ const user = userEvent.setup();
378
+ render(
379
+ <FlowEngineProvider engine={engine}>
380
+ <ConfigProvider>
381
+ <App>
382
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
383
+ Open
384
+ </AddSubModelButton>
385
+ </App>
386
+ </ConfigProvider>
387
+ </FlowEngineProvider>,
388
+ );
389
+
390
+ await user.click(screen.getByText('Open'));
391
+ await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
392
+ await user.hover(screen.getByText('Fields'));
393
+ await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
394
+
395
+ const input = screen.getByRole('textbox');
396
+ await user.click(input);
397
+ fireEvent.compositionStart(input);
398
+ fireEvent.change(input, { target: { value: 'zzzz' }, nativeEvent: { isComposing: true } });
399
+ fireEvent.mouseLeave(screen.getByText('Fields'));
400
+ await act(async () => {
401
+ await new Promise((resolve) => setTimeout(resolve, 300));
402
+ });
403
+
404
+ expect(input).toHaveValue('zzzz');
405
+ expect(screen.getByText('Field 1')).toBeInTheDocument();
406
+ expect(screen.getByText('Field 2')).toBeInTheDocument();
407
+
408
+ fireEvent.compositionEnd(input);
409
+ fireEvent.change(input, { target: { value: 'zzzz' } });
410
+
411
+ await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
412
+ });
413
+
414
+ it('closes searchable submenu after focus without input', async () => {
415
+ const engine = new FlowEngine();
416
+ await engine.flowSettings.forceEnable();
417
+ class Parent extends FlowModel {}
418
+ engine.registerModels({ Parent });
419
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
420
+
421
+ const items = [
422
+ {
423
+ key: 'fields',
424
+ label: 'Fields',
425
+ searchable: true,
426
+ children: [
427
+ { key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
428
+ { key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
429
+ ],
430
+ },
431
+ ];
432
+
433
+ const user = userEvent.setup();
434
+ render(
435
+ <FlowEngineProvider engine={engine}>
436
+ <ConfigProvider>
437
+ <App>
438
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
439
+ Open
440
+ </AddSubModelButton>
441
+ </App>
442
+ </ConfigProvider>
443
+ </FlowEngineProvider>,
444
+ );
445
+
446
+ await user.click(screen.getByText('Open'));
447
+ await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
448
+ await user.hover(screen.getByText('Fields'));
449
+ await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
450
+
451
+ await user.click(screen.getByRole('textbox'));
452
+ fireEvent.mouseLeave(screen.getByText('Fields'));
453
+
454
+ await waitFor(() => expect(screen.queryByText('Field 1')).not.toBeInTheDocument());
455
+ });
456
+
457
+ it('closes active searchable submenu after outside click', async () => {
458
+ const engine = new FlowEngine();
459
+ await engine.flowSettings.forceEnable();
460
+ class Parent extends FlowModel {}
461
+ engine.registerModels({ Parent });
462
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
463
+
464
+ const items = [
465
+ {
466
+ key: 'fields',
467
+ label: 'Fields',
468
+ searchable: true,
469
+ children: [
470
+ { key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
471
+ { key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
472
+ ],
473
+ },
474
+ ];
475
+
476
+ const user = userEvent.setup();
477
+ render(
478
+ <FlowEngineProvider engine={engine}>
479
+ <ConfigProvider>
480
+ <App>
481
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
482
+ Open
483
+ </AddSubModelButton>
484
+ </App>
485
+ </ConfigProvider>
486
+ </FlowEngineProvider>,
487
+ );
488
+
489
+ await user.click(screen.getByText('Open'));
490
+ await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
491
+ await user.hover(screen.getByText('Fields'));
492
+ await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
493
+
494
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Field' } });
495
+ expect(screen.getByText('Field 1')).toBeInTheDocument();
496
+
497
+ fireEvent.pointerDown(document.body);
498
+
499
+ await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
500
+ });
501
+
502
+ it('switches away from active searchable submenu and resets its input', async () => {
503
+ const engine = new FlowEngine();
504
+ await engine.flowSettings.forceEnable();
505
+ class Parent extends FlowModel {}
506
+ engine.registerModels({ Parent });
507
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
508
+
509
+ const items = [
510
+ {
511
+ key: 'fields',
512
+ label: 'Fields',
513
+ searchable: true,
514
+ children: [
515
+ { key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
516
+ { key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
517
+ ],
518
+ },
519
+ {
520
+ key: 'blocks',
521
+ label: 'Blocks',
522
+ searchable: true,
523
+ children: [
524
+ { key: 'b1', label: 'Block 1', createModelOptions: { use: 'Parent' } },
525
+ { key: 'b2', label: 'Block 2', createModelOptions: { use: 'Parent' } },
526
+ ],
527
+ },
528
+ ];
529
+
530
+ const user = userEvent.setup();
531
+ render(
532
+ <FlowEngineProvider engine={engine}>
533
+ <ConfigProvider>
534
+ <App>
535
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
536
+ Open
537
+ </AddSubModelButton>
538
+ </App>
539
+ </ConfigProvider>
540
+ </FlowEngineProvider>,
541
+ );
542
+
543
+ await user.click(screen.getByText('Open'));
544
+ await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
545
+ await user.hover(screen.getByText('Fields'));
546
+ await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
547
+
548
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzzz' } });
549
+ await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
550
+
551
+ await user.hover(screen.getByText('Blocks'));
552
+ await waitFor(() => expect(screen.getByText('Block 1')).toBeInTheDocument());
553
+ expect(screen.queryByText('No data')).not.toBeInTheDocument();
554
+
555
+ await user.hover(screen.getByText('Fields'));
556
+ await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
557
+ expect(screen.getByRole('textbox')).toHaveValue('');
558
+ });
559
+
560
+ it('keeps root group search value when hovering a sibling submenu', async () => {
561
+ const engine = new FlowEngine();
562
+ await engine.flowSettings.forceEnable();
563
+ class Parent extends FlowModel {}
564
+ engine.registerModels({ Parent });
565
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
566
+
567
+ const items = [
568
+ {
569
+ key: 'fields',
570
+ label: '',
571
+ type: 'group' as const,
572
+ searchable: true,
573
+ searchPlaceholder: 'Search fields',
574
+ children: [
575
+ { key: 'nickname', label: 'Nickname', createModelOptions: { use: 'Parent' } },
576
+ { key: 'email', label: 'Email', createModelOptions: { use: 'Parent' } },
577
+ ],
578
+ },
579
+ {
580
+ key: 'association-fields',
581
+ label: 'Display association fields',
582
+ children: [{ key: 'author', label: 'Author', createModelOptions: { use: 'Parent' } }],
583
+ },
584
+ ];
585
+
586
+ const user = userEvent.setup();
587
+ render(
588
+ <FlowEngineProvider engine={engine}>
589
+ <ConfigProvider>
590
+ <App>
591
+ <AddSubModelButton model={parent} subModelKey="items" items={items as any}>
592
+ Open
593
+ </AddSubModelButton>
594
+ </App>
595
+ </ConfigProvider>
596
+ </FlowEngineProvider>,
597
+ );
598
+
599
+ await user.click(screen.getByText('Open'));
600
+ const searchInput = await screen.findByPlaceholderText('Search fields');
601
+ await user.type(searchInput, 'nick');
602
+ await waitFor(() => expect(screen.queryByText('Email')).not.toBeInTheDocument());
603
+
604
+ await user.hover(screen.getByText('Display association fields'));
605
+
606
+ await waitFor(() => expect(screen.getByText('Author')).toBeInTheDocument());
607
+ expect(searchInput).toHaveValue('nick');
608
+ expect(screen.getByText('Nickname')).toBeInTheDocument();
609
+ });
191
610
  });
192
611
 
193
612
  describe('transformItems - hide', () => {
194
613
  it('filters items by hide flag/function recursively', async () => {
195
614
  const engine = new FlowEngine();
196
- engine.flowSettings.forceEnable();
615
+ await engine.flowSettings.forceEnable();
197
616
  class Parent extends FlowModel {}
198
617
  engine.registerModels({ Parent });
199
618
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide' });
@@ -239,7 +658,7 @@ describe('transformItems - hide', () => {
239
658
 
240
659
  it('removes group when all children are hidden (even with async hide)', async () => {
241
660
  const engine = new FlowEngine();
242
- engine.flowSettings.forceEnable();
661
+ await engine.flowSettings.forceEnable();
243
662
  class Parent extends FlowModel {}
244
663
  engine.registerModels({ Parent });
245
664
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-empty-group' });
@@ -272,7 +691,7 @@ describe('transformItems - hide', () => {
272
691
 
273
692
  it('supports async hide functions and disables cache', async () => {
274
693
  const engine = new FlowEngine();
275
- engine.flowSettings.forceEnable();
694
+ await engine.flowSettings.forceEnable();
276
695
  class Parent extends FlowModel {}
277
696
  engine.registerModels({ Parent });
278
697
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-async-hide' });
@@ -300,7 +719,7 @@ describe('transformItems - hide', () => {
300
719
 
301
720
  it('shows items when hide function throws (conservative fallback)', async () => {
302
721
  const engine = new FlowEngine();
303
- engine.flowSettings.forceEnable();
722
+ await engine.flowSettings.forceEnable();
304
723
  class Parent extends FlowModel {}
305
724
  engine.registerModels({ Parent });
306
725
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide-throws' });
@@ -331,15 +750,15 @@ describe('transformItems - toggleable items', () => {
331
750
  class ToggleParent extends FlowModel {}
332
751
  class ToggleChild extends FlowModel {}
333
752
 
334
- const setupEngine = () => {
753
+ const setupEngine = async () => {
335
754
  const engine = new FlowEngine();
336
- engine.flowSettings.forceEnable();
755
+ await engine.flowSettings.forceEnable();
337
756
  engine.registerModels({ ToggleParent, ToggleChild });
338
757
  return engine;
339
758
  };
340
759
 
341
760
  it('marks toggleable item as active when matching sub model exists', async () => {
342
- const engine = setupEngine();
761
+ const engine = await setupEngine();
343
762
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-on' });
344
763
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-on' });
345
764
  parent.addSubModel('items', child);
@@ -371,7 +790,7 @@ describe('transformItems - toggleable items', () => {
371
790
  });
372
791
 
373
792
  it('infers useModel from createModelOptions when toggleable is enabled', async () => {
374
- const engine = setupEngine();
793
+ const engine = await setupEngine();
375
794
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-infer' });
376
795
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-infer' });
377
796
  parent.addSubModel('items', child);
@@ -397,7 +816,7 @@ describe('transformItems - toggleable items', () => {
397
816
  });
398
817
 
399
818
  it('keeps toggleable item off when sub model missing', async () => {
400
- const engine = setupEngine();
819
+ const engine = await setupEngine();
401
820
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-off' });
402
821
 
403
822
  const definition: SubModelItem[] = [
@@ -420,7 +839,7 @@ describe('transformItems - toggleable items', () => {
420
839
  });
421
840
 
422
841
  it('respects keepDropdownOpen override on toggleable items', async () => {
423
- const engine = setupEngine();
842
+ const engine = await setupEngine();
424
843
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-keep' });
425
844
 
426
845
  const definition: SubModelItem[] = [
@@ -443,7 +862,7 @@ describe('transformItems - toggleable items', () => {
443
862
 
444
863
  it('removes object sub model via default remove handler when toggleDetector provided', async () => {
445
864
  const engine = new FlowEngine();
446
- engine.flowSettings.forceEnable();
865
+ await engine.flowSettings.forceEnable();
447
866
 
448
867
  class ObjectParent extends FlowModel {}
449
868
  class ObjectChild extends FlowModel {}
@@ -481,6 +900,8 @@ describe('transformItems - toggleable items', () => {
481
900
  </FlowEngineProvider>,
482
901
  );
483
902
 
903
+ await waitFor(() => expect(screen.getByText('Toggle Menu')).toBeInTheDocument());
904
+
484
905
  await act(async () => {
485
906
  await userEvent.click(screen.getByText('Toggle Menu'));
486
907
  });
@@ -519,16 +940,16 @@ describe('transformItems - caching behaviour', () => {
519
940
  class CacheParent extends FlowModel {}
520
941
  class CacheChild extends FlowModel {}
521
942
 
522
- const setupEngine = () => {
943
+ const setupEngine = async () => {
523
944
  const engine = new FlowEngine();
524
- engine.flowSettings.forceEnable();
945
+ await engine.flowSettings.forceEnable();
525
946
  engine.registerModels({ CacheParent, CacheChild });
526
947
  const parent = engine.createModel<CacheParent>({ use: 'CacheParent', uid: 'cache-parent' });
527
948
  return { engine, parent };
528
949
  };
529
950
 
530
951
  it('reuses cached result when no toggleable items exist', async () => {
531
- const { parent } = setupEngine();
952
+ const { parent } = await setupEngine();
532
953
  const definition: SubModelItem[] = [{ key: 'basic', label: 'Basic', createModelOptions: { use: 'CacheChild' } }];
533
954
 
534
955
  const factory = transformItems(definition, parent, 'items', 'array');
@@ -541,7 +962,7 @@ describe('transformItems - caching behaviour', () => {
541
962
  });
542
963
 
543
964
  it('refreshes toggle state after new sub model is added', async () => {
544
- const { parent, engine } = setupEngine();
965
+ const { parent, engine } = await setupEngine();
545
966
  const createDefinition = (): SubModelItem[] => [
546
967
  {
547
968
  key: 'toggleable',
@@ -570,7 +991,7 @@ describe('transformItems - caching behaviour', () => {
570
991
  describe('AddSubModelButton - refreshTargets linkage', () => {
571
992
  it('clicking an item with refreshTargets triggers toggle recomputation on target branch', async () => {
572
993
  const engine = new FlowEngine();
573
- engine.flowSettings.forceEnable();
994
+ await engine.flowSettings.forceEnable();
574
995
 
575
996
  class Parent extends FlowModel {}
576
997
  class ToggleModel extends FlowModel {}
@@ -642,7 +1063,7 @@ describe('AddSubModelButton - base class menu groups', () => {
642
1063
 
643
1064
  it('renders async children provided by subModelBaseClasses', async () => {
644
1065
  const engine = new FlowEngine();
645
- engine.flowSettings.forceEnable();
1066
+ await engine.flowSettings.forceEnable();
646
1067
 
647
1068
  class Parent extends FlowModel {}
648
1069
  class AsyncLeaf extends FlowModel {}
@@ -687,7 +1108,7 @@ describe('AddSubModelButton - base class menu groups', () => {
687
1108
 
688
1109
  it('skips base class groups whose children resolve to empty', async () => {
689
1110
  const engine = new FlowEngine();
690
- engine.flowSettings.forceEnable();
1111
+ await engine.flowSettings.forceEnable();
691
1112
 
692
1113
  class Parent extends FlowModel {}
693
1114
  class EmptyLeaf extends FlowModel {}
@@ -739,7 +1160,7 @@ describe('AddSubModelButton - base class menu groups', () => {
739
1160
 
740
1161
  it('renders submenu base class with children and respects meta.sort', async () => {
741
1162
  const engine = new FlowEngine();
742
- engine.flowSettings.forceEnable();
1163
+ await engine.flowSettings.forceEnable();
743
1164
 
744
1165
  class Parent extends FlowModel {}
745
1166
  class Leaf extends FlowModel {}
@@ -803,7 +1224,7 @@ describe('AddSubModelButton - base class menu groups', () => {
803
1224
 
804
1225
  it('merges explicit items with base class and grouped sources', async () => {
805
1226
  const engine = new FlowEngine();
806
- engine.flowSettings.forceEnable();
1227
+ await engine.flowSettings.forceEnable();
807
1228
 
808
1229
  class Parent extends FlowModel {}
809
1230
  class BaseChild extends FlowModel {}
@@ -862,7 +1283,7 @@ describe('AddSubModelButton - base class menu groups', () => {
862
1283
  describe('AddSubModelButton - toggle interactions', () => {
863
1284
  it('removes existing toggleable sub model and triggers callbacks', async () => {
864
1285
  const engine = new FlowEngine();
865
- engine.flowSettings.forceEnable();
1286
+ await engine.flowSettings.forceEnable();
866
1287
 
867
1288
  class ToggleParent extends FlowModel {}
868
1289
  const destroySpy = vi.fn();
@@ -926,7 +1347,7 @@ describe('AddSubModelButton - toggle interactions', () => {
926
1347
 
927
1348
  it('creates toggleable sub model and runs lifecycle callbacks', async () => {
928
1349
  const engine = new FlowEngine();
929
- engine.flowSettings.forceEnable();
1350
+ await engine.flowSettings.forceEnable();
930
1351
 
931
1352
  class ToggleParent extends FlowModel {}
932
1353
  const saveSpy = vi.fn();
@@ -998,7 +1419,7 @@ describe('AddSubModelButton - toggle interactions', () => {
998
1419
 
999
1420
  it('updates toggle state after external sub model removal', async () => {
1000
1421
  const engine = new FlowEngine();
1001
- engine.flowSettings.forceEnable();
1422
+ await engine.flowSettings.forceEnable();
1002
1423
 
1003
1424
  class ToggleParent extends FlowModel {}
1004
1425
  class ToggleChild extends FlowModel {}
@@ -1063,9 +1484,9 @@ describe('AddSubModelButton toggleable behavior', () => {
1063
1484
  duplicate = vi.fn().mockResolvedValue(null);
1064
1485
  }
1065
1486
 
1066
- function setup() {
1487
+ async function setup() {
1067
1488
  const engine = new FlowEngine();
1068
- engine.flowSettings.forceEnable();
1489
+ await engine.flowSettings.forceEnable();
1069
1490
  engine.registerModels({ ToggleModel });
1070
1491
  engine.setModelRepository(new FakeRepo());
1071
1492
 
@@ -1118,7 +1539,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1118
1539
  });
1119
1540
 
1120
1541
  test('keeps dropdown open and preserves loaded children on toggle add/remove', async () => {
1121
- const { engine, ui } = setup();
1542
+ const { engine, ui } = await setup();
1122
1543
  const user = userEvent.setup();
1123
1544
 
1124
1545
  render(ui);
@@ -1142,6 +1563,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1142
1563
  },
1143
1564
  { timeout: 3000 },
1144
1565
  );
1566
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1145
1567
 
1146
1568
  // dropdown should remain open and children should still be visible (no flicker / reload)
1147
1569
  expect(screen.getByText('Async Group')).toBeInTheDocument();
@@ -1158,12 +1580,13 @@ describe('AddSubModelButton toggleable behavior', () => {
1158
1580
 
1159
1581
  // ensure destroy has been called (avoid flakiness on exact call counts)
1160
1582
  await waitFor(() => {
1583
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
1161
1584
  expect(repo.destroy).toHaveBeenCalled();
1162
1585
  });
1163
1586
  });
1164
1587
 
1165
1588
  test('toggle state updates without menu closing', async () => {
1166
- const { ui } = setup();
1589
+ const { ui } = await setup();
1167
1590
  const user = userEvent.setup();
1168
1591
 
1169
1592
  render(ui);
@@ -1181,7 +1604,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1181
1604
 
1182
1605
  test('nested submenu (static items) toggle keeps menu open and reflects state', async () => {
1183
1606
  const engine = new FlowEngine();
1184
- engine.flowSettings.forceEnable();
1607
+ await engine.flowSettings.forceEnable();
1185
1608
  engine.registerModels({ ToggleModel });
1186
1609
  const parent = engine.createModel<FlowModel>({ use: FlowModel });
1187
1610
 
@@ -1263,7 +1686,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1263
1686
 
1264
1687
  test('submenu (second-level) toggleable stays open and updates state', async () => {
1265
1688
  const engine = new FlowEngine();
1266
- engine.flowSettings.forceEnable();
1689
+ await engine.flowSettings.forceEnable();
1267
1690
  engine.registerModels({ ToggleModel });
1268
1691
  engine.setModelRepository(new FakeRepo());
1269
1692
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
@@ -1311,18 +1734,81 @@ describe('AddSubModelButton toggleable behavior', () => {
1311
1734
  // click leaf toggle to add
1312
1735
  await user.click(screen.getByText('Leaf Toggle'));
1313
1736
 
1314
- // menu should remain visible; submenu parent still visible
1737
+ // menu and submenu should remain visible after toggling a submenu leaf
1315
1738
  expect(screen.getByText('Fields')).toBeInTheDocument();
1316
-
1317
- // 由于点击叶子项后二级子菜单可能被收起,这里先重新展开再断言开关状态
1318
- await user.hover(screen.getByText('Fields'));
1319
1739
  await waitFor(() => expect(screen.getByText('Leaf Toggle')).toBeInTheDocument());
1320
1740
  await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1321
1741
  });
1322
1742
 
1743
+ test('keepDropdownOpen keeps root menu visible after clicking a nested relation-style leaf', async () => {
1744
+ const engine = new FlowEngine();
1745
+ await engine.flowSettings.forceEnable();
1746
+
1747
+ class Parent extends FlowModel {}
1748
+ class RelationLeafModel extends FlowModel {}
1749
+
1750
+ engine.registerModels({ Parent, RelationLeafModel });
1751
+ engine.setModelRepository(new FakeRepo());
1752
+ vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
1753
+
1754
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
1755
+
1756
+ const items = [
1757
+ {
1758
+ key: 'relation-fields',
1759
+ label: 'Display association fields',
1760
+ children: [
1761
+ {
1762
+ key: 'users',
1763
+ label: 'Users',
1764
+ type: 'group' as const,
1765
+ children: [
1766
+ {
1767
+ key: 'user-name',
1768
+ label: 'User name',
1769
+ createModelOptions: { use: 'RelationLeafModel' },
1770
+ },
1771
+ ],
1772
+ },
1773
+ ],
1774
+ },
1775
+ ];
1776
+
1777
+ render(
1778
+ <FlowEngineProvider engine={engine}>
1779
+ <ConfigProvider>
1780
+ <App>
1781
+ <AddSubModelButton
1782
+ model={parent}
1783
+ items={items as any}
1784
+ subModelType="array"
1785
+ subModelKey="subs"
1786
+ keepDropdownOpen
1787
+ >
1788
+ Open
1789
+ </AddSubModelButton>
1790
+ </App>
1791
+ </ConfigProvider>
1792
+ </FlowEngineProvider>,
1793
+ );
1794
+
1795
+ const user = userEvent.setup();
1796
+ await user.click(screen.getByText('Open'));
1797
+
1798
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1799
+ await user.hover(screen.getByText('Display association fields'));
1800
+ await waitFor(() => expect(screen.getByText('Users')).toBeInTheDocument());
1801
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1802
+
1803
+ await user.click(screen.getByText('User name'));
1804
+
1805
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1806
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1807
+ });
1808
+
1323
1809
  test('top-level toggle updates after opening a second-level branch', async () => {
1324
1810
  const engine = new FlowEngine();
1325
- engine.flowSettings.forceEnable();
1811
+ await engine.flowSettings.forceEnable();
1326
1812
  engine.registerModels({ ToggleModel });
1327
1813
  engine.setModelRepository(new FakeRepo());
1328
1814
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);