From 70d335a9774da33c1043f3c3d2f1febac436079c Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 26 Sep 2026 12:28:27 -0600 Subject: [PATCH] app: search dropdown offers "Channels: No results" only after a search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSuggestions started with channels: [] and reset to that on every keystroke, and the normalizer treats an empty list as "the server found none". So the global search dropdown showed a "Channels: No results" row on focus before anything was typed, and on every keystroke through the debounce and the request. Because that row counts as a result, the SearchBox's own "Searching…" state never appeared. The channel, domain, author and subject groups now start as null, meaning not fetched; an empty list only ever comes from the server. The count summary tolerates the null. Tests render the hook and check no Channels group exists before a search or while the request is pending, and that the "No results" row and real rows still appear after the response. Co-Authored-By: Claude Fable 5.1 --- app/src/components/Search.js | 14 +-- app/src/components/SearchSuggestions.test.js | 105 +++++++++++++++++++ 2 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 app/src/components/SearchSuggestions.test.js diff --git a/app/src/components/Search.js b/app/src/components/Search.js index 96abca9a..fde58c6f 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -131,12 +131,14 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false } export function useSuggestions(searchStr, tagNames, filter, anyTag) { + // The general groups are null until the suggestion request returns: an empty list means the + // server found none, and the dropdown may say "No results" only for that. const defaultSuggestions = { fileGroups: [], - channels: [], - domains: [], - authors: [], - subjects: [], + channels: null, + domains: null, + authors: null, + subjects: null, zimsEstimates: [], } const [suggestions, setSuggestions] = React.useState(defaultSuggestions); @@ -401,8 +403,8 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag) zims: zimSum, otherSum: otherSum, mapPlaces: newSuggestions.mapPlaces || 0, - channels: newSuggestions.channels.length, - domains: newSuggestions.domains.length, + channels: newSuggestions.channels?.length, + domains: newSuggestions.domains?.length, authors: newSuggestions.authors?.length, subjects: newSuggestions.subjects?.length, tags: matchingTags?.length, diff --git a/app/src/components/SearchSuggestions.test.js b/app/src/components/SearchSuggestions.test.js new file mode 100644 index 00000000..798f459d --- /dev/null +++ b/app/src/components/SearchSuggestions.test.js @@ -0,0 +1,105 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {useSearchSuggestions} from './Search'; +import {TagsContext} from '../Tags'; +import {tagsContextFixture} from '../test-fixtures'; +import { + searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions, +} from '../api'; + +// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row +// may only appear once the suggestion request has come back empty, never before the user has +// typed, and never while the request is pending. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + searchSuggestions: jest.fn(), + searchEstimateFiles: jest.fn(), + searchEstimateMap: jest.fn(), + searchEstimateOthers: jest.fn(), + searchEstimateZims: jest.fn(), +})); + +const DEBOUNCE = 500; +const pending = () => new Promise(() => { +}); + +let hook; + +function Probe() { + hook = useSearchSuggestions('', [], false); + return
{JSON.stringify(hook.suggestionsResults)}
; +} + +const results = () => JSON.parse(screen.getByTestId('results').textContent); + +const renderProbe = () => render(, { + contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]], +}); + +beforeEach(() => { + jest.useFakeTimers(); + searchEstimateFiles.mockReturnValue(pending()); + searchEstimateMap.mockReturnValue(pending()); + searchEstimateOthers.mockReturnValue(pending()); + searchEstimateZims.mockReturnValue(pending()); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); +}); + +test('no Channels group before anything has been searched', () => { + searchSuggestions.mockReturnValue(pending()); + renderProbe(); + expect(results().channels).toBeUndefined(); +}); + +test('no Channels group while the suggestion request is pending', () => { + searchSuggestions.mockReturnValue(pending()); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + expect(results().channels).toBeUndefined(); + + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + expect(searchSuggestions).toHaveBeenCalledWith('foo'); + expect(results().channels).toBeUndefined(); + expect(hook.loading).toBe(true); +}); + +test('a Channels "No results" row once the server said there are none', async () => { + let resolve; + searchSuggestions.mockReturnValue(new Promise(res => resolve = res)); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + await act(async () => { + resolve({channels: [], domains: [], authors: [], subjects: []}); + }); + expect(results().channels.results).toEqual([{title: 'No results'}]); +}); + +test('Channels rows once the server found some', async () => { + let resolve; + searchSuggestions.mockReturnValue(new Promise(res => resolve = res)); + renderProbe(); + act(() => { + hook.setSearchStr('foo'); + }); + act(() => { + jest.advanceTimersByTime(DEBOUNCE); + }); + await act(async () => { + resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []}); + }); + expect(results().channels.results.map(i => i.title)).toEqual(['Food']); +});