diff --git a/frontend/components/form/AsyncAutocompleteSC.tsx b/frontend/components/form/AsyncAutocompleteSC.tsx index ec34a4365..7acb9cd34 100644 --- a/frontend/components/form/AsyncAutocompleteSC.tsx +++ b/frontend/components/form/AsyncAutocompleteSC.tsx @@ -56,7 +56,7 @@ type AsyncAutocompleteProps = { ) => ReactNode config: AsyncAutocompleteConfig, defaultValue?: AutocompleteOption - onClear?: ()=>void + onClear?: () => void } @@ -138,12 +138,12 @@ export default function AsyncAutocompleteSC({status, options, config, onSearch(newInputValue) setProcessing(newInputValue) // debugger - } else if (loading === false && - reason !== 'reset') { + } else if (reason !== 'reset') { // reset reason occures when option is selected from the list // because search text is usually not identical to selected item // we ignore onInputChange event when reason==='reset' setInputValue(newInputValue) + // we start new search if processing // is not empty we should reset it?? if (processing !== '') { diff --git a/frontend/components/mention/FindMention.test.tsx b/frontend/components/mention/FindMention.test.tsx index 6c3b75822..2ee0c4a90 100644 --- a/frontend/components/mention/FindMention.test.tsx +++ b/frontend/components/mention/FindMention.test.tsx @@ -1,14 +1,30 @@ -import {render, screen, fireEvent, waitFor, waitForElementToBeRemoved, act} from '@testing-library/react' +import {render, screen, fireEvent, waitFor, act, waitForElementToBeRemoved} from '@testing-library/react' import {MentionItemProps, MentionTypeKeys} from '~/types/Mention' -import FindMention from './FindMention' +import FindMention, {FindMentionProps} from './FindMention' // default is non-resolved promise - for first test const mockSearchFn = jest.fn((props) => new Promise((res, rej) => {})) const mockAdd = jest.fn() const mockCreate = jest.fn() +// mock mention +const mockMentionItem = { + id: '1', + doi: 'doi-test', + url: 'url-test', + title: 'test-title', + authors: 'test-authors', + publisher: 'test-publisher', + publication_year: 2020, + page: null, + // url to external image + image_url: null, + // is_featured?: boolean + mention_type: 'book' as MentionTypeKeys, + source: 'crossref' +} -const props = { +let props:FindMentionProps = { config: { freeSolo: true, minLength: 3, @@ -21,11 +37,10 @@ const props = { onCreate: mockCreate } -beforeAll(() => { - jest.useFakeTimers() -}) +jest.useFakeTimers() -it('has cancel button when freeSolo', async() => { +it('has working cancel button when freeSolo', async () => { + props.config.freeSolo=true const searchFor = 'test string' // render component render() @@ -36,38 +51,36 @@ it('has cancel button when freeSolo', async() => { // need to advance all timers for debounce etc... // Note! it needs to be wrapped in act act(() => { - jest.runAllTimers() + jest.runOnlyPendingTimers() }) const cancelBtn = screen.getByRole('button', {hidden:true}) expect(cancelBtn).toBeInTheDocument() expect(cancelBtn.getAttribute('title')).toEqual('Cancel') - // press the button - fireEvent.click(cancelBtn) + // shows loader. NOTE! we do not return promise by default + const loader = screen.getByTestId('circular-loader') + expect(loader).toBeInTheDocument() + // press cancel button + fireEvent.click(cancelBtn) + // confim that loader is gone + expect(loader).not.toBeInTheDocument() }) -it('shows custom notFound message when freeSolo AND onCreate NOT provided', async() => { - // prepare - jest.useFakeTimers() - // custom props - const props = { - config: { - freeSolo: true, - minLength: 3, - label: 'Test label', - help: 'Test help', - reset: true, - noOptions: { - notFound: 'Tested not found message', - empty: 'Not tested', - minLength: 'Not tested' - } - }, - searchFn: mockSearchFn, - onAdd: mockAdd +it('shows custom notFound message when freeSolo AND onCreate NOT provided', async () => { + const notFound = 'Tested not found message' + props.config = { + ...props.config, + freeSolo: true, + noOptions: { + notFound, + empty: 'Not tested', + minLength: 'Not tested' + } } + props.onCreate = undefined + // resolve with no options mockSearchFn.mockResolvedValueOnce([]) @@ -81,114 +94,139 @@ it('shows custom notFound message when freeSolo AND onCreate NOT provided', asyn // need to advance all timers for debounce etc... // Note! it needs to be wrapped in act - act(() => { - jest.runAllTimers() + await act(() => { + jest.runOnlyPendingTimers() }) - // then wait for loader to be removed - await waitForElementToBeRemoved(() => screen.getByTestId('circular-loader')) - // wait for listbox and notFound message await waitFor(() => { const options = screen.getByRole('listbox') expect(options).toBeInTheDocument() - const notFound = screen.getByText(props.config.noOptions.notFound) - expect(notFound).toBeInTheDocument() + const notFoundMsg = screen.getByText(notFound) + expect(notFoundMsg).toBeInTheDocument() }) }) -describe('reset functionality', () => { - // custom props - const props = { - config: { - freeSolo: true, - minLength: 3, - label: 'Test label', - help: 'Test help', - reset: true, - noOptions: { - notFound: 'Tested not found message', - empty: 'Not tested', - minLength: 'Not tested' - } - }, - searchFn: mockSearchFn, - onAdd: mockAdd - } - const mockMentionItem = { - id: '1', - doi: 'doi-test', - url: 'url-test', - title: 'test-title', - authors: 'test-authors', - publisher: 'test-publisher', - publication_year: 2020, - page: null, - // url to external image - image_url: null, - // is_featured?: boolean - mention_type: 'book' as MentionTypeKeys, - source: 'crossref' - } +it('does not show Add option when onCreate=undefined', async () => { + // resolve with no options + mockSearchFn.mockResolvedValueOnce([mockMentionItem,mockMentionItem]) + // SET RESET TO FALSE + props.config.reset = false + // remove onCreate fn + props.onCreate = undefined + // render component + render() + // input test value + const searchInput = screen.getByRole('combobox') + const searchFor = 'test' + fireEvent.change(searchInput, {target: {value: searchFor}}) + + // need to advance all timers for debounce etc... + // Note! it needs to be wrapped in act + await act(() => { + jest.runOnlyPendingTimers() + }) + + // select only option in dropdown + const options = screen.getAllByRole('option') + expect(options.length).toEqual(2) + // click first options + fireEvent.click(options[0]) + expect(mockAdd).toBeCalledTimes(1) + expect(mockAdd).toBeCalledWith(mockMentionItem) +}) + +// NOT ALLOWED WITH MENTIONS but we test +it('shows Add option when onCreate defined', async () => { + // resolve with no options + mockSearchFn.mockResolvedValueOnce([mockMentionItem,mockMentionItem]) + // SET RESET TO FALSE + props.config.reset = false + // remove onCreate fn + props.onCreate = mockCreate + // render component + render() + // input test value + const searchInput = screen.getByRole('combobox') + const searchFor = 'test' + fireEvent.change(searchInput, {target: {value: searchFor}}) - beforeEach(() => { - // prepare - jest.useFakeTimers() - // resolve with no options - mockSearchFn.mockResolvedValueOnce([mockMentionItem,mockMentionItem]) + // need to advance all timers for debounce etc... + // Note! it needs to be wrapped in act + await act(() => { + jest.runOnlyPendingTimers() }) - it('removes input after selection when reset=true', async () => { - // render component - render() - // input test value - const searchInput = screen.getByRole('combobox') - const searchFor = 'test' - fireEvent.change(searchInput, {target: {value: searchFor}}) - // need to advance all timers for debounce etc... - // Note! it needs to be wrapped in act - act(() => { - jest.runAllTimers() - }) - - // then wait for loader to be removed - await waitForElementToBeRemoved(() => screen.getByTestId('circular-loader')) - const options = screen.getAllByRole('option') - expect(options.length).toEqual(2) - - // select option - fireEvent.click(options[0]) - // exper input to be reset - expect(searchInput).toHaveValue('') + // select only options in dropdown + const options = screen.getAllByRole('option') + expect(options.length).toEqual(3) + + // find Add est + const add = screen.getByText(`Add "${searchFor}"`) + expect(add).toBeInTheDocument() + + // select first option + const firstOption = options[0] + fireEvent.click(firstOption) + expect(mockCreate).toBeCalledTimes(1) + expect(mockCreate).toBeCalledWith(searchFor) +}) + +it('leaves input after selection when reset=false', async () => { + // resolve with no options + mockSearchFn.mockResolvedValueOnce([mockMentionItem,mockMentionItem]) + // SET RESET TO FALSE + props.config.reset = false + // remove onCreate fn + props.onCreate = undefined + // render component + render() + // input test value + const searchInput = screen.getByRole('combobox') + const searchFor = 'test' + fireEvent.change(searchInput, {target: {value: searchFor}}) + + // need to advance all timers for debounce etc... + // Note! it needs to be wrapped in act + await act(() => { + jest.runOnlyPendingTimers() }) - it('leaves input after selection when reset=false', async () => { - // SET RESET TO FALSE - props.config.reset=false - // render component - render() - // input test value - const searchInput = screen.getByRole('combobox') - const searchFor = 'test' - fireEvent.change(searchInput, {target: {value: searchFor}}) - - // need to advance all timers for debounce etc... - // Note! it needs to be wrapped in act - act(() => { - jest.runAllTimers() - }) - - // then wait for loader to be removed - await waitForElementToBeRemoved(() => screen.getByTestId('circular-loader')) - - // select only option in dropdown - const options = screen.getAllByRole('option') - expect(options.length).toEqual(2) - - // click on the option - fireEvent.click(options[0]) - - // expect input to be preset - expect(searchInput).toHaveValue(searchFor) + // select only option in dropdown + const options = screen.getAllByRole('option') + expect(options.length).toEqual(2) + + // click on the option + fireEvent.click(options[0]) + + // expect input to be preset + expect(searchInput).toHaveValue(searchFor) +}) + +it('removes input after selection when reset=true', async () => { + // resolve with no options + mockSearchFn.mockResolvedValueOnce([mockMentionItem, mockMentionItem]) + // SET RESET TO FALSE + props.config.reset = true + // remove onCreate fn + props.onCreate = undefined + // render component + render() + // input test value + const searchInput = screen.getByRole('combobox') + const searchFor = 'test' + fireEvent.change(searchInput, {target: {value: searchFor}}) + + // need to advance all timers for debounce etc... + // Note! it needs to be wrapped in act + await act(() => { + jest.runOnlyPendingTimers() }) + + const options = screen.getAllByRole('option') + expect(options.length).toEqual(2) + // select option + fireEvent.click(options[0]) + // exper input to be reset + expect(searchInput).toHaveValue('') }) diff --git a/frontend/components/mention/FindMention.tsx b/frontend/components/mention/FindMention.tsx index a81b9c946..3a3952e26 100644 --- a/frontend/components/mention/FindMention.tsx +++ b/frontend/components/mention/FindMention.tsx @@ -12,97 +12,119 @@ import {MentionItemProps} from '~/types/Mention' import {getMentionType} from './config' import MentionItemBase from './MentionItemBase' -type FindMentionProps = { +export type FindMentionProps = { config: AsyncAutocompleteConfig searchFn: (title:string) => Promise onAdd: (item: MentionItemProps) => void onCreate?: (keyword: string) => void - onCancel?: () => void } -let cancel = false +type FindMentionState = { + options: AutocompleteOption[] + loading: boolean + searchFor: string | undefined + foundFor: string | undefined +} + +// Use global variable processing +// to return options of the last processing request +let processing = '' export default function FindMention({config, onAdd, searchFn, onCreate}: FindMentionProps) { - const [state, setState] = useState<{ - options: AutocompleteOption[] - loading: boolean - searchFor: string | undefined - foundFor: string | undefined - }>({ + const [state, setState] = useState({ options: [], loading: false, searchFor: undefined, foundFor: undefined }) - const {options,searchFor,foundFor,loading} = state + const {options, searchFor, foundFor, loading} = state // console.group('FindMention') - // console.log('options...', options) // console.log('searchFor...', searchFor) // console.log('foundFor...', foundFor) + // console.log('processing...', processing) // console.log('loading...', loading) - // console.log('cancel...', cancel) + // console.log('options...', options) // console.groupEnd() + useEffect(() => { + // because we use global variable + // we need to reset value on each component load + // otherwise the value is memorized and the last + // processing value will be present. + processing='' + },[]) + useEffect(() => { async function searchForItems() { + if (typeof searchFor == 'undefined') return + if (searchFor === foundFor) return + if (searchFor === processing) return + // flag start of the process + processing = searchFor setState({ searchFor, foundFor, options: [], loading: true }) - if (!searchFor) return // make request - // console.log('call searchFn for...', searchFor) const resp = await searchFn(searchFor) - // console.log('cancel...', cancel) - // debugger - // if cancel is used we abort this function - if (cancel) return + // prepare options const options = resp.map((item,pos) => ({ key: pos.toString(), label: item.title ?? '', data: item })) - // debugger - setState({ - searchFor, - options, - loading: false, - foundFor: searchFor - }) - } - if (searchFor && - searchFor !== foundFor && - loading === false && - cancel == false - ) { - // debugger - searchForItems() + // ONLY if the response concerns the processing term. + // processing is a "global" variable and keeps track of + // the most recent request, previous request are ignored. + // This logic is needed because we allow user to change + // search term during the api request. + if (searchFor === processing) { + // console.group('FindMention.searchForItems.UseResponse') + // console.log('searchFor...', searchFor) + // console.log('foundFor...', foundFor) + // console.log('processing...', processing) + // console.log('loading...', loading) + // console.log('options...', options) + // console.groupEnd() + // debugger + setState({ + searchFor, + options, + loading: false, + foundFor: searchFor + }) + } } - },[searchFor,foundFor,loading,searchFn]) + // call search function + if (searchFor) searchForItems() + // Note! we ignore searchFn in dependency array because it's not memoized + // eslint-disable-next-line react-hooks/exhaustive-deps + },[searchFor,foundFor]) function onCancel() { - // we reset state and set cancel on true - // for any call to abort if in process (see useEffect) + // remove processing value to avoid state update + // see useEffect line 88 + processing = '' + // clear options and loading state setState({ options: [], loading: false, searchFor: undefined, foundFor: undefined }) - cancel = true } - function onAddKeyword(selected:AutocompleteOption) { + function onAddMention(selected: AutocompleteOption) { if (selected && selected.data) { onAdd(selected.data) } } - function createKeyword(newInputValue: string) { + function createMention(newInputValue: string) { if (onCreate) onCreate(newInputValue) } @@ -112,7 +134,7 @@ export default function FindMention({config, onAdd, searchFn, onCreate}: FindMen // and freeSolo (free text) options is enabled // but there are no options to show // we change addOption to No options message - // NOTE! the order of conditionals in this fn matters + // NOTE! the order of conditionals matters if (typeof onCreate == 'undefined' && config.freeSolo === true && options.length === 0) { @@ -120,7 +142,7 @@ export default function FindMention({config, onAdd, searchFn, onCreate}: FindMen // while p or div element is handled as message return

{ config.noOptions?.notFound ?? - 'Hmmm...nothing found. Check spelling or try again later.' + 'Hmm...nothing found. Check the spelling or maybe try later.' }

} // ignore add option if minLength not met or @@ -179,7 +201,6 @@ export default function FindMention({config, onAdd, searchFn, onCreate}: FindMen }} options={options} onSearch={(searchFor) => { - cancel = false, setState({ ...state, // remove options @@ -188,8 +209,8 @@ export default function FindMention({config, onAdd, searchFn, onCreate}: FindMen searchFor, }) }} - onAdd={onAddKeyword} - onCreate={createKeyword} + onAdd={onAddMention} + onCreate={createMention} onRenderOption={renderOption} config={{ ...config, diff --git a/frontend/components/mention/useSearchFn.tsx b/frontend/components/mention/useSearchFn.tsx new file mode 100644 index 000000000..ba93bdf17 --- /dev/null +++ b/frontend/components/mention/useSearchFn.tsx @@ -0,0 +1,67 @@ +import {useEffect, useState} from 'react' +import {MentionItemProps} from '~/types/Mention' +import {AutocompleteOption} from '../form/AsyncAutocompleteSC' + +type useSearchFnProps={ + searchFor: string, + searchFn: (searchFor:string)=>Promise +} + +type useSearchState = { + options: AutocompleteOption[] + loading: boolean + searchFor: string + foundFor?: string +} + +export default function useSearchFn({searchFor, searchFn}: useSearchFnProps) { + const [state, setState] = useState({ + options: [], + loading: true, + searchFor + }) + + useEffect(() => { + let abort=false + async function searchForItems() { + setState({ + options: [], + loading: true, + searchFor, + }) + // make request + // console.log('call searchFn for...', searchFor) + const resp = await searchFn(searchFor) + // console.log('abort...', abort) + const options = resp.map((item,pos) => ({ + key: pos.toString(), + label: item.title ?? '', + data: item + })) + // debugger + // if cancel is used we abort this function + if (abort) return + // debugger + setState({ + searchFor, + options, + loading: false, + foundFor: searchFor + }) + } + if (searchFor && + abort === false + ) { + // debugger + searchForItems() + } + return () => { + debugger + abort=true + } + }, [searchFor, searchFn]) + + return { + ...state + } +}