@nocobase/flow-engine 2.2.0-beta.14 → 2.2.0-beta.16
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/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/FlowContextSelector.js +55 -12
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +14 -3
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowI18n.js +3 -3
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/package.json +4 -4
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +66 -11
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.tsx +16 -4
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +42 -1
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +60 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowI18n.ts +8 -3
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
- package/src/types.ts +2 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { css } from '@emotion/css';
|
|
11
|
-
import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
11
|
+
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
12
12
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useFlowEngine } from '../../provider';
|
|
14
14
|
|
|
@@ -484,13 +484,10 @@ const createSearchItem = (
|
|
|
484
484
|
if (shouldActivateSearchSubmenu) {
|
|
485
485
|
activateSearchSubmenu(searchKey);
|
|
486
486
|
}
|
|
487
|
-
if ((e.nativeEvent as
|
|
487
|
+
if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
|
|
488
488
|
searchHandlers.updateInputValue(searchKey, value);
|
|
489
489
|
return;
|
|
490
490
|
}
|
|
491
|
-
if (!value && shouldActivateSearchSubmenu) {
|
|
492
|
-
deactivateSearchSubmenu(searchKey);
|
|
493
|
-
}
|
|
494
491
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
495
492
|
}}
|
|
496
493
|
onCompositionStart={(e) => {
|
|
@@ -504,11 +501,7 @@ const createSearchItem = (
|
|
|
504
501
|
e.stopPropagation();
|
|
505
502
|
const value = e.currentTarget.value;
|
|
506
503
|
if (shouldActivateSearchSubmenu) {
|
|
507
|
-
|
|
508
|
-
activateSearchSubmenu(searchKey);
|
|
509
|
-
} else {
|
|
510
|
-
deactivateSearchSubmenu(searchKey);
|
|
511
|
-
}
|
|
504
|
+
activateSearchSubmenu(searchKey);
|
|
512
505
|
}
|
|
513
506
|
searchHandlers.endComposition(searchKey, value);
|
|
514
507
|
}}
|
|
@@ -516,11 +509,21 @@ const createSearchItem = (
|
|
|
516
509
|
e.stopPropagation();
|
|
517
510
|
}}
|
|
518
511
|
onKeyDown={(e) => {
|
|
512
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
513
|
+
deactivateSearchSubmenu(searchKey);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (shouldActivateSearchSubmenu) {
|
|
517
|
+
activateSearchSubmenu(searchKey);
|
|
518
|
+
}
|
|
519
519
|
e.stopPropagation();
|
|
520
520
|
}}
|
|
521
521
|
onMouseDown={(e) => {
|
|
522
522
|
// 防止菜单聚焦丢失或页面滚动
|
|
523
523
|
e.stopPropagation();
|
|
524
|
+
if (shouldActivateSearchSubmenu) {
|
|
525
|
+
activateSearchSubmenu(searchKey);
|
|
526
|
+
}
|
|
524
527
|
}}
|
|
525
528
|
size="small"
|
|
526
529
|
style={{
|
|
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
552
555
|
|
|
553
556
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
554
557
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
555
|
-
const
|
|
558
|
+
const MENU_CLOSE_DELAY = 0.3;
|
|
556
559
|
const SUBMENU_MOTION_DISABLED = {
|
|
557
560
|
motionEnter: false,
|
|
558
561
|
motionLeave: false,
|
|
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
|
561
564
|
|
|
562
565
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
563
566
|
const engine = useFlowEngine();
|
|
567
|
+
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
|
|
568
|
+
const triggerId = React.useId();
|
|
564
569
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
565
570
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
566
|
-
const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
|
|
567
571
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
568
572
|
const [rootLoading, setRootLoading] = useState(false);
|
|
573
|
+
const activeSearchKeyRef = useRef<string | null>(null);
|
|
569
574
|
const closeByOutsideClickRef = useRef(false);
|
|
570
575
|
const skipPreserveActiveSearchRef = useRef(false);
|
|
576
|
+
const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
577
|
+
const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
|
|
578
|
+
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join(' ');
|
|
571
581
|
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
572
582
|
const t = engine.translate.bind(engine);
|
|
573
583
|
|
|
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
589
599
|
|
|
590
600
|
const closeMenu = useCallback(() => {
|
|
591
601
|
setMenuVisible(false);
|
|
592
|
-
|
|
602
|
+
activeSearchKeyRef.current = null;
|
|
593
603
|
setOpenKeys(new Set());
|
|
594
604
|
clearAllSearchValues();
|
|
595
605
|
}, [clearAllSearchValues]);
|
|
596
606
|
|
|
597
607
|
const activateSearchSubmenu = useCallback((key: string) => {
|
|
598
|
-
|
|
608
|
+
activeSearchKeyRef.current = key;
|
|
599
609
|
setOpenKeys((prev) => {
|
|
600
610
|
if (prev.has(key)) return prev;
|
|
601
611
|
const next = new Set(prev);
|
|
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
605
615
|
}, []);
|
|
606
616
|
|
|
607
617
|
const deactivateSearchSubmenu = useCallback((key: string) => {
|
|
608
|
-
|
|
618
|
+
if (activeSearchKeyRef.current === key) {
|
|
619
|
+
activeSearchKeyRef.current = null;
|
|
620
|
+
}
|
|
609
621
|
}, []);
|
|
610
622
|
|
|
611
623
|
const closeActiveSearchForPath = useCallback(
|
|
612
624
|
(keyPath: string) => {
|
|
625
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
613
626
|
if (
|
|
614
627
|
!activeSearchKey ||
|
|
615
628
|
keyPath === activeSearchKey ||
|
|
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
621
634
|
|
|
622
635
|
skipPreserveActiveSearchRef.current = true;
|
|
623
636
|
clearSearchValue(activeSearchKey);
|
|
624
|
-
|
|
637
|
+
activeSearchKeyRef.current = null;
|
|
625
638
|
setOpenKeys((prev) => {
|
|
626
639
|
const next = new Set(prev);
|
|
627
640
|
next.delete(activeSearchKey);
|
|
628
641
|
return next;
|
|
629
642
|
});
|
|
630
643
|
},
|
|
631
|
-
[
|
|
644
|
+
[clearSearchValue],
|
|
632
645
|
);
|
|
633
646
|
|
|
634
647
|
const handleMenuOpenChange = useCallback(
|
|
635
648
|
(nextOpenKeys: string[]) => {
|
|
636
649
|
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
637
|
-
|
|
638
|
-
|
|
650
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
651
|
+
if (activeSearchKey && !normalized.includes(activeSearchKey)) {
|
|
652
|
+
if (skipPreserveActiveSearchRef.current) {
|
|
639
653
|
clearSearchValue(activeSearchKey);
|
|
640
|
-
|
|
654
|
+
activeSearchKeyRef.current = null;
|
|
641
655
|
} else {
|
|
642
|
-
normalized =
|
|
656
|
+
normalized = Array.from(openKeys);
|
|
643
657
|
}
|
|
644
658
|
}
|
|
645
659
|
|
|
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
658
672
|
dropdownMenuProps.onOpenChange?.(normalized);
|
|
659
673
|
skipPreserveActiveSearchRef.current = false;
|
|
660
674
|
},
|
|
661
|
-
[
|
|
675
|
+
[clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
|
|
662
676
|
);
|
|
663
677
|
|
|
664
678
|
useEffect(() => {
|
|
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
666
680
|
|
|
667
681
|
const markOutsideClick = (event: MouseEvent | PointerEvent) => {
|
|
668
682
|
const target = event.target as HTMLElement | null;
|
|
669
|
-
const
|
|
683
|
+
const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
|
|
684
|
+
const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
|
|
685
|
+
const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
|
|
670
686
|
closeByOutsideClickRef.current = isOutside;
|
|
671
687
|
if (isOutside) {
|
|
672
688
|
closeMenu();
|
|
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
679
695
|
document.removeEventListener('pointerdown', markOutsideClick, true);
|
|
680
696
|
document.removeEventListener('mousedown', markOutsideClick, true);
|
|
681
697
|
};
|
|
682
|
-
}, [closeMenu, menuVisible]);
|
|
698
|
+
}, [closeMenu, menuVisible, triggerOpenClassName]);
|
|
683
699
|
|
|
684
700
|
// 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
|
|
685
701
|
useEffect(() => {
|
|
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
957
973
|
{...props}
|
|
958
974
|
open={menuVisible}
|
|
959
975
|
destroyPopupOnHide
|
|
976
|
+
mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
|
|
977
|
+
openClassName={mergedOpenClassName}
|
|
960
978
|
overlayClassName={overlayClassName}
|
|
961
979
|
placement="bottomLeft"
|
|
962
980
|
menu={{
|
|
963
981
|
...dropdownMenuProps,
|
|
964
982
|
openKeys: Array.from(openKeys),
|
|
965
983
|
items: items,
|
|
966
|
-
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ??
|
|
984
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
|
|
967
985
|
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
968
986
|
onClick: () => {},
|
|
969
987
|
onOpenChange: handleMenuOpenChange,
|
|
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
974
992
|
},
|
|
975
993
|
}}
|
|
976
994
|
onOpenChange={(visible, info) => {
|
|
977
|
-
if (!visible &&
|
|
995
|
+
if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
|
|
978
996
|
return;
|
|
979
997
|
}
|
|
980
998
|
|
|
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
|
|
|
411
411
|
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
412
412
|
});
|
|
413
413
|
|
|
414
|
-
it('
|
|
414
|
+
it('keeps searchable submenu open while interacting with its input', async () => {
|
|
415
415
|
const engine = new FlowEngine();
|
|
416
416
|
await engine.flowSettings.forceEnable();
|
|
417
417
|
class Parent extends FlowModel {}
|
|
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
|
|
|
451
451
|
await user.click(screen.getByRole('textbox'));
|
|
452
452
|
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
453
453
|
|
|
454
|
-
await
|
|
454
|
+
await act(async () => {
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
455
459
|
});
|
|
456
460
|
|
|
457
461
|
it('closes active searchable submenu after outside click', async () => {
|
|
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
|
|
|
499
503
|
await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
|
|
500
504
|
});
|
|
501
505
|
|
|
506
|
+
it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
|
|
507
|
+
const engine = new FlowEngine();
|
|
508
|
+
await engine.flowSettings.forceEnable();
|
|
509
|
+
class Parent extends FlowModel {}
|
|
510
|
+
engine.registerModels({ Parent });
|
|
511
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
512
|
+
const user = userEvent.setup();
|
|
513
|
+
|
|
514
|
+
render(
|
|
515
|
+
<FlowEngineProvider engine={engine}>
|
|
516
|
+
<ConfigProvider>
|
|
517
|
+
<App>
|
|
518
|
+
<AddSubModelButton
|
|
519
|
+
model={parent}
|
|
520
|
+
subModelKey="items"
|
|
521
|
+
items={[
|
|
522
|
+
{
|
|
523
|
+
key: 'child',
|
|
524
|
+
label: 'Child',
|
|
525
|
+
createModelOptions: { use: 'Parent' },
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
Open
|
|
530
|
+
</AddSubModelButton>
|
|
531
|
+
</App>
|
|
532
|
+
</ConfigProvider>
|
|
533
|
+
</FlowEngineProvider>,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const trigger = screen.getByText('Open');
|
|
537
|
+
await user.hover(trigger);
|
|
538
|
+
await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
|
|
539
|
+
expect(trigger).toHaveClass('ant-dropdown-open');
|
|
540
|
+
|
|
541
|
+
await user.click(trigger);
|
|
542
|
+
|
|
543
|
+
expect(screen.getByText('Child')).toBeInTheDocument();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('closes the dropdown when clicking another dropdown trigger', async () => {
|
|
547
|
+
const engine = new FlowEngine();
|
|
548
|
+
await engine.flowSettings.forceEnable();
|
|
549
|
+
class Parent extends FlowModel {}
|
|
550
|
+
engine.registerModels({ Parent });
|
|
551
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
552
|
+
const user = userEvent.setup();
|
|
553
|
+
|
|
554
|
+
render(
|
|
555
|
+
<FlowEngineProvider engine={engine}>
|
|
556
|
+
<ConfigProvider>
|
|
557
|
+
<App>
|
|
558
|
+
<AddSubModelButton
|
|
559
|
+
model={parent}
|
|
560
|
+
subModelKey="firstItems"
|
|
561
|
+
items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
|
|
562
|
+
>
|
|
563
|
+
Open first
|
|
564
|
+
</AddSubModelButton>
|
|
565
|
+
<AddSubModelButton
|
|
566
|
+
model={parent}
|
|
567
|
+
subModelKey="secondItems"
|
|
568
|
+
items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
|
|
569
|
+
>
|
|
570
|
+
Open second
|
|
571
|
+
</AddSubModelButton>
|
|
572
|
+
</App>
|
|
573
|
+
</ConfigProvider>
|
|
574
|
+
</FlowEngineProvider>,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await user.hover(screen.getByText('Open first'));
|
|
578
|
+
await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
|
|
579
|
+
|
|
580
|
+
fireEvent.pointerDown(screen.getByText('Open second'));
|
|
581
|
+
|
|
582
|
+
await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
|
|
583
|
+
});
|
|
584
|
+
|
|
502
585
|
it('switches away from active searchable submenu and resets its input', async () => {
|
|
503
586
|
const engine = new FlowEngine();
|
|
504
587
|
await engine.flowSettings.forceEnable();
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
import { css, cx } from '@emotion/css';
|
|
11
11
|
import { Space, theme } from 'antd';
|
|
12
|
-
import
|
|
12
|
+
import { FormItemInputContext } from 'antd/es/form/context';
|
|
13
|
+
import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
14
|
import type { MetaTreeNode } from '../../flowContext';
|
|
14
15
|
import { useFlowContext } from '../../FlowContextProvider';
|
|
15
16
|
import { FlowContextSelector } from '../FlowContextSelector';
|
|
16
17
|
import { useResolvedMetaTree } from './useResolvedMetaTree';
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
formatPathToValue as defaultFormatPathToValue,
|
|
20
|
+
loadMetaTreeChildren,
|
|
21
|
+
parseValueToPath as defaultParseValueToPath,
|
|
22
|
+
} from './utils';
|
|
18
23
|
|
|
19
24
|
type RangeIndexes = [number, number, number, number];
|
|
20
25
|
|
|
@@ -31,12 +36,21 @@ export interface VariableHybridInputProps {
|
|
|
31
36
|
value?: string;
|
|
32
37
|
onChange?: (value: string) => void;
|
|
33
38
|
disabled?: boolean;
|
|
39
|
+
readOnly?: boolean;
|
|
34
40
|
placeholder?: string;
|
|
35
41
|
addonBefore?: React.ReactNode;
|
|
36
42
|
metaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
37
43
|
converters?: VariableHybridInputConverters;
|
|
38
44
|
style?: React.CSSProperties;
|
|
39
45
|
className?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Validation status — turns the input border red (`error`) or amber
|
|
48
|
+
* (`warning`). Usually omitted: when rendered inside an antd `Form.Item`, the
|
|
49
|
+
* status is read automatically from `FormItemInputContext`, so dropping this
|
|
50
|
+
* into a `Form.Item` with failing rules colours the border with no extra
|
|
51
|
+
* wiring. An explicit prop wins over the inherited form status.
|
|
52
|
+
*/
|
|
53
|
+
status?: 'error' | 'warning';
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
function reactNodeToPlainText(node: React.ReactNode): string {
|
|
@@ -83,14 +97,38 @@ function normalizeVariableKey(value: string): string {
|
|
|
83
97
|
.trim();
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
function renderHTML(value: string,
|
|
100
|
+
function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
|
|
87
101
|
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
|
88
102
|
return escapeHtml(value || '').replace(re, (matched) => {
|
|
89
|
-
const label =
|
|
103
|
+
const label = resolveLabel(matched) || matched;
|
|
90
104
|
return createTagHTML(matched, label);
|
|
91
105
|
});
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
// Resolve a `{{ … }}` reference path to its slash-joined title chain by walking the (possibly lazily-expanded) meta tree
|
|
109
|
+
// live. Unlike `buildLabelMap` — which pre-walks only already-loaded (array) children into a memoized map — this reads
|
|
110
|
+
// the tree's CURRENT contents at call time, so a level expanded in place (by the cascader's loadData when the user
|
|
111
|
+
// drills in, or by the preload effect) is reflected on the very next render without the memoized map having to rebuild.
|
|
112
|
+
// This is what fixes a just-picked deep node rendering as its raw `{{ … }}` token. Returns undefined if any segment is
|
|
113
|
+
// missing or sits below a still-unresolved (thunk) level — the caller then falls back to the raw token.
|
|
114
|
+
function resolveTitlesByPath(
|
|
115
|
+
roots: MetaTreeNode[] | undefined,
|
|
116
|
+
path: string[] | undefined,
|
|
117
|
+
ctxT: (text: string) => string,
|
|
118
|
+
): string | undefined {
|
|
119
|
+
if (!roots || !path || !path.length) return undefined;
|
|
120
|
+
const titles: string[] = [];
|
|
121
|
+
let nodes: MetaTreeNode[] | undefined = roots;
|
|
122
|
+
for (const segment of path) {
|
|
123
|
+
if (!nodes) return undefined;
|
|
124
|
+
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
|
125
|
+
if (!matched) return undefined;
|
|
126
|
+
titles.push(reactNodeToPlainText(matched.title || matched.name));
|
|
127
|
+
nodes = Array.isArray(matched.children) ? (matched.children as MetaTreeNode[]) : undefined;
|
|
128
|
+
}
|
|
129
|
+
return titles.map(ctxT).join('/');
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
function buildLabelMap(
|
|
95
133
|
nodes: MetaTreeNode[] | undefined,
|
|
96
134
|
ctxT: (text: string) => string,
|
|
@@ -116,6 +154,42 @@ function buildLabelMap(
|
|
|
116
154
|
return map;
|
|
117
155
|
}
|
|
118
156
|
|
|
157
|
+
// Collect every variable reference path in `value` (one per `{{ … }}` token). Used to preload lazy meta-tree levels so
|
|
158
|
+
// a saved reference whose label lives below an unexpanded (thunk) level still resolves to a readable tag.
|
|
159
|
+
function collectReferencePaths(
|
|
160
|
+
value: string,
|
|
161
|
+
regExp: RegExp,
|
|
162
|
+
parseValueToPath: (value?: string) => string[] | undefined,
|
|
163
|
+
): string[][] {
|
|
164
|
+
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
|
165
|
+
const paths: string[][] = [];
|
|
166
|
+
for (const matched of value.match(re) ?? []) {
|
|
167
|
+
const path = parseValueToPath(matched);
|
|
168
|
+
if (path && path.length) {
|
|
169
|
+
paths.push(path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return paths;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Walk one reference path down the meta tree, resolving each lazy `children` thunk in place. Returns true if it
|
|
176
|
+
// resolved at least one level (so the caller knows to recompute the label map). Mirrors `TypedVariableInput`'s preload.
|
|
177
|
+
async function preloadReferencePath(path: string[], roots: MetaTreeNode[]): Promise<boolean> {
|
|
178
|
+
let nodes: MetaTreeNode[] | undefined = roots;
|
|
179
|
+
let didLoad = false;
|
|
180
|
+
for (const segment of path) {
|
|
181
|
+
if (!nodes) break;
|
|
182
|
+
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
|
183
|
+
if (!matched) break;
|
|
184
|
+
if (typeof matched.children === 'function') {
|
|
185
|
+
matched.children = await loadMetaTreeChildren(matched);
|
|
186
|
+
didLoad = true;
|
|
187
|
+
}
|
|
188
|
+
nodes = Array.isArray(matched.children) ? matched.children : undefined;
|
|
189
|
+
}
|
|
190
|
+
return didLoad;
|
|
191
|
+
}
|
|
192
|
+
|
|
119
193
|
function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
|
|
120
194
|
const selection = window.getSelection?.();
|
|
121
195
|
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
|
@@ -222,10 +296,14 @@ function getCurrentRange(element: HTMLElement): RangeIndexes {
|
|
|
222
296
|
}
|
|
223
297
|
|
|
224
298
|
const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props) => {
|
|
225
|
-
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, style } = props;
|
|
299
|
+
const { addonBefore, className, converters, disabled, metaTree, onChange, placeholder, readOnly, style } = props;
|
|
226
300
|
const { token } = theme.useToken();
|
|
227
301
|
const ctx = useFlowContext();
|
|
228
302
|
const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
|
|
303
|
+
// Inherit the antd Form.Item validation status (red/amber border) unless an explicit `status` prop overrides it — so
|
|
304
|
+
// the border colours automatically inside a failing `Form.Item`, no extra wiring for callers.
|
|
305
|
+
const formItemStatus = useContext(FormItemInputContext)?.status;
|
|
306
|
+
const effectiveStatus = props.status ?? formItemStatus;
|
|
229
307
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
230
308
|
const [isComposing, setIsComposing] = useState(false);
|
|
231
309
|
const [changed, setChanged] = useState(false);
|
|
@@ -233,13 +311,63 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
233
311
|
|
|
234
312
|
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
|
|
235
313
|
const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
|
|
314
|
+
const parseValueToPath = converters?.parseValueToPath ?? defaultParseValueToPath;
|
|
315
|
+
|
|
316
|
+
// Bumped after a saved reference's lazy meta-tree levels are resolved, so the label map (below) recomputes once the
|
|
317
|
+
// deep titles are actually loaded.
|
|
318
|
+
const [loadedFlag, setLoadedFlag] = useState(0);
|
|
319
|
+
|
|
320
|
+
// Preload the lazy levels every `{{ … }}` reference in `value` points through. `buildLabelMap` only walks
|
|
321
|
+
// already-loaded (array) children, so without this a reference below an unexpanded thunk level (e.g. a workflow
|
|
322
|
+
// node's output fields under `$jobsMapByNodeKey.<nodeKey>`) renders as the raw `{{ … }}` text instead of its
|
|
323
|
+
// node/field labels. Mirrors `TypedVariableInput`'s preload.
|
|
324
|
+
useEffect(() => {
|
|
325
|
+
if (!value || !Array.isArray(resolvedMetaTree) || !resolvedMetaTree.length) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const paths = collectReferencePaths(value, variableRegExp, parseValueToPath);
|
|
329
|
+
if (!paths.length) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
let cancelled = false;
|
|
333
|
+
const run = async () => {
|
|
334
|
+
let didLoad = false;
|
|
335
|
+
for (const path of paths) {
|
|
336
|
+
const loaded = await preloadReferencePath(path, resolvedMetaTree as MetaTreeNode[]);
|
|
337
|
+
if (cancelled) return;
|
|
338
|
+
didLoad = didLoad || loaded;
|
|
339
|
+
}
|
|
340
|
+
if (didLoad && !cancelled) {
|
|
341
|
+
setLoadedFlag((prev) => prev + 1);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
run();
|
|
345
|
+
return () => {
|
|
346
|
+
cancelled = true;
|
|
347
|
+
};
|
|
348
|
+
}, [value, resolvedMetaTree, variableRegExp, parseValueToPath]);
|
|
236
349
|
|
|
237
350
|
const labelMap = useMemo(
|
|
238
351
|
() => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
|
|
239
|
-
|
|
352
|
+
// `loadedFlag` is read so the map recomputes after a lazy level resolves.
|
|
353
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
354
|
+
[resolvedMetaTree, ctx, converters, loadedFlag],
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Resolve one `{{ … }}` token to its label: the pre-built map first (cheap, covers statically-loaded levels), then a LIVE walk of the current meta tree. The live fallback is what makes a just-picked deep reference render its label immediately — when the user drills into a lazy level, the cascader resolves that level onto the meta tree in place WITHOUT changing the tree reference or bumping `loadedFlag`, so the memoized `labelMap` still misses it; walking the tree's current contents finds the freshly-loaded titles on the same render.
|
|
358
|
+
const resolveLabel = useCallback(
|
|
359
|
+
(matched: string): string | undefined => {
|
|
360
|
+
const mapped = labelMap.get(normalizeVariableKey(matched));
|
|
361
|
+
if (mapped) return mapped;
|
|
362
|
+
const path = parseValueToPath(matched);
|
|
363
|
+
return resolveTitlesByPath(resolvedMetaTree as MetaTreeNode[] | undefined, path, ctx.t);
|
|
364
|
+
},
|
|
365
|
+
// `loadedFlag` is read so a resolved lazy level re-creates this callback and re-renders the tags. `ctx` carries the translation fn.
|
|
366
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
+
[labelMap, parseValueToPath, resolvedMetaTree, ctx, loadedFlag],
|
|
240
368
|
);
|
|
241
369
|
|
|
242
|
-
const [html, setHtml] = useState(() => renderHTML(value,
|
|
370
|
+
const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
|
|
243
371
|
|
|
244
372
|
const emitChange = useCallback(
|
|
245
373
|
(target: HTMLElement) => {
|
|
@@ -249,12 +377,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
249
377
|
);
|
|
250
378
|
|
|
251
379
|
useEffect(() => {
|
|
252
|
-
setHtml(renderHTML(value,
|
|
380
|
+
setHtml(renderHTML(value, variableRegExp, resolveLabel));
|
|
253
381
|
if (!changed) {
|
|
254
382
|
setRange([-1, 0, -1, 0]);
|
|
255
383
|
}
|
|
256
384
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
257
|
-
}, [value,
|
|
385
|
+
}, [value, resolveLabel]);
|
|
258
386
|
|
|
259
387
|
// Restore caret position after html update
|
|
260
388
|
useEffect(() => {
|
|
@@ -331,12 +459,13 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
331
459
|
|
|
332
460
|
const handleInput = useCallback(
|
|
333
461
|
({ currentTarget }: React.FormEvent<HTMLDivElement>) => {
|
|
462
|
+
if (readOnly) return;
|
|
334
463
|
if (isComposing) return;
|
|
335
464
|
setChanged(true);
|
|
336
465
|
setRange(getCurrentRange(currentTarget));
|
|
337
466
|
emitChange(currentTarget);
|
|
338
467
|
},
|
|
339
|
-
[emitChange, isComposing],
|
|
468
|
+
[emitChange, isComposing, readOnly],
|
|
340
469
|
);
|
|
341
470
|
|
|
342
471
|
const handleBlur = useCallback(({ currentTarget }: React.FocusEvent<HTMLDivElement>) => {
|
|
@@ -352,6 +481,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
352
481
|
const handlePaste = useCallback(
|
|
353
482
|
(event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
354
483
|
event.preventDefault();
|
|
484
|
+
if (readOnly) return;
|
|
355
485
|
// Paste as plain text only; variable tags must be inserted via the picker.
|
|
356
486
|
const text = event.clipboardData.getData('text/plain').replace(/\n/g, ' ');
|
|
357
487
|
if (!text) return;
|
|
@@ -360,18 +490,19 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
360
490
|
setRange(getCurrentRange(event.currentTarget));
|
|
361
491
|
emitChange(event.currentTarget);
|
|
362
492
|
},
|
|
363
|
-
[emitChange],
|
|
493
|
+
[emitChange, readOnly],
|
|
364
494
|
);
|
|
365
495
|
|
|
366
496
|
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
|
|
367
497
|
const handleCompositionEnd = useCallback(
|
|
368
498
|
({ currentTarget }: React.CompositionEvent<HTMLDivElement>) => {
|
|
369
499
|
setIsComposing(false);
|
|
500
|
+
if (readOnly) return;
|
|
370
501
|
setChanged(true);
|
|
371
502
|
setRange(getCurrentRange(currentTarget));
|
|
372
503
|
emitChange(currentTarget);
|
|
373
504
|
},
|
|
374
|
-
[emitChange],
|
|
505
|
+
[emitChange, readOnly],
|
|
375
506
|
);
|
|
376
507
|
|
|
377
508
|
const wrapperClassName = useMemo(
|
|
@@ -492,6 +623,42 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
492
623
|
border-color: ${token.colorBorder};
|
|
493
624
|
}
|
|
494
625
|
}
|
|
626
|
+
|
|
627
|
+
&.is-readonly {
|
|
628
|
+
cursor: default;
|
|
629
|
+
|
|
630
|
+
&:hover {
|
|
631
|
+
border-color: ${token.colorBorder};
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
&.is-error {
|
|
636
|
+
border-color: ${token.colorError};
|
|
637
|
+
|
|
638
|
+
&:hover {
|
|
639
|
+
border-color: ${token.colorErrorBorderHover};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
&:focus,
|
|
643
|
+
&:focus-visible {
|
|
644
|
+
border-color: ${token.colorError};
|
|
645
|
+
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorErrorOutline};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
&.is-warning {
|
|
650
|
+
border-color: ${token.colorWarning};
|
|
651
|
+
|
|
652
|
+
&:hover {
|
|
653
|
+
border-color: ${token.colorWarningBorderHover};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
&:focus,
|
|
657
|
+
&:focus-visible {
|
|
658
|
+
border-color: ${token.colorWarning};
|
|
659
|
+
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorWarningOutline};
|
|
660
|
+
}
|
|
661
|
+
}
|
|
495
662
|
`;
|
|
496
663
|
}, [token, addonBefore]);
|
|
497
664
|
|
|
@@ -505,8 +672,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
505
672
|
aria-label="textbox"
|
|
506
673
|
className={cx(editorClassName, {
|
|
507
674
|
'is-disabled': disabled,
|
|
675
|
+
'is-readonly': readOnly,
|
|
676
|
+
'is-error': effectiveStatus === 'error',
|
|
677
|
+
'is-warning': effectiveStatus === 'warning',
|
|
508
678
|
})}
|
|
509
|
-
contentEditable={!disabled}
|
|
679
|
+
contentEditable={!disabled && !readOnly}
|
|
680
|
+
aria-readonly={readOnly}
|
|
510
681
|
data-placeholder={placeholder}
|
|
511
682
|
onInput={handleInput}
|
|
512
683
|
onBlur={handleBlur}
|
|
@@ -519,7 +690,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
|
|
519
690
|
<FlowContextSelector
|
|
520
691
|
metaTree={metaTree}
|
|
521
692
|
disabled={disabled}
|
|
522
|
-
parseValueToPath={
|
|
693
|
+
parseValueToPath={parseValueToPath}
|
|
523
694
|
formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
|
|
524
695
|
onChange={handleSelectorChange}
|
|
525
696
|
/>
|