@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.45

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 (209) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/FlowContextProvider.d.ts +5 -1
  4. package/lib/FlowContextProvider.js +9 -2
  5. package/lib/JSRunner.d.ts +10 -1
  6. package/lib/JSRunner.js +50 -5
  7. package/lib/ViewScopedFlowEngine.js +5 -1
  8. package/lib/components/FieldModelRenderer.js +2 -2
  9. package/lib/components/FlowModelRenderer.d.ts +3 -1
  10. package/lib/components/FlowModelRenderer.js +12 -6
  11. package/lib/components/FormItem.d.ts +6 -0
  12. package/lib/components/FormItem.js +11 -3
  13. package/lib/components/MobilePopup.js +6 -5
  14. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  15. package/lib/components/dnd/gridDragPlanner.js +613 -21
  16. package/lib/components/dnd/index.d.ts +31 -2
  17. package/lib/components/dnd/index.js +244 -23
  18. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  19. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  20. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  21. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -11
  22. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  23. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  24. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  27. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  28. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  29. package/lib/components/subModel/AddSubModelButton.js +27 -1
  30. package/lib/components/subModel/LazyDropdown.js +293 -52
  31. package/lib/components/subModel/index.d.ts +1 -0
  32. package/lib/components/subModel/index.js +19 -0
  33. package/lib/components/subModel/utils.d.ts +1 -1
  34. package/lib/components/subModel/utils.js +9 -3
  35. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  36. package/lib/components/variables/VariableHybridInput.js +499 -0
  37. package/lib/components/variables/index.d.ts +2 -0
  38. package/lib/components/variables/index.js +3 -0
  39. package/lib/data-source/index.d.ts +84 -0
  40. package/lib/data-source/index.js +259 -5
  41. package/lib/executor/FlowExecutor.js +32 -9
  42. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  43. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  44. package/lib/flow-registry/index.d.ts +1 -0
  45. package/lib/flow-registry/index.js +3 -1
  46. package/lib/flowContext.d.ts +3 -0
  47. package/lib/flowContext.js +46 -1
  48. package/lib/flowEngine.d.ts +151 -1
  49. package/lib/flowEngine.js +392 -18
  50. package/lib/flowI18n.js +2 -1
  51. package/lib/flowSettings.d.ts +14 -6
  52. package/lib/flowSettings.js +34 -6
  53. package/lib/index.d.ts +2 -0
  54. package/lib/index.js +7 -0
  55. package/lib/lazy-helper.d.ts +14 -0
  56. package/lib/lazy-helper.js +71 -0
  57. package/lib/locale/en-US.json +1 -0
  58. package/lib/locale/index.d.ts +2 -0
  59. package/lib/locale/zh-CN.json +1 -0
  60. package/lib/models/DisplayItemModel.d.ts +1 -1
  61. package/lib/models/EditableItemModel.d.ts +1 -1
  62. package/lib/models/FilterableItemModel.d.ts +1 -1
  63. package/lib/models/flowModel.d.ts +13 -10
  64. package/lib/models/flowModel.js +81 -21
  65. package/lib/provider.js +38 -23
  66. package/lib/reactive/observer.js +46 -16
  67. package/lib/runjs-context/registry.d.ts +1 -1
  68. package/lib/runjs-context/setup.js +20 -12
  69. package/lib/runjs-context/snippets/index.js +13 -2
  70. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  72. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  74. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  75. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  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/parsePathnameToViewParams.d.ts +5 -1
  82. package/lib/utils/parsePathnameToViewParams.js +29 -5
  83. package/lib/utils/randomId.d.ts +39 -0
  84. package/lib/utils/randomId.js +45 -0
  85. package/lib/utils/runjsTemplateCompat.js +1 -1
  86. package/lib/utils/runjsValue.js +41 -11
  87. package/lib/utils/schema-utils.d.ts +7 -1
  88. package/lib/utils/schema-utils.js +19 -0
  89. package/lib/views/FlowView.d.ts +7 -1
  90. package/lib/views/FlowView.js +11 -1
  91. package/lib/views/PageComponent.js +8 -6
  92. package/lib/views/ViewNavigation.d.ts +12 -2
  93. package/lib/views/ViewNavigation.js +28 -9
  94. package/lib/views/createViewMeta.js +114 -50
  95. package/lib/views/inheritLayoutContext.d.ts +10 -0
  96. package/lib/views/inheritLayoutContext.js +50 -0
  97. package/lib/views/runViewBeforeClose.d.ts +10 -0
  98. package/lib/views/runViewBeforeClose.js +45 -0
  99. package/lib/views/useDialog.d.ts +2 -1
  100. package/lib/views/useDialog.js +22 -3
  101. package/lib/views/useDrawer.d.ts +2 -1
  102. package/lib/views/useDrawer.js +22 -3
  103. package/lib/views/usePage.d.ts +5 -11
  104. package/lib/views/usePage.js +304 -144
  105. package/package.json +6 -5
  106. package/src/FlowContextProvider.tsx +9 -1
  107. package/src/JSRunner.ts +68 -4
  108. package/src/ViewScopedFlowEngine.ts +4 -0
  109. package/src/__tests__/JSRunner.test.ts +27 -1
  110. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  111. package/src/__tests__/flow-engine.test.ts +166 -0
  112. package/src/__tests__/flowContext.test.ts +82 -1
  113. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  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 +16 -0
  120. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  121. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  122. package/src/__tests__/runjsSnippets.test.ts +21 -0
  123. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  124. package/src/components/FieldModelRenderer.tsx +2 -1
  125. package/src/components/FlowModelRenderer.tsx +18 -6
  126. package/src/components/FormItem.tsx +7 -1
  127. package/src/components/MobilePopup.tsx +4 -2
  128. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  129. package/src/components/__tests__/FormItem.test.tsx +25 -0
  130. package/src/components/__tests__/dnd.test.ts +44 -0
  131. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  132. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  133. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  134. package/src/components/dnd/gridDragPlanner.ts +758 -19
  135. package/src/components/dnd/index.tsx +305 -28
  136. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  137. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -11
  138. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  139. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +194 -5
  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 +32 -2
  145. package/src/components/subModel/LazyDropdown.tsx +332 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +522 -37
  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 +7 -1
  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 +68 -1
  154. package/src/data-source/index.ts +322 -6
  155. package/src/executor/FlowExecutor.ts +35 -10
  156. package/src/executor/__tests__/flowExecutor.test.ts +85 -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 +50 -3
  161. package/src/flowEngine.ts +449 -14
  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__/dispatchEvent.when.test.ts +214 -0
  172. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  173. package/src/models/__tests__/flowModel.test.ts +80 -37
  174. package/src/models/flowModel.tsx +122 -36
  175. package/src/provider.tsx +41 -25
  176. package/src/reactive/__tests__/observer.test.tsx +82 -0
  177. package/src/reactive/observer.tsx +87 -25
  178. package/src/runjs-context/registry.ts +1 -1
  179. package/src/runjs-context/setup.ts +22 -12
  180. package/src/runjs-context/snippets/index.ts +12 -1
  181. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  182. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  183. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  184. package/src/types.ts +62 -0
  185. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  186. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +28 -0
  187. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  188. package/src/utils/__tests__/utils.test.ts +62 -0
  189. package/src/utils/createCollectionContextMeta.ts +6 -2
  190. package/src/utils/index.ts +5 -1
  191. package/src/utils/parsePathnameToViewParams.ts +47 -7
  192. package/src/utils/randomId.ts +48 -0
  193. package/src/utils/runjsTemplateCompat.ts +1 -1
  194. package/src/utils/runjsValue.ts +50 -11
  195. package/src/utils/schema-utils.ts +30 -1
  196. package/src/views/FlowView.tsx +22 -2
  197. package/src/views/PageComponent.tsx +7 -4
  198. package/src/views/ViewNavigation.ts +46 -9
  199. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  200. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  201. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  202. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  203. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  204. package/src/views/createViewMeta.ts +106 -34
  205. package/src/views/inheritLayoutContext.ts +26 -0
  206. package/src/views/runViewBeforeClose.ts +19 -0
  207. package/src/views/useDialog.tsx +27 -3
  208. package/src/views/useDrawer.tsx +27 -3
  209. package/src/views/usePage.tsx +367 -179
@@ -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,258 @@ 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
+ });
191
559
  });
192
560
 
193
561
  describe('transformItems - hide', () => {
194
562
  it('filters items by hide flag/function recursively', async () => {
195
563
  const engine = new FlowEngine();
196
- engine.flowSettings.forceEnable();
564
+ await engine.flowSettings.forceEnable();
197
565
  class Parent extends FlowModel {}
198
566
  engine.registerModels({ Parent });
199
567
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide' });
@@ -239,7 +607,7 @@ describe('transformItems - hide', () => {
239
607
 
240
608
  it('removes group when all children are hidden (even with async hide)', async () => {
241
609
  const engine = new FlowEngine();
242
- engine.flowSettings.forceEnable();
610
+ await engine.flowSettings.forceEnable();
243
611
  class Parent extends FlowModel {}
244
612
  engine.registerModels({ Parent });
245
613
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-empty-group' });
@@ -272,7 +640,7 @@ describe('transformItems - hide', () => {
272
640
 
273
641
  it('supports async hide functions and disables cache', async () => {
274
642
  const engine = new FlowEngine();
275
- engine.flowSettings.forceEnable();
643
+ await engine.flowSettings.forceEnable();
276
644
  class Parent extends FlowModel {}
277
645
  engine.registerModels({ Parent });
278
646
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-async-hide' });
@@ -300,7 +668,7 @@ describe('transformItems - hide', () => {
300
668
 
301
669
  it('shows items when hide function throws (conservative fallback)', async () => {
302
670
  const engine = new FlowEngine();
303
- engine.flowSettings.forceEnable();
671
+ await engine.flowSettings.forceEnable();
304
672
  class Parent extends FlowModel {}
305
673
  engine.registerModels({ Parent });
306
674
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide-throws' });
@@ -331,15 +699,15 @@ describe('transformItems - toggleable items', () => {
331
699
  class ToggleParent extends FlowModel {}
332
700
  class ToggleChild extends FlowModel {}
333
701
 
334
- const setupEngine = () => {
702
+ const setupEngine = async () => {
335
703
  const engine = new FlowEngine();
336
- engine.flowSettings.forceEnable();
704
+ await engine.flowSettings.forceEnable();
337
705
  engine.registerModels({ ToggleParent, ToggleChild });
338
706
  return engine;
339
707
  };
340
708
 
341
709
  it('marks toggleable item as active when matching sub model exists', async () => {
342
- const engine = setupEngine();
710
+ const engine = await setupEngine();
343
711
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-on' });
344
712
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-on' });
345
713
  parent.addSubModel('items', child);
@@ -371,7 +739,7 @@ describe('transformItems - toggleable items', () => {
371
739
  });
372
740
 
373
741
  it('infers useModel from createModelOptions when toggleable is enabled', async () => {
374
- const engine = setupEngine();
742
+ const engine = await setupEngine();
375
743
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-infer' });
376
744
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-infer' });
377
745
  parent.addSubModel('items', child);
@@ -397,7 +765,7 @@ describe('transformItems - toggleable items', () => {
397
765
  });
398
766
 
399
767
  it('keeps toggleable item off when sub model missing', async () => {
400
- const engine = setupEngine();
768
+ const engine = await setupEngine();
401
769
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-off' });
402
770
 
403
771
  const definition: SubModelItem[] = [
@@ -420,7 +788,7 @@ describe('transformItems - toggleable items', () => {
420
788
  });
421
789
 
422
790
  it('respects keepDropdownOpen override on toggleable items', async () => {
423
- const engine = setupEngine();
791
+ const engine = await setupEngine();
424
792
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-keep' });
425
793
 
426
794
  const definition: SubModelItem[] = [
@@ -443,7 +811,7 @@ describe('transformItems - toggleable items', () => {
443
811
 
444
812
  it('removes object sub model via default remove handler when toggleDetector provided', async () => {
445
813
  const engine = new FlowEngine();
446
- engine.flowSettings.forceEnable();
814
+ await engine.flowSettings.forceEnable();
447
815
 
448
816
  class ObjectParent extends FlowModel {}
449
817
  class ObjectChild extends FlowModel {}
@@ -481,6 +849,8 @@ describe('transformItems - toggleable items', () => {
481
849
  </FlowEngineProvider>,
482
850
  );
483
851
 
852
+ await waitFor(() => expect(screen.getByText('Toggle Menu')).toBeInTheDocument());
853
+
484
854
  await act(async () => {
485
855
  await userEvent.click(screen.getByText('Toggle Menu'));
486
856
  });
@@ -519,16 +889,16 @@ describe('transformItems - caching behaviour', () => {
519
889
  class CacheParent extends FlowModel {}
520
890
  class CacheChild extends FlowModel {}
521
891
 
522
- const setupEngine = () => {
892
+ const setupEngine = async () => {
523
893
  const engine = new FlowEngine();
524
- engine.flowSettings.forceEnable();
894
+ await engine.flowSettings.forceEnable();
525
895
  engine.registerModels({ CacheParent, CacheChild });
526
896
  const parent = engine.createModel<CacheParent>({ use: 'CacheParent', uid: 'cache-parent' });
527
897
  return { engine, parent };
528
898
  };
529
899
 
530
900
  it('reuses cached result when no toggleable items exist', async () => {
531
- const { parent } = setupEngine();
901
+ const { parent } = await setupEngine();
532
902
  const definition: SubModelItem[] = [{ key: 'basic', label: 'Basic', createModelOptions: { use: 'CacheChild' } }];
533
903
 
534
904
  const factory = transformItems(definition, parent, 'items', 'array');
@@ -541,7 +911,7 @@ describe('transformItems - caching behaviour', () => {
541
911
  });
542
912
 
543
913
  it('refreshes toggle state after new sub model is added', async () => {
544
- const { parent, engine } = setupEngine();
914
+ const { parent, engine } = await setupEngine();
545
915
  const createDefinition = (): SubModelItem[] => [
546
916
  {
547
917
  key: 'toggleable',
@@ -570,7 +940,7 @@ describe('transformItems - caching behaviour', () => {
570
940
  describe('AddSubModelButton - refreshTargets linkage', () => {
571
941
  it('clicking an item with refreshTargets triggers toggle recomputation on target branch', async () => {
572
942
  const engine = new FlowEngine();
573
- engine.flowSettings.forceEnable();
943
+ await engine.flowSettings.forceEnable();
574
944
 
575
945
  class Parent extends FlowModel {}
576
946
  class ToggleModel extends FlowModel {}
@@ -642,7 +1012,7 @@ describe('AddSubModelButton - base class menu groups', () => {
642
1012
 
643
1013
  it('renders async children provided by subModelBaseClasses', async () => {
644
1014
  const engine = new FlowEngine();
645
- engine.flowSettings.forceEnable();
1015
+ await engine.flowSettings.forceEnable();
646
1016
 
647
1017
  class Parent extends FlowModel {}
648
1018
  class AsyncLeaf extends FlowModel {}
@@ -687,7 +1057,7 @@ describe('AddSubModelButton - base class menu groups', () => {
687
1057
 
688
1058
  it('skips base class groups whose children resolve to empty', async () => {
689
1059
  const engine = new FlowEngine();
690
- engine.flowSettings.forceEnable();
1060
+ await engine.flowSettings.forceEnable();
691
1061
 
692
1062
  class Parent extends FlowModel {}
693
1063
  class EmptyLeaf extends FlowModel {}
@@ -739,7 +1109,7 @@ describe('AddSubModelButton - base class menu groups', () => {
739
1109
 
740
1110
  it('renders submenu base class with children and respects meta.sort', async () => {
741
1111
  const engine = new FlowEngine();
742
- engine.flowSettings.forceEnable();
1112
+ await engine.flowSettings.forceEnable();
743
1113
 
744
1114
  class Parent extends FlowModel {}
745
1115
  class Leaf extends FlowModel {}
@@ -803,7 +1173,7 @@ describe('AddSubModelButton - base class menu groups', () => {
803
1173
 
804
1174
  it('merges explicit items with base class and grouped sources', async () => {
805
1175
  const engine = new FlowEngine();
806
- engine.flowSettings.forceEnable();
1176
+ await engine.flowSettings.forceEnable();
807
1177
 
808
1178
  class Parent extends FlowModel {}
809
1179
  class BaseChild extends FlowModel {}
@@ -862,7 +1232,7 @@ describe('AddSubModelButton - base class menu groups', () => {
862
1232
  describe('AddSubModelButton - toggle interactions', () => {
863
1233
  it('removes existing toggleable sub model and triggers callbacks', async () => {
864
1234
  const engine = new FlowEngine();
865
- engine.flowSettings.forceEnable();
1235
+ await engine.flowSettings.forceEnable();
866
1236
 
867
1237
  class ToggleParent extends FlowModel {}
868
1238
  const destroySpy = vi.fn();
@@ -926,7 +1296,7 @@ describe('AddSubModelButton - toggle interactions', () => {
926
1296
 
927
1297
  it('creates toggleable sub model and runs lifecycle callbacks', async () => {
928
1298
  const engine = new FlowEngine();
929
- engine.flowSettings.forceEnable();
1299
+ await engine.flowSettings.forceEnable();
930
1300
 
931
1301
  class ToggleParent extends FlowModel {}
932
1302
  const saveSpy = vi.fn();
@@ -995,6 +1365,56 @@ describe('AddSubModelButton - toggle interactions', () => {
995
1365
  const subModels = ((parent.subModels as any).items as FlowModel[]) || [];
996
1366
  expect(subModels).toHaveLength(1);
997
1367
  });
1368
+
1369
+ it('updates toggle state after external sub model removal', async () => {
1370
+ const engine = new FlowEngine();
1371
+ await engine.flowSettings.forceEnable();
1372
+
1373
+ class ToggleParent extends FlowModel {}
1374
+ class ToggleChild extends FlowModel {}
1375
+
1376
+ engine.registerModels({ ToggleParent, ToggleChild });
1377
+ const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-external-remove' });
1378
+ const existing = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-external-remove' });
1379
+ parent.addSubModel('items', existing);
1380
+
1381
+ render(
1382
+ <FlowEngineProvider engine={engine}>
1383
+ <ConfigProvider>
1384
+ <App>
1385
+ <AddSubModelButton
1386
+ model={parent}
1387
+ subModelKey="items"
1388
+ items={[
1389
+ {
1390
+ key: 'toggle-child',
1391
+ label: 'Toggle Child',
1392
+ toggleable: true,
1393
+ useModel: 'ToggleChild',
1394
+ createModelOptions: { use: 'ToggleChild' },
1395
+ },
1396
+ ]}
1397
+ >
1398
+ Toggle Menu
1399
+ </AddSubModelButton>
1400
+ </App>
1401
+ </ConfigProvider>
1402
+ </FlowEngineProvider>,
1403
+ );
1404
+
1405
+ await act(async () => {
1406
+ await userEvent.click(screen.getByText('Toggle Menu'));
1407
+ });
1408
+
1409
+ await waitFor(() => expect(screen.getByText('Toggle Child')).toBeInTheDocument());
1410
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1411
+
1412
+ await act(async () => {
1413
+ await existing.destroy();
1414
+ });
1415
+
1416
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'));
1417
+ });
998
1418
  });
999
1419
 
1000
1420
  // ========================
@@ -1013,9 +1433,9 @@ describe('AddSubModelButton toggleable behavior', () => {
1013
1433
  duplicate = vi.fn().mockResolvedValue(null);
1014
1434
  }
1015
1435
 
1016
- function setup() {
1436
+ async function setup() {
1017
1437
  const engine = new FlowEngine();
1018
- engine.flowSettings.forceEnable();
1438
+ await engine.flowSettings.forceEnable();
1019
1439
  engine.registerModels({ ToggleModel });
1020
1440
  engine.setModelRepository(new FakeRepo());
1021
1441
 
@@ -1068,7 +1488,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1068
1488
  });
1069
1489
 
1070
1490
  test('keeps dropdown open and preserves loaded children on toggle add/remove', async () => {
1071
- const { engine, ui } = setup();
1491
+ const { engine, ui } = await setup();
1072
1492
  const user = userEvent.setup();
1073
1493
 
1074
1494
  render(ui);
@@ -1092,6 +1512,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1092
1512
  },
1093
1513
  { timeout: 3000 },
1094
1514
  );
1515
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1095
1516
 
1096
1517
  // dropdown should remain open and children should still be visible (no flicker / reload)
1097
1518
  expect(screen.getByText('Async Group')).toBeInTheDocument();
@@ -1108,12 +1529,13 @@ describe('AddSubModelButton toggleable behavior', () => {
1108
1529
 
1109
1530
  // ensure destroy has been called (avoid flakiness on exact call counts)
1110
1531
  await waitFor(() => {
1532
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
1111
1533
  expect(repo.destroy).toHaveBeenCalled();
1112
1534
  });
1113
1535
  });
1114
1536
 
1115
1537
  test('toggle state updates without menu closing', async () => {
1116
- const { ui } = setup();
1538
+ const { ui } = await setup();
1117
1539
  const user = userEvent.setup();
1118
1540
 
1119
1541
  render(ui);
@@ -1131,7 +1553,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1131
1553
 
1132
1554
  test('nested submenu (static items) toggle keeps menu open and reflects state', async () => {
1133
1555
  const engine = new FlowEngine();
1134
- engine.flowSettings.forceEnable();
1556
+ await engine.flowSettings.forceEnable();
1135
1557
  engine.registerModels({ ToggleModel });
1136
1558
  const parent = engine.createModel<FlowModel>({ use: FlowModel });
1137
1559
 
@@ -1213,7 +1635,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1213
1635
 
1214
1636
  test('submenu (second-level) toggleable stays open and updates state', async () => {
1215
1637
  const engine = new FlowEngine();
1216
- engine.flowSettings.forceEnable();
1638
+ await engine.flowSettings.forceEnable();
1217
1639
  engine.registerModels({ ToggleModel });
1218
1640
  engine.setModelRepository(new FakeRepo());
1219
1641
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
@@ -1261,18 +1683,81 @@ describe('AddSubModelButton toggleable behavior', () => {
1261
1683
  // click leaf toggle to add
1262
1684
  await user.click(screen.getByText('Leaf Toggle'));
1263
1685
 
1264
- // menu should remain visible; submenu parent still visible
1686
+ // menu and submenu should remain visible after toggling a submenu leaf
1265
1687
  expect(screen.getByText('Fields')).toBeInTheDocument();
1266
-
1267
- // 由于点击叶子项后二级子菜单可能被收起,这里先重新展开再断言开关状态
1268
- await user.hover(screen.getByText('Fields'));
1269
1688
  await waitFor(() => expect(screen.getByText('Leaf Toggle')).toBeInTheDocument());
1270
1689
  await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1271
1690
  });
1272
1691
 
1692
+ test('keepDropdownOpen keeps root menu visible after clicking a nested relation-style leaf', async () => {
1693
+ const engine = new FlowEngine();
1694
+ await engine.flowSettings.forceEnable();
1695
+
1696
+ class Parent extends FlowModel {}
1697
+ class RelationLeafModel extends FlowModel {}
1698
+
1699
+ engine.registerModels({ Parent, RelationLeafModel });
1700
+ engine.setModelRepository(new FakeRepo());
1701
+ vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
1702
+
1703
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
1704
+
1705
+ const items = [
1706
+ {
1707
+ key: 'relation-fields',
1708
+ label: 'Display association fields',
1709
+ children: [
1710
+ {
1711
+ key: 'users',
1712
+ label: 'Users',
1713
+ type: 'group' as const,
1714
+ children: [
1715
+ {
1716
+ key: 'user-name',
1717
+ label: 'User name',
1718
+ createModelOptions: { use: 'RelationLeafModel' },
1719
+ },
1720
+ ],
1721
+ },
1722
+ ],
1723
+ },
1724
+ ];
1725
+
1726
+ render(
1727
+ <FlowEngineProvider engine={engine}>
1728
+ <ConfigProvider>
1729
+ <App>
1730
+ <AddSubModelButton
1731
+ model={parent}
1732
+ items={items as any}
1733
+ subModelType="array"
1734
+ subModelKey="subs"
1735
+ keepDropdownOpen
1736
+ >
1737
+ Open
1738
+ </AddSubModelButton>
1739
+ </App>
1740
+ </ConfigProvider>
1741
+ </FlowEngineProvider>,
1742
+ );
1743
+
1744
+ const user = userEvent.setup();
1745
+ await user.click(screen.getByText('Open'));
1746
+
1747
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1748
+ await user.hover(screen.getByText('Display association fields'));
1749
+ await waitFor(() => expect(screen.getByText('Users')).toBeInTheDocument());
1750
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1751
+
1752
+ await user.click(screen.getByText('User name'));
1753
+
1754
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1755
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1756
+ });
1757
+
1273
1758
  test('top-level toggle updates after opening a second-level branch', async () => {
1274
1759
  const engine = new FlowEngine();
1275
- engine.flowSettings.forceEnable();
1760
+ await engine.flowSettings.forceEnable();
1276
1761
  engine.registerModels({ ToggleModel });
1277
1762
  engine.setModelRepository(new FakeRepo());
1278
1763
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);