From a0f4102fa03f468f458b2a91b7e3fa288409b85f Mon Sep 17 00:00:00 2001 From: gchust Date: Sat, 14 Feb 2026 21:12:30 +0800 Subject: [PATCH] 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 --- .../components/filter/VariableFilterItem.tsx | 59 ++-- .../__tests__/VariableFilterItem.test.tsx | 63 +++-- .../src/components/FlowContextSelector.tsx | 220 ++++++++++++++- .../__tests__/FlowContextSelector.test.tsx | 263 +++++++++++++++++- .../variables/__tests__/utils.test.ts | 84 +++++- .../src/components/variables/utils.ts | 73 ++++- 6 files changed, 687 insertions(+), 75 deletions(-) diff --git a/packages/core/client/src/flow/components/filter/VariableFilterItem.tsx b/packages/core/client/src/flow/components/filter/VariableFilterItem.tsx index dc12dd66167..9800fd1e822 100644 --- a/packages/core/client/src/flow/components/filter/VariableFilterItem.tsx +++ b/packages/core/client/src/flow/components/filter/VariableFilterItem.tsx @@ -304,8 +304,14 @@ export const VariableFilterItem: React.FC = 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 = 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
(null); if (!formRef.current) { @@ -472,27 +472,25 @@ export const VariableFilterItem: React.FC = 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 ( - +
@@ -561,6 +559,7 @@ export const VariableFilterItem: React.FC = observer( model.context.app, setRightValue, enumOptions, + xComp, ]); // Null 占位组件(仿照 DefaultValue.tsx 的实现) diff --git a/packages/core/client/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx b/packages/core/client/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx index 73fb941aa89..c43c564aa36 100644 --- a/packages/core/client/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +++ b/packages/core/client/src/flow/components/filter/__tests__/VariableFilterItem.test.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('@nocobase/flow-engine'); - const MockVariableInput = ({ onChange }: any) => ( - - ); + const MockVariableInput = (props: any) => { + const { onChange } = props; + (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__ = props; + + return ( + + ); + }; 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(); + + 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 () => { diff --git a/packages/core/flow-engine/src/components/FlowContextSelector.tsx b/packages/core/flow-engine/src/components/FlowContextSelector.tsx index 2f800d4befe..1f14610e778 100644 --- a/packages/core/flow-engine/src/components/FlowContextSelector.tsx +++ b/packages/core/flow-engine/src/components/FlowContextSelector.tsx @@ -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 = ({ value, onChange, @@ -47,13 +94,15 @@ const FlowContextSelectorComponent: React.FC = ({ ignoreFieldNames, ...cascaderProps }) => { + const { token } = theme.useToken(); + // 记录最后点击的路径,用于双击检测 const lastSelectedRef = useRef<{ path: string; time: number } | null>(null); const { resolvedMetaTree, loading } = useResolvedMetaTree(metaTree); // 获取引擎上下文中的翻译函数,若不可用则回退为原文 - const flowCtx = useFlowContext(); + const flowCtx = useFlowContext(); const translateOptions = useCallback( (items: ContextSelectorItem[] | undefined): ContextSelectorItem[] => { @@ -63,7 +112,9 @@ const FlowContextSelectorComponent: React.FC = ({ 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 = ({ // 用于强制重新渲染的状态 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 = ({ // 触发 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([]); @@ -158,23 +222,36 @@ const FlowContextSelectorComponent: React.FC = ({ 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 = ({ 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 ( + <> +
+ setSearchText(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + /> +
+ {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) => { + 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 ( 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 ? ( + e.stopPropagation()} + disabled={restCascaderProps.disabled} + /> + ) : ( + children || defaultChildren + )} ); }; diff --git a/packages/core/flow-engine/src/components/variables/__tests__/FlowContextSelector.test.tsx b/packages/core/flow-engine/src/components/variables/__tests__/FlowContextSelector.test.tsx index 3f693b0e7d8..693a8c27acd 100644 --- a/packages/core/flow-engine/src/components/variables/__tests__/FlowContextSelector.test.tsx +++ b/packages/core/flow-engine/src/components/variables/__tests__/FlowContextSelector.test.tsx @@ -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( + + flowContext.getPropertyMetaTree()} showSearch={false} /> + , + ); + + 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( + + + , + ); + + 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( + + + {null} + + , + ); + + 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( + + flowContext.getPropertyMetaTree()}>{null} + , + ); + + 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( + + flowContext.getPropertyMetaTree()} value="{{ ctx.user.name }}"> + {null} + + , + ); + + 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( + + flowContext.getPropertyMetaTree()} + value="{{ ctx.user.name }}" + onChange={onChange} + > + {null} + + , + ); + + 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( + + flowContext.getPropertyMetaTree()} + value="{{ ctx.user.name }}" + onChange={onChange} + > + {null} + + , + ); + + 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( + + flowContext.getPropertyMetaTree()} + value="invalid.path" + parseValueToPath={customParseValueToPath as any} + > + {null} + + , + ); + + 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(); diff --git a/packages/core/flow-engine/src/components/variables/__tests__/utils.test.ts b/packages/core/flow-engine/src/components/variables/__tests__/utils.test.ts index 43a767b4b27..ee88c824503 100644 --- a/packages/core/flow-engine/src/components/variables/__tests__/utils.test.ts +++ b/packages/core/flow-engine/src/components/variables/__tests__/utils.test.ts @@ -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(); diff --git a/packages/core/flow-engine/src/components/variables/utils.ts b/packages/core/flow-engine/src/components/variables/utils.ts index 2b29f011941..69ac14b188a 100644 --- a/packages/core/flow-engine/src/components/variables/utils.ts +++ b/packages/core/flow-engine/src/components/variables/utils.ts @@ -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);