mirror of
https://github.com/langgenius/dify.git
synced 2026-01-31 20:54:33 +00:00
Compare commits
3 Commits
refactor/d
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b03b610ff | ||
|
|
189b72962c | ||
|
|
70f2c0ce77 |
@@ -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' })
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
157
web/app/components/goto-anything/components/empty-state.spec.tsx
Normal file
157
web/app/components/goto-anything/components/empty-state.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
105
web/app/components/goto-anything/components/empty-state.tsx
Normal file
105
web/app/components/goto-anything/components/empty-state.tsx
Normal 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
|
||||
273
web/app/components/goto-anything/components/footer.spec.tsx
Normal file
273
web/app/components/goto-anything/components/footer.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
90
web/app/components/goto-anything/components/footer.tsx
Normal file
90
web/app/components/goto-anything/components/footer.tsx
Normal 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
|
||||
14
web/app/components/goto-anything/components/index.ts
Normal file
14
web/app/components/goto-anything/components/index.ts
Normal 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'
|
||||
38
web/app/components/goto-anything/components/result-item.tsx
Normal file
38
web/app/components/goto-anything/components/result-item.tsx
Normal 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
|
||||
49
web/app/components/goto-anything/components/result-list.tsx
Normal file
49
web/app/components/goto-anything/components/result-list.tsx
Normal 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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
62
web/app/components/goto-anything/components/search-input.tsx
Normal file
62
web/app/components/goto-anything/components/search-input.tsx
Normal 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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
11
web/app/components/goto-anything/hooks/index.ts
Normal file
11
web/app/components/goto-anything/hooks/index.ts
Normal 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'
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user