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:
gchust
2026-02-14 21:12:30 +08:00
committed by GitHub
parent 6b9fd00b79
commit a0f4102fa0
6 changed files with 687 additions and 75 deletions
@@ -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 的实现)
@@ -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>
);
};
@@ -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);