mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(navigator): empty-state + skeleton views, fix double search fetch (P4 wave 1a)
Visual polish, first wave:
- NavigatorEmptyStateView: replaces the bare "No rooms found" text with a
centered icon + message + a Create-room CTA. Reuses existing i18n keys
(navigator.search.returned.no.results / .roomsettings.moderation.none /
.createroom.create) so no new localization entries are needed.
- NavigatorSearchSkeletonView: animate-pulse placeholder rows shown while a
search is in flight and no result is cached yet (matches the HK dashboard
skeleton pattern). Replaces the NitroCard.Content spinner overlay for the
result list.
Bug fix bundled in: NavigatorSearchView called useNavigatorSearch() a second
time purely to read searchResult for its input-sync effect. Since the hook is
not a useBetween singleton, that registered a duplicate NavigatorSearchEvent
listener AND fired a duplicate NavigatorSearchComposer on every search.
NavigatorView now owns the single useNavigatorSearch() call and passes
searchResult to NavigatorSearchView via prop.
Test maintenance: useNavigatorSearch.test.tsx was written for the original
useNitroQuery implementation, which upstream reverted (05d71dd1) to
useMessageEvent + useState. Removed the dead QueryClient scaffolding, fixed
case 1 (assert no fetch starts with empty tab), dropped case 7 (the query
invalidator no longer exists). 6 cases, all green.
Full suite 471/471. Typecheck: only the environmental renderer-mismatch
errors (soundboard / rare-values / floorplan APIs absent from the linked
renderer), none in navigator files.
This commit is contained in:
@@ -14,8 +14,10 @@ import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
|
||||
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
|
||||
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
|
||||
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
|
||||
import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView';
|
||||
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
|
||||
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
|
||||
import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView';
|
||||
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
|
||||
|
||||
export const NavigatorView: FC<{}> = props =>
|
||||
@@ -132,7 +134,7 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<FaPlus className="fa-icon" />
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<NitroCard.Content isLoading={ isFetching }>
|
||||
<NitroCard.Content>
|
||||
{ !isCreatorOpen &&
|
||||
<div className="flex h-full overflow-hidden gap-2">
|
||||
{ isOpenSavesSearches &&
|
||||
@@ -140,13 +142,13 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
|
||||
</div> }
|
||||
<div className="flex flex-col w-full overflow-hidden gap-2">
|
||||
<NavigatorSearchView />
|
||||
<NavigatorSearchView searchResult={ searchResult } />
|
||||
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
|
||||
{ (isFetching && !searchResult) &&
|
||||
<NavigatorSearchSkeletonView /> }
|
||||
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
|
||||
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
|
||||
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
|
||||
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
|
||||
</div> }
|
||||
<NavigatorEmptyStateView code={ searchResult.code } onCreateRoom={ () => useNavigatorUiStore.getState().openCreator() } /> }
|
||||
</div>
|
||||
<Flex className="nitro-card-divider pt-2 border-t gap-2">
|
||||
<Flex pointer alignItems="center" justifyContent="center"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FC } from 'react';
|
||||
import { FaPlus, FaSearch } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { Button } from '../../../../common';
|
||||
|
||||
interface NavigatorEmptyStateViewProps
|
||||
{
|
||||
code: string;
|
||||
onCreateRoom: () => void;
|
||||
}
|
||||
|
||||
export const NavigatorEmptyStateView: FC<NavigatorEmptyStateViewProps> = props =>
|
||||
{
|
||||
const { code, onCreateRoom } = props;
|
||||
|
||||
const isMyWorld = (code === 'myworld_view');
|
||||
const messageKey = isMyWorld ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results';
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-black/5 text-muted">
|
||||
<FaSearch size={ 26 } className="opacity-40" />
|
||||
</div>
|
||||
<div className="text-sm text-muted max-w-[240px]">
|
||||
{ LocalizeText(messageKey) }
|
||||
</div>
|
||||
<Button variant="primary" onClick={ onCreateRoom }>
|
||||
<FaPlus className="fa-icon me-1" />
|
||||
{ LocalizeText('navigator.createroom.create') }
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
interface NavigatorSearchSkeletonViewProps
|
||||
{
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const NavigatorSearchSkeletonView: FC<NavigatorSearchSkeletonViewProps> = props =>
|
||||
{
|
||||
const { rows = 5 } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2" aria-hidden="true">
|
||||
{ Array.from({ length: rows }).map((_, index) =>
|
||||
<div key={ index } className="nitro-card-panel flex items-center gap-2 px-2 py-2">
|
||||
<div className="h-10 w-10 shrink-0 rounded bg-black/10 animate-pulse" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-3 w-1/2 rounded bg-black/10 animate-pulse" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-black/10 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-4 w-8 shrink-0 rounded bg-black/10 animate-pulse" />
|
||||
</div>) }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,21 @@
|
||||
import { NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { FC, KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
|
||||
import { Button } from '../../../../common';
|
||||
import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks';
|
||||
import { useNavigatorData, useNavigatorUiStore } from '../../../../hooks';
|
||||
|
||||
export const NavigatorSearchView: FC<{}> = props =>
|
||||
interface NavigatorSearchViewProps
|
||||
{
|
||||
searchResult: NavigatorSearchResultSet | null;
|
||||
}
|
||||
|
||||
export const NavigatorSearchView: FC<NavigatorSearchViewProps> = props =>
|
||||
{
|
||||
const { searchResult } = props;
|
||||
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
|
||||
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).
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { FlatCreatedEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
||||
@@ -13,23 +11,12 @@ 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 }
|
||||
}
|
||||
});
|
||||
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
|
||||
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
|
||||
// the UI blank when the listener never matched. These tests exercise the
|
||||
// event-driven implementation directly — no QueryClient scaffolding.
|
||||
|
||||
/** 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`. */
|
||||
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
|
||||
const makeSearchEvent = (code: string) =>
|
||||
{
|
||||
// Cast constructors as `any` so tsgo doesn't check required args against
|
||||
@@ -66,7 +53,6 @@ describe('useNavigatorSearch', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
// Reset UI store state before each test
|
||||
useNavigatorUiStore.setState(INITIAL_UI);
|
||||
});
|
||||
|
||||
@@ -76,59 +62,44 @@ describe('useNavigatorSearch', () =>
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
|
||||
it('1. with empty tabCode no fetch starts (the request effect is gated)', () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// 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();
|
||||
// No tab selected → the request effect short-circuits, nothing fetches.
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
});
|
||||
|
||||
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
|
||||
it('2. after setTab("public"), the hook starts fetching and a matching event resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// 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 () =>
|
||||
it('3. after setFilter("cocco"), a new fetch fires and a matching event resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// First establish a tab
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
// Resolve the initial query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
@@ -136,7 +107,6 @@ describe('useNavigatorSearch', () =>
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Now set a filter — triggers new query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
@@ -144,7 +114,6 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with matching event
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
@@ -152,24 +121,19 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
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 () =>
|
||||
it('4. after setTab("events"), currentFilter resets to "" and a new fetch fires for events', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// 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(() =>
|
||||
{
|
||||
@@ -177,20 +141,16 @@ describe('useNavigatorSearch', () =>
|
||||
});
|
||||
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);
|
||||
@@ -202,8 +162,7 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
act(() =>
|
||||
{
|
||||
@@ -226,8 +185,7 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
act(() =>
|
||||
{
|
||||
@@ -236,67 +194,20 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
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
|
||||
// The wrong-tab event is filtered out by the accept guard.
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user