@nocobase/flow-engine 2.1.0-beta.37 → 2.1.0-beta.38

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 (38) hide show
  1. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +8 -1
  2. package/lib/components/subModel/LazyDropdown.js +200 -16
  3. package/lib/flowContext.js +3 -0
  4. package/lib/flowEngine.js +3 -3
  5. package/lib/models/flowModel.js +3 -3
  6. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  7. package/lib/utils/parsePathnameToViewParams.js +28 -4
  8. package/lib/views/ViewNavigation.d.ts +12 -2
  9. package/lib/views/ViewNavigation.js +22 -7
  10. package/lib/views/createViewMeta.js +114 -50
  11. package/lib/views/inheritLayoutContext.d.ts +10 -0
  12. package/lib/views/inheritLayoutContext.js +50 -0
  13. package/lib/views/useDialog.js +2 -0
  14. package/lib/views/useDrawer.js +2 -0
  15. package/lib/views/usePage.js +2 -0
  16. package/package.json +4 -4
  17. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  18. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  19. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -1
  20. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +5 -2
  21. package/src/components/subModel/LazyDropdown.tsx +228 -16
  22. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +203 -1
  23. package/src/executor/__tests__/flowExecutor.test.ts +28 -0
  24. package/src/flowContext.ts +3 -0
  25. package/src/flowEngine.ts +4 -3
  26. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  27. package/src/models/__tests__/flowModel.test.ts +33 -34
  28. package/src/models/flowModel.tsx +3 -3
  29. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  30. package/src/utils/parsePathnameToViewParams.ts +45 -5
  31. package/src/views/ViewNavigation.ts +40 -7
  32. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  33. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  34. package/src/views/createViewMeta.ts +106 -34
  35. package/src/views/inheritLayoutContext.ts +26 -0
  36. package/src/views/useDialog.tsx +2 -0
  37. package/src/views/useDrawer.tsx +2 -0
  38. package/src/views/usePage.tsx +2 -0
@@ -237,6 +237,15 @@ const getToolbarPopupContainer = (triggerNode?: HTMLElement | null) => {
237
237
  );
238
238
  };
239
239
 
240
+ const removeExtraMenuItemClickHandlers = (item: FlowModelExtraMenuItem): FlowModelExtraMenuItem => {
241
+ const { onClick: _onClick, children, ...rest } = item;
242
+
243
+ return {
244
+ ...rest,
245
+ children: children?.length ? children.map(removeExtraMenuItemClickHandlers) : undefined,
246
+ };
247
+ };
248
+
240
249
  export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
241
250
  model,
242
251
  showDeleteButton = true,
@@ -870,7 +879,8 @@ export const DefaultSettingsIcon: React.FC<DefaultSettingsIconProps> = ({
870
879
  // });
871
880
 
872
881
  if (commonExtras.length > 0) {
873
- items.push(...(commonExtras as MenuProps['items']));
882
+ // Antd Menu 会同时触发 item.onClick menu.onClick,这里统一交给 handleMenuClick 执行。
883
+ items.push(...(commonExtras.map(removeExtraMenuItemClickHandlers) as MenuProps['items']));
874
884
  }
875
885
 
876
886
  // 添加复制uid按钮
@@ -800,14 +800,16 @@ describe('DefaultSettingsIcon - extra menu items', () => {
800
800
  await waitFor(() => {
801
801
  const menu = (globalThis as any).__lastDropdownMenu;
802
802
  const items = (menu?.items || []) as any[];
803
- expect(items.some((it) => String(it.key || '') === 'extra-action')).toBe(true);
803
+ const extraActionItem = items.find((it) => String(it.key || '') === 'extra-action');
804
+ expect(extraActionItem).toBeTruthy();
805
+ expect(extraActionItem.onClick).toBeUndefined();
804
806
  });
805
807
 
806
808
  const menu = (globalThis as any).__lastDropdownMenu;
807
809
  await act(async () => {
808
810
  menu.onClick?.({ key: 'extra-action' });
809
811
  });
810
- expect(onClick).toHaveBeenCalled();
812
+ expect(onClick).toHaveBeenCalledTimes(1);
811
813
  expect((globalThis as any).__lastDropdownOpen).toBe(false);
812
814
  } finally {
813
815
  dispose?.();
@@ -880,6 +882,7 @@ describe('DefaultSettingsIcon - extra menu items', () => {
880
882
  'insert-after',
881
883
  'insert-inner',
882
884
  ]);
885
+ expect((nested.children || []).find((it) => String(it.key || '') === 'insert-before')?.onClick).toBeUndefined();
883
886
  expect((nested.children || []).find((it) => String(it.key || '') === 'insert-inner')?.disabled).toBe(true);
884
887
  });
885
888
 
@@ -247,18 +247,75 @@ const useKeepDropdownOpen = () => {
247
247
  */
248
248
  const useMenuSearch = () => {
249
249
  const [searchValues, setSearchValues] = useState<Record<string, string>>({});
250
+ const [inputValues, setInputValues] = useState<Record<string, string>>({});
250
251
  const [isSearching, setIsSearching] = useState(false);
252
+ const [composingCount, setComposingCount] = useState(0);
253
+ const composingKeysRef = useRef<Set<string>>(new Set());
251
254
  const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
252
255
 
253
- const updateSearchValue = (key: string, value: string) => {
256
+ const updateSearchValue = useCallback((key: string, value: string) => {
254
257
  setIsSearching(true);
258
+ setInputValues((prev) => ({ ...prev, [key]: value }));
255
259
  setSearchValues((prev) => ({ ...prev, [key]: value }));
256
260
 
257
261
  if (searchTimeoutRef.current) {
258
262
  clearTimeout(searchTimeoutRef.current);
259
263
  }
260
264
  searchTimeoutRef.current = setTimeout(() => setIsSearching(false), 300);
261
- };
265
+ }, []);
266
+
267
+ const startComposition = useCallback((key: string) => {
268
+ composingKeysRef.current.add(key);
269
+ setIsSearching(true);
270
+ setComposingCount(composingKeysRef.current.size);
271
+ if (searchTimeoutRef.current) {
272
+ clearTimeout(searchTimeoutRef.current);
273
+ searchTimeoutRef.current = null;
274
+ }
275
+ }, []);
276
+
277
+ const endComposition = useCallback(
278
+ (key: string, value: string) => {
279
+ composingKeysRef.current.delete(key);
280
+ setComposingCount(composingKeysRef.current.size);
281
+ updateSearchValue(key, value);
282
+ },
283
+ [updateSearchValue],
284
+ );
285
+
286
+ const updateInputValue = useCallback((key: string, value: string) => {
287
+ setInputValues((prev) => ({ ...prev, [key]: value }));
288
+ }, []);
289
+
290
+ const clearSearchValue = useCallback((key: string) => {
291
+ composingKeysRef.current.delete(key);
292
+ setComposingCount(composingKeysRef.current.size);
293
+
294
+ setInputValues((prev) => {
295
+ if (!(key in prev)) return prev;
296
+ const next = { ...prev };
297
+ delete next[key];
298
+ return next;
299
+ });
300
+ setSearchValues((prev) => {
301
+ if (!(key in prev)) return prev;
302
+ const next = { ...prev };
303
+ delete next[key];
304
+ return next;
305
+ });
306
+ }, []);
307
+
308
+ const clearAllSearchValues = useCallback(() => {
309
+ composingKeysRef.current.clear();
310
+ setComposingCount(0);
311
+ setInputValues({});
312
+ setSearchValues({});
313
+ setIsSearching(false);
314
+ }, []);
315
+
316
+ const isComposing = useCallback((key?: string) => {
317
+ return key ? composingKeysRef.current.has(key) : composingKeysRef.current.size > 0;
318
+ }, []);
262
319
 
263
320
  useEffect(() => {
264
321
  return () => {
@@ -270,8 +327,15 @@ const useMenuSearch = () => {
270
327
 
271
328
  return {
272
329
  searchValues,
273
- isSearching,
330
+ inputValues,
331
+ isSearching: isSearching || composingCount > 0,
274
332
  updateSearchValue,
333
+ updateInputValue,
334
+ startComposition,
335
+ endComposition,
336
+ clearSearchValue,
337
+ clearAllSearchValues,
338
+ isComposing,
275
339
  };
276
340
  };
277
341
 
@@ -390,23 +454,63 @@ const createSearchItem = (
390
454
  currentSearchValue: string,
391
455
  menuVisible: boolean,
392
456
  t: (key: string) => string,
393
- updateSearchValue: (key: string, value: string) => void,
457
+ searchHandlers: {
458
+ updateSearchValue: (key: string, value: string) => void;
459
+ updateInputValue: (key: string, value: string) => void;
460
+ startComposition: (key: string) => void;
461
+ endComposition: (key: string, value: string) => void;
462
+ isComposing: (key: string) => boolean;
463
+ },
464
+ activateSearchSubmenu: (key: string) => void,
465
+ deactivateSearchSubmenu: (key: string) => void,
394
466
  ) => ({
395
467
  key: `${searchKey}-search`,
396
468
  type: 'group' as const,
397
469
  label: (
398
- <div>
470
+ <div onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
399
471
  <SearchInputWithAutoFocus
400
472
  visible={menuVisible}
401
473
  variant="borderless"
402
474
  allowClear
403
475
  placeholder={t(item.searchPlaceholder || 'Search')}
404
476
  value={currentSearchValue}
477
+ onFocus={(e) => {
478
+ e.stopPropagation();
479
+ }}
405
480
  onChange={(e) => {
406
481
  e.stopPropagation();
407
- updateSearchValue(searchKey, e.target.value);
482
+ const value = e.target.value;
483
+ activateSearchSubmenu(searchKey);
484
+ if ((e.nativeEvent as any)?.isComposing || searchHandlers.isComposing(searchKey)) {
485
+ searchHandlers.updateInputValue(searchKey, value);
486
+ return;
487
+ }
488
+ if (!value) {
489
+ deactivateSearchSubmenu(searchKey);
490
+ }
491
+ searchHandlers.updateSearchValue(searchKey, value);
492
+ }}
493
+ onCompositionStart={(e) => {
494
+ e.stopPropagation();
495
+ activateSearchSubmenu(searchKey);
496
+ searchHandlers.startComposition(searchKey);
497
+ }}
498
+ onCompositionEnd={(e) => {
499
+ e.stopPropagation();
500
+ const value = e.currentTarget.value;
501
+ if (value) {
502
+ activateSearchSubmenu(searchKey);
503
+ } else {
504
+ deactivateSearchSubmenu(searchKey);
505
+ }
506
+ searchHandlers.endComposition(searchKey, value);
507
+ }}
508
+ onClick={(e) => {
509
+ e.stopPropagation();
510
+ }}
511
+ onKeyDown={(e) => {
512
+ e.stopPropagation();
408
513
  }}
409
- onClick={(e) => e.stopPropagation()}
410
514
  onMouseDown={(e) => {
411
515
  // 防止菜单聚焦丢失或页面滚动
412
516
  e.stopPropagation();
@@ -441,14 +545,22 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
441
545
 
442
546
  // 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
443
547
  const DROPDOWN_PERSIST_TTL_MS = 350;
548
+ const SUBMENU_CLOSE_DELAY = 0.05;
549
+ const SUBMENU_MOTION_DISABLED = {
550
+ motionEnter: false,
551
+ motionLeave: false,
552
+ };
444
553
  const dropdownPersistRegistry: Map<string, number> = new Map();
445
554
 
446
555
  const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
447
556
  const engine = useFlowEngine();
448
557
  const [menuVisible, setMenuVisible] = useState(false);
449
558
  const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
559
+ const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
450
560
  const [rootItems, setRootItems] = useState<Item[]>([]);
451
561
  const [rootLoading, setRootLoading] = useState(false);
562
+ const closeByOutsideClickRef = useRef(false);
563
+ const skipPreserveActiveSearchRef = useRef(false);
452
564
  const dropdownMaxHeight = useNiceDropdownMaxHeight();
453
565
  const t = engine.translate.bind(engine);
454
566
 
@@ -463,23 +575,105 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
463
575
  openKeys,
464
576
  refreshKeys,
465
577
  );
466
- const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
578
+ const searchHandlers = useMenuSearch();
579
+ const { searchValues, inputValues, clearSearchValue, clearAllSearchValues } = searchHandlers;
467
580
  const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
468
581
  useSubmenuStyles(menuVisible, dropdownMaxHeight);
582
+
583
+ const closeMenu = useCallback(() => {
584
+ setMenuVisible(false);
585
+ setActiveSearchKey(null);
586
+ setOpenKeys(new Set());
587
+ clearAllSearchValues();
588
+ }, [clearAllSearchValues]);
589
+
590
+ const activateSearchSubmenu = useCallback((key: string) => {
591
+ setActiveSearchKey(key);
592
+ setOpenKeys((prev) => {
593
+ if (prev.has(key)) return prev;
594
+ const next = new Set(prev);
595
+ next.add(key);
596
+ return next;
597
+ });
598
+ }, []);
599
+
600
+ const deactivateSearchSubmenu = useCallback((key: string) => {
601
+ setActiveSearchKey((prev) => (prev === key ? null : prev));
602
+ }, []);
603
+
604
+ const closeActiveSearchForPath = useCallback(
605
+ (keyPath: string) => {
606
+ if (
607
+ !activeSearchKey ||
608
+ keyPath === activeSearchKey ||
609
+ keyPath.startsWith(`${activeSearchKey}/`) ||
610
+ activeSearchKey.startsWith(`${keyPath}/`)
611
+ ) {
612
+ return;
613
+ }
614
+
615
+ skipPreserveActiveSearchRef.current = true;
616
+ clearSearchValue(activeSearchKey);
617
+ setActiveSearchKey(null);
618
+ setOpenKeys((prev) => {
619
+ const next = new Set(prev);
620
+ next.delete(activeSearchKey);
621
+ return next;
622
+ });
623
+ },
624
+ [activeSearchKey, clearSearchValue],
625
+ );
626
+
469
627
  const handleMenuOpenChange = useCallback(
470
628
  (nextOpenKeys: string[]) => {
471
- if (!nextOpenKeys.length && shouldPreventClose()) {
629
+ let normalized = normalizeOpenKeys(nextOpenKeys);
630
+ if (activeSearchKey && openKeys.has(activeSearchKey) && !normalized.includes(activeSearchKey)) {
631
+ if (normalized.length || skipPreserveActiveSearchRef.current) {
632
+ clearSearchValue(activeSearchKey);
633
+ setActiveSearchKey(null);
634
+ } else {
635
+ normalized = [activeSearchKey];
636
+ }
637
+ }
638
+
639
+ if (!normalized.length && shouldPreventClose()) {
472
640
  dropdownMenuProps.onOpenChange?.(Array.from(openKeys));
641
+ skipPreserveActiveSearchRef.current = false;
473
642
  return;
474
643
  }
475
644
 
476
- const normalized = normalizeOpenKeys(nextOpenKeys);
645
+ Array.from(openKeys).forEach((key) => {
646
+ if (!normalized.includes(key)) {
647
+ clearSearchValue(key);
648
+ }
649
+ });
477
650
  setOpenKeys(new Set(normalized));
478
651
  dropdownMenuProps.onOpenChange?.(normalized);
652
+ skipPreserveActiveSearchRef.current = false;
479
653
  },
480
- [dropdownMenuProps, openKeys, shouldPreventClose],
654
+ [activeSearchKey, clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
481
655
  );
482
656
 
657
+ useEffect(() => {
658
+ if (!menuVisible) return;
659
+
660
+ const markOutsideClick = (event: MouseEvent | PointerEvent) => {
661
+ const target = event.target as HTMLElement | null;
662
+ const isOutside = !target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
663
+ closeByOutsideClickRef.current = isOutside;
664
+ if (isOutside) {
665
+ closeMenu();
666
+ }
667
+ };
668
+
669
+ document.addEventListener('pointerdown', markOutsideClick, true);
670
+ document.addEventListener('mousedown', markOutsideClick, true);
671
+ return () => {
672
+ document.removeEventListener('pointerdown', markOutsideClick, true);
673
+ document.removeEventListener('mousedown', markOutsideClick, true);
674
+ };
675
+ }, [closeMenu, menuVisible]);
676
+
483
677
  // 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
484
678
  useEffect(() => {
485
679
  if (!persistKey) return;
@@ -542,6 +736,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
542
736
  ): any[] {
543
737
  const searchKey = keyPath;
544
738
  const currentSearchValue = searchValues[searchKey] || '';
739
+ const currentInputValue = inputValues[searchKey] ?? currentSearchValue;
545
740
 
546
741
  // 递归过滤:当 child 为分组时,会继续向下过滤其 children;
547
742
  // 仅保留自身匹配或存在匹配子项的分组。
@@ -566,7 +761,16 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
566
761
  : children;
567
762
 
568
763
  const resolvedFiltered = resolve(filteredChildren, [...path, item.key]);
569
- const searchItem = createSearchItem(item, searchKey, currentSearchValue, menuVisible, t, updateSearchValue);
764
+ const searchItem = createSearchItem(
765
+ item,
766
+ searchKey,
767
+ currentInputValue,
768
+ menuVisible,
769
+ t,
770
+ searchHandlers,
771
+ activateSearchSubmenu,
772
+ deactivateSearchSubmenu,
773
+ );
570
774
  const dividerItem = { key: `${keyPath}-search-divider`, type: 'divider' as const };
571
775
 
572
776
  if (currentSearchValue && resolvedFiltered.length === 0) {
@@ -641,6 +845,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
641
845
  key: keyPath,
642
846
  label,
643
847
  onClick: (info: any) => {},
848
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
644
849
  children: buildSearchChildren(children, item, keyPath, path, menuVisible, resolveItems),
645
850
  };
646
851
  }
@@ -694,6 +899,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
694
899
  onClick: (info: any) => {
695
900
  if (!itemShouldKeepOpen) handleLeafClick(info);
696
901
  },
902
+ onMouseEnter: () => closeActiveSearchForPath(keyPath),
697
903
  onMouseDown: () => {
698
904
  if (!itemShouldKeepOpen) {
699
905
  return;
@@ -748,6 +954,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
748
954
  ...dropdownMenuProps,
749
955
  openKeys: Array.from(openKeys),
750
956
  items: items,
957
+ subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? SUBMENU_CLOSE_DELAY,
958
+ motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
751
959
  onClick: () => {},
752
960
  onOpenChange: handleMenuOpenChange,
753
961
  style: {
@@ -756,9 +964,8 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
756
964
  ...dropdownMenuProps?.style,
757
965
  },
758
966
  }}
759
- onOpenChange={(visible) => {
760
- // 阻止在搜索时关闭菜单
761
- if (!visible && isSearching) {
967
+ onOpenChange={(visible, info) => {
968
+ if (!visible && activeSearchKey && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
762
969
  return;
763
970
  }
764
971
 
@@ -767,7 +974,12 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
767
974
  return;
768
975
  }
769
976
 
770
- setMenuVisible(visible);
977
+ if (!visible) {
978
+ closeMenu();
979
+ } else {
980
+ setMenuVisible(visible);
981
+ }
982
+ closeByOutsideClickRef.current = false;
771
983
  }}
772
984
  >
773
985
  {props.children}
@@ -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,
@@ -354,6 +354,208 @@ describe('transformItems - searchable flags', () => {
354
354
  await user.type(searchInput, 'display');
355
355
  await waitFor(() => expect(screen.getByText('Field display name')).toBeInTheDocument());
356
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
+ });
357
559
  });
358
560
 
359
561
  describe('transformItems - hide', () => {
@@ -81,6 +81,34 @@ describe('FlowExecutor', () => {
81
81
  expect(result.step2).toBe('step2-ok');
82
82
  });
83
83
 
84
+ it('runFlow warns and skips steps without use or handler', async () => {
85
+ const flows = {
86
+ referenceSettings: {
87
+ steps: {
88
+ target: {},
89
+ },
90
+ },
91
+ } satisfies Record<string, Omit<FlowDefinitionOptions, 'key'>>;
92
+ const model = createModelWithFlows('m-empty-step', flows);
93
+ const loggerChildSpy = vi.spyOn(engine.logger, 'child').mockReturnValue(engine.logger);
94
+ const loggerWarnSpy = vi.spyOn(engine.logger, 'warn').mockImplementation(() => {});
95
+ const loggerErrorSpy = vi.spyOn(engine.logger, 'error').mockImplementation(() => {});
96
+
97
+ try {
98
+ const result = await engine.executor.runFlow(model, 'referenceSettings');
99
+
100
+ expect(result).toEqual({});
101
+ expect(loggerWarnSpy).toHaveBeenCalledWith(
102
+ "BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
103
+ );
104
+ expect(loggerErrorSpy).not.toHaveBeenCalled();
105
+ } finally {
106
+ loggerChildSpy.mockRestore();
107
+ loggerWarnSpy.mockRestore();
108
+ loggerErrorSpy.mockRestore();
109
+ }
110
+ });
111
+
84
112
  it("dispatchEvent('beforeRender') executes flows in sort order and caches result (when options specify)", async () => {
85
113
  const calls: string[] = [];
86
114
  const mkFlow = (key: string, sort: number) => ({
@@ -3581,6 +3581,9 @@ export class FlowEngineContext extends BaseFlowEngineContext {
3581
3581
  },
3582
3582
  });
3583
3583
  this.defineMethod('aclCheck', function (params) {
3584
+ if (this.skipAclCheck) {
3585
+ return true;
3586
+ }
3584
3587
  return this.acl.aclCheck(params);
3585
3588
  });
3586
3589
  this.defineMethod('createResource', function (this: BaseFlowEngineContext, resourceType) {