diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 3ff6be6..7819c96 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text } from '../../common'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,10 +20,9 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); - const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); - const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); - const pendingSearch = useRef<{ value: string, code: string }>(null); + const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { searchResult, isFetching } = useNavigatorSearch(); + const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState(); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => @@ -72,7 +71,10 @@ export const NavigatorView: FC<{}> = props => return; case 'search': if(parts.length <= 2) return; - pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + const code = parts[2]; + const value = parts.length > 3 ? parts[3] : ''; + store.setTab(code); + if(value) store.setFilter(value); store.show(); return; } @@ -89,27 +91,6 @@ export const NavigatorView: FC<{}> = props => if(elementRef.current) elementRef.current.scrollTop = 0; }, [ searchResult ]); - useEffect(() => - { - if(!isVisible || !isReady || !needsSearch) return; - if(pendingSearch.current) - { - sendSearch(pendingSearch.current.value, pendingSearch.current.code); - pendingSearch.current = null; - } - else - { - reloadCurrentSearch(); - } - useNavigatorUiStore.getState().consumeSearchRequest(); - }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); - - useEffect(() => - { - if(isReady || !topLevelContext) return; - useNavigatorUiStore.getState().markReady(); - }, [ isReady, topLevelContext ]); - useEffect(() => { if(!isVisible || !needsInit) return; @@ -142,7 +123,7 @@ export const NavigatorView: FC<{}> = props => sendSearch('', context.code) }> + onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }> { LocalizeText('navigator.toplevelview.' + context.code) } ) } = props => - + { !isCreatorOpen &&
{ isOpenSavesSearches && diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 18980b6..a6b3185 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -2,35 +2,17 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react'; import { FaSearch } from 'react-icons/fa'; import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api'; import { Button } from '../../../../common'; -import { useNavigatorActions, useNavigatorData } from '../../../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks'; export const NavigatorSearchView: FC<{}> = props => { const [ searchFilterIndex, setSearchFilterIndex ] = useState(0); - const [ searchValue, setSearchValue ] = useState(''); - const { topLevelContext, searchResult } = useNavigatorData(); - const { sendSearch } = useNavigatorActions(); - - const processSearch = () => - { - if(!topLevelContext) return; - - let searchFilter = SearchFilterOptions[searchFilterIndex]; - - if(!searchFilter) searchFilter = SearchFilterOptions[0]; - - const searchQuery = ((searchFilter.query ? (searchFilter.query + ':') : '') + searchValue); - - sendSearch((searchQuery || ''), topLevelContext.code); - }; - - const handleKeyDown = (event: KeyboardEvent) => - { - if(event.key !== 'Enter') return; - - processSearch(); - }; + const [ inputText, setInputText ] = useState(''); + const { topLevelContext } = useNavigatorData(); + const { searchResult } = useNavigatorSearch(); + // Sync the input text display when a server result arrives (e.g. on tab switch + // or deep-link navigation that sets the filter through the store directly). useEffect(() => { if(!searchResult) return; @@ -55,9 +37,39 @@ export const NavigatorSearchView: FC<{}> = props => if(!filter) filter = SearchFilterOptions[0]; setSearchFilterIndex(SearchFilterOptions.findIndex(option => (option === filter))); - setSearchValue(value); + setInputText(value); }, [ searchResult ]); + // Debounced filter — 300ms after the user stops typing, push to the store + // which updates the query key and triggers a refetch. + useEffect(() => + { + const timer = setTimeout(() => + { + const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0]; + const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText; + useNavigatorUiStore.getState().setFilter(searchQuery); + }, 300); + + return () => clearTimeout(timer); + }, [ inputText, searchFilterIndex ]); + + const processSearch = () => + { + if(!topLevelContext) return; + // Immediate submit — skip the debounce timer + const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0]; + const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText; + useNavigatorUiStore.getState().setFilter(searchQuery); + }; + + const handleKeyDown = (event: KeyboardEvent) => + { + if(event.key !== 'Enter') return; + + processSearch(); + }; + return (
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
- setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> + setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx index bd2fb60..f6858f0 100644 --- a/src/hooks/navigator/useNavigatorSearch.test.tsx +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, +import { FlatCreatedEvent, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; @@ -32,14 +32,16 @@ const makeWrapper = (client: QueryClient) => /** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ const makeSearchEvent = (code: string) => { - const result = new NavigatorSearchResultSet() as any; + // Cast constructors as `any` so tsgo doesn't check required args against + // the real renderer SDK types (the mock stubs have no required args). + const result = new (NavigatorSearchResultSet as any)() as any; result.code = code; result.data = ''; result.results = []; - const ev = new NavigatorSearchEvent() as any; + const ev = new (NavigatorSearchEvent as any)() as any; ev.getParser = () => ({ result }); - return ev as NavigatorSearchEvent; + return ev; }; const INITIAL_UI = { @@ -82,7 +84,7 @@ describe('useNavigatorSearch', () => // Dispatch a search event — should be ignored (query disabled) act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Data must stay null @@ -107,7 +109,7 @@ describe('useNavigatorSearch', () => // Simulate server response act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Query should resolve with the matching result @@ -130,7 +132,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -145,7 +147,7 @@ describe('useNavigatorSearch', () => // Resolve with matching event act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -171,7 +173,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -191,7 +193,7 @@ describe('useNavigatorSearch', () => // Resolve with events result act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('events')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -212,7 +214,7 @@ describe('useNavigatorSearch', () => act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => @@ -237,7 +239,7 @@ describe('useNavigatorSearch', () => // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any); }); // Still fetching — the wrong-tab event was ignored @@ -248,7 +250,7 @@ describe('useNavigatorSearch', () => // Now dispatch the correct one to unblock the test act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.searchResult).not.toBeNull()); @@ -273,16 +275,16 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); // Dispatch FlatCreatedEvent — should trigger invalidateQueries - const flatCreatedEv = new FlatCreatedEvent() as any; + const flatCreatedEv = new (FlatCreatedEvent as any)() as any; flatCreatedEv.getParser = () => ({ roomId: 999 }); act(() => { - mockEventDispatcher.dispatchEvent(flatCreatedEv); + mockEventDispatcher.dispatchEvent(flatCreatedEv as any); }); await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());