diff --git a/web/__tests__/goto-anything/command-selector.test.tsx b/web/__tests__/goto-anything/command-selector.test.tsx new file mode 100644 index 0000000000..1073b9d481 --- /dev/null +++ b/web/__tests__/goto-anything/command-selector.test.tsx @@ -0,0 +1,333 @@ +import React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import CommandSelector from '../../app/components/goto-anything/command-selector' +import type { ActionItem } from '../../app/components/goto-anything/actions/types' + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +jest.mock('cmdk', () => ({ + Command: { + Group: ({ children, className }: any) =>
{children}
, + Item: ({ children, onSelect, value, className }: any) => ( +
onSelect && onSelect()} + data-value={value} + data-testid={`command-item-${value}`} + > + {children} +
+ ), + }, +})) + +describe('CommandSelector', () => { + const mockActions: Record = { + app: { + key: '@app', + shortcut: '@app', + title: 'Search Applications', + description: 'Search apps', + search: jest.fn(), + }, + knowledge: { + key: '@knowledge', + shortcut: '@knowledge', + title: 'Search Knowledge', + description: 'Search knowledge bases', + search: jest.fn(), + }, + plugin: { + key: '@plugin', + shortcut: '@plugin', + title: 'Search Plugins', + description: 'Search plugins', + search: jest.fn(), + }, + node: { + key: '@node', + shortcut: '@node', + title: 'Search Nodes', + description: 'Search workflow nodes', + search: jest.fn(), + }, + } + + const mockOnCommandSelect = jest.fn() + const mockOnCommandValueChange = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Basic Rendering', () => { + it('should render all actions when no filter is provided', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() + }) + + it('should render empty filter as showing all actions', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() + }) + }) + + describe('Filtering Functionality', () => { + it('should filter actions based on searchFilter - single match', () => { + render( + , + ) + + expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() + expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() + }) + + it('should filter actions with multiple matches', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument() + expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() + }) + + it('should be case-insensitive when filtering', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument() + }) + + it('should match partial strings', () => { + render( + , + ) + + expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() + expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() + }) + }) + + describe('Empty State', () => { + it('should show empty state when no matches found', () => { + render( + , + ) + + expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() + expect(screen.queryByTestId('command-item-@knowledge')).not.toBeInTheDocument() + expect(screen.queryByTestId('command-item-@plugin')).not.toBeInTheDocument() + expect(screen.queryByTestId('command-item-@node')).not.toBeInTheDocument() + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() + }) + + it('should not show empty state when filter is empty', () => { + render( + , + ) + + expect(screen.queryByText('app.gotoAnything.noMatchingCommands')).not.toBeInTheDocument() + }) + }) + + describe('Selection and Highlight Management', () => { + it('should call onCommandValueChange when filter changes and first item differs', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(mockOnCommandValueChange).toHaveBeenCalledWith('@knowledge') + }) + + it('should not call onCommandValueChange if current value still exists', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(mockOnCommandValueChange).not.toHaveBeenCalled() + }) + + it('should handle onCommandSelect callback correctly', () => { + render( + , + ) + + const knowledgeItem = screen.getByTestId('command-item-@knowledge') + fireEvent.click(knowledgeItem) + + expect(mockOnCommandSelect).toHaveBeenCalledWith('@knowledge') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty actions object', () => { + render( + , + ) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + }) + + it('should handle special characters in filter', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() + }) + + it('should handle undefined onCommandValueChange gracefully', () => { + const { rerender } = render( + , + ) + + expect(() => { + rerender( + , + ) + }).not.toThrow() + }) + }) + + describe('Backward Compatibility', () => { + it('should work without searchFilter prop (backward compatible)', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@app')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@plugin')).toBeInTheDocument() + expect(screen.getByTestId('command-item-@node')).toBeInTheDocument() + }) + + it('should work without commandValue and onCommandValueChange props', () => { + render( + , + ) + + expect(screen.getByTestId('command-item-@knowledge')).toBeInTheDocument() + expect(screen.queryByTestId('command-item-@app')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index 169f377c31..d66a22eab7 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react' +import { useEffect } from 'react' import { Command } from 'cmdk' import { useTranslation } from 'react-i18next' import type { ActionItem } from './actions/types' @@ -6,18 +7,54 @@ import type { ActionItem } from './actions/types' type Props = { actions: Record onCommandSelect: (commandKey: string) => void + searchFilter?: string + commandValue?: string + onCommandValueChange?: (value: string) => void } -const CommandSelector: FC = ({ actions, onCommandSelect }) => { +const CommandSelector: FC = ({ actions, onCommandSelect, searchFilter, commandValue, onCommandValueChange }) => { const { t } = useTranslation() + const filteredActions = Object.values(actions).filter((action) => { + if (!searchFilter) + return true + const filterLower = searchFilter.toLowerCase() + return action.shortcut.toLowerCase().includes(filterLower) + || action.key.toLowerCase().includes(filterLower) + }) + + useEffect(() => { + if (filteredActions.length > 0 && onCommandValueChange) { + const currentValueExists = filteredActions.some(action => action.shortcut === commandValue) + if (!currentValueExists) + onCommandValueChange(filteredActions[0].shortcut) + } + }, [searchFilter, filteredActions.length]) + + if (filteredActions.length === 0) { + return ( +
+
+
+
+ {t('app.gotoAnything.noMatchingCommands')} +
+
+ {t('app.gotoAnything.tryDifferentSearch')} +
+
+
+
+ ) + } + return (
{t('app.gotoAnything.selectSearchType')}
- {Object.values(actions).map(action => ( + {filteredActions.map(action => ( = ({ wait: 300, }) - const isCommandsMode = searchQuery.trim() === '@' + const isCommandsMode = searchQuery.trim() === '@' || (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions)) const searchMode = useMemo(() => { if (isCommandsMode) return 'commands' @@ -253,8 +253,9 @@ const GotoAnything: FC = ({ value={searchQuery} placeholder={t('app.gotoAnything.searchPlaceholder')} onChange={(e) => { - setCmdVal('') setSearchQuery(e.target.value) + if (!e.target.value.startsWith('@')) + setCmdVal('') }} className='flex-1 !border-0 !bg-transparent !shadow-none' wrapperClassName='flex-1 !border-0 !bg-transparent' @@ -301,6 +302,9 @@ const GotoAnything: FC = ({ ) : ( Object.entries(groupedResults).map(([type, results], groupIndex) => (