mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(navigator): drive search via TanStack Query + setTab/setFilter UI store
NavigatorView reads searchResult/isFetching from useNavigatorSearch instead of useNavigatorData/useNavigatorUiState. Tab clicks call setTab(code) on the UI store, which atomically updates the query key and triggers refetch. The 4 lifecycle useEffect blocks driving the old imperative flow (needsSearch / reloadCurrentSearch / markReady) are removed — the query handles all of it now. NavigatorSearchView has a debounced (300ms) onChange -> setFilter that drives the same query refetch. Explicit submit (Enter / button) skips the debounce and calls setFilter immediately. linkTracker case 'search' now setTab + setFilter + show — no more pendingSearch ref. useNavigatorSearch.test.tsx: cast constructors as any to satisfy tsgo against real renderer types while keeping runtime stubs no-arg-safe. yarn typecheck / test / lint:hooks all clean (only pre-existing floorplan environmental failures).
This commit is contained in:
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useNitroEvent<RoomSessionEvent>(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 =>
|
||||
<NitroCard.TabItem
|
||||
key={ index }
|
||||
isActive={ topLevelContext === context && !isCreatorOpen }
|
||||
onClick={ () => sendSearch('', context.code) }>
|
||||
onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }>
|
||||
{ LocalizeText('navigator.toplevelview.' + context.code) }
|
||||
</NitroCard.TabItem>) }
|
||||
<NitroCard.TabItem
|
||||
@@ -151,7 +132,7 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<FaPlus className="fa-icon" />
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<NitroCard.Content isLoading={ isLoading }>
|
||||
<NitroCard.Content isLoading={ isFetching }>
|
||||
{ !isCreatorOpen &&
|
||||
<div className="flex h-full overflow-hidden gap-2">
|
||||
{ isOpenSavesSearches &&
|
||||
|
||||
@@ -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<HTMLInputElement>) =>
|
||||
{
|
||||
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<HTMLInputElement>) =>
|
||||
{
|
||||
if(event.key !== 'Enter') return;
|
||||
|
||||
processSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-1">
|
||||
<div className="flex shrink-0">
|
||||
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex w-full gap-1">
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ searchValue } onChange={ event => setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<Button variant="primary" onClick={ processSearch }>
|
||||
<FaSearch className="fa-icon" />
|
||||
</Button>
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user