feat: support key up and down to select variable item (#35527)

This commit is contained in:
Joel 2026-04-24 10:32:06 +08:00 committed by GitHub
parent ed8d3f3e8d
commit 38fc2a6574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 318 additions and 43 deletions

View File

@ -599,6 +599,48 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
})
})
it('defaults to the first workflow variable and removes the full slash query when selecting by keyboard', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('first_value', VarType.string),
makeWorkflowNodeVar('second_value', VarType.string),
]),
])
render((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
const dispatchSpy = vi.spyOn(editor, 'dispatchCommand')
await setEditorText(editor, '/e', true)
await flushNextTick()
const firstItem = screen.getByText('first_value').closest('[data-selected]')
const secondItem = screen.getByText('second_value').closest('[data-selected]')
expect(firstItem).toHaveAttribute('data-selected', 'true')
expect(secondItem).toHaveAttribute('data-selected', 'false')
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(firstItem).toHaveAttribute('data-selected', 'false')
expect(secondItem).toHaveAttribute('data-selected', 'true')
fireEvent.keyDown(document, { key: 'Enter' })
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value'])
await waitFor(() => expect(readEditorText(editor)).not.toContain('/e'))
})
it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => {
const captures: Captures = { editor: null, eventEmitter: null }

View File

@ -7,6 +7,7 @@ import type {
ExternalToolBlockType,
HistoryBlockType,
LastRunBlockType,
MenuTextMatch,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
@ -89,14 +90,14 @@ const ComponentPicker = ({
],
})
const [editor] = useLexicalComposerContext()
const triggerMatchRef = useRef<string | null>(null)
const triggerMatchRef = useRef<MenuTextMatch | null>(null)
const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 75,
})
const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => {
const match = baseCheckForTriggerMatch(text, editor)
triggerMatchRef.current = match?.matchingString ?? null
triggerMatchRef.current = match
return match
}, [baseCheckForTriggerMatch])
@ -183,7 +184,8 @@ const ComponentPicker = ({
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
const currentTriggerMatch = triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor)
const needRemove = currentTriggerMatch ? $splitNodeContainingQuery(currentTriggerMatch) : null
if (needRemove)
needRemove.remove()
})
@ -214,7 +216,7 @@ const ComponentPicker = ({
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
const effectiveQueryString = triggerMatchRef.current ?? queryString
const effectiveQueryString = triggerMatchRef.current?.matchingString ?? queryString
if (blurHidden)
return null

View File

@ -52,6 +52,42 @@ describe('VarReferenceVars', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('should select the first visible variable by default and support arrow navigation in slash mode', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
vars={createVars([{
title: 'Node A',
nodeId: 'node-a',
vars: [
{ variable: 'first_value', type: VarType.string },
{ variable: 'second_value', type: VarType.string },
],
}])}
onChange={onChange}
/>,
)
const firstItem = screen.getByText('first_value').closest('[data-selected]')
const secondItem = screen.getByText('second_value').closest('[data-selected]')
expect(firstItem).toHaveAttribute('data-selected', 'true')
expect(secondItem).toHaveAttribute('data-selected', 'false')
fireEvent.keyDown(document, { key: 'ArrowDown' })
expect(firstItem).toHaveAttribute('data-selected', 'false')
expect(secondItem).toHaveAttribute('data-selected', 'true')
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({
variable: 'second_value',
}))
})
it('should call onChange when a variable item is chosen', () => {
const onChange = vi.fn()
@ -172,6 +208,43 @@ describe('VarReferenceVars', () => {
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
})
it('should resolve selectors for special variables and file support from keyboard selection', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
isSupportFileVar
vars={createVars([
{
title: 'Specials',
nodeId: 'node-special',
vars: [
{ variable: 'env.API_KEY', type: VarType.string },
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
{ variable: 'current', type: VarType.string },
{ variable: 'asset', type: VarType.file },
],
},
])}
onChange={onChange}
/>,
)
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
fireEvent.keyDown(document, { key: 'ArrowDown' })
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' }))
expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' }))
expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' }))
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
})
it('should render object vars and select them by node path', () => {
const onChange = vi.fn()
@ -251,4 +324,26 @@ describe('VarReferenceVars', () => {
fireEvent.click(screen.getByText('asset'))
expect(onChange).not.toHaveBeenCalled()
})
it('should ignore file vars when file support is disabled during keyboard selection', () => {
const onChange = vi.fn()
render(
<VarReferenceVars
hideSearch
vars={createVars([
{
title: 'Files',
nodeId: 'node-files',
vars: [{ variable: 'asset', type: VarType.file }],
},
])}
onChange={onChange}
/>,
)
fireEvent.keyDown(document, { key: 'Enter' })
expect(onChange).not.toHaveBeenCalled()
})
})

View File

@ -12,11 +12,8 @@ import {
import { useHover } from 'ahooks'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import Input from '@/app/components/base/input'
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
@ -31,6 +28,42 @@ import {
getVariableDisplayName,
} from './var-reference-vars.helpers'
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
const resolveValueSelector = ({
itemData,
isFlat,
isSupportFileVar,
nodeId,
objPath,
}: {
itemData: Var
isFlat?: boolean
isSupportFileVar?: boolean
nodeId: string
objPath: string[]
}) => {
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
const isRagVariable = itemData.isRagVariable
return getValueSelector({
itemData,
isFlat,
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
})
}
type ItemProps = {
nodeId: string
title: string
@ -47,6 +80,8 @@ type ItemProps = {
zIndex?: number
className?: string
preferSchemaType?: boolean
isSelected?: boolean
onActivate?: () => void
}
const Item: FC<ItemProps> = ({
@ -64,11 +99,11 @@ const Item: FC<ItemProps> = ({
zIndex,
className,
preferSchemaType,
isSelected,
onActivate,
}) => {
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
const isFile = itemData.type === VarType.file && !isStructureOutput
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
const isSys = itemData.variable.startsWith('sys.')
const isEnv = itemData.variable.startsWith('env.')
const isChatVar = itemData.variable.startsWith('conversation.')
const isRagVariable = itemData.isRagVariable
@ -76,15 +111,21 @@ const Item: FC<ItemProps> = ({
if (!isFlat)
return null
const variable = itemData.variable
let Icon
switch (variable) {
case 'current':
Icon = isInCodeGeneratorInstructionEditor ? CodeAssistant : MagicEdit
return <Icon className="h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600" />
return (
<span
aria-hidden
className={cn(
'h-3.5 w-3.5 shrink-0 text-util-colors-violet-violet-600',
isInCodeGeneratorInstructionEditor ? 'i-custom-vender-line-general-code-assistant' : 'i-custom-vender-line-general-magic-edit',
)}
/>
)
case 'error_message':
return <Variable02 className="h-3.5 w-3.5 shrink-0 text-util-colors-orange-dark-orange-dark-600" />
return <span aria-hidden className="i-custom-vender-solid-development-variable-02 h-3.5 w-3.5 shrink-0 text-util-colors-orange-dark-orange-dark-600" />
default:
return <Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
return <span aria-hidden className="i-custom-vender-solid-development-variable-02 h-3.5 w-3.5 shrink-0 text-text-accent" />
}
}, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable])
@ -147,15 +188,10 @@ const Item: FC<ItemProps> = ({
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
const valueSelector = getValueSelector({
const valueSelector = resolveValueSelector({
itemData,
isFlat,
isSupportFileVar,
isFile,
isSys,
isEnv,
isChatVar,
isRagVariable,
nodeId,
objPath,
})
@ -173,11 +209,13 @@ const Item: FC<ItemProps> = ({
ref={itemRef}
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
data-selected={isSelected ? 'true' : 'false'}
onClick={handleChosen}
onMouseEnter={onActivate}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
@ -210,7 +248,7 @@ const Item: FC<ItemProps> = ({
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
{
(isObj || isStructureOutput) && (
<ChevronRight className={cn('ml-0.5 h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
<span aria-hidden className={cn('ml-0.5 i-custom-vender-line-arrows-chevron-right h-3 w-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
)
}
</div>
@ -221,7 +259,7 @@ const Item: FC<ItemProps> = ({
open={open}
onOpenChange={noop}
>
<PopoverTrigger render={itemTrigger} />
<PopoverTrigger nativeButton={false} render={itemTrigger} />
<PopoverContent
placement="left-start"
sideOffset={0}
@ -285,25 +323,122 @@ const VarReferenceVars: FC<Props> = ({
}) => {
const { t } = useTranslation()
const [internalSearchValue, setInternalSearchValue] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const searchValue = searchText ?? internalSearchValue
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onClose?.()
}
}
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
const selectableItems = useMemo(() => {
return filteredVars.flatMap(node => node.vars.map(item => ({
nodeId: node.nodeId,
isFlat: node.isFlat,
itemData: item,
})))
}, [filteredVars])
const indexedFilteredVars = useMemo(() => {
let optionIndex = 0
return filteredVars.map(node => ({
...node,
vars: node.vars.map(variable => ({
variable,
optionIndex: optionIndex++,
})),
}))
}, [filteredVars])
const [selectedIndex, setSelectedIndex] = useState(-1)
const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1
useEffect(() => {
const listElement = listRef.current
const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null
if (!listElement || !selectedElement)
return
const selectedTop = selectedElement.offsetTop
const selectedBottom = selectedTop + selectedElement.offsetHeight
const visibleTop = listElement.scrollTop
const visibleBottom = visibleTop + listElement.clientHeight
if (selectedTop < visibleTop)
listElement.scrollTop = selectedTop
else if (selectedBottom > visibleBottom)
listElement.scrollTop = selectedBottom - listElement.clientHeight
}, [effectiveSelectedIndex])
const selectItem = useCallback((index: number) => {
const selectedItem = selectableItems[index]
if (!selectedItem)
return
const { itemData, nodeId, isFlat } = selectedItem
const valueSelector = resolveValueSelector({
itemData,
isFlat,
isSupportFileVar,
nodeId,
objPath: [],
})
if (valueSelector)
onChange(valueSelector, itemData)
}, [isSupportFileVar, onChange, selectableItems])
const handleKeyboardEvent = useCallback((event: Pick<KeyboardEvent, 'key' | 'preventDefault' | 'stopPropagation'>) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose?.()
return
}
if (!selectableItems.length)
return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
setSelectedIndex(
event.key === 'ArrowDown'
? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1)
: Math.max(effectiveSelectedIndex - 1, 0),
)
return
}
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
selectItem(effectiveSelectedIndex)
}
}, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem])
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
handleKeyboardEvent(e)
}, [handleKeyboardEvent])
useEffect(() => {
if (!hideSearch)
return
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.altKey || event.ctrlKey || event.metaKey)
return
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
return
handleKeyboardEvent(event)
}
document.addEventListener('keydown', handleDocumentKeyDown, true)
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
}, [handleKeyboardEvent, hideSearch])
return (
<>
{
!hideSearch && (
<>
<div className={cn('var-search-input-wrapper mx-2 mt-2 mb-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<div className={cn('mx-2 mt-2 mb-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<Input
className="var-search-input"
className={VAR_SEARCH_INPUT_CLASS_NAME}
showLeftIcon
showClearIcon
value={searchValue}
@ -328,11 +463,10 @@ const VarReferenceVars: FC<Props> = ({
{filteredVars.length > 0
? (
<div className={cn('max-h-[85vh] overflow-y-auto', maxHeightClass)}>
<div ref={listRef} className={cn('max-h-[85vh] overflow-x-hidden overflow-y-auto', maxHeightClass)}>
{
filteredVars.map((item, i) => (
<div key={i} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}>
indexedFilteredVars.map((item, i) => (
<div key={item.nodeId} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}>
{!item.isFlat && (
<div
className="truncate px-3 system-xs-medium-uppercase leading-[22px] text-text-tertiary"
@ -341,25 +475,27 @@ const VarReferenceVars: FC<Props> = ({
{item.title}
</div>
)}
{item.vars.map((v, j) => (
{item.vars.map(({ variable, optionIndex }) => (
<Item
key={j}
key={optionIndex}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
itemData={variable}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isException={variable.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
zIndex={zIndex}
preferSchemaType={preferSchemaType}
isSelected={effectiveSelectedIndex === optionIndex}
onActivate={() => setSelectedIndex(optionIndex)}
/>
))}
{item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && (
{item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && (
<div className="relative mt-[14px] flex items-center space-x-1">
<div className="h-0 w-3 shrink-0 border border-divider-subtle"></div>
<div className="system-2xs-semibold-uppercase text-text-tertiary">{t('debug.lastOutput', { ns: 'workflow' })}</div>