diff --git a/app/src/components/OtherSearchView.test.js b/app/src/components/OtherSearchView.test.js new file mode 100644 index 00000000..a0b2ea37 --- /dev/null +++ b/app/src/components/OtherSearchView.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {OtherSearchView} from './Search'; +import {searchChannels} from '../api'; + +// What the search page's Other tab shows for each state of useSearchChannels. "No Channels" +// must only appear once the server has confirmed there are none. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + searchChannels: jest.fn(), +})); + +const NO_CHANNELS = /No Channels/; + +beforeEach(() => searchChannels.mockReset()); + +test('pending: shows a loader, not "No Channels"', () => { + searchChannels.mockReturnValue(new Promise(() => { + })); + const {container} = render(); + expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument(); + expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument(); +}); + +test('failed: shows an error, not "No Channels"', async () => { + searchChannels.mockResolvedValue(undefined); + const spy = jest.spyOn(console, 'error').mockImplementation(() => { + }); + try { + render(); + await act(async () => { + }); + expect(screen.getByText(/Could not fetch the channels/)).toBeInTheDocument(); + expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument(); + } finally { + spy.mockRestore(); + } +}); + +test('empty: shows "No Channels"', async () => { + searchChannels.mockResolvedValue({channels: []}); + render(); + await act(async () => { + }); + expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument(); +}); + +test('loaded: lists the channels', async () => { + searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]}); + render(); + await act(async () => { + }); + expect(screen.getByText('Cooking')).toBeInTheDocument(); + expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument(); +}); diff --git a/app/src/components/Search.js b/app/src/components/Search.js index c064b848..96abca9a 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -6,7 +6,7 @@ import {ShortcutHint} from "./ShortcutHint"; import {ZimSearchView} from "./Zim"; import {MapSearchView} from "./MapSearchView"; import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions} from "../api"; -import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common"; +import {ErrorMessage, filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common"; import _ from "lodash"; import {TagsContext} from "../Tags"; import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui"; @@ -541,14 +541,17 @@ function SearchChannelPreview({channel}) { } -function OtherSearchView({loading}) { +// `channels` follows useSearchChannels: null = pending, undefined = fetch failed, [] = none. +export function OtherSearchView({loading}) { const {searchParams} = React.useContext(QueryContext); const [activeValue, setActiveValue] = React.useState('channels'); const activeTags = searchParams.getAll('tag'); const {channels, loading: channelsLoading} = useSearchChannels(activeTags); - if (loading || channelsLoading) { + if (loading || channelsLoading || channels === null) { return + } else if (channels === undefined) { + return Could not fetch the channels. } return diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index e54ae984..668aab21 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -2079,16 +2079,31 @@ export const useCalcQuery = () => { export const useSearchChannels = (defaultTagNames) => { const [tagNames, setTagNames] = useState(defaultTagNames || []); - const [channels, setChannels] = useState([]); - const [loading, setLoading] = useState(false); + // null = pending, undefined = fetch failed, [] = no channels. A search starts on mount, so the + // first render is already loading; consumers must never see [] for a search that has not run. + const [channels, setChannels] = useState(null); + const [loading, setLoading] = useState(true); + // Only the newest request may write state; a slow response for previous tags is dropped. + const requestGen = useRef(0); const localSearchChannels = async () => { + const gen = ++requestGen.current; + setChannels(null); setLoading(true); try { const {channels: newChannels} = await searchChannels(tagNames); - setChannels(newChannels); + if (gen === requestGen.current) { + setChannels(newChannels || []); + } + } catch (e) { + console.error(e); + if (gen === requestGen.current) { + setChannels(undefined); + } } finally { - setLoading(false); + if (gen === requestGen.current) { + setLoading(false); + } } }; diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 9c010598..707a5b63 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -1,8 +1,8 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; import {render, screen} from '../test-utils'; -import {usePages, useDriveTemperature, useDriveHealth, useVideoExtras} from './customHooks'; -import {getVideoComments, getVideoDescription} from '../api'; +import {usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useVideoExtras} from './customHooks'; +import {getVideoComments, getVideoDescription, searchChannels} from '../api'; import {QueryContext, StatusContext} from '../contexts/contexts'; import {Paginator} from '../components/Common'; @@ -38,6 +38,7 @@ jest.mock('../api', () => ({ ...jest.requireActual('../api'), getVideoComments: jest.fn(), getVideoDescription: jest.fn(), + searchChannels: jest.fn(), })); // Mock Media so Paginator renders both mobile + tablet variants synchronously. @@ -441,3 +442,67 @@ describe('useVideoExtras', () => { expect(result.current.description).toBe('edited'); }); }); + +describe('useSearchChannels', () => { + // State contract: null = pending, undefined = fetch failed, [] = no channels. The Other tab + // shows "No Channels" only for the last of those; `loading` is true from the first render. + beforeEach(() => searchChannels.mockReset()); + + test('is loading with no channels on the first render', () => { + searchChannels.mockReturnValue(new Promise(() => { + })); + const {result} = renderHook(() => useSearchChannels(['a'])); + expect(result.current.loading).toBe(true); + expect(result.current.channels).toBeNull(); + }); + + test('resolves to the channels and stops loading', async () => { + searchChannels.mockResolvedValue({channels: [{id: 1, name: 'One'}]}); + const {result} = renderHook(() => useSearchChannels(['a'])); + await act(async () => { + }); + expect(searchChannels).toHaveBeenCalledWith(['a']); + expect(result.current.channels).toEqual([{id: 1, name: 'One'}]); + expect(result.current.loading).toBe(false); + }); + + test('a failed fetch yields undefined, not "no channels"', async () => { + // The api helper returns undefined on a non-OK response, which the hook cannot destructure. + searchChannels.mockResolvedValue(undefined); + const spy = jest.spyOn(console, 'error').mockImplementation(() => { + }); + try { + const {result} = renderHook(() => useSearchChannels(['a'])); + await act(async () => { + }); + expect(result.current.channels).toBeUndefined(); + expect(result.current.loading).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + test('changing tags resets to pending, and a slow response for the old tags is dropped', async () => { + let resolveOld; + searchChannels.mockReturnValueOnce(new Promise(res => resolveOld = res)); + const {result} = renderHook(() => useSearchChannels(['old'])); + expect(result.current.loading).toBe(true); + + searchChannels.mockResolvedValue({channels: [{id: 2, name: 'New'}]}); + act(() => { + result.current.setTagNames(['new']); + }); + expect(result.current.channels).toBeNull(); + expect(result.current.loading).toBe(true); + await act(async () => { + }); + expect(result.current.channels).toEqual([{id: 2, name: 'New'}]); + expect(result.current.loading).toBe(false); + + await act(async () => { + resolveOld({channels: [{id: 1, name: 'Old'}]}); + }); + expect(result.current.channels).toEqual([{id: 2, name: 'New'}]); + expect(result.current.loading).toBe(false); + }); +});