mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #170 from simoleo89/feat/navigator-p2-query
feat(navigator): TanStack Query for search (P2)
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
# Navigator Modernization — P2: TanStack Query for Search
|
||||
|
||||
**Branch**: `feat/navigator-p2-query` (forked from `feat/navigator-modernization` @ `1148c0a6`)
|
||||
**Date**: 2026-05-27
|
||||
**Depends on**: P1 (hook split) — merged or pending merge
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets:
|
||||
- **Instant tab switching** when the same tab/filter was visited before in the session (cache hit, no round-trip)
|
||||
- **Stale-while-revalidate** on revisit (shows cached results while refetching in background)
|
||||
- **Server-driven refresh** via `useNitroEventInvalidator` on `FlatCreatedEvent` and `RoomSettingsUpdatedEvent` (and possibly `FavouriteChangedEvent` if the active tab is `favorites_view`)
|
||||
- **Single source of truth** for `isFetching` — no separate `isLoading` flag to manage
|
||||
|
||||
## 2. Architecture changes
|
||||
|
||||
### 2.1 New file: `src/hooks/navigator/useNavigatorSearch.ts`
|
||||
|
||||
The query hook. Reads `currentTabCode` + `currentFilter` from `navigatorUiStore`, fires `NavigatorSearchComposer`, waits for `NavigatorSearchEvent`, returns the parsed `NavigatorSearchResultSet`.
|
||||
|
||||
```ts
|
||||
import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const query = useNitroQuery<typeof NavigatorSearchEvent, NavigatorSearchResultSet>({
|
||||
key: [ 'navigator', 'search', tabCode, filter ],
|
||||
request: () => new NavigatorSearchComposer(tabCode, filter),
|
||||
parser: NavigatorSearchEvent,
|
||||
select: e => e.getParser()?.result ?? null,
|
||||
accept: e => {
|
||||
const result = e.getParser()?.result;
|
||||
// accept-filter: only this query's matching tab code
|
||||
return !!result && result.code === tabCode;
|
||||
},
|
||||
enabled: !!tabCode,
|
||||
staleTime: 30_000 // re-fetch after 30s of staleness on revisit
|
||||
});
|
||||
|
||||
useNitroEventInvalidator(FlatCreatedEvent, [ 'navigator', 'search' ]);
|
||||
useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ 'navigator', 'search' ]);
|
||||
|
||||
return {
|
||||
searchResult: query.data,
|
||||
isFetching: query.isFetching,
|
||||
refetch: query.refetch
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 `navigatorUiStore.ts` additions
|
||||
|
||||
Add 2 new state fields + 2 new actions:
|
||||
|
||||
```ts
|
||||
type NavigatorUiState = {
|
||||
// ...existing 9 flags...
|
||||
currentTabCode: string; // '' until NavigatorMetadataEvent arrives, then first top-level context code
|
||||
currentFilter: string; // '' by default
|
||||
};
|
||||
|
||||
type NavigatorUiActions = {
|
||||
// ...existing 15 actions...
|
||||
setTab(code: string): void; // also clears currentFilter
|
||||
setFilter(value: string): void;
|
||||
};
|
||||
```
|
||||
|
||||
`setTab(code)` resets `currentFilter` to `''` because switching tabs starts a fresh search. `setFilter` updates only the filter — the user is typing in the same tab.
|
||||
|
||||
### 2.3 `useNavigatorStore.ts` — remove search state ownership
|
||||
|
||||
Remove:
|
||||
- `useState<NavigatorSearchResultSet>(null)` for `searchResult`
|
||||
- `useMessageEvent<NavigatorSearchEvent>` listener
|
||||
- `sendSearch` and `reloadCurrentSearch` actions
|
||||
- The `useNavigatorUiStore.getState().setLoading(...)` calls (no longer needed)
|
||||
- The `topLevelContextRef` and `searchResultRef` (only consumed inside `reloadCurrentSearch`)
|
||||
|
||||
Keep:
|
||||
- `topLevelContext` + `topLevelContexts` (these still come from `NavigatorMetadataEvent` and drive the tab list)
|
||||
- The `NavigatorMetadataEvent` listener — but it now ALSO calls `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` on first arrival, to seed the initial tab. The query then activates because `currentTabCode` becomes non-empty (`enabled: !!tabCode`).
|
||||
|
||||
### 2.4 `useNavigatorData.ts` — remove `searchResult` from return shape
|
||||
|
||||
`useNavigatorData()` no longer returns `searchResult`. Consumers that need it call `useNavigatorSearch()` instead.
|
||||
|
||||
### 2.5 `useNavigatorActions.ts` — empty or removed
|
||||
|
||||
Both `sendSearch` and `reloadCurrentSearch` are gone. Either:
|
||||
- Remove the file + the export — consumers use `useNavigatorUiStore.getState().setTab(...)` / `setFilter(...)` directly
|
||||
- Or keep the file as an empty re-export for forward compat. (Decision: REMOVE — minimize dead API).
|
||||
|
||||
### 2.6 `useNavigatorUiState.ts` — add the 2 new flags
|
||||
|
||||
Add `currentTabCode` and `currentFilter` to the per-key selector list and return shape.
|
||||
|
||||
### 2.7 `useNavigatorSearch.test.tsx` — new
|
||||
|
||||
Test cases:
|
||||
- Initial mount with empty tabCode → query is disabled, no request fired
|
||||
- After `setTab('public')` → query fires NavigatorSearchComposer('public', '')
|
||||
- After `setFilter('cocco')` → query fires NavigatorSearchComposer('public', 'cocco')
|
||||
- After `setTab('events')` → currentFilter resets to '', query fires NavigatorSearchComposer('events', '')
|
||||
- `FlatCreatedEvent` invalidates the cache → refetch
|
||||
- `RoomSettingsUpdatedEvent` invalidates the cache → refetch
|
||||
- `NavigatorSearchEvent` with WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED by `accept` filter — does NOT update query data
|
||||
|
||||
### 2.8 `NavigatorView.tsx` — major rewrite
|
||||
|
||||
Replace:
|
||||
- `useNavigatorActions` import → gone
|
||||
- `useNavigatorData` no longer destructures `searchResult` — get it from `useNavigatorSearch` instead
|
||||
- 4 `useEffect` blocks driving the imperative search flow (`needsSearch`, `needsInit` lifecycle, `reloadCurrentSearch` orchestration) → gone
|
||||
- Tab `onClick={ () => sendSearch('', context.code) }` → `onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }`
|
||||
- `isLoading` from `useNavigatorUiState()` → `isFetching` from `useNavigatorSearch()` query
|
||||
- `NavigatorInitComposer` initial dispatch on first `isVisible` — KEEP (still need it to get `topLevelContexts` populated)
|
||||
- `pendingSearch` ref — gone (linkTracker `case 'search'` directly does `setTab(code); setFilter(value)`)
|
||||
|
||||
Major simplification: the file shrinks ~30 lines.
|
||||
|
||||
### 2.9 `NavigatorSearchView.tsx` — drive setFilter
|
||||
|
||||
Read the file. The component currently exposes a search input that, on enter or button click, calls `sendSearch(value, currentTabCode)`. After P2 it:
|
||||
- Reads `currentFilter` from `useNavigatorUiState`
|
||||
- onChange → `useNavigatorUiStore.getState().setFilter(value)` (debounced 300ms)
|
||||
- No more `sendSearch` reference
|
||||
|
||||
Debounce: use a local `useState` for the input text + a `useEffect` that calls `setFilter(text)` 300ms after the last keystroke. Standard pattern.
|
||||
|
||||
## 3. Backward-compat considerations
|
||||
|
||||
- `useNavigatorActions.sendSearch` and `useNavigatorActions.reloadCurrentSearch` are REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration.
|
||||
- `useNavigatorData.searchResult` is REMOVED. Only `NavigatorView` reads it currently — easy to migrate.
|
||||
- The `useNavigatorActions` filter itself becomes empty — consider whether to delete the file entirely. **Decision: delete the file** to minimize the API surface. Tasks 5-8 of P1 migrated `NavigatorSearchView` to use `useNavigatorActions` — that's the only consumer; it migrates to `useNavigatorUiStore` directly.
|
||||
|
||||
## 4. Out of scope (each gets its own future spec)
|
||||
|
||||
- Reactive favourite stars on cards (P3)
|
||||
- Visual rework: empty states, virtualization, chip-based UI (P4)
|
||||
- Form Action on search input (P6)
|
||||
|
||||
## 5. Acceptance criteria
|
||||
|
||||
P2 is complete when:
|
||||
|
||||
1. `src/hooks/navigator/useNavigatorSearch.ts` exists and exports `useNavigatorSearch`
|
||||
2. `useNavigatorStore.ts` no longer owns `searchResult`, no longer subscribes to `NavigatorSearchEvent`, no longer exposes `sendSearch` or `reloadCurrentSearch`
|
||||
3. `navigatorUiStore.ts` has `currentTabCode` + `currentFilter` state and `setTab` + `setFilter` actions
|
||||
4. `useNavigatorActions.ts` is deleted; barrel no longer exports `useNavigatorActions`
|
||||
5. `useNavigatorData.ts` no longer returns `searchResult`
|
||||
6. `useNavigatorUiState.ts` returns `currentTabCode` + `currentFilter`
|
||||
7. `NavigatorView.tsx` reads `searchResult` from `useNavigatorSearch()`, uses `isFetching` for the loading flag, calls `setTab` on tab clicks
|
||||
8. `NavigatorSearchView.tsx` debounces `setFilter` calls
|
||||
9. `yarn typecheck` clean (same pre-existing floorplan errors)
|
||||
10. `yarn test --run` green; smoke test updated; new `useNavigatorSearch.test.tsx` with 7 cases
|
||||
11. `yarn lint:hooks` clean
|
||||
12. Manual smoke: switch tabs rapidly → results cached, no flicker. Type filter → debounced refetch. Create a room → list refreshes.
|
||||
|
||||
## 6. Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `NavigatorSearchEvent` arrives unsolicited (server-side push) — query wouldn't update | The `accept` filter checks the result's code matches the current tabCode, so only matching events update the query. Unsolicited results to a non-active tab are ignored (acceptable — when the user switches to that tab, the cache is empty and a fresh query fires). |
|
||||
| Removing `useNavigatorActions` breaks an import we missed | Type-checker catches it. The P1 grep showed only Navigator-internal consumers use it. |
|
||||
| Removing the `isLoading`/`isReady`/`needsInit`/`needsSearch` flags from `navigatorUiStore` (they're now derivable from query state) — too aggressive? | KEEP them in P2. Only `searchResult` ownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them. |
|
||||
| Debounce timing on search input | 300ms is standard; if it feels laggy the user can lower it later — pure UX tune |
|
||||
|
||||
## 7. Plan (executable)
|
||||
|
||||
### Task 1: Add UI store state + actions (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/navigatorUiStore.ts`, `src/hooks/navigator/navigatorUiStore.test.ts`
|
||||
|
||||
- [ ] Add `currentTabCode: string` (initial `''`) and `currentFilter: string` (initial `''`) to `NavigatorUiState`
|
||||
- [ ] Add `setTab(code: string): void` and `setFilter(value: string): void` to `NavigatorUiActions`
|
||||
- [ ] `setTab(code)` sets `{ currentTabCode: code, currentFilter: '' }` (atomic reset on tab change)
|
||||
- [ ] `setFilter(value)` sets `{ currentFilter: value }` (no tab side-effect)
|
||||
- [ ] Update test file: 3 new cases — `setTab` updates tab and resets filter; `setFilter` updates filter without touching tab; idempotent `setTab` on same code resets filter to '' regardless
|
||||
- [ ] `yarn test --run src/hooks/navigator/navigatorUiStore.test.ts` → green
|
||||
- [ ] Commit: `feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)`
|
||||
|
||||
### Task 2: Create `useNavigatorSearch` query hook (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/useNavigatorSearch.ts`, `src/hooks/navigator/useNavigatorSearch.test.tsx`
|
||||
|
||||
Implement per §2.1 + §2.7 above. 7 test cases.
|
||||
|
||||
The test will need: `QueryClientProvider` wrapper, mock for `NavigatorSearchComposer` (probably already in mock), `NavigatorSearchEvent` dispatch with parser.result.code matching/non-matching.
|
||||
|
||||
- [ ] Commit: `feat(navigator): useNavigatorSearch query hook (P2 core)`
|
||||
|
||||
### Task 3: Strip search ownership from `useNavigatorStore` + `useNavigatorData` + remove `useNavigatorActions`
|
||||
|
||||
**Files**: `useNavigatorStore.ts`, `useNavigatorData.ts`, `useNavigatorActions.ts` (DELETE), `useNavigatorUiState.ts`, `index.ts`
|
||||
|
||||
- [ ] Remove `searchResult` state + `setSearchResult` from `useNavigatorStore`
|
||||
- [ ] Remove `NavigatorSearchEvent` listener from `useNavigatorStore`
|
||||
- [ ] Remove `sendSearch` and `reloadCurrentSearch` from `useNavigatorStore` return
|
||||
- [ ] Remove `setLoading` calls inside `useNavigatorStore`
|
||||
- [ ] Remove `topLevelContextRef` and `searchResultRef` (no longer used after sendSearch/reload removal)
|
||||
- [ ] In `NavigatorMetadataEvent` handler, add `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` after `setTopLevelContext(...)` — seeds the query when contexts arrive
|
||||
- [ ] Remove `searchResult` from `useNavigatorData` destructure + return
|
||||
- [ ] DELETE `src/hooks/navigator/useNavigatorActions.ts`
|
||||
- [ ] Update `useNavigatorUiState.ts` to expose `currentTabCode` + `currentFilter` per-key selectors
|
||||
- [ ] Update `src/hooks/navigator/index.ts` to remove `useNavigatorActions` export, add `useNavigatorSearch` export
|
||||
- [ ] Update `useNavigatorStore.test.tsx` smoke test: 2 cases that expected `searchResult` in data shape or `sendSearch/reloadCurrentSearch` in actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely)
|
||||
- [ ] Verify typecheck: ONLY consumer-side errors expected (NavigatorView still references the old API). Hook files clean.
|
||||
- [ ] Commit: `refactor(navigator): remove search ownership from useNavigatorStore`
|
||||
|
||||
### Task 4: Migrate `NavigatorView.tsx` + `NavigatorSearchView.tsx`
|
||||
|
||||
**Files**: `src/components/navigator/NavigatorView.tsx`, `src/components/navigator/views/search/NavigatorSearchView.tsx`
|
||||
|
||||
- [ ] In `NavigatorView`:
|
||||
- Import `useNavigatorSearch`
|
||||
- Replace `useNavigatorData` destructure of `searchResult` with `useNavigatorSearch()` call returning `{ searchResult, isFetching }`
|
||||
- Drop `useNavigatorActions` import + destructure (it's gone)
|
||||
- Drop the 4 lifecycle `useEffect` blocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow:
|
||||
- Keep the `NavigatorInitComposer` on first `isVisible` — still needed for metadata
|
||||
- Tab clicks call `useNavigatorUiStore.getState().setTab(context.code)`
|
||||
- linkTracker `case 'search'`: `store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();` (no more `pendingSearch` ref)
|
||||
- Replace `<NitroCard.Content isLoading={ isLoading }>` with `isFetching` from the query
|
||||
- Drop the `pendingSearch` ref
|
||||
- [ ] In `NavigatorSearchView`:
|
||||
- Read `currentFilter` from `useNavigatorUiState` for the initial input value
|
||||
- Local `useState` for the text being typed (mirrors the store value)
|
||||
- Debounce: `useEffect` with 300ms timer calling `useNavigatorUiStore.getState().setFilter(text)`
|
||||
- Remove all `useNavigatorActions` references — the search submit happens via store, query refires automatically
|
||||
- [ ] `yarn typecheck` clean
|
||||
- [ ] `yarn test --run` green
|
||||
- [ ] `yarn lint:hooks` clean
|
||||
- [ ] Commit: `feat(navigator): drive search via TanStack Query + setTab/setFilter UI store actions`
|
||||
|
||||
### Task 5: PR
|
||||
|
||||
- [ ] Push branch
|
||||
- [ ] Open PR against `duckietm:Dev`: `feat(navigator): TanStack Query for search (P2)`
|
||||
@@ -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,5 +1,5 @@
|
||||
export { useNavigatorActions } from './useNavigatorActions';
|
||||
export { useNavigatorData } from './useNavigatorData';
|
||||
export { useNavigatorSearch } from './useNavigatorSearch';
|
||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
||||
export { useDoorState } from '../rooms/widgets/useDoorState';
|
||||
|
||||
@@ -10,7 +10,9 @@ const INITIAL = {
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: ''
|
||||
};
|
||||
|
||||
describe('useNavigatorUiStore', () =>
|
||||
@@ -32,6 +34,8 @@ describe('useNavigatorUiStore', () =>
|
||||
expect(s.isLoading).toBe(false);
|
||||
expect(s.needsInit).toBe(true);
|
||||
expect(s.needsSearch).toBe(false);
|
||||
expect(s.currentTabCode).toBe('');
|
||||
expect(s.currentFilter).toBe('');
|
||||
});
|
||||
|
||||
describe('show / hide / toggle', () =>
|
||||
@@ -141,4 +145,31 @@ describe('useNavigatorUiStore', () =>
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab + filter', () =>
|
||||
{
|
||||
it("setTab('public') sets currentTabCode and clears currentFilter", () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ currentTabCode: 'events', currentFilter: 'habbo' });
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
});
|
||||
|
||||
it("setFilter('cocco') sets currentFilter without touching tab", () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('events');
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
||||
});
|
||||
|
||||
it('setTab on same code still resets currentFilter', () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ currentTabCode: 'public', currentFilter: 'test' });
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ export type NavigatorUiState = {
|
||||
isLoading: boolean;
|
||||
needsInit: boolean;
|
||||
needsSearch: boolean;
|
||||
currentTabCode: string;
|
||||
currentFilter: string;
|
||||
};
|
||||
|
||||
export type NavigatorUiActions = {
|
||||
@@ -28,6 +30,8 @@ export type NavigatorUiActions = {
|
||||
markInitDone(): void;
|
||||
requestSearch(): void;
|
||||
consumeSearchRequest(): void;
|
||||
setTab(code: string): void;
|
||||
setFilter(value: string): void;
|
||||
};
|
||||
|
||||
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
|
||||
@@ -40,6 +44,8 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: '',
|
||||
|
||||
show: () => set({ isVisible: true, needsSearch: true }),
|
||||
hide: () => set({ isVisible: false }),
|
||||
@@ -57,5 +63,7 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
|
||||
markReady: () => set({ isReady: true }),
|
||||
markInitDone: () => set({ needsInit: false }),
|
||||
requestSearch: () => set({ needsSearch: true }),
|
||||
consumeSearchRequest: () => set({ needsSearch: false })
|
||||
consumeSearchRequest: () => set({ needsSearch: false }),
|
||||
setTab: (code) => set({ currentTabCode: code, currentFilter: '' }),
|
||||
setFilter: (value) => set({ currentFilter: value })
|
||||
}));
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorActions = () =>
|
||||
{
|
||||
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
|
||||
return { sendSearch, reloadCurrentSearch };
|
||||
};
|
||||
@@ -6,12 +6,12 @@ export const useNavigatorData = () =>
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData
|
||||
navigatorSearches, navigatorData
|
||||
} = useBetween(useNavigatorStore);
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { FlatCreatedEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
import { useNavigatorSearch } from './useNavigatorSearch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a fresh QueryClient with retries off so failures are immediate. */
|
||||
const makeQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
/** Wrapper factory — each test gets its own QueryClient instance. */
|
||||
const makeWrapper = (client: QueryClient) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={ client }>
|
||||
{ children }
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
|
||||
const makeSearchEvent = (code: string) =>
|
||||
{
|
||||
// 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)() as any;
|
||||
ev.getParser = () => ({ result });
|
||||
return ev;
|
||||
};
|
||||
|
||||
const INITIAL_UI = {
|
||||
isVisible: false,
|
||||
isReady: false,
|
||||
isCreatorOpen: false,
|
||||
isRoomInfoOpen: false,
|
||||
isRoomLinkOpen: false,
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: ''
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useNavigatorSearch', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
// Reset UI store state before each test
|
||||
useNavigatorUiStore.setState(INITIAL_UI);
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Dispatch a search event — should be ignored (query disabled)
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Data must stay null
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Activate the query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
// Hook should start fetching
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Simulate server response
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Query should resolve with the matching result
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// First establish a tab
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
// Resolve the initial query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Now set a filter — triggers new query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with matching event
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
|
||||
// Confirm filter is set
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
||||
});
|
||||
|
||||
it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Establish public tab with a filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
useNavigatorUiStore.getState().setFilter('some-filter');
|
||||
});
|
||||
|
||||
// Resolve the public+filter query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Switch to events tab — should atomically reset filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('events');
|
||||
});
|
||||
|
||||
// Filter must be cleared
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||
|
||||
// New query for 'events' fires
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with events result
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
expect((result.current.searchResult as any).code).toBe('events');
|
||||
});
|
||||
|
||||
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
{
|
||||
expect(result.current.searchResult).not.toBeNull();
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Dispatch an event for a DIFFERENT tab — should be rejected by accept filter
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
|
||||
});
|
||||
|
||||
// Still fetching — the wrong-tab event was ignored
|
||||
// (the query promise stays pending until it times out or a matching event arrives)
|
||||
// After the wrong-tab dispatch, data should NOT be updated
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
|
||||
// Now dispatch the correct one to unblock the test
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
// Only the correct-tab result is stored
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
});
|
||||
|
||||
it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
|
||||
// Spy on invalidateQueries to confirm the invalidator calls it
|
||||
const invalidateSpy = vi.spyOn(client, 'invalidateQueries');
|
||||
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Establish a resolved query so there is something to invalidate
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
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)() as any;
|
||||
flatCreatedEv.getParser = () => ({ roomId: 999 });
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(flatCreatedEv as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());
|
||||
|
||||
// The invalidation should target the 'navigator', 'search' key prefix
|
||||
const calls = invalidateSpy.mock.calls;
|
||||
const calledWithSearchKey = calls.some(call =>
|
||||
{
|
||||
const opts = call[0] as any;
|
||||
const key: string[] = opts?.queryKey ?? [];
|
||||
return key[0] === 'navigator' && key[1] === 'search';
|
||||
});
|
||||
expect(calledWithSearchKey).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer';
|
||||
import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const;
|
||||
|
||||
/**
|
||||
* TanStack Query wrapper for navigator search.
|
||||
*
|
||||
* Cache key: ['navigator', 'search', tabCode, filter]
|
||||
* - Fires NavigatorSearchComposer(tabCode, filter) on miss.
|
||||
* - Listens for NavigatorSearchEvent and resolves with the result.
|
||||
* - accept-filter: rejects events whose result.code !== tabCode (defends
|
||||
* against server-side cross-tab pushes resolving the wrong query slot).
|
||||
* - Disabled when tabCode is '' (initial state, before metadata arrives).
|
||||
* - Invalidates on FlatCreatedEvent (new room created) and
|
||||
* RoomSettingsUpdatedEvent (room renamed / settings changed).
|
||||
*/
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const query = useNitroQuery<NavigatorSearchEvent, NavigatorSearchResultSet | null>({
|
||||
key: [ ...SEARCH_BASE_KEY, tabCode, filter ],
|
||||
request: () => new NavigatorSearchComposer(tabCode, filter),
|
||||
parser: NavigatorSearchEvent,
|
||||
select: e => e.getParser()?.result ?? null,
|
||||
accept: e =>
|
||||
{
|
||||
const result = e.getParser()?.result;
|
||||
return !!result && result.code === tabCode;
|
||||
},
|
||||
enabled: !!tabCode,
|
||||
staleTime: 30_000
|
||||
});
|
||||
|
||||
useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]);
|
||||
useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]);
|
||||
|
||||
return {
|
||||
searchResult: query.data ?? null,
|
||||
isFetching: query.isFetching,
|
||||
refetch: query.refetch
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index';
|
||||
import { useNavigatorData, useNavigatorUiState } from './index';
|
||||
|
||||
describe('navigator filter shapes (smoke)', () =>
|
||||
{
|
||||
@@ -10,24 +10,18 @@ describe('navigator filter shapes (smoke)', () =>
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
||||
'navigatorData', 'navigatorSearches',
|
||||
'searchResult', 'topLevelContext', 'topLevelContexts'
|
||||
'topLevelContext', 'topLevelContexts'
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('useNavigatorUiState returns the 9 documented flags', () =>
|
||||
it('useNavigatorUiState returns the 11 documented flags', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorUiState());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'currentFilter', 'currentTabCode',
|
||||
'isCreatorOpen', 'isLoading', 'isOpenSavesSearches',
|
||||
'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible',
|
||||
'needsInit', 'needsSearch'
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorActions());
|
||||
expect(typeof result.current.sendSearch).toBe('function');
|
||||
expect(typeof result.current.reloadCurrentSearch).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,13 @@ import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent,
|
||||
HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser,
|
||||
NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent,
|
||||
NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch,
|
||||
NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType,
|
||||
NavigatorSearchesEvent,
|
||||
NavigatorTopLevelContext, NitroEventType,
|
||||
RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent,
|
||||
RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent,
|
||||
RoomForwardEvent, RoomScoreEvent,
|
||||
SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent,
|
||||
UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
||||
LocalizeText, NotificationAlertType, SendMessageComposer,
|
||||
TryVisitRoom, VisitDesktop } from '../../api';
|
||||
@@ -27,7 +27,6 @@ export const useNavigatorStore = () =>
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
|
||||
settingsReceived: false,
|
||||
@@ -44,41 +43,8 @@ export const useNavigatorStore = () =>
|
||||
canRate: true
|
||||
});
|
||||
|
||||
// Refs let handlers stay [] deps without losing access to fresh state.
|
||||
const topLevelContextsRef = useRef(topLevelContexts);
|
||||
topLevelContextsRef.current = topLevelContexts;
|
||||
const topLevelContextRef = useRef(topLevelContext);
|
||||
topLevelContextRef.current = topLevelContext;
|
||||
const searchResultRef = useRef(searchResult);
|
||||
searchResultRef.current = searchResult;
|
||||
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
const sendSearch = useCallback((searchValue: string, contextCode: string) =>
|
||||
{
|
||||
useNavigatorUiStore.getState().closeCreator();
|
||||
SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue));
|
||||
useNavigatorUiStore.getState().setLoading(true);
|
||||
}, []);
|
||||
|
||||
const reloadCurrentSearch = useCallback(() =>
|
||||
{
|
||||
if(!useNavigatorUiStore.getState().isReady)
|
||||
{
|
||||
useNavigatorUiStore.getState().requestSearch();
|
||||
return;
|
||||
}
|
||||
const sr = searchResultRef.current;
|
||||
if(sr)
|
||||
{
|
||||
sendSearch(sr.data, sr.code);
|
||||
return;
|
||||
}
|
||||
const ctx = topLevelContextRef.current;
|
||||
if(!ctx) return;
|
||||
sendSearch('', ctx.code);
|
||||
}, [ sendSearch ]);
|
||||
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -99,12 +65,6 @@ export const useNavigatorStore = () =>
|
||||
});
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomSettingsUpdatedEvent>(RoomSettingsUpdatedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -223,28 +183,8 @@ export const useNavigatorStore = () =>
|
||||
const parser = event.getParser();
|
||||
setTopLevelContexts(parser.topLevelContexts);
|
||||
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const contexts = topLevelContextsRef.current;
|
||||
setTopLevelContext(prev =>
|
||||
{
|
||||
let next = prev;
|
||||
if(!next) next = (contexts && contexts.length && contexts[0]) || null;
|
||||
if(!next) return null;
|
||||
if(contexts && contexts.length)
|
||||
{
|
||||
for(const ctx of contexts)
|
||||
{
|
||||
if(ctx.code === parser.result.code) next = ctx;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSearchResult(parser.result);
|
||||
useNavigatorUiStore.getState().setLoading(false);
|
||||
// Seed the query's tab code so useNavigatorSearch activates immediately
|
||||
useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '');
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserFlatCatsEvent>(UserFlatCatsEvent, useCallback(event =>
|
||||
@@ -342,7 +282,6 @@ export const useNavigatorStore = () =>
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
searchResult, navigatorSearches, navigatorData,
|
||||
sendSearch, reloadCurrentSearch
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,8 +11,11 @@ export const useNavigatorUiState = () =>
|
||||
const isLoading = useNavigatorUiStore(s => s.isLoading);
|
||||
const needsInit = useNavigatorUiStore(s => s.needsInit);
|
||||
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
|
||||
const currentTabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const currentFilter = useNavigatorUiStore(s => s.currentFilter);
|
||||
return {
|
||||
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
|
||||
isOpenSavesSearches, isLoading, needsInit, needsSearch
|
||||
isOpenSavesSearches, isLoading, needsInit, needsSearch,
|
||||
currentTabCode, currentFilter
|
||||
};
|
||||
};
|
||||
|
||||
@@ -533,7 +533,9 @@ export const GetCommunication = vi.fn(() => ({
|
||||
{
|
||||
const wrapper = _msgEventWrappers.get(event);
|
||||
if(wrapper) msgListeners.get(event.type)?.delete(wrapper);
|
||||
}
|
||||
},
|
||||
// Stub for SendMessageComposer which calls GetCommunication().connection.send(...)
|
||||
connection: { send: vi.fn() }
|
||||
}));
|
||||
export const GetConfiguration = vi.fn(stubManager);
|
||||
export const GetLocalizationManager = vi.fn(stubManager);
|
||||
|
||||
Reference in New Issue
Block a user