diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx new file mode 100644 index 0000000000000..be18ccdf36deb --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.spec.tsx @@ -0,0 +1,44 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import NavBarAISearch from './NavBarAISearch'; + +import '@testing-library/jest-dom'; + +jest.mock('./NavBarAISearchListbox', () => () => null); +jest.mock('./NavBarSearchListbox', () => () => null); +jest.mock('tinykeys', () => ({ __esModule: true, default: () => jest.fn() })); +jest.mock('./hooks/useNavBarAISearch', () => ({ + useNavBarAISearch: () => ({ + aiSearchActive: false, + canSearchWithAIFromTopBar: true, + appliedFilterChips: [], + aiSearchButtonTooltip: 'Search with AI', + handleRemoveFilter: jest.fn(), + handleToggleAISearch: jest.fn(), + }), +})); + +describe('NavBarAISearch', () => { + it('clears the search text', async () => { + const user = userEvent.setup(); + const wrapper = mockAppRoot() + .withTranslations('en', 'core', { + Clear: 'Clear', + Search_rooms: 'Search rooms', + }) + .build(); + + render(, { wrapper }); + + const searchInput = screen.getByRole('combobox', { name: 'Search rooms' }); + await user.type(searchInput, 'deployment errors'); + expect(searchInput).toHaveValue('deployment errors'); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(searchInput).toHaveValue(''); + expect(searchInput).toHaveFocus(); + }); +}); diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx index da245b91129b5..d388f9865b738 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearch.tsx @@ -24,15 +24,8 @@ const NavBarAISearch = () => { const shortcut = getShortcutLabel(); const methods = useForm({ defaultValues: { filterText: '', appliedFilters: emptySearchFilters() } }); - const { - formState: { isDirty }, - register, - reset, - setFocus, - setValue, - watch, - } = methods; - const { filterText, appliedFilters } = watch(); + const { register, resetField, setFocus, setValue, watch } = methods; + const { filterText = '', appliedFilters = emptySearchFilters() } = watch(); const { ref: filterRef, ...rest } = register('filterText'); @@ -54,12 +47,13 @@ const NavBarAISearch = () => { const placeholder = [searchLabel, shortcut].filter(Boolean).join(' '); const handleEscSearch = useCallback(() => { - reset(); + resetField('filterText'); + setValue('appliedFilters', emptySearchFilters(), { shouldDirty: true }); state.close(); - }, [reset, state]); + }, [resetField, setValue, state]); const handleClearText = useStableCallback(() => { - reset(); + resetField('filterText'); setFocus('filterText'); }); @@ -106,7 +100,7 @@ const NavBarAISearch = () => { appliedFilterChips={appliedFilterChips} aiSearchActive={aiSearchActive} aiSearchButtonTooltip={aiSearchButtonTooltip} - isDirty={isDirty} + hasSearchText={Boolean(filterText)} onClearText={handleClearText} onRemoveFilter={handleRemoveFilter} onToggleAISearch={handleToggleAISearch} diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx index 3480632681e2a..9f913950bd97c 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarAISearchListbox.tsx @@ -1,6 +1,6 @@ import type { OverlayTriggerAria } from '@react-aria/overlays'; import type { OverlayTriggerState } from '@react-stately/overlays'; -import type { NavBarSearchFormValues } from '@rocket.chat/ai-search'; +import { emptySearchFilters, type NavBarSearchFormValues } from '@rocket.chat/ai-search'; import { Tile } from '@rocket.chat/fuselage'; import { useOutsideClick, useStableCallback } from '@rocket.chat/fuselage-hooks'; import { CustomScrollbars } from '@rocket.chat/ui-client'; @@ -30,12 +30,13 @@ const NavBarAISearchListBox = ({ state, overlayProps, aiSearchActive, aiSearchAv const handleKeyDown = useListboxNavigation(state); useOutsideClick([containerRef], state.close); - const { reset, watch } = useFormContext(); - const { filterText, appliedFilters } = watch(); + const { resetField, setValue, watch } = useFormContext(); + const { filterText = '', appliedFilters = emptySearchFilters() } = watch(); const handleSelect = useStableCallback(() => { state.close(); - reset(); + resetField('filterText'); + setValue('appliedFilters', emptySearchFilters(), { shouldDirty: true }); }); const { data: aiItems, isFetching } = useAISearchItems(filterText, appliedFilters, aiSearchActive); diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx index 702803fe27e0a..3da76cd69647b 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchFilterSuggestions.tsx @@ -1,4 +1,5 @@ import { + emptySearchFilters, mergeSearchFilters, parseSearchFilterText, type NavBarSearchFormValues, @@ -46,7 +47,8 @@ const NavBarSearchFilterSuggestions = ({ suggestions }: NavBarSearchFilterSugges event.preventDefault(); event.stopPropagation(); const { searchText, filters } = parseSearchFilterText(value); - setValue('appliedFilters', mergeSearchFilters(getValues('appliedFilters'), filters), { shouldDirty: true }); + const appliedFilters = getValues('appliedFilters') ?? emptySearchFilters(); + setValue('appliedFilters', mergeSearchFilters(appliedFilters, filters), { shouldDirty: true }); setValue('filterText', searchText, { shouldDirty: true }); setFocus('filterText'); }, diff --git a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx index d0b7f599df571..cc8edcd21bdd6 100644 --- a/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx +++ b/apps/meteor/client/navbar/NavBarSearch/NavBarSearchInputAddon.tsx @@ -7,7 +7,7 @@ export type NavBarSearchInputAddonProps = { appliedFilterChips: SearchFilterChip[]; aiSearchActive: boolean; aiSearchButtonTooltip: string; - isDirty: boolean; + hasSearchText: boolean; onClearText: () => void; onRemoveFilter: (filterKey: string) => void; onToggleAISearch: () => void; @@ -18,7 +18,7 @@ const NavBarSearchInputAddon = ({ appliedFilterChips, aiSearchActive, aiSearchButtonTooltip, - isDirty, + hasSearchText, onClearText, onRemoveFilter, onToggleAISearch, @@ -45,7 +45,7 @@ const NavBarSearchInputAddon = ({ ))} )} - {isDirty ? : } + {hasSearchText ? : } (); - const { filterText, appliedFilters } = watch(); + const { filterText = '', appliedFilters = emptySearchFilters() } = watch(); if (!items.length) { return null; diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts new file mode 100644 index 0000000000000..e6f8461699ce9 --- /dev/null +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts @@ -0,0 +1,332 @@ +import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, AI_SEARCH_PAGE_SIZE, emptySearchFilters } from '@rocket.chat/ai-search'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { AISearchResult } from '@rocket.chat/rest-typings'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useAISearchItems } from './useAISearchItems'; +import { createFakeSubscription } from '../../../../tests/mocks/data'; + +let mockDebounce: (value: unknown) => unknown = (value) => value; + +jest.mock('@rocket.chat/fuselage-hooks', () => ({ + ...jest.requireActual('@rocket.chat/fuselage-hooks'), + useDebouncedValue: (value: unknown) => mockDebounce(value), +})); + +type AutocompleteUser = { + _id: string; + name: string; + username: string; + nickname: string; + status: UserStatus; + avatarETag: string; +}; + +const setup = ({ + subscriptions = [], + intelligent = [], + users = [], +}: { + subscriptions?: ReturnType[]; + intelligent?: AISearchResult[]; + users?: AutocompleteUser[]; +} = {}) => { + const aiSearchHandler = jest.fn(() => ({ + intelligent, + meta: { intelligentSearchEnabled: true, intelligentSearchConfigured: true, answerGenerationConfigured: true }, + })); + const usersAutocompleteHandler = jest.fn(() => ({ items: users })); + + const wrapper = mockAppRoot() + .withTranslations('en', 'core', { + Search_in_this_room: 'Search in this room', + Search_messages_from_this_username: 'Search messages from this username', + Search_messages_from_this_user: 'Search messages from this user', + Today: 'Today', + Yesterday: 'Yesterday', + Last_7_days: 'Last 7 days', + }) + .withSubscriptions(subscriptions) + .withEndpoint('GET', '/v1/ai.search', aiSearchHandler) + .withEndpoint('GET', '/v1/users.autocomplete', usersAutocompleteHandler) + .build(); + + return { wrapper, aiSearchHandler, usersAutocompleteHandler }; +}; + +const formatDate = (date: Date): string => + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + +beforeEach(() => { + mockDebounce = (value) => value; +}); + +describe('useAISearchItems', () => { + describe('when AI search is inactive', () => { + it('passes the filter text through untouched and yields no items', async () => { + const { wrapper, aiSearchHandler, usersAutocompleteHandler } = setup(); + const { result } = renderHook( + () => useAISearchItems('in:general deploy', { ...emptySearchFilters(), roomNames: ['support'] }, false), + { wrapper }, + ); + + await act(async () => undefined); + + expect(result.current.data.searchText).toBe('in:general deploy'); + expect(result.current.data.intelligent).toEqual([]); + expect(result.current.data.filterSuggestions).toEqual([]); + expect(result.current.isFetching).toBe(false); + expect(aiSearchHandler).not.toHaveBeenCalled(); + expect(usersAutocompleteHandler).not.toHaveBeenCalled(); + }); + + it('defaults to inactive when only the filter text is provided', async () => { + const { wrapper, aiSearchHandler } = setup(); + const { result } = renderHook(() => useAISearchItems('deploy'), { wrapper }); + + await act(async () => undefined); + + expect(result.current.data.searchText).toBe('deploy'); + expect(aiSearchHandler).not.toHaveBeenCalled(); + }); + }); + + describe('intelligent search', () => { + it('strips inline filter tokens and queries ai.search with the parsed filters', async () => { + const intelligent = [{ _id: 'msg-1', msgId: 'msg-1', text: 'Deployment failed' }]; + const { wrapper, aiSearchHandler } = setup({ intelligent }); + const { result } = renderHook( + () => useAISearchItems('in:general from:john after:2026-01-01 before:2026-02-01 deploy failed', emptySearchFilters(), true), + { wrapper }, + ); + + expect(result.current.data.searchText).toBe('deploy failed'); + expect(result.current.isFetching).toBe(true); + + await waitFor(() => expect(result.current.data.intelligent).toEqual(intelligent)); + expect(result.current.isFetching).toBe(false); + expect(aiSearchHandler).toHaveBeenCalledTimes(1); + expect(aiSearchHandler).toHaveBeenCalledWith({ + query: 'deploy failed', + intelligentCount: AI_SEARCH_PAGE_SIZE, + roomNames: 'general', + fromUsernames: 'john', + startDate: '2026-01-01', + endDate: '2026-02-01', + }); + }); + + it('merges applied filters with inline filter tokens in the request', async () => { + const { wrapper, aiSearchHandler } = setup(); + const { result } = renderHook( + () => + useAISearchItems( + 'in:general hello', + { ...emptySearchFilters(), roomNames: ['support'], fromUsernames: ['jane'], startDate: '2026-05-01' }, + true, + ), + { wrapper }, + ); + + await waitFor(() => expect(aiSearchHandler).toHaveBeenCalled()); + expect(aiSearchHandler).toHaveBeenCalledWith({ + query: 'hello', + intelligentCount: AI_SEARCH_PAGE_SIZE, + roomNames: 'support,general', + fromUsernames: 'jane', + startDate: '2026-05-01', + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + }); + + it('does not hit ai.search when only filter tokens are present', async () => { + const { wrapper, aiSearchHandler } = setup(); + const { result } = renderHook(() => useAISearchItems('in:general ', emptySearchFilters(), true), { wrapper }); + + await act(async () => undefined); + + expect(result.current.data.searchText).toBe(''); + expect(result.current.data.intelligent).toEqual([]); + expect(aiSearchHandler).not.toHaveBeenCalled(); + }); + + it('withholds intelligent results while the debounced search text lags behind', async () => { + mockDebounce = () => 'stale'; + const intelligent = [{ _id: 'msg-1', text: 'Old result' }]; + const { wrapper, aiSearchHandler } = setup({ intelligent }); + const { result } = renderHook(() => useAISearchItems('deploy', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => expect(aiSearchHandler).toHaveBeenCalledWith(expect.objectContaining({ query: 'stale' }))); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data.intelligent).toEqual([]); + }); + }); + + describe('room filter suggestions', () => { + it('suggests rooms while an in: token is being typed', async () => { + const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', fname: 'General', t: 'c' })]; + const { wrapper, aiSearchHandler } = setup({ subscriptions }); + const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper }); + + await act(async () => undefined); + + expect(result.current.data.searchText).toBe(''); + expect(aiSearchHandler).not.toHaveBeenCalled(); + expect(result.current.data.filterSuggestions).toEqual([ + { + key: 'in-rid-general', + group: 'rooms', + title: '#General', + description: 'Search in this room', + value: 'in:general ', + icon: 'hash', + }, + ]); + }); + + it('caps room suggestions at the configured limit', async () => { + const subscriptions = Array.from({ length: AI_SEARCH_FILTER_SUGGESTION_LIMIT + 2 }, (_, index) => + createFakeSubscription({ rid: `rid-${index}`, name: `general-${index}`, t: 'c' }), + ); + const { wrapper } = setup({ subscriptions }); + const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper }); + + await act(async () => undefined); + + expect(result.current.data.filterSuggestions).toHaveLength(AI_SEARCH_FILTER_SUGGESTION_LIMIT); + }); + + it('does not suggest rooms without an active in: token', async () => { + const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', t: 'c' })]; + const { wrapper } = setup({ subscriptions }); + const { result } = renderHook(() => useAISearchItems('general stuff', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data.filterSuggestions).toEqual([]); + }); + }); + + describe('user filter suggestions', () => { + const john: AutocompleteUser = { + _id: 'user-1', + name: 'John Doe', + username: 'john', + nickname: '', + status: UserStatus.ONLINE, + avatarETag: '', + }; + + it('suggests usernames from users.autocomplete while a from: token is active', async () => { + const { wrapper, usersAutocompleteHandler } = setup({ users: [john] }); + const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => + expect(result.current.data.filterSuggestions).toEqual([ + { + key: 'from-user-1', + group: 'users', + title: '@john', + description: 'John Doe', + value: 'from:john ', + icon: 'user', + }, + { + key: 'from-current', + group: 'users', + title: 'from:jo', + description: 'Search messages from this username', + value: 'from:jo ', + icon: 'user', + }, + ]), + ); + expect(usersAutocompleteHandler).toHaveBeenCalledWith({ + selector: JSON.stringify({ term: 'jo', conditions: {}, exceptions: [] }), + }); + }); + + it('withholds username suggestions while the debounced from: value lags behind', async () => { + mockDebounce = (value) => (value === 'jo' ? '' : value); + const { wrapper, usersAutocompleteHandler } = setup({ users: [john] }); + const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => expect(usersAutocompleteHandler).toHaveBeenCalled()); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data.filterSuggestions).toEqual([ + { + key: 'from-current', + group: 'users', + title: 'from:jo', + description: 'Search messages from this username', + value: 'from:jo ', + icon: 'user', + }, + ]); + }); + }); + + describe('date filter suggestions', () => { + // freeze the clock so the hook's `new Date()` and the expected values below agree even across midnight + beforeEach(() => { + jest.useFakeTimers({ now: new Date('2026-07-15T12:00:00Z'), advanceTimers: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('offers date suggestions for a trailing after: token', async () => { + const { wrapper } = setup(); + const { result } = renderHook(() => useAISearchItems('after:', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(today.getDate() - 7); + + expect(result.current.data.filterSuggestions).toEqual([ + { + key: `after-${formatDate(today)}`, + group: 'dates', + title: `after:${formatDate(today)}`, + description: 'Today', + value: `after:${formatDate(today)} `, + icon: 'calendar', + }, + { + key: `after-${formatDate(yesterday)}`, + group: 'dates', + title: `after:${formatDate(yesterday)}`, + description: 'Yesterday', + value: `after:${formatDate(yesterday)} `, + icon: 'calendar', + }, + { + key: `after-${formatDate(lastWeek)}`, + group: 'dates', + title: `after:${formatDate(lastWeek)}`, + description: 'Last 7 days', + value: `after:${formatDate(lastWeek)} `, + icon: 'calendar', + }, + ]); + }); + + it('offers date suggestions for a trailing before: token', async () => { + const { wrapper } = setup(); + const { result } = renderHook(() => useAISearchItems('before:', emptySearchFilters(), true), { wrapper }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data.filterSuggestions).toHaveLength(3); + expect(result.current.data.filterSuggestions.every(({ key, group }) => key.startsWith('before-') && group === 'dates')).toBe(true); + }); + }); +});