mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-29 02:03:53 +08:00
fix: association collection fields menu can't be expanded after search (#8604)
* fix: can't expand fields menu after search * Codex changes Co-authored-by: Codex * fix(flow-engine): use theme token for cascader search divider * fix: render error * fix: incorrect focus bug
This commit is contained in:
@@ -304,8 +304,14 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
||||
return metaTreeNode?.paths.slice(1).join('.') || null;
|
||||
},
|
||||
resolvePathFromValue(v) {
|
||||
if (!v) return v;
|
||||
return ['collection', ...String(v).split('.')];
|
||||
if (v === null || v === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(v).trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return ['collection', ...normalized.split('.')];
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
@@ -442,16 +448,10 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
||||
// 组件类型保持稳定,避免输入过程中重挂载导致失焦
|
||||
function Dynamic({ dynValue }: { dynValue: unknown }) {
|
||||
const onChangeValueRef = React.useRef<(v: unknown) => void>(() => {});
|
||||
// 将外部变更回调保持最新引用
|
||||
const onChangeValue = useCallback(
|
||||
(v: VariableFilterItemValue['value']) => {
|
||||
setRightValue(v);
|
||||
},
|
||||
[setRightValue],
|
||||
);
|
||||
useEffect(() => {
|
||||
onChangeValueRef.current = onChangeValue;
|
||||
}, [onChangeValue]);
|
||||
// 使用 ref 持有最新回调,避免 form effects 捕获旧闭包
|
||||
onChangeValueRef.current = (v: unknown) => {
|
||||
setRightValue(v as VariableFilterItemValue['value']);
|
||||
};
|
||||
|
||||
const formRef = React.useRef<Form | null>(null);
|
||||
if (!formRef.current) {
|
||||
@@ -472,27 +472,25 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
||||
formRef.current?.setValues({ value: dynValue });
|
||||
}, [dynValue]);
|
||||
|
||||
const schemaRHS: ISchema = useMemo(
|
||||
() =>
|
||||
merge(
|
||||
{
|
||||
name: 'value',
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
style: { width: 200 },
|
||||
placeholder: stableT('Enter value'),
|
||||
},
|
||||
'x-read-pretty': false,
|
||||
'x-validator': undefined,
|
||||
'x-decorator': undefined,
|
||||
},
|
||||
mergedSchema || {},
|
||||
),
|
||||
[mergedSchema, stableT],
|
||||
const schemaRHS: ISchema = merge(
|
||||
{
|
||||
name: 'value',
|
||||
'x-component': 'Input',
|
||||
'x-component-props': {
|
||||
style: { width: 200 },
|
||||
placeholder: stableT('Enter value'),
|
||||
},
|
||||
'x-read-pretty': false,
|
||||
'x-validator': undefined,
|
||||
'x-decorator': undefined,
|
||||
},
|
||||
mergedSchema || {},
|
||||
);
|
||||
const form = formRef.current;
|
||||
if (!form) return null;
|
||||
|
||||
return (
|
||||
<FormProvider form={formRef.current!}>
|
||||
<FormProvider form={form}>
|
||||
<div style={{ flex: '1 1 40%', minWidth: 160, maxWidth: '100%' }}>
|
||||
<SchemaComponent schema={schemaRHS} />
|
||||
</div>
|
||||
@@ -561,6 +559,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
||||
model.context.app,
|
||||
setRightValue,
|
||||
enumOptions,
|
||||
xComp,
|
||||
]);
|
||||
|
||||
// Null 占位组件(仿照 DefaultValue.tsx 的实现)
|
||||
|
||||
+42
-21
@@ -19,27 +19,32 @@ import { observable } from '@formily/reactive';
|
||||
// Mock VariableInput to a minimal test double (single button)
|
||||
vi.mock('@nocobase/flow-engine', async () => {
|
||||
const actual = await vi.importActual<any>('@nocobase/flow-engine');
|
||||
const MockVariableInput = ({ onChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="variable-input"
|
||||
onClick={() =>
|
||||
onChange?.(
|
||||
(globalThis as any).__TEST_PATH__ || 'name',
|
||||
(globalThis as any).__TEST_META__ || {
|
||||
interface: 'input',
|
||||
uiSchema: { 'x-component': 'Input', 'x-component-props': { placeholder: 'Enter value' } },
|
||||
paths: ['collection', 'name'],
|
||||
name: 'name',
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
mock-variable-input
|
||||
</button>
|
||||
);
|
||||
const MockVariableInput = (props: any) => {
|
||||
const { onChange } = props;
|
||||
(globalThis as any).__LAST_VARIABLE_INPUT_PROPS__ = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="variable-input"
|
||||
onClick={() =>
|
||||
onChange?.(
|
||||
(globalThis as any).__TEST_PATH__ || 'name',
|
||||
(globalThis as any).__TEST_META__ || {
|
||||
interface: 'input',
|
||||
uiSchema: { 'x-component': 'Input', 'x-component-props': { placeholder: 'Enter value' } },
|
||||
paths: ['collection', 'name'],
|
||||
name: 'name',
|
||||
title: 'Name',
|
||||
type: 'string',
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
mock-variable-input
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return { ...actual, VariableInput: MockVariableInput };
|
||||
});
|
||||
|
||||
@@ -116,6 +121,22 @@ describe('VariableFilterItem', () => {
|
||||
beforeEach(() => {
|
||||
// Ensure document body for antd portals if needed
|
||||
document.body.innerHTML = '';
|
||||
delete (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
|
||||
});
|
||||
|
||||
it('returns undefined path for empty left value in converter', () => {
|
||||
const value: VariableFilterItemValue = { path: '', operator: '', value: '' };
|
||||
const model = CreateModel();
|
||||
|
||||
render(<VariableFilterItem value={value} model={model} rightAsVariable={false} />);
|
||||
|
||||
const leftVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
|
||||
const resolvePathFromValue = leftVariableInputProps?.converters?.resolvePathFromValue;
|
||||
|
||||
expect(typeof resolvePathFromValue).toBe('function');
|
||||
expect(resolvePathFromValue('')).toBeUndefined();
|
||||
expect(resolvePathFromValue(' ')).toBeUndefined();
|
||||
expect(resolvePathFromValue('name')).toEqual(['collection', 'name']);
|
||||
});
|
||||
|
||||
it('renders static right input when rightAsVariable=false and updates value on typing', async () => {
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { Button, Cascader, Tooltip } from 'antd';
|
||||
import { Button, Cascader, Input, Tooltip, theme } from 'antd';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { cx, css } from '@emotion/css';
|
||||
import type { ContextSelectorItem, FlowContextSelectorProps } from './variables/types';
|
||||
import {
|
||||
buildContextSelectorItems,
|
||||
filterLoadedContextSelectorItems,
|
||||
formatPathToValue,
|
||||
parseValueToPath,
|
||||
preloadContextSelectorPath,
|
||||
@@ -34,6 +35,52 @@ const cascaderPopupAutoHeightClassName = css`
|
||||
}
|
||||
`;
|
||||
|
||||
type SelectedPathInfo = {
|
||||
text: string;
|
||||
meta?: ContextSelectorItem['meta'];
|
||||
};
|
||||
|
||||
const normalizePath = (path: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return path.map((segment) => String(segment));
|
||||
};
|
||||
|
||||
const getSelectedPathInfo = (path: string[] | undefined, options: ContextSelectorItem[]): SelectedPathInfo => {
|
||||
if (!Array.isArray(path) || path.length === 0) {
|
||||
return { text: '', meta: undefined };
|
||||
}
|
||||
|
||||
const labels: string[] = [];
|
||||
let currentOptions = options;
|
||||
let selectedMeta: ContextSelectorItem['meta'] | undefined;
|
||||
|
||||
for (const segment of path) {
|
||||
const matchedOption = currentOptions.find((item) => String(item.value) === String(segment));
|
||||
if (!matchedOption) {
|
||||
break;
|
||||
}
|
||||
|
||||
const label =
|
||||
typeof matchedOption.meta?.title === 'string'
|
||||
? matchedOption.meta.title
|
||||
: typeof matchedOption.label === 'string'
|
||||
? matchedOption.label
|
||||
: String(matchedOption.value);
|
||||
|
||||
labels.push(label);
|
||||
selectedMeta = matchedOption.meta;
|
||||
currentOptions = Array.isArray(matchedOption.children) ? matchedOption.children : [];
|
||||
}
|
||||
|
||||
return {
|
||||
text: labels.join(' / '),
|
||||
meta: selectedMeta,
|
||||
};
|
||||
};
|
||||
|
||||
const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
@@ -47,13 +94,15 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
ignoreFieldNames,
|
||||
...cascaderProps
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// 记录最后点击的路径,用于双击检测
|
||||
const lastSelectedRef = useRef<{ path: string; time: number } | null>(null);
|
||||
|
||||
const { resolvedMetaTree, loading } = useResolvedMetaTree(metaTree);
|
||||
|
||||
// 获取引擎上下文中的翻译函数,若不可用则回退为原文
|
||||
const flowCtx = useFlowContext<any>();
|
||||
const flowCtx = useFlowContext();
|
||||
|
||||
const translateOptions = useCallback(
|
||||
(items: ContextSelectorItem[] | undefined): ContextSelectorItem[] => {
|
||||
@@ -63,7 +112,9 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
const meta = o.meta;
|
||||
const disabled = meta ? !!(typeof meta.disabled === 'function' ? meta.disabled() : meta.disabled) : false;
|
||||
const disabledReason = meta
|
||||
? ((typeof meta.disabledReason === 'function' ? meta.disabledReason() : meta.disabledReason) as any)
|
||||
? typeof meta.disabledReason === 'function'
|
||||
? meta.disabledReason()
|
||||
: meta.disabledReason
|
||||
: undefined;
|
||||
|
||||
// 文本国际化:仅当 label 为字符串时进行翻译
|
||||
@@ -98,7 +149,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
|
||||
// 用于强制重新渲染的状态
|
||||
const [updateFlag, setUpdateFlag] = useState(0);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const inlineFocusByPointerRef = useRef(false);
|
||||
const triggerUpdate = useCallback(() => setUpdateFlag((prev) => prev + 1), []);
|
||||
const isSearchEnabled = showSearch || children === null;
|
||||
|
||||
// 构建选项
|
||||
// 注意:rc-cascader 内部对 options 做了基于引用的缓存(useEntities)。
|
||||
@@ -106,13 +161,22 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
// 触发 rc-cascader 重新构建 pathKeyEntities,避免二级节点未被索引导致的报错。
|
||||
const options = useMemo(() => {
|
||||
if (!resolvedMetaTree) return [];
|
||||
const refreshSeq = updateFlag;
|
||||
const base = buildContextSelectorItems(resolvedMetaTree);
|
||||
return translateOptions(base).filter((item) => {
|
||||
const filtered = translateOptions(base).filter((item) => {
|
||||
if (!ignoreFieldNames || ignoreFieldNames.length === 0) return true;
|
||||
return !ignoreFieldNames.includes(item.meta?.name || '');
|
||||
});
|
||||
return refreshSeq >= 0 ? filtered : [];
|
||||
}, [resolvedMetaTree, updateFlag, translateOptions, ignoreFieldNames]);
|
||||
|
||||
const displayOptions = useMemo(() => {
|
||||
if (!isSearchEnabled || !searchText.trim()) {
|
||||
return options;
|
||||
}
|
||||
return filterLoadedContextSelectorItems(options, searchText);
|
||||
}, [isSearchEnabled, options, searchText]);
|
||||
|
||||
// 内部展开路径:在 onlyLeafSelectable=true 时,点击父节点不会触发 onChange,
|
||||
// 但会触发 loadData。我们在此记录路径以在懒加载后保持展开。
|
||||
const [tempSelectedPath, setTempSelectedPath] = useState<string[]>([]);
|
||||
@@ -158,23 +222,36 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
triggerUpdate();
|
||||
}
|
||||
},
|
||||
[triggerUpdate],
|
||||
[triggerUpdate, translateOptions],
|
||||
);
|
||||
|
||||
const currentPath = useMemo(() => {
|
||||
return customParseValueToPath(value);
|
||||
return normalizePath(customParseValueToPath(value));
|
||||
}, [value, customParseValueToPath]);
|
||||
|
||||
// 当 metaTree 为子层(如 getPropertyMetaTree('{{ ctx.collection }}') 返回的是 collection 的子节点)
|
||||
// 而 value path 仍包含根键(如 ['collection', 'field'])时,自动丢弃不存在的首段,确保级联能正确对齐。
|
||||
const effectivePath = useMemo(() => {
|
||||
if (!currentPath || currentPath.length === 0) return currentPath;
|
||||
|
||||
if (options.length === 0) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
const topValues = new Set(options.map((o) => String(o.value)));
|
||||
const needTrim = !topValues.has(String(currentPath[0]));
|
||||
const fixed = needTrim ? currentPath.slice(1) : currentPath;
|
||||
return fixed;
|
||||
}, [currentPath, options]);
|
||||
|
||||
const cascaderValue = useMemo(() => {
|
||||
if (tempSelectedPath.length > 0) {
|
||||
return tempSelectedPath;
|
||||
}
|
||||
|
||||
return Array.isArray(effectivePath) ? effectivePath : undefined;
|
||||
}, [effectivePath, tempSelectedPath]);
|
||||
|
||||
// 预加载:当存在有效路径时,按路径逐级加载 children,保证默认展开和选中路径可用
|
||||
const pathToPreload = useMemo(() => {
|
||||
const finalPath = effectivePath && effectivePath.length > 0 ? effectivePath : tempSelectedPath;
|
||||
@@ -251,21 +328,140 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
return cx(cascaderPopupAutoHeightClassName, cascaderProps.popupClassName);
|
||||
}, [cascaderProps.popupClassName]);
|
||||
|
||||
const cascaderSearchInputClassName = useMemo(() => {
|
||||
return css`
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid ${token.colorSplit};
|
||||
`;
|
||||
}, [token.colorSplit]);
|
||||
|
||||
const {
|
||||
onDropdownVisibleChange: cascaderOnDropdownVisibleChange,
|
||||
dropdownRender: cascaderDropdownRender,
|
||||
...restCascaderProps
|
||||
} = cascaderProps;
|
||||
|
||||
const selectedPathInfo = useMemo(() => getSelectedPathInfo(effectivePath, options), [effectivePath, options]);
|
||||
|
||||
const mergedOpen = open !== undefined ? open : children === null ? dropdownOpen : undefined;
|
||||
|
||||
const isDropdownVisible = !!mergedOpen;
|
||||
|
||||
const handleDropdownVisibleChange = useCallback(
|
||||
(visible: boolean) => {
|
||||
if (open === undefined) {
|
||||
setDropdownOpen(visible);
|
||||
}
|
||||
if (!visible) {
|
||||
setSearchText('');
|
||||
}
|
||||
cascaderOnDropdownVisibleChange?.(visible);
|
||||
},
|
||||
[cascaderOnDropdownVisibleChange, open],
|
||||
);
|
||||
|
||||
const renderDropdown = useCallback(
|
||||
(menu: React.ReactElement) => {
|
||||
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
||||
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
||||
if (!isSearchEnabled || children === null) {
|
||||
return cascaderMenu;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cascaderSearchInputClassName}>
|
||||
<Input
|
||||
allowClear
|
||||
size="small"
|
||||
value={searchText}
|
||||
placeholder={flowCtx.t('Search')}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{cascaderMenu}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
||||
);
|
||||
|
||||
const inlinePlaceholder =
|
||||
typeof restCascaderProps.placeholder === 'string' ? restCascaderProps.placeholder : flowCtx.t('Search');
|
||||
const hasSelectedPath = Array.isArray(effectivePath) && effectivePath.length > 0;
|
||||
|
||||
const handleInlineInputFocus = useCallback(() => {
|
||||
if (open === undefined && !inlineFocusByPointerRef.current) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const markInlineFocusByPointer = useCallback(() => {
|
||||
inlineFocusByPointerRef.current = true;
|
||||
}, []);
|
||||
|
||||
const resetInlineFocusByPointer = useCallback(() => {
|
||||
inlineFocusByPointerRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handleInlineInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextValue = event.target.value;
|
||||
|
||||
// 下拉关闭态下点击清空:应清空真实已选值,而不是仅清空搜索词。
|
||||
if (!isDropdownVisible && nextValue === '' && hasSelectedPath) {
|
||||
setTempSelectedPath([]);
|
||||
// 清空语义:传空 meta,确保上层(如 VariableInput)进入 clear 分支。
|
||||
onChange?.('', undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (open === undefined && !isDropdownVisible) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
|
||||
setSearchText(nextValue);
|
||||
},
|
||||
[hasSelectedPath, isDropdownVisible, onChange, open],
|
||||
);
|
||||
|
||||
const inlinePathText = Array.isArray(effectivePath) ? effectivePath.join(' / ') : '';
|
||||
const inlineInputValue = isDropdownVisible ? searchText : selectedPathInfo.text || inlinePathText;
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
{...cascaderProps}
|
||||
options={options}
|
||||
value={tempSelectedPath && tempSelectedPath.length > 0 ? tempSelectedPath : effectivePath}
|
||||
{...restCascaderProps}
|
||||
options={displayOptions}
|
||||
value={cascaderValue}
|
||||
onChange={handleChange}
|
||||
loadData={handleLoadData}
|
||||
loading={loading}
|
||||
changeOnSelect={!onlyLeafSelectable}
|
||||
expandTrigger="click"
|
||||
open={open}
|
||||
showSearch={children === null}
|
||||
open={mergedOpen}
|
||||
showSearch={false}
|
||||
popupClassName={mergedPopupClassName}
|
||||
dropdownRender={renderDropdown}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
>
|
||||
{children === null ? null : children || defaultChildren}
|
||||
{children === null ? (
|
||||
<Input
|
||||
allowClear
|
||||
value={inlineInputValue}
|
||||
placeholder={inlinePlaceholder}
|
||||
onMouseDown={markInlineFocusByPointer}
|
||||
onMouseUp={resetInlineFocusByPointer}
|
||||
onMouseLeave={resetInlineFocusByPointer}
|
||||
onFocus={handleInlineInputFocus}
|
||||
onBlur={resetInlineFocusByPointer}
|
||||
onChange={handleInlineInputChange}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={restCascaderProps.disabled}
|
||||
/>
|
||||
) : (
|
||||
children || defaultChildren
|
||||
)}
|
||||
</Cascader>
|
||||
);
|
||||
};
|
||||
|
||||
+260
-3
@@ -11,6 +11,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as FlowContextSelectorModule from '../../FlowContextSelector';
|
||||
import type { MetaTreeNode } from '../../../flowContext';
|
||||
import { createTestFlowContext, TestFlowContextWrapper } from './test-utils';
|
||||
|
||||
const FlowContextSelector = FlowContextSelectorModule.FlowContextSelector;
|
||||
@@ -176,13 +177,242 @@ describe('FlowContextSelector', () => {
|
||||
const cascader = screen.getByRole('button');
|
||||
fireEvent.click(cascader);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText('Search');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search functionality is enabled via showSearch prop
|
||||
// The actual search input behavior depends on antd's internal implementation
|
||||
// This test verifies that showSearch prop is accepted
|
||||
fireEvent.change(searchInput, { target: { value: 'config' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Config')).toBeInTheDocument();
|
||||
expect(screen.queryByText('User')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render search input when search is disabled', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector metaTree={() => flowContext.getPropertyMetaTree()} showSearch={false} />
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should load and expand lazy relation node after searching', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
const loadOrgChildren = vi.fn(async () => [
|
||||
{
|
||||
name: 'org_name',
|
||||
title: 'Org Name',
|
||||
type: 'string',
|
||||
paths: ['org_oho', 'org_name'],
|
||||
parentTitles: ['org_oho'],
|
||||
},
|
||||
]);
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: 'org_oho',
|
||||
title: 'org_oho',
|
||||
type: 'object',
|
||||
paths: ['org_oho'],
|
||||
children: loadOrgChildren,
|
||||
},
|
||||
{
|
||||
name: 'staff',
|
||||
title: 'staff',
|
||||
type: 'string',
|
||||
paths: ['staff'],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector metaTree={metaTree} showSearch onlyLeafSelectable={true} />
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
const searchInput = await screen.findByPlaceholderText('Search');
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'oho' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('org_oho')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('org_oho'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadOrgChildren).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Org Name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should support inline search input when children is null and keep lazy expand', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
const loadOrgChildren = vi.fn(async () => [
|
||||
{
|
||||
name: 'org_name',
|
||||
title: 'Org Name',
|
||||
type: 'string',
|
||||
paths: ['org_oho', 'org_name'],
|
||||
parentTitles: ['org_oho'],
|
||||
},
|
||||
]);
|
||||
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: 'org_oho',
|
||||
title: 'org_oho',
|
||||
type: 'object',
|
||||
paths: ['org_oho'],
|
||||
children: loadOrgChildren,
|
||||
},
|
||||
{
|
||||
name: 'staff',
|
||||
title: 'staff',
|
||||
type: 'string',
|
||||
paths: ['staff'],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector metaTree={metaTree} onlyLeafSelectable={true}>
|
||||
{null}
|
||||
</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
const inlineInput = screen.getByRole('textbox');
|
||||
fireEvent.focus(inlineInput);
|
||||
fireEvent.change(inlineInput, { target: { value: 'oho' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('org_oho')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('org_oho'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadOrgChildren).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Org Name')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep dropdown open on first pointer click in inline input mode', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector metaTree={() => flowContext.getPropertyMetaTree()}>{null}</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
const inlineInput = await screen.findByRole('textbox');
|
||||
|
||||
fireEvent.mouseDown(inlineInput);
|
||||
fireEvent.focus(inlineInput);
|
||||
fireEvent.mouseUp(inlineInput);
|
||||
fireEvent.click(inlineInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show selected path text when inline input dropdown is closed', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector metaTree={() => flowContext.getPropertyMetaTree()} value="{{ ctx.user.name }}">
|
||||
{null}
|
||||
</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
const inlineInput = screen.getByRole('textbox') as HTMLInputElement;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(inlineInput.value).toBe('User / Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selected value when clearing inline input while dropdown is closed', async () => {
|
||||
const onChange = vi.fn();
|
||||
const flowContext = createTestFlowContext();
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector
|
||||
metaTree={() => flowContext.getPropertyMetaTree()}
|
||||
value="{{ ctx.user.name }}"
|
||||
onChange={onChange}
|
||||
>
|
||||
{null}
|
||||
</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
const inlineInput = screen.getByRole('textbox');
|
||||
fireEvent.change(inlineInput, { target: { value: '' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith('', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear selected value when clicking inline clear icon while dropdown is closed', async () => {
|
||||
const onChange = vi.fn();
|
||||
const flowContext = createTestFlowContext();
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector
|
||||
metaTree={() => flowContext.getPropertyMetaTree()}
|
||||
value="{{ ctx.user.name }}"
|
||||
onChange={onChange}
|
||||
>
|
||||
{null}
|
||||
</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
|
||||
const clearIcon = document.querySelector('.ant-input-clear-icon') as HTMLElement | null;
|
||||
expect(clearIcon).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(clearIcon!);
|
||||
fireEvent.mouseUp(clearIcon!);
|
||||
fireEvent.click(clearIcon!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith('', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle FlowContext metaTree', async () => {
|
||||
@@ -456,6 +686,33 @@ describe('FlowContextSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-array parsed path in inline input mode', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
const customParseValueToPath = vi.fn().mockReturnValue('' as any);
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<FlowContextSelector
|
||||
metaTree={() => flowContext.getPropertyMetaTree()}
|
||||
value="invalid.path"
|
||||
parseValueToPath={customParseValueToPath as any}
|
||||
>
|
||||
{null}
|
||||
</FlowContextSelector>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
const inlineInput = await screen.findByRole('textbox');
|
||||
expect(inlineInput).toBeInTheDocument();
|
||||
expect((inlineInput as HTMLInputElement).value).toBe('');
|
||||
|
||||
fireEvent.focus(inlineInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('User')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle metaTree function returning non-array', async () => {
|
||||
const invalidMetaTree = vi.fn().mockResolvedValue(null);
|
||||
const flowContext = createTestFlowContext();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
formatPathToValue,
|
||||
loadMetaTreeChildren,
|
||||
searchInLoadedNodes,
|
||||
filterLoadedContextSelectorItems,
|
||||
buildContextSelectorItems,
|
||||
isVariableValue,
|
||||
createDefaultConverters,
|
||||
@@ -150,6 +151,83 @@ describe('Variable Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterLoadedContextSelectorItems', () => {
|
||||
const loadedChildren = [
|
||||
{
|
||||
label: 'Org Name',
|
||||
value: 'org_name',
|
||||
paths: ['org', 'org_name'],
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
label: 'Org Code',
|
||||
value: 'org_code',
|
||||
paths: ['org', 'org_code'],
|
||||
isLeaf: true,
|
||||
},
|
||||
] satisfies ContextSelectorItem[];
|
||||
|
||||
const asyncChildLoader = async () => [];
|
||||
|
||||
const mockOptions: ContextSelectorItem[] = [
|
||||
{
|
||||
label: 'Organization',
|
||||
value: 'org',
|
||||
paths: ['org'],
|
||||
children: loadedChildren,
|
||||
},
|
||||
{
|
||||
label: 'Staff',
|
||||
value: 'staff',
|
||||
paths: ['staff'],
|
||||
meta: {
|
||||
name: 'staff',
|
||||
title: 'Staff',
|
||||
type: 'object',
|
||||
paths: ['staff'],
|
||||
children: asyncChildLoader,
|
||||
} satisfies MetaTreeNode,
|
||||
},
|
||||
{
|
||||
label: 'Config',
|
||||
value: 'config',
|
||||
paths: ['config'],
|
||||
isLeaf: true,
|
||||
},
|
||||
];
|
||||
|
||||
it('should keep original tree when keyword is empty', () => {
|
||||
const result = filterLoadedContextSelectorItems(mockOptions, ' ');
|
||||
expect(result).toBe(mockOptions);
|
||||
});
|
||||
|
||||
it('should keep parent node reference when parent matches', () => {
|
||||
const result = filterLoadedContextSelectorItems(mockOptions, 'orga');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(mockOptions[0]);
|
||||
expect(result[0].children).toBe(loadedChildren);
|
||||
});
|
||||
|
||||
it('should keep tree shape and trim children when only child matches', () => {
|
||||
const result = filterLoadedContextSelectorItems(mockOptions, 'code');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).not.toBe(mockOptions[0]);
|
||||
expect(result[0].value).toBe('org');
|
||||
expect(result[0].children).toHaveLength(1);
|
||||
expect(result[0].children?.[0]).toBe(loadedChildren[1]);
|
||||
});
|
||||
|
||||
it('should not recurse into unloaded children', () => {
|
||||
const result = filterLoadedContextSelectorItems(mockOptions, 'staff child');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty array when no node matches', () => {
|
||||
const result = filterLoadedContextSelectorItems(mockOptions, 'not-exists');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildContextSelectorItems', () => {
|
||||
it('should convert MetaTreeNode[] to ContextSelectorItem[]', () => {
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
@@ -234,9 +312,9 @@ describe('Variable Utils', () => {
|
||||
it('should handle invalid metaTree input', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(buildContextSelectorItems(null as any)).toEqual([]);
|
||||
expect(buildContextSelectorItems(undefined as any)).toEqual([]);
|
||||
expect(buildContextSelectorItems({} as any)).toEqual([]);
|
||||
expect(buildContextSelectorItems(null as unknown as MetaTreeNode[])).toEqual([]);
|
||||
expect(buildContextSelectorItems(undefined as unknown as MetaTreeNode[])).toEqual([]);
|
||||
expect(buildContextSelectorItems({} as unknown as MetaTreeNode[])).toEqual([]);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledTimes(3);
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
@@ -13,6 +13,18 @@ import type { MetaTreeNode } from '../../flowContext';
|
||||
import type { ContextSelectorItem, Converters } from './types';
|
||||
import { isVariableExpression } from '../../utils';
|
||||
|
||||
const getContextSelectorLabelText = (node: ContextSelectorItem) => {
|
||||
if (typeof node.label === 'string') {
|
||||
return node.label;
|
||||
}
|
||||
|
||||
if (typeof node.meta?.title === 'string') {
|
||||
return node.meta.title;
|
||||
}
|
||||
|
||||
return node.value;
|
||||
};
|
||||
|
||||
export const parseValueToPath = (value: string): string[] | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
|
||||
@@ -66,12 +78,7 @@ export const searchInLoadedNodes = (
|
||||
const nodePath = [...currentPath, node.value];
|
||||
|
||||
// 计算可搜索的纯文本标签
|
||||
const labelText =
|
||||
typeof node.label === 'string'
|
||||
? node.label
|
||||
: typeof node.meta?.title === 'string'
|
||||
? node.meta!.title
|
||||
: String(node.value);
|
||||
const labelText = getContextSelectorLabelText(node);
|
||||
|
||||
// 检查节点标签是否匹配搜索文本
|
||||
if (labelText.toLowerCase().includes(lowerSearchText)) {
|
||||
@@ -89,6 +96,60 @@ export const searchInLoadedNodes = (
|
||||
return results;
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅在“已加载节点”范围内按关键字过滤 options(保留树结构)。
|
||||
* - 匹配父节点:保留原节点引用(含原 children),避免不必要的实体重建。
|
||||
* - 匹配子节点:返回裁剪后的父节点副本,children 仅包含命中分支。
|
||||
* - 未加载 children(即 children 不为数组)不会递归搜索。
|
||||
*/
|
||||
export const filterLoadedContextSelectorItems = (
|
||||
options: ContextSelectorItem[] | undefined,
|
||||
keyword: string,
|
||||
): ContextSelectorItem[] => {
|
||||
if (!Array.isArray(options) || options.length === 0) return [];
|
||||
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const filterNode = (node: ContextSelectorItem): ContextSelectorItem | null => {
|
||||
const labelText = getContextSelectorLabelText(node).toLowerCase();
|
||||
const selfMatched = labelText.includes(normalizedKeyword);
|
||||
|
||||
if (selfMatched) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredChildren = node.children
|
||||
.map((child) => filterNode(child))
|
||||
.filter((item): item is ContextSelectorItem => item !== null);
|
||||
|
||||
if (filteredChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 所有子节点都保留原引用时,直接复用父节点对象。
|
||||
if (
|
||||
filteredChildren.length === node.children.length &&
|
||||
filteredChildren.every((child, idx) => child === node.children![idx])
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
};
|
||||
|
||||
return options.map((node) => filterNode(node)).filter((item): item is ContextSelectorItem => item !== null);
|
||||
};
|
||||
|
||||
export const buildContextSelectorItems = (metaTree: MetaTreeNode[]): ContextSelectorItem[] => {
|
||||
if (!metaTree || !Array.isArray(metaTree)) {
|
||||
console.warn('buildContextSelectorItems received invalid metaTree:', metaTree);
|
||||
|
||||
Reference in New Issue
Block a user