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

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 (194) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/FormItem.d.ts +6 -0
  10. package/lib/components/FormItem.js +11 -3
  11. package/lib/components/MobilePopup.js +6 -5
  12. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  13. package/lib/components/dnd/gridDragPlanner.js +613 -21
  14. package/lib/components/dnd/index.d.ts +31 -2
  15. package/lib/components/dnd/index.js +244 -23
  16. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  17. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  18. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  19. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  20. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  21. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  22. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  27. package/lib/components/subModel/AddSubModelButton.js +27 -1
  28. package/lib/components/subModel/LazyDropdown.js +96 -39
  29. package/lib/components/subModel/index.d.ts +1 -0
  30. package/lib/components/subModel/index.js +19 -0
  31. package/lib/components/subModel/utils.d.ts +1 -1
  32. package/lib/components/subModel/utils.js +9 -3
  33. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  34. package/lib/components/variables/VariableHybridInput.js +499 -0
  35. package/lib/components/variables/index.d.ts +2 -0
  36. package/lib/components/variables/index.js +3 -0
  37. package/lib/data-source/index.d.ts +75 -0
  38. package/lib/data-source/index.js +247 -5
  39. package/lib/executor/FlowExecutor.js +32 -9
  40. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  41. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  42. package/lib/flow-registry/index.d.ts +1 -0
  43. package/lib/flow-registry/index.js +3 -1
  44. package/lib/flowContext.d.ts +3 -0
  45. package/lib/flowContext.js +43 -1
  46. package/lib/flowEngine.d.ts +151 -1
  47. package/lib/flowEngine.js +389 -15
  48. package/lib/flowI18n.js +2 -1
  49. package/lib/flowSettings.d.ts +14 -6
  50. package/lib/flowSettings.js +34 -6
  51. package/lib/index.d.ts +2 -0
  52. package/lib/index.js +7 -0
  53. package/lib/lazy-helper.d.ts +14 -0
  54. package/lib/lazy-helper.js +71 -0
  55. package/lib/locale/en-US.json +1 -0
  56. package/lib/locale/index.d.ts +2 -0
  57. package/lib/locale/zh-CN.json +1 -0
  58. package/lib/models/DisplayItemModel.d.ts +1 -1
  59. package/lib/models/EditableItemModel.d.ts +1 -1
  60. package/lib/models/FilterableItemModel.d.ts +1 -1
  61. package/lib/models/flowModel.d.ts +13 -10
  62. package/lib/models/flowModel.js +78 -18
  63. package/lib/provider.js +38 -23
  64. package/lib/reactive/observer.js +46 -16
  65. package/lib/runjs-context/registry.d.ts +1 -1
  66. package/lib/runjs-context/setup.js +20 -12
  67. package/lib/runjs-context/snippets/index.js +13 -2
  68. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  69. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  70. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  72. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  73. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  74. package/lib/types.d.ts +50 -2
  75. package/lib/types.js +1 -0
  76. package/lib/utils/createCollectionContextMeta.js +6 -2
  77. package/lib/utils/index.d.ts +3 -2
  78. package/lib/utils/index.js +7 -0
  79. package/lib/utils/parsePathnameToViewParams.js +1 -1
  80. package/lib/utils/randomId.d.ts +39 -0
  81. package/lib/utils/randomId.js +45 -0
  82. package/lib/utils/runjsTemplateCompat.js +1 -1
  83. package/lib/utils/runjsValue.js +41 -11
  84. package/lib/utils/schema-utils.d.ts +7 -1
  85. package/lib/utils/schema-utils.js +19 -0
  86. package/lib/views/FlowView.d.ts +7 -1
  87. package/lib/views/FlowView.js +11 -1
  88. package/lib/views/PageComponent.js +8 -6
  89. package/lib/views/ViewNavigation.js +6 -2
  90. package/lib/views/runViewBeforeClose.d.ts +10 -0
  91. package/lib/views/runViewBeforeClose.js +45 -0
  92. package/lib/views/useDialog.d.ts +2 -1
  93. package/lib/views/useDialog.js +20 -3
  94. package/lib/views/useDrawer.d.ts +2 -1
  95. package/lib/views/useDrawer.js +20 -3
  96. package/lib/views/usePage.d.ts +5 -11
  97. package/lib/views/usePage.js +302 -144
  98. package/package.json +6 -5
  99. package/src/JSRunner.ts +68 -4
  100. package/src/ViewScopedFlowEngine.ts +4 -0
  101. package/src/__tests__/JSRunner.test.ts +27 -1
  102. package/src/__tests__/flow-engine.test.ts +166 -0
  103. package/src/__tests__/flowContext.test.ts +82 -1
  104. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  105. package/src/__tests__/flowSettings.test.ts +94 -15
  106. package/src/__tests__/objectVariable.test.ts +24 -0
  107. package/src/__tests__/provider.test.tsx +24 -2
  108. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  109. package/src/__tests__/runjsContext.test.ts +16 -0
  110. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  111. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  112. package/src/__tests__/runjsSnippets.test.ts +21 -0
  113. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  114. package/src/components/FieldModelRenderer.tsx +2 -1
  115. package/src/components/FlowModelRenderer.tsx +18 -6
  116. package/src/components/FormItem.tsx +7 -1
  117. package/src/components/MobilePopup.tsx +4 -2
  118. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  119. package/src/components/__tests__/FormItem.test.tsx +25 -0
  120. package/src/components/__tests__/dnd.test.ts +44 -0
  121. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  122. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  123. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  124. package/src/components/dnd/gridDragPlanner.ts +758 -19
  125. package/src/components/dnd/index.tsx +305 -28
  126. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  127. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  128. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  129. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  130. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  131. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  132. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  133. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  134. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  135. package/src/components/subModel/LazyDropdown.tsx +107 -43
  136. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +319 -36
  137. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  138. package/src/components/subModel/index.ts +1 -0
  139. package/src/components/subModel/utils.ts +7 -1
  140. package/src/components/variables/VariableHybridInput.tsx +531 -0
  141. package/src/components/variables/index.ts +2 -0
  142. package/src/data-source/__tests__/collection.test.ts +41 -2
  143. package/src/data-source/__tests__/index.test.ts +68 -1
  144. package/src/data-source/index.ts +304 -6
  145. package/src/executor/FlowExecutor.ts +35 -10
  146. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  147. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  148. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  149. package/src/flow-registry/index.ts +1 -0
  150. package/src/flowContext.ts +47 -3
  151. package/src/flowEngine.ts +445 -11
  152. package/src/flowI18n.ts +2 -1
  153. package/src/flowSettings.ts +40 -6
  154. package/src/index.ts +2 -0
  155. package/src/lazy-helper.tsx +57 -0
  156. package/src/locale/en-US.json +1 -0
  157. package/src/locale/zh-CN.json +1 -0
  158. package/src/models/DisplayItemModel.tsx +1 -1
  159. package/src/models/EditableItemModel.tsx +1 -1
  160. package/src/models/FilterableItemModel.tsx +1 -1
  161. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  162. package/src/models/__tests__/flowModel.test.ts +47 -3
  163. package/src/models/flowModel.tsx +119 -33
  164. package/src/provider.tsx +41 -25
  165. package/src/reactive/__tests__/observer.test.tsx +82 -0
  166. package/src/reactive/observer.tsx +87 -25
  167. package/src/runjs-context/registry.ts +1 -1
  168. package/src/runjs-context/setup.ts +22 -12
  169. package/src/runjs-context/snippets/index.ts +12 -1
  170. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  171. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  172. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  173. package/src/types.ts +62 -0
  174. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  175. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  176. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  177. package/src/utils/__tests__/utils.test.ts +62 -0
  178. package/src/utils/createCollectionContextMeta.ts +6 -2
  179. package/src/utils/index.ts +5 -1
  180. package/src/utils/parsePathnameToViewParams.ts +2 -2
  181. package/src/utils/randomId.ts +48 -0
  182. package/src/utils/runjsTemplateCompat.ts +1 -1
  183. package/src/utils/runjsValue.ts +50 -11
  184. package/src/utils/schema-utils.ts +30 -1
  185. package/src/views/FlowView.tsx +22 -2
  186. package/src/views/PageComponent.tsx +7 -4
  187. package/src/views/ViewNavigation.ts +6 -2
  188. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  189. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  190. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  191. package/src/views/runViewBeforeClose.ts +19 -0
  192. package/src/views/useDialog.tsx +25 -3
  193. package/src/views/useDrawer.tsx +25 -3
  194. package/src/views/usePage.tsx +365 -179
@@ -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,56 @@ 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
+ });
191
357
  });
192
358
 
193
359
  describe('transformItems - hide', () => {
194
360
  it('filters items by hide flag/function recursively', async () => {
195
361
  const engine = new FlowEngine();
196
- engine.flowSettings.forceEnable();
362
+ await engine.flowSettings.forceEnable();
197
363
  class Parent extends FlowModel {}
198
364
  engine.registerModels({ Parent });
199
365
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide' });
@@ -239,7 +405,7 @@ describe('transformItems - hide', () => {
239
405
 
240
406
  it('removes group when all children are hidden (even with async hide)', async () => {
241
407
  const engine = new FlowEngine();
242
- engine.flowSettings.forceEnable();
408
+ await engine.flowSettings.forceEnable();
243
409
  class Parent extends FlowModel {}
244
410
  engine.registerModels({ Parent });
245
411
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-empty-group' });
@@ -272,7 +438,7 @@ describe('transformItems - hide', () => {
272
438
 
273
439
  it('supports async hide functions and disables cache', async () => {
274
440
  const engine = new FlowEngine();
275
- engine.flowSettings.forceEnable();
441
+ await engine.flowSettings.forceEnable();
276
442
  class Parent extends FlowModel {}
277
443
  engine.registerModels({ Parent });
278
444
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-async-hide' });
@@ -300,7 +466,7 @@ describe('transformItems - hide', () => {
300
466
 
301
467
  it('shows items when hide function throws (conservative fallback)', async () => {
302
468
  const engine = new FlowEngine();
303
- engine.flowSettings.forceEnable();
469
+ await engine.flowSettings.forceEnable();
304
470
  class Parent extends FlowModel {}
305
471
  engine.registerModels({ Parent });
306
472
  const parent = engine.createModel<FlowModel>({ use: 'Parent', uid: 'p-hide-throws' });
@@ -331,15 +497,15 @@ describe('transformItems - toggleable items', () => {
331
497
  class ToggleParent extends FlowModel {}
332
498
  class ToggleChild extends FlowModel {}
333
499
 
334
- const setupEngine = () => {
500
+ const setupEngine = async () => {
335
501
  const engine = new FlowEngine();
336
- engine.flowSettings.forceEnable();
502
+ await engine.flowSettings.forceEnable();
337
503
  engine.registerModels({ ToggleParent, ToggleChild });
338
504
  return engine;
339
505
  };
340
506
 
341
507
  it('marks toggleable item as active when matching sub model exists', async () => {
342
- const engine = setupEngine();
508
+ const engine = await setupEngine();
343
509
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-on' });
344
510
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-on' });
345
511
  parent.addSubModel('items', child);
@@ -371,7 +537,7 @@ describe('transformItems - toggleable items', () => {
371
537
  });
372
538
 
373
539
  it('infers useModel from createModelOptions when toggleable is enabled', async () => {
374
- const engine = setupEngine();
540
+ const engine = await setupEngine();
375
541
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-infer' });
376
542
  const child = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-infer' });
377
543
  parent.addSubModel('items', child);
@@ -397,7 +563,7 @@ describe('transformItems - toggleable items', () => {
397
563
  });
398
564
 
399
565
  it('keeps toggleable item off when sub model missing', async () => {
400
- const engine = setupEngine();
566
+ const engine = await setupEngine();
401
567
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-off' });
402
568
 
403
569
  const definition: SubModelItem[] = [
@@ -420,7 +586,7 @@ describe('transformItems - toggleable items', () => {
420
586
  });
421
587
 
422
588
  it('respects keepDropdownOpen override on toggleable items', async () => {
423
- const engine = setupEngine();
589
+ const engine = await setupEngine();
424
590
  const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-keep' });
425
591
 
426
592
  const definition: SubModelItem[] = [
@@ -443,7 +609,7 @@ describe('transformItems - toggleable items', () => {
443
609
 
444
610
  it('removes object sub model via default remove handler when toggleDetector provided', async () => {
445
611
  const engine = new FlowEngine();
446
- engine.flowSettings.forceEnable();
612
+ await engine.flowSettings.forceEnable();
447
613
 
448
614
  class ObjectParent extends FlowModel {}
449
615
  class ObjectChild extends FlowModel {}
@@ -481,6 +647,8 @@ describe('transformItems - toggleable items', () => {
481
647
  </FlowEngineProvider>,
482
648
  );
483
649
 
650
+ await waitFor(() => expect(screen.getByText('Toggle Menu')).toBeInTheDocument());
651
+
484
652
  await act(async () => {
485
653
  await userEvent.click(screen.getByText('Toggle Menu'));
486
654
  });
@@ -519,16 +687,16 @@ describe('transformItems - caching behaviour', () => {
519
687
  class CacheParent extends FlowModel {}
520
688
  class CacheChild extends FlowModel {}
521
689
 
522
- const setupEngine = () => {
690
+ const setupEngine = async () => {
523
691
  const engine = new FlowEngine();
524
- engine.flowSettings.forceEnable();
692
+ await engine.flowSettings.forceEnable();
525
693
  engine.registerModels({ CacheParent, CacheChild });
526
694
  const parent = engine.createModel<CacheParent>({ use: 'CacheParent', uid: 'cache-parent' });
527
695
  return { engine, parent };
528
696
  };
529
697
 
530
698
  it('reuses cached result when no toggleable items exist', async () => {
531
- const { parent } = setupEngine();
699
+ const { parent } = await setupEngine();
532
700
  const definition: SubModelItem[] = [{ key: 'basic', label: 'Basic', createModelOptions: { use: 'CacheChild' } }];
533
701
 
534
702
  const factory = transformItems(definition, parent, 'items', 'array');
@@ -541,7 +709,7 @@ describe('transformItems - caching behaviour', () => {
541
709
  });
542
710
 
543
711
  it('refreshes toggle state after new sub model is added', async () => {
544
- const { parent, engine } = setupEngine();
712
+ const { parent, engine } = await setupEngine();
545
713
  const createDefinition = (): SubModelItem[] => [
546
714
  {
547
715
  key: 'toggleable',
@@ -570,7 +738,7 @@ describe('transformItems - caching behaviour', () => {
570
738
  describe('AddSubModelButton - refreshTargets linkage', () => {
571
739
  it('clicking an item with refreshTargets triggers toggle recomputation on target branch', async () => {
572
740
  const engine = new FlowEngine();
573
- engine.flowSettings.forceEnable();
741
+ await engine.flowSettings.forceEnable();
574
742
 
575
743
  class Parent extends FlowModel {}
576
744
  class ToggleModel extends FlowModel {}
@@ -642,7 +810,7 @@ describe('AddSubModelButton - base class menu groups', () => {
642
810
 
643
811
  it('renders async children provided by subModelBaseClasses', async () => {
644
812
  const engine = new FlowEngine();
645
- engine.flowSettings.forceEnable();
813
+ await engine.flowSettings.forceEnable();
646
814
 
647
815
  class Parent extends FlowModel {}
648
816
  class AsyncLeaf extends FlowModel {}
@@ -687,7 +855,7 @@ describe('AddSubModelButton - base class menu groups', () => {
687
855
 
688
856
  it('skips base class groups whose children resolve to empty', async () => {
689
857
  const engine = new FlowEngine();
690
- engine.flowSettings.forceEnable();
858
+ await engine.flowSettings.forceEnable();
691
859
 
692
860
  class Parent extends FlowModel {}
693
861
  class EmptyLeaf extends FlowModel {}
@@ -739,7 +907,7 @@ describe('AddSubModelButton - base class menu groups', () => {
739
907
 
740
908
  it('renders submenu base class with children and respects meta.sort', async () => {
741
909
  const engine = new FlowEngine();
742
- engine.flowSettings.forceEnable();
910
+ await engine.flowSettings.forceEnable();
743
911
 
744
912
  class Parent extends FlowModel {}
745
913
  class Leaf extends FlowModel {}
@@ -803,7 +971,7 @@ describe('AddSubModelButton - base class menu groups', () => {
803
971
 
804
972
  it('merges explicit items with base class and grouped sources', async () => {
805
973
  const engine = new FlowEngine();
806
- engine.flowSettings.forceEnable();
974
+ await engine.flowSettings.forceEnable();
807
975
 
808
976
  class Parent extends FlowModel {}
809
977
  class BaseChild extends FlowModel {}
@@ -862,7 +1030,7 @@ describe('AddSubModelButton - base class menu groups', () => {
862
1030
  describe('AddSubModelButton - toggle interactions', () => {
863
1031
  it('removes existing toggleable sub model and triggers callbacks', async () => {
864
1032
  const engine = new FlowEngine();
865
- engine.flowSettings.forceEnable();
1033
+ await engine.flowSettings.forceEnable();
866
1034
 
867
1035
  class ToggleParent extends FlowModel {}
868
1036
  const destroySpy = vi.fn();
@@ -926,7 +1094,7 @@ describe('AddSubModelButton - toggle interactions', () => {
926
1094
 
927
1095
  it('creates toggleable sub model and runs lifecycle callbacks', async () => {
928
1096
  const engine = new FlowEngine();
929
- engine.flowSettings.forceEnable();
1097
+ await engine.flowSettings.forceEnable();
930
1098
 
931
1099
  class ToggleParent extends FlowModel {}
932
1100
  const saveSpy = vi.fn();
@@ -995,6 +1163,56 @@ describe('AddSubModelButton - toggle interactions', () => {
995
1163
  const subModels = ((parent.subModels as any).items as FlowModel[]) || [];
996
1164
  expect(subModels).toHaveLength(1);
997
1165
  });
1166
+
1167
+ it('updates toggle state after external sub model removal', async () => {
1168
+ const engine = new FlowEngine();
1169
+ await engine.flowSettings.forceEnable();
1170
+
1171
+ class ToggleParent extends FlowModel {}
1172
+ class ToggleChild extends FlowModel {}
1173
+
1174
+ engine.registerModels({ ToggleParent, ToggleChild });
1175
+ const parent = engine.createModel<ToggleParent>({ use: 'ToggleParent', uid: 'toggle-parent-external-remove' });
1176
+ const existing = engine.createModel<ToggleChild>({ use: 'ToggleChild', uid: 'toggle-child-external-remove' });
1177
+ parent.addSubModel('items', existing);
1178
+
1179
+ render(
1180
+ <FlowEngineProvider engine={engine}>
1181
+ <ConfigProvider>
1182
+ <App>
1183
+ <AddSubModelButton
1184
+ model={parent}
1185
+ subModelKey="items"
1186
+ items={[
1187
+ {
1188
+ key: 'toggle-child',
1189
+ label: 'Toggle Child',
1190
+ toggleable: true,
1191
+ useModel: 'ToggleChild',
1192
+ createModelOptions: { use: 'ToggleChild' },
1193
+ },
1194
+ ]}
1195
+ >
1196
+ Toggle Menu
1197
+ </AddSubModelButton>
1198
+ </App>
1199
+ </ConfigProvider>
1200
+ </FlowEngineProvider>,
1201
+ );
1202
+
1203
+ await act(async () => {
1204
+ await userEvent.click(screen.getByText('Toggle Menu'));
1205
+ });
1206
+
1207
+ await waitFor(() => expect(screen.getByText('Toggle Child')).toBeInTheDocument());
1208
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1209
+
1210
+ await act(async () => {
1211
+ await existing.destroy();
1212
+ });
1213
+
1214
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false'));
1215
+ });
998
1216
  });
999
1217
 
1000
1218
  // ========================
@@ -1013,9 +1231,9 @@ describe('AddSubModelButton toggleable behavior', () => {
1013
1231
  duplicate = vi.fn().mockResolvedValue(null);
1014
1232
  }
1015
1233
 
1016
- function setup() {
1234
+ async function setup() {
1017
1235
  const engine = new FlowEngine();
1018
- engine.flowSettings.forceEnable();
1236
+ await engine.flowSettings.forceEnable();
1019
1237
  engine.registerModels({ ToggleModel });
1020
1238
  engine.setModelRepository(new FakeRepo());
1021
1239
 
@@ -1068,7 +1286,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1068
1286
  });
1069
1287
 
1070
1288
  test('keeps dropdown open and preserves loaded children on toggle add/remove', async () => {
1071
- const { engine, ui } = setup();
1289
+ const { engine, ui } = await setup();
1072
1290
  const user = userEvent.setup();
1073
1291
 
1074
1292
  render(ui);
@@ -1092,6 +1310,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1092
1310
  },
1093
1311
  { timeout: 3000 },
1094
1312
  );
1313
+ await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1095
1314
 
1096
1315
  // dropdown should remain open and children should still be visible (no flicker / reload)
1097
1316
  expect(screen.getByText('Async Group')).toBeInTheDocument();
@@ -1108,12 +1327,13 @@ describe('AddSubModelButton toggleable behavior', () => {
1108
1327
 
1109
1328
  // ensure destroy has been called (avoid flakiness on exact call counts)
1110
1329
  await waitFor(() => {
1330
+ expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
1111
1331
  expect(repo.destroy).toHaveBeenCalled();
1112
1332
  });
1113
1333
  });
1114
1334
 
1115
1335
  test('toggle state updates without menu closing', async () => {
1116
- const { ui } = setup();
1336
+ const { ui } = await setup();
1117
1337
  const user = userEvent.setup();
1118
1338
 
1119
1339
  render(ui);
@@ -1131,7 +1351,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1131
1351
 
1132
1352
  test('nested submenu (static items) toggle keeps menu open and reflects state', async () => {
1133
1353
  const engine = new FlowEngine();
1134
- engine.flowSettings.forceEnable();
1354
+ await engine.flowSettings.forceEnable();
1135
1355
  engine.registerModels({ ToggleModel });
1136
1356
  const parent = engine.createModel<FlowModel>({ use: FlowModel });
1137
1357
 
@@ -1213,7 +1433,7 @@ describe('AddSubModelButton toggleable behavior', () => {
1213
1433
 
1214
1434
  test('submenu (second-level) toggleable stays open and updates state', async () => {
1215
1435
  const engine = new FlowEngine();
1216
- engine.flowSettings.forceEnable();
1436
+ await engine.flowSettings.forceEnable();
1217
1437
  engine.registerModels({ ToggleModel });
1218
1438
  engine.setModelRepository(new FakeRepo());
1219
1439
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
@@ -1261,18 +1481,81 @@ describe('AddSubModelButton toggleable behavior', () => {
1261
1481
  // click leaf toggle to add
1262
1482
  await user.click(screen.getByText('Leaf Toggle'));
1263
1483
 
1264
- // menu should remain visible; submenu parent still visible
1484
+ // menu and submenu should remain visible after toggling a submenu leaf
1265
1485
  expect(screen.getByText('Fields')).toBeInTheDocument();
1266
-
1267
- // 由于点击叶子项后二级子菜单可能被收起,这里先重新展开再断言开关状态
1268
- await user.hover(screen.getByText('Fields'));
1269
1486
  await waitFor(() => expect(screen.getByText('Leaf Toggle')).toBeInTheDocument());
1270
1487
  await waitFor(() => expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true'));
1271
1488
  });
1272
1489
 
1490
+ test('keepDropdownOpen keeps root menu visible after clicking a nested relation-style leaf', async () => {
1491
+ const engine = new FlowEngine();
1492
+ await engine.flowSettings.forceEnable();
1493
+
1494
+ class Parent extends FlowModel {}
1495
+ class RelationLeafModel extends FlowModel {}
1496
+
1497
+ engine.registerModels({ Parent, RelationLeafModel });
1498
+ engine.setModelRepository(new FakeRepo());
1499
+ vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
1500
+
1501
+ const parent = engine.createModel<FlowModel>({ use: 'Parent' });
1502
+
1503
+ const items = [
1504
+ {
1505
+ key: 'relation-fields',
1506
+ label: 'Display association fields',
1507
+ children: [
1508
+ {
1509
+ key: 'users',
1510
+ label: 'Users',
1511
+ type: 'group' as const,
1512
+ children: [
1513
+ {
1514
+ key: 'user-name',
1515
+ label: 'User name',
1516
+ createModelOptions: { use: 'RelationLeafModel' },
1517
+ },
1518
+ ],
1519
+ },
1520
+ ],
1521
+ },
1522
+ ];
1523
+
1524
+ render(
1525
+ <FlowEngineProvider engine={engine}>
1526
+ <ConfigProvider>
1527
+ <App>
1528
+ <AddSubModelButton
1529
+ model={parent}
1530
+ items={items as any}
1531
+ subModelType="array"
1532
+ subModelKey="subs"
1533
+ keepDropdownOpen
1534
+ >
1535
+ Open
1536
+ </AddSubModelButton>
1537
+ </App>
1538
+ </ConfigProvider>
1539
+ </FlowEngineProvider>,
1540
+ );
1541
+
1542
+ const user = userEvent.setup();
1543
+ await user.click(screen.getByText('Open'));
1544
+
1545
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1546
+ await user.hover(screen.getByText('Display association fields'));
1547
+ await waitFor(() => expect(screen.getByText('Users')).toBeInTheDocument());
1548
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1549
+
1550
+ await user.click(screen.getByText('User name'));
1551
+
1552
+ await waitFor(() => expect(screen.getByText('Display association fields')).toBeInTheDocument());
1553
+ await waitFor(() => expect(getSubmenuTitle('Display association fields')).toHaveAttribute('aria-expanded', 'true'));
1554
+ });
1555
+
1273
1556
  test('top-level toggle updates after opening a second-level branch', async () => {
1274
1557
  const engine = new FlowEngine();
1275
- engine.flowSettings.forceEnable();
1558
+ await engine.flowSettings.forceEnable();
1276
1559
  engine.registerModels({ ToggleModel });
1277
1560
  engine.setModelRepository(new FakeRepo());
1278
1561
  vi.spyOn(engine.flowSettings, 'open').mockResolvedValue(false as any);
@@ -100,6 +100,30 @@ describe('subModel/utils', () => {
100
100
  expect(groups[0].children).toBeTruthy();
101
101
  });
102
102
 
103
+ it('preserves searchable meta on generated groups', async () => {
104
+ const engine = new FlowEngine();
105
+
106
+ class Base extends FlowModel {}
107
+ Base.define({
108
+ label: 'Base Group',
109
+ searchable: true,
110
+ searchPlaceholder: 'Search fields',
111
+ });
112
+ const BaseDC = attachDefineChildren(Base, async () => [{ key: 'title', label: 'Title' }]);
113
+
114
+ engine.registerModels({ Base: BaseDC });
115
+
116
+ const model = engine.createModel({ use: 'FlowModel' });
117
+ const ctx = model.context;
118
+
119
+ const groupsFactory = buildSubModelGroups([BaseDC]);
120
+ const groups = await groupsFactory(ctx);
121
+
122
+ expect(groups).toHaveLength(1);
123
+ expect(groups[0].searchable).toBe(true);
124
+ expect(groups[0].searchPlaceholder).toBe('Search fields');
125
+ });
126
+
103
127
  it('invokes buildSubModelItems when meta.children is false', async () => {
104
128
  const engine = new FlowEngine();
105
129
 
@@ -8,5 +8,6 @@
8
8
  */
9
9
 
10
10
  export * from './AddSubModelButton';
11
+ export { default as LazyDropdown } from './LazyDropdown';
11
12
  export * from './utils';
12
13
  //
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import * as _ from 'lodash';
10
+ import _ from 'lodash';
11
11
  import type { Collection } from '../../data-source';
12
12
  import { FlowModelContext } from '../../flowContext';
13
13
  import { FlowModelMeta, ModelConstructor } from '../../types';
@@ -196,12 +196,16 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
196
196
  const baseKey = typeof subModelBaseClass === 'string' ? subModelBaseClass : BaseClass.name;
197
197
  const menuType = BaseClass?.meta?.menuType || 'group';
198
198
  const groupSort = BaseClass?.meta?.sort ?? 1000;
199
+ const searchable = !!BaseClass?.meta?.searchable;
200
+ const searchPlaceholder = BaseClass?.meta?.searchPlaceholder;
199
201
  if (menuType === 'submenu') {
200
202
  // 作为可点击的一级项,展开二级子菜单
201
203
  items.push({
202
204
  key: baseKey,
203
205
  label: groupLabel,
204
206
  sort: groupSort,
207
+ searchable,
208
+ searchPlaceholder,
205
209
  children,
206
210
  });
207
211
  } else {
@@ -211,6 +215,8 @@ export function buildSubModelGroups(subModelBaseClasses: (string | ModelConstruc
211
215
  type: 'group',
212
216
  label: groupLabel,
213
217
  sort: groupSort,
218
+ searchable,
219
+ searchPlaceholder,
214
220
  children,
215
221
  });
216
222
  }