mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
app: search Other tab distinguishes pending channels from none
useSearchChannels initialised channels to [] and loading to false, and only set loading inside its mount effect, so the Other tab's first render painted "No Channels" with a count of 0 before the search had run. A failed search was rendered the same way, since the hook had no catch and the API helper returns undefined on a non-OK response. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] = no channels; loading is true from the first render because the search starts on mount. * Changing tags resets to pending, and only the newest request may write state, so a slow response for previous tags is dropped. * The tab shows a loader while pending, an error on failure, and "No Channels" only once the server confirmed there are none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
811793d767
commit
770e80fa3e
4 changed files with 148 additions and 9 deletions
56
app/src/components/OtherSearchView.test.js
Normal file
56
app/src/components/OtherSearchView.test.js
Normal file
|
|
@ -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(<OtherSearchView loading={false}/>);
|
||||
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(<OtherSearchView loading={false}/>);
|
||||
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(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('loaded: lists the channels', async () => {
|
||||
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
|
||||
render(<OtherSearchView loading={false}/>);
|
||||
await act(async () => {
|
||||
});
|
||||
expect(screen.getByText('Cooking')).toBeInTheDocument();
|
||||
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -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}) {
|
|||
</div>
|
||||
}
|
||||
|
||||
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 <Panel><Loading/></Panel>
|
||||
} else if (channels === undefined) {
|
||||
return <Panel><ErrorMessage>Could not fetch the channels.</ErrorMessage></Panel>
|
||||
}
|
||||
|
||||
return <Accordion value={activeValue} onChange={setActiveValue}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue