Compare commits

...

3 Commits

Author SHA1 Message Date
CodingOnStar
7b03b610ff test: refactor GotoAnything tests to use userEvent consistently
- Updated GotoAnything component tests to utilize userEvent for simulating user interactions, ensuring consistency across test cases.
- Enhanced search input handling by replacing direct calls to userEvent with a setup user instance.
- Improved readability and maintainability of tests by standardizing interaction methods.
2026-01-30 16:53:02 +08:00
CodingOnStar
189b72962c test: update button name matching in CreateAppModal tests
- Modified button name queries in CreateAppModal tests to use regular expressions for improved matching.
- Ensured consistency across tests in both app and explore components.
- Updated assertions to reflect changes in button name handling.
2026-01-30 16:39:41 +08:00
CodingOnStar
70f2c0ce77 test: enhance CommandSelector and GotoAnythingProvider tests
- Added tests for CommandSelector to verify command visibility based on filters and modes.
- Implemented tests for GotoAnythingProvider to ensure correct flag settings for various pathname scenarios.
- Introduced EmptyState and Footer components with corresponding tests to handle different states in the UI.
- Improved search input handling and result rendering in GotoAnything component.
2026-01-30 15:35:45 +08:00
26 changed files with 3666 additions and 517 deletions

View File

@@ -124,7 +124,7 @@ describe('CreateAppModal', () => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({
name: 'My App',
@@ -152,7 +152,7 @@ describe('CreateAppModal', () => {
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
fireEvent.change(nameInput, { target: { value: 'My App' } })
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' }))
fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ }))
await waitFor(() => expect(mockCreateApp).toHaveBeenCalled())
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' })

View File

@@ -138,7 +138,7 @@ describe('CreateAppModal', () => {
setup({ appName: 'My App', isEditModal: false })
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
})
@@ -146,7 +146,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
})
@@ -166,7 +166,7 @@ describe('CreateAppModal', () => {
it('should not render modal content when hidden', () => {
setup({ show: false })
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
})
})
@@ -175,13 +175,13 @@ describe('CreateAppModal', () => {
it('should disable confirm action when confirmDisabled is true', () => {
setup({ confirmDisabled: true })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
it('should disable confirm action when appName is empty', () => {
setup({ appName: ' ' })
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
})
@@ -245,7 +245,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: false })
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
})
it('should allow saving when apps quota is reached in edit mode', () => {
@@ -257,7 +257,7 @@ describe('CreateAppModal', () => {
setup({ isEditModal: true })
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled()
})
})
@@ -384,7 +384,7 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -433,7 +433,7 @@ describe('CreateAppModal', () => {
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
// Submit and verify the payload uses the original icon (cancel reverts to props)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -471,7 +471,7 @@ describe('CreateAppModal', () => {
appIconBackground: '#000000',
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -495,7 +495,7 @@ describe('CreateAppModal', () => {
const { onConfirm } = setup({ appDescription: 'Old description' })
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -512,7 +512,7 @@ describe('CreateAppModal', () => {
appIconBackground: null,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -536,7 +536,7 @@ describe('CreateAppModal', () => {
fireEvent.click(screen.getByRole('switch'))
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -551,7 +551,7 @@ describe('CreateAppModal', () => {
it('should omit max_active_requests when input is empty', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -564,7 +564,7 @@ describe('CreateAppModal', () => {
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
act(() => {
vi.advanceTimersByTime(300)
})
@@ -576,7 +576,7 @@ describe('CreateAppModal', () => {
it('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
const { onConfirm, onHide } = setup({ appName: 'My App' })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
act(() => {

View File

@@ -81,4 +81,205 @@ describe('CommandSelector', () => {
expect(onSelect).toHaveBeenCalledWith('/zen')
})
it('should show all slash commands when no filter provided', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="/"
/>
</Command>,
)
// Should show the zen command from mock
expect(screen.getByText('/zen')).toBeInTheDocument()
})
it('should exclude slash action when in @ mode', () => {
const actions = {
...createActions(),
slash: {
key: '/',
shortcut: '/',
title: 'Slash',
search: vi.fn(),
description: '',
} as ActionItem,
}
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
// Should show @ commands but not /
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.queryByText('/')).not.toBeInTheDocument()
})
it('should show all actions when no filter in @ mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('@app')).toBeInTheDocument()
expect(screen.getByText('@plugin')).toBeInTheDocument()
})
it('should set default command value when items exist but value does not', () => {
const actions = createActions()
const onSelect = vi.fn()
const onCommandValueChange = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
commandValue="non-existent"
onCommandValueChange={onCommandValueChange}
/>
</Command>,
)
expect(onCommandValueChange).toHaveBeenCalledWith('@app')
})
it('should NOT set command value when value already exists in items', () => {
const actions = createActions()
const onSelect = vi.fn()
const onCommandValueChange = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
commandValue="@app"
onCommandValueChange={onCommandValueChange}
/>
</Command>,
)
expect(onCommandValueChange).not.toHaveBeenCalled()
})
it('should show no matching commands message when filter has no results', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="nonexistent"
originalQuery="@nonexistent"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument()
})
it('should show no matching commands for slash mode with no results', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter="nonexistentcommand"
originalQuery="/nonexistentcommand"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument()
})
it('should render description for @ commands', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument()
})
it('should render group header for @ mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="@"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument()
})
it('should render group header for slash mode', () => {
const actions = createActions()
const onSelect = vi.fn()
render(
<Command>
<CommandSelector
actions={actions}
onCommandSelect={onSelect}
searchFilter=""
originalQuery="/"
/>
</Command>,
)
expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,157 @@
import { render, screen } from '@testing-library/react'
import EmptyState from './empty-state'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, shortcuts?: string }) => {
if (options?.shortcuts !== undefined)
return `${key}:${options.shortcuts}`
return `${options?.ns || 'common'}.${key}`
},
}),
}))
describe('EmptyState', () => {
describe('loading variant', () => {
it('should render loading spinner', () => {
render(<EmptyState variant="loading" />)
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
})
it('should have spinner animation class', () => {
const { container } = render(<EmptyState variant="loading" />)
const spinner = container.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
})
describe('error variant', () => {
it('should render error message when error has message', () => {
const error = new Error('Connection failed')
render(<EmptyState variant="error" error={error} />)
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
expect(screen.getByText('Connection failed')).toBeInTheDocument()
})
it('should render generic error when error has no message', () => {
render(<EmptyState variant="error" error={null} />)
expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.servicesUnavailableMessage')).toBeInTheDocument()
})
it('should render generic error when error is undefined', () => {
render(<EmptyState variant="error" />)
expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument()
})
it('should have red error text styling', () => {
const error = new Error('Test error')
const { container } = render(<EmptyState variant="error" error={error} />)
const errorText = container.querySelector('.text-red-500')
expect(errorText).toBeInTheDocument()
})
})
describe('default variant', () => {
it('should render search title', () => {
render(<EmptyState variant="default" />)
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
})
it('should render all hint messages', () => {
render(<EmptyState variant="default" />)
expect(screen.getByText('app.gotoAnything.searchHint')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.commandHint')).toBeInTheDocument()
expect(screen.getByText('app.gotoAnything.slashHint')).toBeInTheDocument()
})
})
describe('no-results variant', () => {
describe('general search mode', () => {
it('should render generic no results message', () => {
render(<EmptyState variant="no-results" searchMode="general" />)
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
it('should show specific search hint with shortcuts', () => {
const Actions = {
app: { key: '@app', shortcut: '@app' },
plugin: { key: '@plugin', shortcut: '@plugin' },
} as unknown as Record<string, import('../actions/types').ActionItem>
render(<EmptyState variant="no-results" searchMode="general" Actions={Actions} />)
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument()
})
})
describe('app search mode', () => {
it('should render no apps found message', () => {
render(<EmptyState variant="no-results" searchMode="@app" />)
expect(screen.getByText('app.gotoAnything.emptyState.noAppsFound')).toBeInTheDocument()
})
it('should show try different term hint', () => {
render(<EmptyState variant="no-results" searchMode="@app" />)
expect(screen.getByText('app.gotoAnything.emptyState.tryDifferentTerm')).toBeInTheDocument()
})
})
describe('plugin search mode', () => {
it('should render no plugins found message', () => {
render(<EmptyState variant="no-results" searchMode="@plugin" />)
expect(screen.getByText('app.gotoAnything.emptyState.noPluginsFound')).toBeInTheDocument()
})
})
describe('knowledge search mode', () => {
it('should render no knowledge bases found message', () => {
render(<EmptyState variant="no-results" searchMode="@knowledge" />)
expect(screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound')).toBeInTheDocument()
})
})
describe('node search mode', () => {
it('should render no workflow nodes found message', () => {
render(<EmptyState variant="no-results" searchMode="@node" />)
expect(screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound')).toBeInTheDocument()
})
})
describe('unknown search mode', () => {
it('should fallback to generic no results message', () => {
render(<EmptyState variant="no-results" searchMode="@unknown" />)
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
})
})
describe('default props', () => {
it('should use general as default searchMode', () => {
render(<EmptyState variant="no-results" />)
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
it('should use empty object as default Actions', () => {
render(<EmptyState variant="no-results" searchMode="general" />)
// Should show empty shortcuts
expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,105 @@
'use client'
import type { FC } from 'react'
import type { ActionItem } from '../actions/types'
import { useTranslation } from 'react-i18next'
export type EmptyStateVariant = 'no-results' | 'error' | 'default' | 'loading'
export type EmptyStateProps = {
variant: EmptyStateVariant
searchMode?: string
error?: Error | null
Actions?: Record<string, ActionItem>
}
const EmptyState: FC<EmptyStateProps> = ({
variant,
searchMode = 'general',
error,
Actions = {},
}) => {
const { t } = useTranslation()
if (variant === 'loading') {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
<span className="text-sm">{t('gotoAnything.searching', { ns: 'app' })}</span>
</div>
</div>
)
}
if (variant === 'error') {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-red-500">
{error?.message
? t('gotoAnything.searchFailed', { ns: 'app' })
: t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{error?.message || t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })}
</div>
</div>
</div>
)
}
if (variant === 'default') {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium">{t('gotoAnything.searchTitle', { ns: 'app' })}</div>
<div className="mt-3 space-y-1 text-xs text-text-quaternary">
<div>{t('gotoAnything.searchHint', { ns: 'app' })}</div>
<div>{t('gotoAnything.commandHint', { ns: 'app' })}</div>
<div>{t('gotoAnything.slashHint', { ns: 'app' })}</div>
</div>
</div>
</div>
)
}
// variant === 'no-results'
const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
const getNoResultsMessage = () => {
if (!isCommandSearch) {
return t('gotoAnything.noResults', { ns: 'app' })
}
const keyMap = {
app: 'gotoAnything.emptyState.noAppsFound',
plugin: 'gotoAnything.emptyState.noPluginsFound',
knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'gotoAnything.emptyState.noWorkflowNodesFound',
} as const
return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' })
}
const getHintMessage = () => {
if (isCommandSearch) {
return t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' })
}
const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ')
return t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts })
}
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium">{getNoResultsMessage()}</div>
<div className="mt-1 text-xs text-text-quaternary">{getHintMessage()}</div>
</div>
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,273 @@
import { render, screen } from '@testing-library/react'
import Footer from './footer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => {
if (options?.count !== undefined)
return `${key}:${options.count}`
if (options?.scope)
return `${key}:${options.scope}`
return `${options?.ns || 'common'}.${key}`
},
}),
}))
describe('Footer', () => {
describe('left content', () => {
describe('when there are results', () => {
it('should show result count', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('gotoAnything.resultCount:5')).toBeInTheDocument()
})
it('should show scope when not in general mode', () => {
render(
<Footer
resultCount={3}
searchMode="@app"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('gotoAnything.inScope:app')).toBeInTheDocument()
})
it('should NOT show scope when in general mode', () => {
render(
<Footer
resultCount={3}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.queryByText(/inScope/)).not.toBeInTheDocument()
})
})
describe('when there is an error', () => {
it('should show error message', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
})
it('should have red text styling', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
const errorText = container.querySelector('.text-red-500')
expect(errorText).toBeInTheDocument()
})
it('should show error even with results', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.someServicesUnavailable')).toBeInTheDocument()
})
})
describe('when no results and no error', () => {
it('should show select to navigate in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={true}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.selectToNavigate')).toBeInTheDocument()
})
it('should show searching when has query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument()
})
it('should show start typing when no query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.startTyping')).toBeInTheDocument()
})
})
})
describe('right content', () => {
describe('when there are results or error', () => {
it('should show clear to search all when in specific mode', () => {
render(
<Footer
resultCount={5}
searchMode="@app"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.clearToSearchAll')).toBeInTheDocument()
})
it('should show use @ for specific when in general mode', () => {
render(
<Footer
resultCount={5}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
})
it('should show same hint when error', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={true}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.useAtForSpecific')).toBeInTheDocument()
})
})
describe('when no results and no error', () => {
it('should show tips when has query', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={true}
/>,
)
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
})
it('should show tips when in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={true}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.tips')).toBeInTheDocument()
})
it('should show press ESC to close when no query and not in commands mode', () => {
render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
expect(screen.getByText('app.gotoAnything.pressEscToClose')).toBeInTheDocument()
})
})
})
describe('styling', () => {
it('should have border and background classes', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
const footer = container.firstChild
expect(footer).toHaveClass('border-t', 'border-divider-subtle', 'bg-components-panel-bg-blur')
})
it('should have flex layout for content', () => {
const { container } = render(
<Footer
resultCount={0}
searchMode="general"
isError={false}
isCommandsMode={false}
hasQuery={false}
/>,
)
const flexContainer = container.querySelector('.flex.items-center.justify-between')
expect(flexContainer).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,90 @@
'use client'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export type FooterProps = {
resultCount: number
searchMode: string
isError: boolean
isCommandsMode: boolean
hasQuery: boolean
}
const Footer: FC<FooterProps> = ({
resultCount,
searchMode,
isError,
isCommandsMode,
hasQuery,
}) => {
const { t } = useTranslation()
const renderLeftContent = () => {
if (resultCount > 0 || isError) {
if (isError) {
return (
<span className="text-red-500">
{t('gotoAnything.someServicesUnavailable', { ns: 'app' })}
</span>
)
}
return (
<>
{t('gotoAnything.resultCount', { ns: 'app', count: resultCount })}
{searchMode !== 'general' && (
<span className="ml-2 opacity-60">
{t('gotoAnything.inScope', { ns: 'app', scope: searchMode.replace('@', '') })}
</span>
)}
</>
)
}
return (
<span className="opacity-60">
{(() => {
if (isCommandsMode)
return t('gotoAnything.selectToNavigate', { ns: 'app' })
if (hasQuery)
return t('gotoAnything.searching', { ns: 'app' })
return t('gotoAnything.startTyping', { ns: 'app' })
})()}
</span>
)
}
const renderRightContent = () => {
if (resultCount > 0 || isError) {
return (
<span className="opacity-60">
{searchMode !== 'general'
? t('gotoAnything.clearToSearchAll', { ns: 'app' })
: t('gotoAnything.useAtForSpecific', { ns: 'app' })}
</span>
)
}
return (
<span className="opacity-60">
{hasQuery || isCommandsMode
? t('gotoAnything.tips', { ns: 'app' })
: t('gotoAnything.pressEscToClose', { ns: 'app' })}
</span>
)
}
return (
<div className="border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary">
<div className="flex min-h-[16px] items-center justify-between">
<span>{renderLeftContent()}</span>
{renderRightContent()}
</div>
</div>
)
}
export default Footer

View File

@@ -0,0 +1,14 @@
export { default as EmptyState } from './empty-state'
export type { EmptyStateProps, EmptyStateVariant } from './empty-state'
export { default as Footer } from './footer'
export type { FooterProps } from './footer'
export { default as ResultItem } from './result-item'
export type { ResultItemProps } from './result-item'
export { default as ResultList } from './result-list'
export type { ResultListProps } from './result-list'
export { default as SearchInput } from './search-input'
export type { SearchInputProps } from './search-input'

View File

@@ -0,0 +1,38 @@
'use client'
import type { FC } from 'react'
import type { SearchResult } from '../actions/types'
import { Command } from 'cmdk'
export type ResultItemProps = {
result: SearchResult
onSelect: () => void
}
const ResultItem: FC<ResultItemProps> = ({ result, onSelect }) => {
return (
<Command.Item
key={`${result.type}-${result.id}`}
value={`${result.type}-${result.id}`}
className="flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt data-[selected=true]:bg-state-base-hover-alt"
onSelect={onSelect}
>
{result.icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text-secondary">
{result.title}
</div>
{result.description && (
<div className="mt-0.5 truncate text-xs text-text-quaternary">
{result.description}
</div>
)}
</div>
<div className="text-xs capitalize text-text-quaternary">
{result.type}
</div>
</Command.Item>
)
}
export default ResultItem

View File

@@ -0,0 +1,49 @@
'use client'
import type { FC } from 'react'
import type { SearchResult } from '../actions/types'
import { Command } from 'cmdk'
import { useTranslation } from 'react-i18next'
import ResultItem from './result-item'
export type ResultListProps = {
groupedResults: Record<string, SearchResult[]>
onSelect: (result: SearchResult) => void
}
const ResultList: FC<ResultListProps> = ({ groupedResults, onSelect }) => {
const { t } = useTranslation()
const getGroupHeading = (type: string) => {
const typeMap = {
'app': 'gotoAnything.groups.apps',
'plugin': 'gotoAnything.groups.plugins',
'knowledge': 'gotoAnything.groups.knowledgeBases',
'workflow-node': 'gotoAnything.groups.workflowNodes',
'command': 'gotoAnything.groups.commands',
} as const
return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' })
}
return (
<>
{Object.entries(groupedResults).map(([type, results]) => (
<Command.Group
key={type}
heading={getGroupHeading(type)}
className="p-2 capitalize text-text-secondary"
>
{results.map(result => (
<ResultItem
key={`${result.type}-${result.id}`}
result={result}
onSelect={() => onSelect(result)}
/>
))}
</Command.Group>
))}
</>
)
}
export default ResultList

View File

@@ -0,0 +1,206 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SearchInput from './search-input'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => `${options?.ns || 'common'}.${key}`,
}),
}))
vi.mock('@remixicon/react', () => ({
RiSearchLine: ({ className }: { className?: string }) => (
<svg data-testid="search-icon" className={className} />
),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: ({ keys, textColor }: { keys: string[], textColor: string }) => (
<div data-testid="shortcuts-name" data-keys={keys.join(',')} data-color={textColor}>
{keys.join('+')}
</div>
),
}))
vi.mock('@/app/components/base/input', async () => {
const { forwardRef } = await import('react')
type MockInputProps = {
value?: string
placeholder?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
className?: string
wrapperClassName?: string
autoFocus?: boolean
}
const MockInput = forwardRef<HTMLInputElement, MockInputProps>(
({ value, placeholder, onChange, onKeyDown, className, wrapperClassName, autoFocus }, ref) => (
<input
ref={ref}
value={value}
placeholder={placeholder}
onChange={onChange}
onKeyDown={onKeyDown}
className={className}
data-wrapper-class={wrapperClassName}
autoFocus={autoFocus}
data-testid="search-input"
/>
),
)
MockInput.displayName = 'MockInput'
return { default: MockInput }
})
describe('SearchInput', () => {
const defaultProps = {
inputRef: { current: null } as RefObject<HTMLInputElement | null>,
value: '',
onChange: vi.fn(),
searchMode: 'general',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('rendering', () => {
it('should render search icon', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByTestId('search-icon')).toBeInTheDocument()
})
it('should render input field', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('should render shortcuts name', () => {
render(<SearchInput {...defaultProps} />)
const shortcuts = screen.getByTestId('shortcuts-name')
expect(shortcuts).toBeInTheDocument()
expect(shortcuts).toHaveAttribute('data-keys', 'ctrl,K')
expect(shortcuts).toHaveAttribute('data-color', 'secondary')
})
it('should use provided placeholder', () => {
render(<SearchInput {...defaultProps} placeholder="Custom placeholder" />)
expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument()
})
it('should use default placeholder from translation', () => {
render(<SearchInput {...defaultProps} />)
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
})
describe('mode label', () => {
it('should NOT show mode badge in general mode', () => {
render(<SearchInput {...defaultProps} searchMode="general" />)
expect(screen.queryByText('GENERAL')).not.toBeInTheDocument()
})
it('should show SCOPES label in scopes mode', () => {
render(<SearchInput {...defaultProps} searchMode="scopes" />)
expect(screen.getByText('SCOPES')).toBeInTheDocument()
})
it('should show COMMANDS label in commands mode', () => {
render(<SearchInput {...defaultProps} searchMode="commands" />)
expect(screen.getByText('COMMANDS')).toBeInTheDocument()
})
it('should show APP label in @app mode', () => {
render(<SearchInput {...defaultProps} searchMode="@app" />)
expect(screen.getByText('APP')).toBeInTheDocument()
})
it('should show PLUGIN label in @plugin mode', () => {
render(<SearchInput {...defaultProps} searchMode="@plugin" />)
expect(screen.getByText('PLUGIN')).toBeInTheDocument()
})
it('should show KNOWLEDGE label in @knowledge mode', () => {
render(<SearchInput {...defaultProps} searchMode="@knowledge" />)
expect(screen.getByText('KNOWLEDGE')).toBeInTheDocument()
})
it('should show NODE label in @node mode', () => {
render(<SearchInput {...defaultProps} searchMode="@node" />)
expect(screen.getByText('NODE')).toBeInTheDocument()
})
it('should uppercase custom mode label', () => {
render(<SearchInput {...defaultProps} searchMode="@custom" />)
expect(screen.getByText('CUSTOM')).toBeInTheDocument()
})
})
describe('input interactions', () => {
it('should call onChange when typing', () => {
const onChange = vi.fn()
render(<SearchInput {...defaultProps} onChange={onChange} />)
const input = screen.getByTestId('search-input')
fireEvent.change(input, { target: { value: 'test query' } })
expect(onChange).toHaveBeenCalledWith('test query')
})
it('should call onKeyDown when pressing keys', () => {
const onKeyDown = vi.fn()
render(<SearchInput {...defaultProps} onKeyDown={onKeyDown} />)
const input = screen.getByTestId('search-input')
fireEvent.keyDown(input, { key: 'Enter' })
expect(onKeyDown).toHaveBeenCalled()
})
it('should render with provided value', () => {
render(<SearchInput {...defaultProps} value="existing query" />)
expect(screen.getByDisplayValue('existing query')).toBeInTheDocument()
})
it('should NOT throw when onKeyDown is undefined', () => {
render(<SearchInput {...defaultProps} onKeyDown={undefined} />)
const input = screen.getByTestId('search-input')
expect(() => fireEvent.keyDown(input, { key: 'Enter' })).not.toThrow()
})
})
describe('styling', () => {
it('should have search icon styling', () => {
render(<SearchInput {...defaultProps} />)
const icon = screen.getByTestId('search-icon')
expect(icon).toHaveClass('h-4', 'w-4', 'text-text-quaternary')
})
it('should have mode badge styling when visible', () => {
const { container } = render(<SearchInput {...defaultProps} searchMode="@app" />)
const badge = container.querySelector('.bg-gray-100')
expect(badge).toBeInTheDocument()
expect(badge).toHaveClass('rounded', 'px-2', 'text-xs', 'font-medium')
})
})
})

View File

@@ -0,0 +1,62 @@
'use client'
import type { FC, KeyboardEvent, RefObject } from 'react'
import { RiSearchLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
export type SearchInputProps = {
inputRef: RefObject<HTMLInputElement | null>
value: string
onChange: (value: string) => void
onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void
searchMode: string
placeholder?: string
}
const SearchInput: FC<SearchInputProps> = ({
inputRef,
value,
onChange,
onKeyDown,
searchMode,
placeholder,
}) => {
const { t } = useTranslation()
const getModeLabel = () => {
if (searchMode === 'scopes')
return 'SCOPES'
else if (searchMode === 'commands')
return 'COMMANDS'
else
return searchMode.replace('@', '').toUpperCase()
}
return (
<div className="flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3">
<RiSearchLine className="h-4 w-4 text-text-quaternary" />
<div className="flex flex-1 items-center gap-2">
<Input
ref={inputRef}
value={value}
placeholder={placeholder || t('gotoAnything.searchPlaceholder', { ns: 'app' })}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 !border-0 !bg-transparent !shadow-none"
wrapperClassName="flex-1 !border-0 !bg-transparent"
autoFocus
/>
{searchMode !== 'general' && (
<div className="flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span>{getModeLabel()}</span>
</div>
)}
</div>
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
</div>
)
}
export default SearchInput

View File

@@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
let pathnameMock = '/'
let pathnameMock: string | null | undefined = '/'
vi.mock('next/navigation', () => ({
usePathname: () => pathnameMock,
}))
@@ -57,4 +57,79 @@ describe('GotoAnythingProvider', () => {
expect(screen.getByTestId('status')).toHaveTextContent('false|true')
})
})
it('should set both flags to false when pathname is null', async () => {
pathnameMock = null
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should set both flags to false when pathname is undefined', async () => {
pathnameMock = undefined
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should set both flags to false for regular paths', async () => {
pathnameMock = '/apps'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
it('should NOT match non-pipeline dataset paths', async () => {
pathnameMock = '/datasets/abc/documents'
render(
<GotoAnythingProvider>
<ContextConsumer />
</GotoAnythingProvider>,
)
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('false|false')
})
})
})
describe('useGotoAnythingContext', () => {
it('should return default values when used outside provider', () => {
const TestComponent = () => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
return (
<div data-testid="context">
{String(isWorkflowPage)}
|
{String(isRagPipelinePage)}
</div>
)
}
render(<TestComponent />)
expect(screen.getByTestId('context')).toHaveTextContent('false|false')
})
})

View File

@@ -0,0 +1,11 @@
export { useGotoAnythingModal } from './use-goto-anything-modal'
export type { UseGotoAnythingModalReturn } from './use-goto-anything-modal'
export { useGotoAnythingNavigation } from './use-goto-anything-navigation'
export type { UseGotoAnythingNavigationOptions, UseGotoAnythingNavigationReturn } from './use-goto-anything-navigation'
export { useGotoAnythingResults } from './use-goto-anything-results'
export type { UseGotoAnythingResultsOptions, UseGotoAnythingResultsReturn } from './use-goto-anything-results'
export { useGotoAnythingSearch } from './use-goto-anything-search'
export type { UseGotoAnythingSearchReturn } from './use-goto-anything-search'

View File

@@ -0,0 +1,291 @@
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingModal } from './use-goto-anything-modal'
type KeyPressEvent = {
preventDefault: () => void
target?: EventTarget
}
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
let mockIsEventTargetInputArea = false
vi.mock('ahooks', () => ({
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
const keyList = Array.isArray(keys) ? keys : [keys]
keyList.forEach((key) => {
keyPressHandlers[key] = handler
})
},
}))
vi.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
isEventTargetInputArea: () => mockIsEventTargetInputArea,
}))
describe('useGotoAnythingModal', () => {
beforeEach(() => {
Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key])
mockIsEventTargetInputArea = false
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initialization', () => {
it('should initialize with show=false', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
})
it('should provide inputRef initialized to null', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.inputRef).toBeDefined()
expect(result.current.inputRef.current).toBe(null)
})
it('should provide setShow function', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(typeof result.current.setShow).toBe('function')
})
it('should provide handleClose function', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(typeof result.current.handleClose).toBe('function')
})
})
describe('keyboard shortcuts', () => {
it('should toggle show state when Ctrl+K is triggered', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
act(() => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
expect(result.current.show).toBe(true)
})
it('should toggle back to closed when Ctrl+K is triggered twice', () => {
const { result } = renderHook(() => useGotoAnythingModal())
act(() => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
expect(result.current.show).toBe(true)
act(() => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
expect(result.current.show).toBe(false)
})
it('should NOT toggle when focus is in input area and modal is closed', () => {
mockIsEventTargetInputArea = true
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
act(() => {
keyPressHandlers['ctrl.k']?.({ preventDefault: vi.fn(), target: document.body })
})
// Should remain closed because focus is in input area
expect(result.current.show).toBe(false)
})
it('should close modal when escape is pressed and modal is open', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Press escape
act(() => {
keyPressHandlers.esc?.({ preventDefault: vi.fn() })
})
expect(result.current.show).toBe(false)
})
it('should NOT do anything when escape is pressed and modal is already closed', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
const preventDefaultMock = vi.fn()
act(() => {
keyPressHandlers.esc?.({ preventDefault: preventDefaultMock })
})
// Should remain closed, and preventDefault should not be called
expect(result.current.show).toBe(false)
expect(preventDefaultMock).not.toHaveBeenCalled()
})
it('should call preventDefault when Ctrl+K is triggered', () => {
renderHook(() => useGotoAnythingModal())
const preventDefaultMock = vi.fn()
act(() => {
keyPressHandlers['ctrl.k']?.({ preventDefault: preventDefaultMock, target: document.body })
})
expect(preventDefaultMock).toHaveBeenCalled()
})
})
describe('handleClose', () => {
it('should close modal when handleClose is called', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// Open modal first
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Close via handleClose
act(() => {
result.current.handleClose()
})
expect(result.current.show).toBe(false)
})
it('should be safe to call handleClose when modal is already closed', () => {
const { result } = renderHook(() => useGotoAnythingModal())
expect(result.current.show).toBe(false)
act(() => {
result.current.handleClose()
})
expect(result.current.show).toBe(false)
})
})
describe('setShow', () => {
it('should accept boolean value', () => {
const { result } = renderHook(() => useGotoAnythingModal())
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
act(() => {
result.current.setShow(false)
})
expect(result.current.show).toBe(false)
})
it('should accept function value', () => {
const { result } = renderHook(() => useGotoAnythingModal())
act(() => {
result.current.setShow(prev => !prev)
})
expect(result.current.show).toBe(true)
act(() => {
result.current.setShow(prev => !prev)
})
expect(result.current.show).toBe(false)
})
})
describe('focus management', () => {
it('should call requestAnimationFrame when modal opens', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
const { result } = renderHook(() => useGotoAnythingModal())
act(() => {
result.current.setShow(true)
})
expect(rafSpy).toHaveBeenCalled()
rafSpy.mockRestore()
})
it('should not call requestAnimationFrame when modal closes', () => {
const { result } = renderHook(() => useGotoAnythingModal())
// First open
act(() => {
result.current.setShow(true)
})
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
// Then close
act(() => {
result.current.setShow(false)
})
expect(rafSpy).not.toHaveBeenCalled()
rafSpy.mockRestore()
})
it('should focus input when modal opens and inputRef.current exists', () => {
// Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
return 0
}
const { result } = renderHook(() => useGotoAnythingModal())
// Create a mock input element with focus method
const mockFocus = vi.fn()
const mockInput = { focus: mockFocus } as unknown as HTMLInputElement
// Manually set the inputRef
Object.defineProperty(result.current.inputRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.setShow(true)
})
expect(mockFocus).toHaveBeenCalled()
// Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
it('should not throw when inputRef.current is null when modal opens', () => {
// Mock requestAnimationFrame to execute callback immediately
const originalRAF = window.requestAnimationFrame
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
callback(0)
return 0
}
const { result } = renderHook(() => useGotoAnythingModal())
// inputRef.current is already null by default
// Should not throw
act(() => {
result.current.setShow(true)
})
expect(result.current.show).toBe(true)
// Restore original requestAnimationFrame
window.requestAnimationFrame = originalRAF
})
})
})

View File

@@ -0,0 +1,59 @@
'use client'
import type { RefObject } from 'react'
import { useKeyPress } from 'ahooks'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
export type UseGotoAnythingModalReturn = {
show: boolean
setShow: (show: boolean | ((prev: boolean) => boolean)) => void
inputRef: RefObject<HTMLInputElement | null>
handleClose: () => void
}
export const useGotoAnythingModal = (): UseGotoAnythingModalReturn => {
const [show, setShow] = useState<boolean>(false)
const inputRef = useRef<HTMLInputElement>(null)
// Handle keyboard shortcuts
const handleToggleModal = useCallback((e: KeyboardEvent) => {
// Allow closing when modal is open, even if focus is in the search input
if (!show && isEventTargetInputArea(e.target as HTMLElement))
return
e.preventDefault()
setShow(prev => !prev)
}, [show])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
exactMatch: true,
useCapture: true,
})
useKeyPress(['esc'], (e) => {
if (show) {
e.preventDefault()
setShow(false)
}
})
const handleClose = useCallback(() => {
setShow(false)
}, [])
// Focus input when modal opens
useEffect(() => {
if (show) {
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [show])
return {
show,
setShow,
inputRef,
handleClose,
}
}

View File

@@ -0,0 +1,391 @@
import type * as React from 'react'
import type { Plugin } from '../../plugins/types'
import type { CommonNodeType } from '../../workflow/types'
import type { DataSet } from '@/models/datasets'
import type { App } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingNavigation } from './use-goto-anything-navigation'
const mockRouterPush = vi.fn()
const mockSelectWorkflowNode = vi.fn()
type MockCommandResult = {
mode: string
execute?: () => void
} | null
let mockFindCommandResult: MockCommandResult = null
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: (...args: unknown[]) => mockSelectWorkflowNode(...args),
}))
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommandResult,
},
}))
const createMockActionItem = (
key: '@app' | '@knowledge' | '@plugin' | '@node' | '/',
extra: Record<string, unknown> = {},
) => ({
key,
shortcut: key,
title: `${key} title`,
description: `${key} description`,
search: vi.fn().mockResolvedValue([]),
...extra,
})
const createMockOptions = (overrides = {}) => ({
Actions: {
slash: createMockActionItem('/', { action: vi.fn() }),
app: createMockActionItem('@app'),
},
setSearchQuery: vi.fn(),
clearSelection: vi.fn(),
inputRef: { current: { focus: vi.fn() } } as unknown as React.RefObject<HTMLInputElement>,
onClose: vi.fn(),
...overrides,
})
describe('useGotoAnythingNavigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFindCommandResult = null
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('initialization', () => {
it('should return handleCommandSelect function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.handleCommandSelect).toBe('function')
})
it('should return handleNavigate function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.handleNavigate).toBe('function')
})
it('should initialize activePlugin as undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(result.current.activePlugin).toBeUndefined()
})
it('should return setActivePlugin function', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
expect(typeof result.current.setActivePlugin).toBe('function')
})
})
describe('handleCommandSelect', () => {
it('should execute direct mode slash command immediately', () => {
const execute = vi.fn()
mockFindCommandResult = { mode: 'direct', execute }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/theme')
})
expect(execute).toHaveBeenCalled()
expect(options.onClose).toHaveBeenCalled()
expect(options.setSearchQuery).toHaveBeenCalledWith('')
})
it('should NOT execute when handler has no execute function', () => {
mockFindCommandResult = { mode: 'direct', execute: undefined }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/theme')
})
expect(options.onClose).not.toHaveBeenCalled()
// Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/theme ')
})
it('should proceed with submenu mode for non-direct commands', () => {
mockFindCommandResult = { mode: 'submenu' }
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/language')
})
expect(options.setSearchQuery).toHaveBeenCalledWith('/language ')
expect(options.clearSelection).toHaveBeenCalled()
})
it('should handle @ commands (scopes)', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('@app')
})
expect(options.setSearchQuery).toHaveBeenCalledWith('@app ')
expect(options.clearSelection).toHaveBeenCalled()
})
it('should focus input after setting search query', () => {
const focusMock = vi.fn()
const options = createMockOptions({
inputRef: { current: { focus: focusMock } },
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('@app')
})
act(() => {
vi.runAllTimers()
})
expect(focusMock).toHaveBeenCalled()
})
it('should handle null handler from registry', () => {
mockFindCommandResult = null
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleCommandSelect('/unknown')
})
// Should proceed with submenu mode
expect(options.setSearchQuery).toHaveBeenCalledWith('/unknown ')
})
})
describe('handleNavigate', () => {
it('should navigate to path for default result types', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: '1',
type: 'app' as const,
title: 'My App',
path: '/apps/1',
data: { id: '1', name: 'My App' } as unknown as App,
})
})
expect(options.onClose).toHaveBeenCalled()
expect(options.setSearchQuery).toHaveBeenCalledWith('')
expect(mockRouterPush).toHaveBeenCalledWith('/apps/1')
})
it('should NOT call router.push when path is empty', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: '1',
type: 'app' as const,
title: 'My App',
path: '',
data: { id: '1', name: 'My App' } as unknown as App,
})
})
expect(mockRouterPush).not.toHaveBeenCalled()
})
it('should execute slash command action for command type', () => {
const actionMock = vi.fn()
const options = createMockOptions({
Actions: {
slash: { key: '/', shortcut: '/', action: actionMock },
},
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
const commandResult = {
id: 'cmd-1',
type: 'command' as const,
title: 'Theme Dark',
data: { command: 'theme.set', args: { theme: 'dark' } },
}
act(() => {
result.current.handleNavigate(commandResult)
})
expect(actionMock).toHaveBeenCalledWith(commandResult)
})
it('should set activePlugin for plugin type', () => {
const options = createMockOptions()
const pluginData = { name: 'My Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'plugin-1',
type: 'plugin' as const,
title: 'My Plugin',
data: pluginData,
})
})
expect(result.current.activePlugin).toEqual(pluginData)
})
it('should select workflow node for workflow-node type', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'node-1',
type: 'workflow-node' as const,
title: 'Start Node',
metadata: { nodeId: 'node-123', nodeData: {} as CommonNodeType },
data: { id: 'node-1' } as unknown as CommonNodeType,
})
})
expect(mockSelectWorkflowNode).toHaveBeenCalledWith('node-123', true)
})
it('should NOT select workflow node when metadata.nodeId is missing', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'node-1',
type: 'workflow-node' as const,
title: 'Start Node',
metadata: undefined,
data: { id: 'node-1' } as unknown as CommonNodeType,
})
})
expect(mockSelectWorkflowNode).not.toHaveBeenCalled()
})
it('should handle knowledge type (default case with path)', () => {
const options = createMockOptions()
const { result } = renderHook(() => useGotoAnythingNavigation(options))
act(() => {
result.current.handleNavigate({
id: 'kb-1',
type: 'knowledge' as const,
title: 'My Knowledge Base',
path: '/datasets/kb-1',
data: { id: 'kb-1', name: 'My Knowledge Base' } as unknown as DataSet,
})
})
expect(mockRouterPush).toHaveBeenCalledWith('/datasets/kb-1')
})
})
describe('setActivePlugin', () => {
it('should update activePlugin state', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
const plugin = { name: 'Test Plugin', latest_package_identifier: 'test-pkg' } as unknown as Plugin
act(() => {
result.current.setActivePlugin(plugin)
})
expect(result.current.activePlugin).toEqual(plugin)
})
it('should clear activePlugin when set to undefined', () => {
const { result } = renderHook(() => useGotoAnythingNavigation(createMockOptions()))
// First set a plugin
act(() => {
result.current.setActivePlugin({ name: 'Plugin', latest_package_identifier: 'pkg' } as unknown as Plugin)
})
expect(result.current.activePlugin).toBeDefined()
// Then clear it
act(() => {
result.current.setActivePlugin(undefined)
})
expect(result.current.activePlugin).toBeUndefined()
})
})
describe('edge cases', () => {
it('should handle undefined inputRef.current', () => {
const options = createMockOptions({
inputRef: { current: null },
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
// Should not throw
act(() => {
result.current.handleCommandSelect('@app')
})
act(() => {
vi.runAllTimers()
})
// No error should occur
})
it('should handle missing slash action', () => {
const options = createMockOptions({
Actions: {},
})
const { result } = renderHook(() => useGotoAnythingNavigation(options))
// Should not throw
act(() => {
result.current.handleNavigate({
id: 'cmd-1',
type: 'command' as const,
title: 'Command',
data: { command: 'test-command' },
})
})
// No error should occur
})
})
})

View File

@@ -0,0 +1,96 @@
'use client'
import type { RefObject } from 'react'
import type { Plugin } from '../../plugins/types'
import type { ActionItem, SearchResult } from '../actions/types'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { slashCommandRegistry } from '../actions/commands/registry'
export type UseGotoAnythingNavigationReturn = {
handleCommandSelect: (commandKey: string) => void
handleNavigate: (result: SearchResult) => void
activePlugin: Plugin | undefined
setActivePlugin: (plugin: Plugin | undefined) => void
}
export type UseGotoAnythingNavigationOptions = {
Actions: Record<string, ActionItem>
setSearchQuery: (query: string) => void
clearSelection: () => void
inputRef: RefObject<HTMLInputElement | null>
onClose: () => void
}
export const useGotoAnythingNavigation = (
options: UseGotoAnythingNavigationOptions,
): UseGotoAnythingNavigationReturn => {
const {
Actions,
setSearchQuery,
clearSelection,
inputRef,
onClose,
} = options
const router = useRouter()
const [activePlugin, setActivePlugin] = useState<Plugin>()
const handleCommandSelect = useCallback((commandKey: string) => {
// Check if it's a slash command
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
onClose()
setSearchQuery('')
return
}
}
// Otherwise, proceed with the normal flow (submenu mode)
setSearchQuery(`${commandKey} `)
clearSelection()
setTimeout(() => {
inputRef.current?.focus()
}, 0)
}, [onClose, setSearchQuery, clearSelection, inputRef])
// Handle navigation to selected result
const handleNavigate = useCallback((result: SearchResult) => {
onClose()
setSearchQuery('')
switch (result.type) {
case 'command': {
// Execute slash commands
const action = Actions.slash
action?.action?.(result)
break
}
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
// Handle workflow node selection and navigation
if (result.metadata?.nodeId)
selectWorkflowNode(result.metadata.nodeId, true)
break
default:
if (result.path)
router.push(result.path)
}
}, [router, Actions, onClose, setSearchQuery])
return {
handleCommandSelect,
handleNavigate,
activePlugin,
setActivePlugin,
}
}

View File

@@ -0,0 +1,354 @@
import type { SearchResult } from '../actions/types'
import { renderHook } from '@testing-library/react'
import { useGotoAnythingResults } from './use-goto-anything-results'
type MockQueryResult = {
data: Array<{ id: string, type: string, title: string }> | undefined
isLoading: boolean
isError: boolean
error: Error | null
}
type UseQueryOptions = {
queryFn: () => Promise<SearchResult[]>
}
let mockQueryResult: MockQueryResult = { data: [], isLoading: false, isError: false, error: null }
let capturedQueryFn: (() => Promise<SearchResult[]>) | null = null
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: UseQueryOptions) => {
capturedQueryFn = options.queryFn
return mockQueryResult
},
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
const mockMatchAction = vi.fn()
const mockSearchAnything = vi.fn()
vi.mock('../actions', () => ({
matchAction: (...args: unknown[]) => mockMatchAction(...args),
searchAnything: (...args: unknown[]) => mockSearchAnything(...args),
}))
const createMockActionItem = (key: '@app' | '@knowledge' | '@plugin' | '@node' | '/') => ({
key,
shortcut: key,
title: `${key} title`,
description: `${key} description`,
search: vi.fn().mockResolvedValue([]),
})
const createMockOptions = (overrides = {}) => ({
searchQueryDebouncedValue: '',
searchMode: 'general',
isCommandsMode: false,
Actions: { app: createMockActionItem('@app') },
isWorkflowPage: false,
isRagPipelinePage: false,
cmdVal: '_',
setCmdVal: vi.fn(),
...overrides,
})
describe('useGotoAnythingResults', () => {
beforeEach(() => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
capturedQueryFn = null
mockMatchAction.mockReset()
mockSearchAnything.mockReset()
})
describe('initialization', () => {
it('should return empty arrays when no results', () => {
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual([])
expect(result.current.dedupedResults).toEqual([])
expect(result.current.groupedResults).toEqual({})
})
it('should return loading state', () => {
mockQueryResult = { data: [], isLoading: true, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.isLoading).toBe(true)
})
it('should return error state', () => {
const error = new Error('Test error')
mockQueryResult = { data: [], isLoading: false, isError: true, error }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.isError).toBe(true)
expect(result.current.error).toBe(error)
})
})
describe('dedupedResults', () => {
it('should remove duplicate results', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '1', type: 'app', title: 'App 1 Duplicate' },
{ id: '2', type: 'app', title: 'App 2' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.dedupedResults).toHaveLength(2)
expect(result.current.dedupedResults[0].id).toBe('1')
expect(result.current.dedupedResults[1].id).toBe('2')
})
it('should keep first occurrence when duplicates exist', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'First' },
{ id: '1', type: 'app', title: 'Second' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.dedupedResults).toHaveLength(1)
expect(result.current.dedupedResults[0].title).toBe('First')
})
it('should handle different types with same id', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App' },
{ id: '1', type: 'plugin', title: 'Plugin' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
// Different types, same id = different keys, so both should remain
expect(result.current.dedupedResults).toHaveLength(2)
})
})
describe('groupedResults', () => {
it('should group results by type', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'app', title: 'App 2' },
{ id: '3', type: 'plugin', title: 'Plugin 1' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.groupedResults.app).toHaveLength(2)
expect(result.current.groupedResults.plugin).toHaveLength(1)
})
it('should handle single type', () => {
mockQueryResult = {
data: [
{ id: '1', type: 'knowledge', title: 'KB 1' },
{ id: '2', type: 'knowledge', title: 'KB 2' },
],
isLoading: false,
isError: false,
error: null,
}
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(Object.keys(result.current.groupedResults)).toEqual(['knowledge'])
expect(result.current.groupedResults.knowledge).toHaveLength(2)
})
it('should return empty object when no results', () => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.groupedResults).toEqual({})
})
})
describe('auto-select first result', () => {
it('should call setCmdVal when results change and current value does not exist', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [{ id: '1', type: 'app', title: 'App 1' }],
isLoading: false,
isError: false,
error: null,
}
renderHook(() => useGotoAnythingResults(createMockOptions({
cmdVal: 'non-existent',
setCmdVal,
})))
expect(setCmdVal).toHaveBeenCalledWith('app-1')
})
it('should NOT call setCmdVal when in commands mode', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [{ id: '1', type: 'app', title: 'App 1' }],
isLoading: false,
isError: false,
error: null,
}
renderHook(() => useGotoAnythingResults(createMockOptions({
isCommandsMode: true,
setCmdVal,
})))
expect(setCmdVal).not.toHaveBeenCalled()
})
it('should NOT call setCmdVal when results are empty', () => {
const setCmdVal = vi.fn()
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
renderHook(() => useGotoAnythingResults(createMockOptions({
setCmdVal,
})))
expect(setCmdVal).not.toHaveBeenCalled()
})
it('should NOT call setCmdVal when current value exists in results', () => {
const setCmdVal = vi.fn()
mockQueryResult = {
data: [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'app', title: 'App 2' },
],
isLoading: false,
isError: false,
error: null,
}
renderHook(() => useGotoAnythingResults(createMockOptions({
cmdVal: 'app-2',
setCmdVal,
})))
expect(setCmdVal).not.toHaveBeenCalled()
})
})
describe('error handling', () => {
it('should return error as Error | null', () => {
const error = new Error('Search failed')
mockQueryResult = { data: [], isLoading: false, isError: true, error }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.error).toBeInstanceOf(Error)
expect(result.current.error?.message).toBe('Search failed')
})
it('should return null error when no error', () => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.error).toBeNull()
})
})
describe('searchResults', () => {
it('should return raw search results', () => {
const mockData = [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'plugin', title: 'Plugin 1' },
]
mockQueryResult = { data: mockData, isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual(mockData)
})
it('should default to empty array when data is undefined', () => {
mockQueryResult = { data: undefined, isLoading: false, isError: false, error: null }
const { result } = renderHook(() => useGotoAnythingResults(createMockOptions()))
expect(result.current.searchResults).toEqual([])
})
})
describe('queryFn execution', () => {
it('should call matchAction with lowercased query', async () => {
const mockActions = { app: createMockActionItem('@app') }
mockMatchAction.mockReturnValue({ key: '@app' })
mockSearchAnything.mockResolvedValue([])
renderHook(() => useGotoAnythingResults(createMockOptions({
searchQueryDebouncedValue: 'TEST QUERY',
Actions: mockActions,
})))
expect(capturedQueryFn).toBeDefined()
await capturedQueryFn!()
expect(mockMatchAction).toHaveBeenCalledWith('test query', mockActions)
})
it('should call searchAnything with correct parameters', async () => {
const mockActions = { app: createMockActionItem('@app') }
const mockAction = { key: '@app' }
mockMatchAction.mockReturnValue(mockAction)
mockSearchAnything.mockResolvedValue([{ id: '1', type: 'app', title: 'Result' }])
renderHook(() => useGotoAnythingResults(createMockOptions({
searchQueryDebouncedValue: 'My Query',
Actions: mockActions,
})))
expect(capturedQueryFn).toBeDefined()
const result = await capturedQueryFn!()
expect(mockSearchAnything).toHaveBeenCalledWith('en_US', 'my query', mockAction, mockActions)
expect(result).toEqual([{ id: '1', type: 'app', title: 'Result' }])
})
it('should handle searchAnything returning results', async () => {
const expectedResults = [
{ id: '1', type: 'app', title: 'App 1' },
{ id: '2', type: 'plugin', title: 'Plugin 1' },
]
mockMatchAction.mockReturnValue(null)
mockSearchAnything.mockResolvedValue(expectedResults)
renderHook(() => useGotoAnythingResults(createMockOptions({
searchQueryDebouncedValue: 'search term',
})))
expect(capturedQueryFn).toBeDefined()
const result = await capturedQueryFn!()
expect(result).toEqual(expectedResults)
})
})
})

View File

@@ -0,0 +1,115 @@
'use client'
import type { ActionItem, SearchResult } from '../actions/types'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { matchAction, searchAnything } from '../actions'
export type UseGotoAnythingResultsReturn = {
searchResults: SearchResult[]
dedupedResults: SearchResult[]
groupedResults: Record<string, SearchResult[]>
isLoading: boolean
isError: boolean
error: Error | null
}
export type UseGotoAnythingResultsOptions = {
searchQueryDebouncedValue: string
searchMode: string
isCommandsMode: boolean
Actions: Record<string, ActionItem>
isWorkflowPage: boolean
isRagPipelinePage: boolean
cmdVal: string
setCmdVal: (val: string) => void
}
export const useGotoAnythingResults = (
options: UseGotoAnythingResultsOptions,
): UseGotoAnythingResultsReturn => {
const {
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
Actions,
isWorkflowPage,
isRagPipelinePage,
cmdVal,
setCmdVal,
} = options
const defaultLocale = useGetLanguage()
// Use action keys as stable cache key instead of the full Actions object
// (Actions contains functions which are not serializable)
const actionKeys = useMemo(() => Object.keys(Actions).sort(), [Actions])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Actions intentionally excluded: contains non-serializable functions; actionKeys provides stable representation
queryKey: [
'goto-anything',
'search-result',
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
actionKeys,
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action, Actions)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
gcTime: 300000,
},
)
const dedupedResults = useMemo(() => {
const seen = new Set<string>()
return searchResults.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key))
return false
seen.add(key)
return true
})
}, [searchResults])
// Group results by type
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
if (!acc[result.type])
acc[result.type] = []
acc[result.type].push(result)
return acc
}, {} as Record<string, SearchResult[]>), [dedupedResults])
// Auto-select first result when results change
useEffect(() => {
if (isCommandsMode)
return
if (!dedupedResults.length)
return
const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal)
if (!currentValueExists)
setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`)
}, [isCommandsMode, dedupedResults, cmdVal, setCmdVal])
return {
searchResults,
dedupedResults,
groupedResults,
isLoading,
isError,
error: error as Error | null,
}
}

View File

@@ -0,0 +1,301 @@
import type { ActionItem } from '../actions/types'
import { act, renderHook } from '@testing-library/react'
import { useGotoAnythingSearch } from './use-goto-anything-search'
let mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
let mockMatchActionResult: Partial<ActionItem> | undefined
vi.mock('ahooks', () => ({
useDebounce: <T>(value: T) => value,
}))
vi.mock('../context', () => ({
useGotoAnythingContext: () => mockContextValue,
}))
vi.mock('../actions', () => ({
createActions: (isWorkflowPage: boolean, isRagPipelinePage: boolean) => {
const base = {
slash: { key: '/', shortcut: '/' },
app: { key: '@app', shortcut: '@app' },
knowledge: { key: '@knowledge', shortcut: '@kb' },
}
if (isWorkflowPage) {
return { ...base, node: { key: '@node', shortcut: '@node' } }
}
if (isRagPipelinePage) {
return { ...base, ragNode: { key: '@node', shortcut: '@node' } }
}
return base
},
matchAction: () => mockMatchActionResult,
}))
describe('useGotoAnythingSearch', () => {
beforeEach(() => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
mockMatchActionResult = undefined
})
describe('initialization', () => {
it('should initialize with empty search query', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchQuery).toBe('')
})
it('should initialize cmdVal with "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.cmdVal).toBe('_')
})
it('should initialize searchMode as "general"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchMode).toBe('general')
})
it('should initialize isCommandsMode as false', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.isCommandsMode).toBe(false)
})
it('should provide setSearchQuery function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.setSearchQuery).toBe('function')
})
it('should provide setCmdVal function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.setCmdVal).toBe('function')
})
it('should provide clearSelection function', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(typeof result.current.clearSelection).toBe('function')
})
})
describe('Actions', () => {
it('should provide Actions based on context', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions).toBeDefined()
expect(typeof result.current.Actions).toBe('object')
})
it('should include node action when on workflow page', () => {
mockContextValue = { isWorkflowPage: true, isRagPipelinePage: false }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.node).toBeDefined()
})
it('should include ragNode action when on RAG pipeline page', () => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: true }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.ragNode).toBeDefined()
})
it('should not include node actions when on regular page', () => {
mockContextValue = { isWorkflowPage: false, isRagPipelinePage: false }
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.Actions.node).toBeUndefined()
expect(result.current.Actions.ragNode).toBeUndefined()
})
})
describe('isCommandsMode', () => {
it('should return true when query is exactly "@"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query is exactly "/"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query starts with "@" and no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@unknown')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return true when query starts with "/" and no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/unknown')
})
expect(result.current.isCommandsMode).toBe(true)
})
it('should return false when query starts with "@" and action matches', () => {
mockMatchActionResult = { key: '@app', shortcut: '@app' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@app test')
})
expect(result.current.isCommandsMode).toBe(false)
})
it('should return false for regular search query', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('hello world')
})
expect(result.current.isCommandsMode).toBe(false)
})
})
describe('searchMode', () => {
it('should return "general" when query is empty', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
expect(result.current.searchMode).toBe('general')
})
it('should return "scopes" when in commands mode and query starts with "@"', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@')
})
expect(result.current.searchMode).toBe('scopes')
})
it('should return "commands" when in commands mode and query starts with "/"', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/')
})
expect(result.current.searchMode).toBe('commands')
})
it('should return "general" when no action matches', () => {
mockMatchActionResult = undefined
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('hello')
})
expect(result.current.searchMode).toBe('general')
})
it('should return action key when action matches', () => {
mockMatchActionResult = { key: '@app', shortcut: '@app' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('@app test')
})
expect(result.current.searchMode).toBe('@app')
})
it('should return "@command" when action key is "/"', () => {
mockMatchActionResult = { key: '/', shortcut: '/' }
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('/theme dark')
})
expect(result.current.searchMode).toBe('@command')
})
})
describe('clearSelection', () => {
it('should reset cmdVal to "_"', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
// First change cmdVal
act(() => {
result.current.setCmdVal('app-1')
})
expect(result.current.cmdVal).toBe('app-1')
// Then clear
act(() => {
result.current.clearSelection()
})
expect(result.current.cmdVal).toBe('_')
})
})
describe('setSearchQuery', () => {
it('should update search query', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('test query')
})
expect(result.current.searchQuery).toBe('test query')
})
it('should handle empty string', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery('test')
})
expect(result.current.searchQuery).toBe('test')
act(() => {
result.current.setSearchQuery('')
})
expect(result.current.searchQuery).toBe('')
})
})
describe('setCmdVal', () => {
it('should update cmdVal', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setCmdVal('plugin-2')
})
expect(result.current.cmdVal).toBe('plugin-2')
})
})
describe('searchQueryDebouncedValue', () => {
it('should return trimmed debounced value', () => {
const { result } = renderHook(() => useGotoAnythingSearch())
act(() => {
result.current.setSearchQuery(' test ')
})
// Since we mock useDebounce to return value directly
expect(result.current.searchQueryDebouncedValue).toBe('test')
})
})
})

View File

@@ -0,0 +1,77 @@
'use client'
import type { ActionItem } from '../actions/types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { createActions, matchAction } from '../actions'
import { useGotoAnythingContext } from '../context'
export type UseGotoAnythingSearchReturn = {
searchQuery: string
setSearchQuery: (query: string) => void
searchQueryDebouncedValue: string
searchMode: string
isCommandsMode: boolean
cmdVal: string
setCmdVal: (val: string) => void
clearSelection: () => void
Actions: Record<string, ActionItem>
}
export const useGotoAnythingSearch = (): UseGotoAnythingSearchReturn => {
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const [searchQuery, setSearchQuery] = useState<string>('')
const [cmdVal, setCmdVal] = useState<string>('_')
// Filter actions based on context
const Actions = useMemo(() => {
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
wait: 300,
})
const isCommandsMode = useMemo(() => {
const trimmed = searchQuery.trim()
return trimmed === '@' || trimmed === '/'
|| (trimmed.startsWith('@') && !matchAction(trimmed, Actions))
|| (trimmed.startsWith('/') && !matchAction(trimmed, Actions))
}, [searchQuery, Actions])
const searchMode = useMemo(() => {
if (isCommandsMode) {
// Distinguish between @ (scopes) and / (commands) mode
if (searchQuery.trim().startsWith('@'))
return 'scopes'
else if (searchQuery.trim().startsWith('/'))
return 'commands'
return 'commands' // default fallback
}
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
if (!action)
return 'general'
return action.key === '/' ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
// Prevent automatic selection of the first option when cmdVal is not set
const clearSelection = useCallback(() => {
setCmdVal('_')
}, [])
return {
searchQuery,
setSearchQuery,
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
cmdVal,
setCmdVal,
clearSelection,
Actions,
}
}

View File

@@ -1,9 +1,27 @@
import type { ReactNode } from 'react'
import type { ActionItem, SearchResult } from './actions/types'
import { act, render, screen } from '@testing-library/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import GotoAnything from './index'
// Test helper type that matches SearchResult but allows ReactNode for icon and flexible data
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
icon?: ReactNode
data?: Record<string, unknown>
}
// Mock react-i18next to return namespace.key format
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { ns?: string }) => {
const ns = options?.ns || 'common'
return `${ns}.${key}`
},
i18n: { language: 'en' },
}),
}))
const routerPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
@@ -12,10 +30,15 @@ vi.mock('next/navigation', () => ({
usePathname: () => '/',
}))
const keyPressHandlers: Record<string, (event: any) => void> = {}
type KeyPressEvent = {
preventDefault: () => void
target?: EventTarget
}
const keyPressHandlers: Record<string, (event: KeyPressEvent) => void> = {}
vi.mock('ahooks', () => ({
useDebounce: (value: any) => value,
useKeyPress: (keys: string | string[], handler: (event: any) => void) => {
useDebounce: <T,>(value: T) => value,
useKeyPress: (keys: string | string[], handler: (event: KeyPressEvent) => void) => {
const keyList = Array.isArray(keys) ? keys : [keys]
keyList.forEach((key) => {
keyPressHandlers[key] = handler
@@ -32,7 +55,7 @@ const triggerKeyPress = (combo: string) => {
}
}
let mockQueryResult = { data: [] as SearchResult[], isLoading: false, isError: false, error: null as Error | null }
let mockQueryResult = { data: [] as TestSearchResult[], isLoading: false, isError: false, error: null as Error | null }
vi.mock('@tanstack/react-query', () => ({
useQuery: () => mockQueryResult,
}))
@@ -76,9 +99,16 @@ vi.mock('./actions/commands', () => ({
SlashCommandProvider: () => null,
}))
type MockSlashCommand = {
mode: string
execute?: () => void
isAvailable?: () => boolean
} | null
let mockFindCommand: MockSlashCommand = null
vi.mock('./actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => null,
findCommand: () => mockFindCommand,
getAvailableCommands: () => [],
getAllCommands: () => [],
},
@@ -86,6 +116,7 @@ vi.mock('./actions/commands/registry', () => ({
vi.mock('@/app/components/workflow/utils/common', () => ({
getKeyboardKeyCodeBySystem: () => 'ctrl',
getKeyboardKeyNameBySystem: (key: string) => key,
isEventTargetInputArea: () => false,
isMac: () => false,
}))
@@ -95,10 +126,11 @@ vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
}))
vi.mock('../plugins/install-plugin/install-from-marketplace', () => ({
default: (props: { manifest?: { name?: string }, onClose: () => void }) => (
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
<div data-testid="install-modal">
<span>{props.manifest?.name}</span>
<button onClick={props.onClose}>close</button>
<button onClick={props.onClose} data-testid="close-install">close</button>
<button onClick={props.onSuccess} data-testid="success-install">success</button>
</div>
),
}))
@@ -110,65 +142,504 @@ describe('GotoAnything', () => {
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
matchActionMock.mockReset()
searchAnythingMock.mockClear()
mockFindCommand = null
})
it('should open modal via shortcut and navigate to selected result', async () => {
mockQueryResult = {
data: [{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
} as any],
isLoading: false,
isError: false,
error: null,
}
describe('modal behavior', () => {
it('should open modal via Ctrl+K shortcut', async () => {
render(<GotoAnything />)
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
})
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
await userEvent.type(input, 'app')
it('should close modal via ESC key', async () => {
render(<GotoAnything />)
const result = await screen.findByText('Sample App')
await userEvent.click(result)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
expect(routerPush).toHaveBeenCalledWith('/apps/1')
triggerKeyPress('esc')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
it('should toggle modal when pressing Ctrl+K twice', async () => {
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
it('should call onHide when modal closes', async () => {
const onHide = vi.fn()
render(<GotoAnything onHide={onHide} />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
triggerKeyPress('esc')
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
it('should reset search query when modal opens', async () => {
const user = userEvent.setup()
render(<GotoAnything />)
// Open modal first time
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
// Type something
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test')
// Close modal
triggerKeyPress('esc')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
// Open modal again - should be empty
triggerKeyPress('ctrl.k')
await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
expect(newInput).toHaveValue('')
})
})
})
it('should open plugin installer when selecting plugin result', async () => {
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
} as any],
isLoading: false,
isError: false,
error: null,
}
describe('search functionality', () => {
it('should navigate to selected result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
triggerKeyPress('ctrl.k')
const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder')
await userEvent.type(input, 'plugin')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const pluginItem = await screen.findByText('Plugin Item')
await userEvent.click(pluginItem)
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'app')
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
const result = await screen.findByText('Sample App')
await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/apps/1')
})
it('should clear selection when typing without prefix', async () => {
const user = userEvent.setup()
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test query')
// Should not throw and input should have value
expect(input).toHaveValue('test query')
})
})
describe('empty states', () => {
it('should show loading state', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: true,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
// Loading state shows in both EmptyState (spinner) and Footer
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
})
it('should show error state', async () => {
const user = userEvent.setup()
const testError = new Error('Search failed')
mockQueryResult = {
data: [],
isLoading: false,
isError: true,
error: testError,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
})
it('should show default state when no query', async () => {
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
})
it('should show no results state when search returns empty', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'nonexistent')
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
})
describe('plugin installation', () => {
it('should open plugin installer when selecting plugin result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
})
it('should close plugin installer via close button', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
const closeBtn = await screen.findByTestId('close-install')
await user.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})
it('should close plugin installer on success', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
const successBtn = await screen.findByTestId('success-install')
await user.click(successBtn)
await waitFor(() => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})
})
describe('slash command handling', () => {
it('should execute direct slash command on Enter', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'direct',
execute: executeMock,
isAvailable: () => true,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
expect(executeMock).toHaveBeenCalled()
})
it('should NOT execute unavailable slash command', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'direct',
execute: executeMock,
isAvailable: () => false,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
expect(executeMock).not.toHaveBeenCalled()
})
it('should NOT execute non-direct mode slash command on Enter', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'submenu',
execute: executeMock,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/language')
await user.keyboard('{Enter}')
expect(executeMock).not.toHaveBeenCalled()
})
it('should close modal after executing direct slash command', async () => {
const user = userEvent.setup()
mockFindCommand = {
mode: 'direct',
execute: vi.fn(),
isAvailable: () => true,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
})
describe('result navigation', () => {
it('should handle knowledge result navigation', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'kb-1',
type: 'knowledge',
title: 'Knowledge Base',
description: 'desc',
path: '/datasets/kb-1',
icon: <div />,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'knowledge')
const result = await screen.findByText('Knowledge Base')
await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/datasets/kb-1')
})
it('should NOT navigate when result has no path', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'item-1',
type: 'app',
title: 'No Path Item',
description: 'desc',
path: '',
icon: <div />,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
render(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'no path')
const result = await screen.findByText('No Path Item')
await user.click(result)
expect(routerPush).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,300 +1,149 @@
'use client'
import type { FC } from 'react'
import type { Plugin } from '../plugins/types'
import type { SearchResult } from './actions'
import { RiSearchLine } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { useDebounce, useKeyPress } from 'ahooks'
import type { FC, KeyboardEvent } from 'react'
import { Command } from 'cmdk'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '@/app/components/workflow/utils/common'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
import { useGetLanguage } from '@/context/i18n'
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
import { createActions, matchAction, searchAnything } from './actions'
import { SlashCommandProvider } from './actions/commands'
import { slashCommandRegistry } from './actions/commands/registry'
import CommandSelector from './command-selector'
import { EmptyState, Footer, ResultList, SearchInput } from './components'
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
import {
useGotoAnythingModal,
useGotoAnythingNavigation,
useGotoAnythingResults,
useGotoAnythingSearch,
} from './hooks'
type Props = {
onHide?: () => void
}
const GotoAnything: FC<Props> = ({
onHide,
}) => {
const router = useRouter()
const defaultLocale = useGetLanguage()
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const { t } = useTranslation()
const [show, setShow] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [cmdVal, setCmdVal] = useState<string>('_')
const inputRef = useRef<HTMLInputElement>(null)
const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext()
const prevShowRef = useRef(false)
// Filter actions based on context
const Actions = useMemo(() => {
// Create actions based on current page context
return createActions(isWorkflowPage, isRagPipelinePage)
}, [isWorkflowPage, isRagPipelinePage])
// Search state management (called first so setSearchQuery is available)
const {
searchQuery,
setSearchQuery,
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
cmdVal,
setCmdVal,
clearSelection,
Actions,
} = useGotoAnythingSearch()
const [activePlugin, setActivePlugin] = useState<Plugin>()
// Modal state management
const {
show,
setShow,
inputRef,
handleClose: modalClose,
} = useGotoAnythingModal()
// Handle keyboard shortcuts
const handleToggleModal = useCallback((e: KeyboardEvent) => {
// Allow closing when modal is open, even if focus is in the search input
if (!show && isEventTargetInputArea(e.target as HTMLElement))
return
e.preventDefault()
setShow((prev) => {
if (!prev) {
// Opening modal - reset search state
setSearchQuery('')
}
return !prev
})
}, [show])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
exactMatch: true,
useCapture: true,
})
useKeyPress(['esc'], (e) => {
if (show) {
e.preventDefault()
setShow(false)
// Reset state when modal opens/closes
useEffect(() => {
if (show && !prevShowRef.current) {
// Modal just opened - reset search
setSearchQuery('')
}
else if (!show && prevShowRef.current) {
// Modal just closed
setSearchQuery('')
clearSelection()
onHide?.()
}
prevShowRef.current = show
}, [show, setSearchQuery, clearSelection, onHide])
// Results fetching and processing
const {
dedupedResults,
groupedResults,
isLoading,
isError,
error,
} = useGotoAnythingResults({
searchQueryDebouncedValue,
searchMode,
isCommandsMode,
Actions,
isWorkflowPage,
isRagPipelinePage,
cmdVal,
setCmdVal,
})
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
wait: 300,
// Navigation handlers
const {
handleCommandSelect,
handleNavigate,
activePlugin,
setActivePlugin,
} = useGotoAnythingNavigation({
Actions,
setSearchQuery,
clearSelection,
inputRef,
onClose: () => setShow(false),
})
const isCommandsMode = searchQuery.trim() === '@' || searchQuery.trim() === '/'
|| (searchQuery.trim().startsWith('@') && !matchAction(searchQuery.trim(), Actions))
|| (searchQuery.trim().startsWith('/') && !matchAction(searchQuery.trim(), Actions))
// Handle search input change
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value)
if (!value.startsWith('@') && !value.startsWith('/'))
clearSelection()
}, [setSearchQuery, clearSelection])
const searchMode = useMemo(() => {
if (isCommandsMode) {
// Distinguish between @ (scopes) and / (commands) mode
if (searchQuery.trim().startsWith('@'))
return 'scopes'
else if (searchQuery.trim().startsWith('/'))
return 'commands'
return 'commands' // default fallback
}
// Handle search input keydown for slash commands
const handleSearchKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const query = searchQuery.trim()
// Check if it's a complete slash command
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
if (!action)
return 'general'
return action.key === '/' ? '@command' : action.key
}, [searchQueryDebouncedValue, Actions, isCommandsMode, searchQuery])
const { data: searchResults = [], isLoading, isError, error } = useQuery(
{
queryKey: [
'goto-anything',
'search-result',
searchQueryDebouncedValue,
searchMode,
isWorkflowPage,
isRagPipelinePage,
defaultLocale,
Actions,
],
queryFn: async () => {
const query = searchQueryDebouncedValue.toLowerCase()
const action = matchAction(query, Actions)
return await searchAnything(defaultLocale, query, action, Actions)
},
enabled: !!searchQueryDebouncedValue && !isCommandsMode,
staleTime: 30000,
gcTime: 300000,
},
)
// Prevent automatic selection of the first option when cmdVal is not set
const clearSelection = () => {
setCmdVal('_')
}
const handleCommandSelect = useCallback((commandKey: string) => {
// Check if it's a slash command
if (commandKey.startsWith('/')) {
const commandName = commandKey.substring(1)
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
if (handler?.mode === 'direct' && handler.execute) {
handler.execute()
setShow(false)
setSearchQuery('')
return
// If it's a direct mode command, execute immediately
const isAvailable = handler?.isAvailable?.() ?? true
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
}
}
}
}, [searchQuery, setShow, setSearchQuery])
// Otherwise, proceed with the normal flow (submenu mode)
setSearchQuery(`${commandKey} `)
clearSelection()
setTimeout(() => {
inputRef.current?.focus()
}, 0)
}, [])
// Handle navigation to selected result
const handleNavigate = useCallback((result: SearchResult) => {
setShow(false)
setSearchQuery('')
switch (result.type) {
case 'command': {
// Execute slash commands
const action = Actions.slash
action?.action?.(result)
break
}
case 'plugin':
setActivePlugin(result.data)
break
case 'workflow-node':
// Handle workflow node selection and navigation
if (result.metadata?.nodeId)
selectWorkflowNode(result.metadata.nodeId, true)
break
default:
if (result.path)
router.push(result.path)
}
}, [router])
const dedupedResults = useMemo(() => {
const seen = new Set<string>()
return searchResults.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key))
return false
seen.add(key)
return true
})
}, [searchResults])
// Group results by type
const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
if (!acc[result.type])
acc[result.type] = []
acc[result.type].push(result)
return acc
}, {} as { [key: string]: SearchResult[] }), [dedupedResults])
useEffect(() => {
if (isCommandsMode)
return
if (!dedupedResults.length)
return
const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal)
if (!currentValueExists)
setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`)
}, [isCommandsMode, dedupedResults, cmdVal])
const emptyResult = useMemo(() => {
if (dedupedResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
return null
const isCommandSearch = searchMode !== 'general'
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
if (isError) {
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-red-500">{t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })}</div>
<div className="mt-1 text-xs text-text-quaternary">
{t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })}
</div>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium">
{isCommandSearch
? (() => {
const keyMap = {
app: 'gotoAnything.emptyState.noAppsFound',
plugin: 'gotoAnything.emptyState.noPluginsFound',
knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound',
node: 'gotoAnything.emptyState.noWorkflowNodesFound',
} as const
return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' })
})()
: t('gotoAnything.noResults', { ns: 'app' })}
</div>
<div className="mt-1 text-xs text-text-quaternary">
{isCommandSearch
? t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' })
: t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })}
</div>
</div>
</div>
)
}, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
const defaultUI = useMemo(() => {
if (searchQuery.trim())
return null
return (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium">{t('gotoAnything.searchTitle', { ns: 'app' })}</div>
<div className="mt-3 space-y-1 text-xs text-text-quaternary">
<div>{t('gotoAnything.searchHint', { ns: 'app' })}</div>
<div>{t('gotoAnything.commandHint', { ns: 'app' })}</div>
<div>{t('gotoAnything.slashHint', { ns: 'app' })}</div>
</div>
</div>
</div>
)
}, [searchQuery, Actions])
useEffect(() => {
if (show) {
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [show])
// Determine which empty state to show
const emptyStateVariant = useMemo(() => {
if (isLoading)
return 'loading'
if (isError)
return 'error'
if (!searchQuery.trim())
return 'default'
if (dedupedResults.length === 0 && !isCommandsMode)
return 'no-results'
return null
}, [isLoading, isError, searchQuery, dedupedResults.length, isCommandsMode])
return (
<>
<SlashCommandProvider />
<Modal
isShow={show}
onClose={() => {
setShow(false)
setSearchQuery('')
clearSelection()
onHide?.()
}}
onClose={modalClose}
closable={false}
className="!w-[480px] !p-0"
highPriority={true}
@@ -307,78 +156,24 @@ const GotoAnything: FC<Props> = ({
disablePointerSelection
loop
>
<div className="flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3">
<RiSearchLine className="h-4 w-4 text-text-quaternary" />
<div className="flex flex-1 items-center gap-2">
<Input
ref={inputRef}
value={searchQuery}
placeholder={t('gotoAnything.searchPlaceholder', { ns: 'app' })}
onChange={(e) => {
setSearchQuery(e.target.value)
if (!e.target.value.startsWith('@') && !e.target.value.startsWith('/'))
clearSelection()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const query = searchQuery.trim()
// Check if it's a complete slash command
if (query.startsWith('/')) {
const commandName = query.substring(1).split(' ')[0]
const handler = slashCommandRegistry.findCommand(commandName)
// If it's a direct mode command, execute immediately
const isAvailable = handler?.isAvailable?.() ?? true
if (handler?.mode === 'direct' && handler.execute && isAvailable) {
e.preventDefault()
handler.execute()
setShow(false)
setSearchQuery('')
}
}
}
}}
className="flex-1 !border-0 !bg-transparent !shadow-none"
wrapperClassName="flex-1 !border-0 !bg-transparent"
autoFocus
/>
{searchMode !== 'general' && (
<div className="flex items-center gap-1 rounded bg-gray-100 px-2 py-[2px] text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<span>
{(() => {
if (searchMode === 'scopes')
return 'SCOPES'
else if (searchMode === 'commands')
return 'COMMANDS'
else
return searchMode.replace('@', '').toUpperCase()
})()}
</span>
</div>
)}
</div>
<ShortcutsName keys={['ctrl', 'K']} textColor="secondary" />
</div>
<SearchInput
inputRef={inputRef}
value={searchQuery}
onChange={handleSearchChange}
onKeyDown={handleSearchKeyDown}
searchMode={searchMode}
placeholder={t('gotoAnything.searchPlaceholder', { ns: 'app' })}
/>
<Command.List className="h-[240px] overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
<span className="text-sm">{t('gotoAnything.searching', { ns: 'app' })}</span>
</div>
</div>
{emptyStateVariant === 'loading' && (
<EmptyState variant="loading" />
)}
{isError && (
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
<div>
<div className="text-sm font-medium text-red-500">{t('gotoAnything.searchFailed', { ns: 'app' })}</div>
<div className="mt-1 text-xs text-text-quaternary">
{error.message}
</div>
</div>
</div>
{emptyStateVariant === 'error' && (
<EmptyState variant="error" error={error} />
)}
{!isLoading && !isError && (
<>
{isCommandsMode
@@ -393,118 +188,46 @@ const GotoAnything: FC<Props> = ({
/>
)
: (
Object.entries(groupedResults).map(([type, results], groupIndex) => (
<Command.Group
key={groupIndex}
heading={(() => {
const typeMap = {
'app': 'gotoAnything.groups.apps',
'plugin': 'gotoAnything.groups.plugins',
'knowledge': 'gotoAnything.groups.knowledgeBases',
'workflow-node': 'gotoAnything.groups.workflowNodes',
'command': 'gotoAnything.groups.commands',
} as const
return t(typeMap[type as keyof typeof typeMap] || `${type}s`, { ns: 'app' })
})()}
className="p-2 capitalize text-text-secondary"
>
{results.map(result => (
<Command.Item
key={`${result.type}-${result.id}`}
value={`${result.type}-${result.id}`}
className="flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] hover:bg-state-base-hover aria-[selected=true]:bg-state-base-hover-alt data-[selected=true]:bg-state-base-hover-alt"
onSelect={() => handleNavigate(result)}
>
{result.icon}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-text-secondary">
{result.title}
</div>
{result.description && (
<div className="mt-0.5 truncate text-xs text-text-quaternary">
{result.description}
</div>
)}
</div>
<div className="text-xs capitalize text-text-quaternary">
{result.type}
</div>
</Command.Item>
))}
</Command.Group>
))
<ResultList
groupedResults={groupedResults}
onSelect={handleNavigate}
/>
)}
{!isCommandsMode && emptyResult}
{!isCommandsMode && defaultUI}
{!isCommandsMode && emptyStateVariant === 'no-results' && (
<EmptyState
variant="no-results"
searchMode={searchMode}
Actions={Actions}
/>
)}
{!isCommandsMode && emptyStateVariant === 'default' && (
<EmptyState variant="default" />
)}
</>
)}
</Command.List>
{/* Always show footer to prevent height jumping */}
<div className="border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary">
<div className="flex min-h-[16px] items-center justify-between">
{(!!dedupedResults.length || isError)
? (
<>
<span>
{isError
? (
<span className="text-red-500">{t('gotoAnything.someServicesUnavailable', { ns: 'app' })}</span>
)
: (
<>
{t('gotoAnything.resultCount', { ns: 'app', count: dedupedResults.length })}
{searchMode !== 'general' && (
<span className="ml-2 opacity-60">
{t('gotoAnything.inScope', { ns: 'app', scope: searchMode.replace('@', '') })}
</span>
)}
</>
)}
</span>
<span className="opacity-60">
{searchMode !== 'general'
? t('gotoAnything.clearToSearchAll', { ns: 'app' })
: t('gotoAnything.useAtForSpecific', { ns: 'app' })}
</span>
</>
)
: (
<>
<span className="opacity-60">
{(() => {
if (isCommandsMode)
return t('gotoAnything.selectToNavigate', { ns: 'app' })
if (searchQuery.trim())
return t('gotoAnything.searching', { ns: 'app' })
return t('gotoAnything.startTyping', { ns: 'app' })
})()}
</span>
<span className="opacity-60">
{searchQuery.trim() || isCommandsMode
? t('gotoAnything.tips', { ns: 'app' })
: t('gotoAnything.pressEscToClose', { ns: 'app' })}
</span>
</>
)}
</div>
</div>
<Footer
resultCount={dedupedResults.length}
searchMode={searchMode}
isError={isError}
isCommandsMode={isCommandsMode}
hasQuery={!!searchQuery.trim()}
/>
</Command>
</div>
</Modal>
{
activePlugin && (
<InstallFromMarketplace
manifest={activePlugin}
uniqueIdentifier={activePlugin.latest_package_identifier}
onClose={() => setActivePlugin(undefined)}
onSuccess={() => setActivePlugin(undefined)}
/>
)
}
{activePlugin && (
<InstallFromMarketplace
manifest={activePlugin}
uniqueIdentifier={activePlugin.latest_package_identifier}
onClose={() => setActivePlugin(undefined)}
onSuccess={() => setActivePlugin(undefined)}
/>
)}
</>
)
}

View File

@@ -551,8 +551,8 @@ describe('WorkflowOnboardingModal', () => {
// Assert
const escKey = screen.getByText('workflow.onboarding.escTip.key')
expect(escKey.closest('kbd')).toBeInTheDocument()
expect(escKey.closest('kbd')).toHaveClass('system-kbd')
// ShortcutsName renders a <div> with class system-kbd, not a <kbd> element
expect(escKey.closest('.system-kbd')).toBeInTheDocument()
})
it('should have descriptive text for ESC functionality', () => {

View File

@@ -2000,16 +2000,6 @@
"count": 4
}
},
"app/components/goto-anything/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
}
},
"app/components/goto-anything/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/header/account-setting/data-source-page-new/card.tsx": {
"ts/no-explicit-any": {
"count": 2