@nocobase/flow-engine 2.1.0-beta.37 → 2.1.0-beta.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.
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +8 -1
- package/lib/components/subModel/LazyDropdown.js +200 -16
- package/lib/data-source/index.d.ts +9 -0
- package/lib/data-source/index.js +12 -0
- package/lib/flowContext.js +3 -0
- package/lib/flowEngine.js +3 -3
- package/lib/models/flowModel.js +3 -3
- package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
- package/lib/utils/parsePathnameToViewParams.js +28 -4
- package/lib/views/ViewNavigation.d.ts +12 -2
- package/lib/views/ViewNavigation.js +22 -7
- package/lib/views/createViewMeta.js +114 -50
- package/lib/views/inheritLayoutContext.d.ts +10 -0
- package/lib/views/inheritLayoutContext.js +50 -0
- package/lib/views/useDialog.js +2 -0
- package/lib/views/useDrawer.js +2 -0
- package/lib/views/usePage.js +2 -0
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +115 -1
- package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -1
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +5 -2
- package/src/components/subModel/LazyDropdown.tsx +228 -16
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +203 -1
- package/src/data-source/index.ts +18 -0
- package/src/executor/__tests__/flowExecutor.test.ts +28 -0
- package/src/flowContext.ts +3 -0
- package/src/flowEngine.ts +4 -3
- package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
- package/src/models/__tests__/flowModel.test.ts +33 -34
- package/src/models/flowModel.tsx +3 -3
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
- package/src/utils/parsePathnameToViewParams.ts +45 -5
- package/src/views/ViewNavigation.ts +40 -7
- package/src/views/__tests__/ViewNavigation.test.ts +52 -0
- package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
- package/src/views/createViewMeta.ts +106 -34
- package/src/views/inheritLayoutContext.ts +26 -0
- package/src/views/useDialog.tsx +2 -0
- package/src/views/useDrawer.tsx +2 -0
- 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
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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', () => {
|
package/src/data-source/index.ts
CHANGED
|
@@ -37,7 +37,13 @@ export class DataSourceManager {
|
|
|
37
37
|
addFieldInterfaceGroups?: (groups: Record<string, { label: string; order?: number }>) => void;
|
|
38
38
|
addFieldInterfaceComponentOption?: (name: string, option: any) => void;
|
|
39
39
|
addFieldInterfaceOperator?: (name: string, operator: any) => void;
|
|
40
|
+
registerFieldFilterOperator?: (operator: any) => void;
|
|
41
|
+
registerFieldFilterOperatorGroup?: (name: string, operators?: any[]) => void;
|
|
42
|
+
addFieldFilterOperatorsToGroup?: (name: string, operators?: any[]) => void;
|
|
40
43
|
getFieldInterface?: (name: string) => any;
|
|
44
|
+
registerFieldInterfaceConfigure?: (options: unknown) => void;
|
|
45
|
+
getFieldInterfaceConfigure?: (name: string, collectionInfo?: unknown) => unknown;
|
|
46
|
+
getFieldInterfaceConfigureProperties?: (name: string, collectionInfo?: any) => Record<string, any>;
|
|
41
47
|
};
|
|
42
48
|
loaders = new Map<string, DataSourceLoader>();
|
|
43
49
|
loadedKeys = new Set<string>();
|
|
@@ -77,6 +83,18 @@ export class DataSourceManager {
|
|
|
77
83
|
this.collectionFieldInterfaceManager?.addFieldInterfaceOperator?.(name, operator);
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
registerFieldFilterOperator(operator: any) {
|
|
87
|
+
this.collectionFieldInterfaceManager?.registerFieldFilterOperator?.(operator);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerFieldFilterOperatorGroup(name: string, operators: any[] = []) {
|
|
91
|
+
this.collectionFieldInterfaceManager?.registerFieldFilterOperatorGroup?.(name, operators);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
addFieldFilterOperatorsToGroup(name: string, operators: any[] = []) {
|
|
95
|
+
this.collectionFieldInterfaceManager?.addFieldFilterOperatorsToGroup?.(name, operators);
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
registerLoader(key: string, loader: DataSourceLoader) {
|
|
81
99
|
this.loaders.set(key, loader);
|
|
82
100
|
}
|