From 4f31dd0a7f27357c5cc382c58a8cbae2e9fa2ad9 Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Thu, 26 Sep 2024 19:18:22 +0200 Subject: [PATCH 1/7] feat(hooks): implement suggestion api hook --- .../js/oarepo_ui/forms/constants.js | 2 + .../semantic-ui/js/oarepo_ui/forms/hooks.js | 214 +++++++++++++++++- 2 files changed, 209 insertions(+), 7 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/constants.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/constants.js index 056d7565..c6be71c4 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/constants.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/constants.js @@ -5,3 +5,5 @@ export const overridableComponentIds = [ "FormFields.container", "CustomFields.container", ]; + +export const DEFAULT_SUGGESTION_SIZE = 20; diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index 249d5d05..e20a08ec 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -1,3 +1,5 @@ +import * as React from "react"; +import axios from "axios"; import { useEffect, useCallback, useState, useContext, useMemo } from "react"; import { FormConfigContext, FieldDataContext } from "./contexts"; import { @@ -12,11 +14,17 @@ import _omit from "lodash/omit"; import _pick from "lodash/pick"; import _isEmpty from "lodash/isEmpty"; import _isObject from "lodash/isObject"; +import _debounce from "lodash/debounce"; +import _uniqBy from "lodash/uniqBy"; import { i18next } from "@translations/oarepo_ui/i18next"; import { relativeUrl } from "../util"; import { decode } from "html-entities"; import sanitizeHtml from "sanitize-html"; import { getValidTagsForEditor } from "@js/oarepo_ui"; +import { DEFAULT_SUGGESTION_SIZE } from "./constants"; +import { withCancel } from "react-searchkit"; +import queryString from "query-string"; +import { Message } from "semantic-ui-react"; export const extractFEErrorMessages = (obj) => { const errorMessages = []; @@ -178,7 +186,7 @@ export const useDepositApiClient = ({ ? new baseApiClient(createUrl, recordSerializer) : new OARepoDepositApiClient(createUrl, recordSerializer); - async function save(saveWithoutDisplayingValidationErrors = false) { + async function save (saveWithoutDisplayingValidationErrors = false) { let response; let errorsObj = {}; const errorPaths = []; @@ -250,7 +258,7 @@ export const useDepositApiClient = ({ } } - async function publish({ validate = false } = {}) { + async function publish ({ validate = false } = {}) { // call save and if save returns false, exit const saveResult = await save(); @@ -319,11 +327,11 @@ export const useDepositApiClient = ({ } } - async function read(recordUrl) { + async function read (recordUrl) { return await apiClient.readDraft({ self: recordUrl }); } - async function _delete(redirectUrl) { + async function _delete (redirectUrl) { if (!redirectUrl) throw new Error( "You must provide url where to be redirected after deleting a draft" @@ -351,7 +359,7 @@ export const useDepositApiClient = ({ } } - async function preview() { + async function preview () { setSubmitting(true); try { const saveResult = await save(); @@ -419,10 +427,10 @@ export const useDepositFileApiClient = (baseApiClient) => { ? new baseApiClient() : new OARepoDepositFileApiClient(); - async function read(draft) { + async function read (draft) { return await apiClient.readDraftFiles(draft); } - async function _delete(file) { + async function _delete (file) { setValues(_omit(values, ["errors"])); setSubmitting(true); try { @@ -490,4 +498,196 @@ export const useSanitizeInput = () => { }; }; +export const useSuggestionApi = ({ + initialSuggestions = [], + serializeSuggestions = (suggestions) => + suggestions.map((item) => ({ + text: item.title, + value: item.id, + key: item.id, + })), + serializeAddedValue, + debounceTime = 500, + preSearchChange = (x) => x, + suggestionAPIUrl, + suggestionAPIQueryParams = {}, + suggestionAPIHeaders = {}, + searchOnFocus = false, + searchQueryParamName = "suggest", + loadingMessage = "Loading...", + suggestionsErrorMessage = i18next.t("Something went wrong..."), + noQueryMessage = i18next.t("Search..."), + noResultsMessage = i18next.t("No results found."), +}) => { + const _initialSuggestions = initialSuggestions + ? serializeSuggestions(initialSuggestions) + : []; + + const [state, setState] = React.useState({ + isFetching: false, + suggestions: _initialSuggestions, + selectedSuggestions: _initialSuggestions, + error: false, + searchQuery: null, + open: false, + }); + + const [cancellableAction, setCancellableAction] = React.useState(); + + const handleAddition = React.useCallback( + (e, { value }, callbackFunc) => { + const { selectedSuggestions } = state; + const selectedSuggestion = serializeAddedValue + ? serializeAddedValue(value) + : { text: value, value, key: value, name: value }; + + const newSelectedSuggestions = [ + ...selectedSuggestions, + selectedSuggestion, + ]; + + setState((prevState) => ({ + selectedSuggestions: newSelectedSuggestions, + suggestions: _uniqBy( + [...prevState.suggestions, ...newSelectedSuggestions], + "value" + ), + })); + + callbackFunc(newSelectedSuggestions); + }, + [state.selectedSuggestions, serializeAddedValue] + ); + + const onSearchChange = React.useCallback( + _debounce(async (e, { searchQuery }) => { + cancellableAction && cancellableAction.cancel(); + await executeSearch(searchQuery); + // eslint-disable-next-line react/destructuring-assignment + }, debounceTime), + [cancellableAction, debounceTime] + ); + + const executeSearch = React.useCallback( + async (searchQuery) => { + const query = preSearchChange(searchQuery); + // If there is no query change, then display prevState suggestions + const { searchQuery: prevSearchQuery } = state; + if (prevSearchQuery === searchQuery) { + return; + } + setState({ isFetching: true, searchQuery: query }); + try { + const suggestions = await fetchSuggestions(query); + + const serializedSuggestions = serializeSuggestions(suggestions); + setState((prevState) => ({ + suggestions: _uniqBy( + [...prevState.selectedSuggestions, ...serializedSuggestions], + "value" + ), + isFetching: false, + error: false, + open: true, + })); + } catch (e) { + console.error(e); + setState({ + error: true, + isFetching: false, + }); + } + }, + [cancellableAction, preSearchChange, sserializeSuggestions] + ); + + const fetchSuggestions = React.useCallback( + async (searchQuery) => { + const _cancellableFetch = withCancel( + axios.get(suggestionAPIUrl, { + params: { + [searchQueryParamName]: searchQuery, + size: DEFAULT_SUGGESTION_SIZE, + ...suggestionAPIQueryParams, + }, + headers: suggestionAPIHeaders, + // There is a bug in axios that prevents brackets from being encoded, + // remove the paramsSerializer when fixed. + // https://github.com/axios/axios/issues/3316 + paramsSerializer: (params) => + queryString.stringify(params, { arrayFormat: "repeat" }), + }) + ); + setCancellableAction(_cancellableFetch); + + try { + const response = await _cancellableFetch.promise; + return response?.data?.hits?.hits; + } catch (e) { + console.error(e); + } + }, + [ + suggestionAPIUrl, + searchQueryParamName, + suggestionAPIQueryParams, + suggestionAPIHeaders, + ] + ); + + const getNoResultsMessage = React.useCallback(() => { + const { isFetching, error, searchQuery } = state; + if (isFetching) { + return loadingMessage; + } + if (error) { + return ; + } + if (!searchQuery) { + return noQueryMessage; + } + return noResultsMessage; + }, [ + loadingMessage, + noQueryMessage, + noResultsMessage, + suggestionsErrorMessage, + ]); + + const onClose = React.useCallback(() => { + setState({ open: false }); + }, []); + + const onBlur = React.useCallback(() => { + setState((prevState) => ({ + open: false, + error: false, + searchQuery: searchOnFocus ? prevState.searchQuery : null, + suggestions: searchOnFocus + ? prevState.suggestions + : prevState.selectedSuggestions, + })); + }, [searchOnFocus]); + + const onFocus = React.useCallback(async () => { + setState({ open: true }); + if (searchOnFocus) { + const { searchQuery } = state; + await executeSearch(searchQuery || ""); + } + }, [searchOnFocus]); + + return { + ...state, + fetchSuggestions, + executeSearch, + onSearchChange, + handleAddition, + getNoResultsMessage, + onClose, + onBlur, + onFocus, + }; +}; + export default useSanitizeInput; From 03dbd8842dfcbd08c02e7f93c4833788227f9824 Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Fri, 27 Sep 2024 19:44:49 +0200 Subject: [PATCH 2/7] refactor(suggest): cleanup use suggestions api hook --- .../semantic-ui/js/oarepo_ui/forms/hooks.js | 72 +++++++------------ 1 file changed, 25 insertions(+), 47 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index e20a08ec..8c4a0249 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -17,12 +17,12 @@ import _isObject from "lodash/isObject"; import _debounce from "lodash/debounce"; import _uniqBy from "lodash/uniqBy"; import { i18next } from "@translations/oarepo_ui/i18next"; -import { relativeUrl } from "../util"; +import { getTitleFromMultilingualObject, relativeUrl } from "../util"; import { decode } from "html-entities"; import sanitizeHtml from "sanitize-html"; import { getValidTagsForEditor } from "@js/oarepo_ui"; import { DEFAULT_SUGGESTION_SIZE } from "./constants"; -import { withCancel } from "react-searchkit"; +import { withCancel } from "react-invenio-forms"; import queryString from "query-string"; import { Message } from "semantic-ui-react"; @@ -502,7 +502,7 @@ export const useSuggestionApi = ({ initialSuggestions = [], serializeSuggestions = (suggestions) => suggestions.map((item) => ({ - text: item.title, + text: getTitleFromMultilingualObject(item.title), value: item.id, key: item.id, })), @@ -523,42 +523,17 @@ export const useSuggestionApi = ({ ? serializeSuggestions(initialSuggestions) : []; - const [state, setState] = React.useState({ + const _initialState = { isFetching: false, suggestions: _initialSuggestions, - selectedSuggestions: _initialSuggestions, error: false, searchQuery: null, open: false, - }); + } + const [state, setState] = React.useState(_initialState); const [cancellableAction, setCancellableAction] = React.useState(); - const handleAddition = React.useCallback( - (e, { value }, callbackFunc) => { - const { selectedSuggestions } = state; - const selectedSuggestion = serializeAddedValue - ? serializeAddedValue(value) - : { text: value, value, key: value, name: value }; - - const newSelectedSuggestions = [ - ...selectedSuggestions, - selectedSuggestion, - ]; - - setState((prevState) => ({ - selectedSuggestions: newSelectedSuggestions, - suggestions: _uniqBy( - [...prevState.suggestions, ...newSelectedSuggestions], - "value" - ), - })); - - callbackFunc(newSelectedSuggestions); - }, - [state.selectedSuggestions, serializeAddedValue] - ); - const onSearchChange = React.useCallback( _debounce(async (e, { searchQuery }) => { cancellableAction && cancellableAction.cancel(); @@ -576,29 +551,32 @@ export const useSuggestionApi = ({ if (prevSearchQuery === searchQuery) { return; } - setState({ isFetching: true, searchQuery: query }); + setState((prevState) => ({...prevState, isFetching: true, searchQuery: query })); try { const suggestions = await fetchSuggestions(query); const serializedSuggestions = serializeSuggestions(suggestions); - setState((prevState) => ({ - suggestions: _uniqBy( - [...prevState.selectedSuggestions, ...serializedSuggestions], - "value" - ), - isFetching: false, - error: false, - open: true, - })); + setState((prevState) => { + const newSuggestions = [...serializedSuggestions] + + return { + ...prevState, + suggestions: _uniqBy(newSuggestions, "value"), + isFetching: false, + error: false, + open: true, + }; + }); } catch (e) { console.error(e); - setState({ + setState((prevState) => ({ + ...prevState, error: true, isFetching: false, - }); + })); } }, - [cancellableAction, preSearchChange, sserializeSuggestions] + [cancellableAction, preSearchChange, serializeSuggestions] ); const fetchSuggestions = React.useCallback( @@ -655,11 +633,12 @@ export const useSuggestionApi = ({ ]); const onClose = React.useCallback(() => { - setState({ open: false }); + setState((prevState) => ({ ...prevState, open: false })); }, []); const onBlur = React.useCallback(() => { setState((prevState) => ({ + ...prevState, open: false, error: false, searchQuery: searchOnFocus ? prevState.searchQuery : null, @@ -670,7 +649,7 @@ export const useSuggestionApi = ({ }, [searchOnFocus]); const onFocus = React.useCallback(async () => { - setState({ open: true }); + setState((prevState) => ({ ...prevState, open: true })); if (searchOnFocus) { const { searchQuery } = state; await executeSearch(searchQuery || ""); @@ -682,7 +661,6 @@ export const useSuggestionApi = ({ fetchSuggestions, executeSearch, onSearchChange, - handleAddition, getNoResultsMessage, onClose, onBlur, From b16bf43b019ab37d76c3ea208a2db45a016a9177 Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Mon, 30 Sep 2024 14:15:28 +0200 Subject: [PATCH 3/7] refactor(suggest): further cleanup use suggestions api hook --- .../semantic-ui/js/oarepo_ui/forms/hooks.js | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index 8c4a0249..21a3e6f5 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -506,7 +506,6 @@ export const useSuggestionApi = ({ value: item.id, key: item.id, })), - serializeAddedValue, debounceTime = 500, preSearchChange = (x) => x, suggestionAPIUrl, @@ -514,10 +513,6 @@ export const useSuggestionApi = ({ suggestionAPIHeaders = {}, searchOnFocus = false, searchQueryParamName = "suggest", - loadingMessage = "Loading...", - suggestionsErrorMessage = i18next.t("Something went wrong..."), - noQueryMessage = i18next.t("Search..."), - noResultsMessage = i18next.t("No results found."), }) => { const _initialSuggestions = initialSuggestions ? serializeSuggestions(initialSuggestions) @@ -613,25 +608,6 @@ export const useSuggestionApi = ({ ] ); - const getNoResultsMessage = React.useCallback(() => { - const { isFetching, error, searchQuery } = state; - if (isFetching) { - return loadingMessage; - } - if (error) { - return ; - } - if (!searchQuery) { - return noQueryMessage; - } - return noResultsMessage; - }, [ - loadingMessage, - noQueryMessage, - noResultsMessage, - suggestionsErrorMessage, - ]); - const onClose = React.useCallback(() => { setState((prevState) => ({ ...prevState, open: false })); }, []); @@ -661,7 +637,6 @@ export const useSuggestionApi = ({ fetchSuggestions, executeSearch, onSearchChange, - getNoResultsMessage, onClose, onBlur, onFocus, From a12103d66808ba0bc155a97d0e8bb6d50fade6c2 Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Tue, 1 Oct 2024 16:33:44 +0200 Subject: [PATCH 4/7] refactor(suggest): massive streamlining of suggestions api hook --- .../semantic-ui/js/oarepo_ui/forms/hooks.js | 196 ++++++------------ 1 file changed, 66 insertions(+), 130 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index 21a3e6f5..35c37e75 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -499,147 +499,83 @@ export const useSanitizeInput = () => { }; export const useSuggestionApi = ({ - initialSuggestions = [], - serializeSuggestions = (suggestions) => - suggestions.map((item) => ({ - text: getTitleFromMultilingualObject(item.title), - value: item.id, - key: item.id, - })), - debounceTime = 500, - preSearchChange = (x) => x, - suggestionAPIUrl, - suggestionAPIQueryParams = {}, - suggestionAPIHeaders = {}, - searchOnFocus = false, - searchQueryParamName = "suggest", -}) => { + initialSuggestions = [], + serializeSuggestions = (suggestions) => + suggestions.map((item) => ({ + text: getTitleFromMultilingualObject(item.title), + value: item.id, + key: item.id, + })), + debounceTime = 500, + preSearchChange = (x) => x, + suggestionAPIUrl, + suggestionAPIQueryParams = {}, + suggestionAPIHeaders = {}, + searchQueryParamName = "suggest" + }) => { + const _initialSuggestions = initialSuggestions ? serializeSuggestions(initialSuggestions) : []; - const _initialState = { - isFetching: false, - suggestions: _initialSuggestions, - error: false, - searchQuery: null, - open: false, - } - - const [state, setState] = React.useState(_initialState); - const [cancellableAction, setCancellableAction] = React.useState(); + const [suggestions, setSuggestions] = useState(_initialSuggestions); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + React.useEffect(() => { + setLoading(true); + setSuggestions(initialSuggestions); + setError(null); + + const cancelToken = axios.CancelToken.source(); + axios + .get(suggestionAPIUrl, { + params: { + [searchQueryParamName]: query, + size: DEFAULT_SUGGESTION_SIZE, + ...suggestionAPIQueryParams, + }, + headers: suggestionAPIHeaders, + cancelToken: cancelToken.token, + // There is a bug in axios that prevents brackets from being encoded, + // remove the paramsSerializer when fixed. + // https://github.com/axios/axios/issues/3316 + paramsSerializer: (params) => + queryString.stringify(params, { arrayFormat: "repeat" }), + }) + .then((res) => { + const searchHits = res?.data?.hits?.hits; + const serializedSuggestions = serializeSuggestions(searchHits); + setSuggestions(_uniqBy(serializedSuggestions, "value")); + }) + .catch((err) => { + setError(err); + }).finally(() => { + setLoading(false) + }); - const onSearchChange = React.useCallback( - _debounce(async (e, { searchQuery }) => { - cancellableAction && cancellableAction.cancel(); - await executeSearch(searchQuery); - // eslint-disable-next-line react/destructuring-assignment - }, debounceTime), - [cancellableAction, debounceTime] - ); + return () => { + cancelToken.cancel(); + }; + }, [query, suggestionAPIUrl, searchQueryParamName]) // suggestionAPIQueryParams, suggestionAPIHeaders]); - const executeSearch = React.useCallback( - async (searchQuery) => { - const query = preSearchChange(searchQuery); - // If there is no query change, then display prevState suggestions - const { searchQuery: prevSearchQuery } = state; - if (prevSearchQuery === searchQuery) { + const executeSearch = (searchQuery) => { + const newQuery = preSearchChange(searchQuery); + // If there is no query change, then keep prevState suggestions + if (query === newQuery) { return; } - setState((prevState) => ({...prevState, isFetching: true, searchQuery: query })); - try { - const suggestions = await fetchSuggestions(query); - - const serializedSuggestions = serializeSuggestions(suggestions); - setState((prevState) => { - const newSuggestions = [...serializedSuggestions] - - return { - ...prevState, - suggestions: _uniqBy(newSuggestions, "value"), - isFetching: false, - error: false, - open: true, - }; - }); - } catch (e) { - console.error(e); - setState((prevState) => ({ - ...prevState, - error: true, - isFetching: false, - })); - } - }, - [cancellableAction, preSearchChange, serializeSuggestions] - ); - - const fetchSuggestions = React.useCallback( - async (searchQuery) => { - const _cancellableFetch = withCancel( - axios.get(suggestionAPIUrl, { - params: { - [searchQueryParamName]: searchQuery, - size: DEFAULT_SUGGESTION_SIZE, - ...suggestionAPIQueryParams, - }, - headers: suggestionAPIHeaders, - // There is a bug in axios that prevents brackets from being encoded, - // remove the paramsSerializer when fixed. - // https://github.com/axios/axios/issues/3316 - paramsSerializer: (params) => - queryString.stringify(params, { arrayFormat: "repeat" }), - }) - ); - setCancellableAction(_cancellableFetch); - - try { - const response = await _cancellableFetch.promise; - return response?.data?.hits?.hits; - } catch (e) { - console.error(e); - } - }, - [ - suggestionAPIUrl, - searchQueryParamName, - suggestionAPIQueryParams, - suggestionAPIHeaders, - ] - ); - - const onClose = React.useCallback(() => { - setState((prevState) => ({ ...prevState, open: false })); - }, []); - - const onBlur = React.useCallback(() => { - setState((prevState) => ({ - ...prevState, - open: false, - error: false, - searchQuery: searchOnFocus ? prevState.searchQuery : null, - suggestions: searchOnFocus - ? prevState.suggestions - : prevState.selectedSuggestions, - })); - }, [searchOnFocus]); - - const onFocus = React.useCallback(async () => { - setState((prevState) => ({ ...prevState, open: true })); - if (searchOnFocus) { - const { searchQuery } = state; - await executeSearch(searchQuery || ""); - } - }, [searchOnFocus]); + console.log('change', {query}, {newQuery}); + _debounce(() => setQuery(query), debounceTime) + } return { - ...state, - fetchSuggestions, + suggestions, + error, + loading, + query, executeSearch, - onSearchChange, - onClose, - onBlur, - onFocus, }; }; From d5656cb4a47ec506108e8d0d37f6c53abe3dbe6d Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Wed, 2 Oct 2024 14:00:02 +0200 Subject: [PATCH 5/7] refactor(suggest): make suggestion api debounce stable & properly manage hook state --- .../semantic-ui/js/oarepo_ui/forms/hooks.js | 85 ++++++++++++------- 1 file changed, 55 insertions(+), 30 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index 35c37e75..1f63553e 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -499,21 +499,20 @@ export const useSanitizeInput = () => { }; export const useSuggestionApi = ({ - initialSuggestions = [], - serializeSuggestions = (suggestions) => - suggestions.map((item) => ({ - text: getTitleFromMultilingualObject(item.title), - value: item.id, - key: item.id, - })), - debounceTime = 500, - preSearchChange = (x) => x, - suggestionAPIUrl, - suggestionAPIQueryParams = {}, - suggestionAPIHeaders = {}, - searchQueryParamName = "suggest" - }) => { - + initialSuggestions = [], + serializeSuggestions = (suggestions) => + suggestions.map((item) => ({ + text: getTitleFromMultilingualObject(item.title), + value: item.id, + key: item.id, + })), + debounceTime = 500, + preSearchChange = (x) => x, + suggestionAPIUrl, + suggestionAPIQueryParams = {}, + suggestionAPIHeaders = {}, + searchQueryParamName = "suggest", +}) => { const _initialSuggestions = initialSuggestions ? serializeSuggestions(initialSuggestions) : []; @@ -522,13 +521,42 @@ export const useSuggestionApi = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [query, setQuery] = useState(""); + // Inspired by: https://dev.to/alexdrocks/using-lodash-debounce-with-react-hooks-for-an-async-data-fetching-input-2p4g + const [didMount, setDidMount] = useState(false); + + const debouncedSearch = useMemo( + () => + _debounce((cancelToken) => fetchSuggestions(cancelToken), debounceTime), + [debounceTime, query] + ); + + useEffect(() => { + return () => { + // Make sure to stop the invocation of the debounced function after unmounting + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); React.useEffect(() => { + if (!didMount) { + // required to not call Suggestion API on initial render + setDidMount(true); + return; + } + + const cancelToken = axios.CancelToken.source(); + debouncedSearch(cancelToken); + + return () => { + cancelToken.cancel(); + }; + }, [query, suggestionAPIUrl, searchQueryParamName]); // suggestionAPIQueryParams, suggestionAPIHeaders]); + + function fetchSuggestions (cancelToken) { setLoading(true); setSuggestions(initialSuggestions); setError(null); - const cancelToken = axios.CancelToken.source(); axios .get(suggestionAPIUrl, { params: { @@ -551,23 +579,20 @@ export const useSuggestionApi = ({ }) .catch((err) => { setError(err); - }).finally(() => { - setLoading(false) + }) + .finally(() => { + setLoading(false); }); + } - return () => { - cancelToken.cancel(); - }; - }, [query, suggestionAPIUrl, searchQueryParamName]) // suggestionAPIQueryParams, suggestionAPIHeaders]); + function executeSearch (searchQuery) { + const newQuery = preSearchChange(searchQuery); + // If there is no query change, then keep prevState suggestions + if (query === newQuery) { + return; + } - const executeSearch = (searchQuery) => { - const newQuery = preSearchChange(searchQuery); - // If there is no query change, then keep prevState suggestions - if (query === newQuery) { - return; - } - console.log('change', {query}, {newQuery}); - _debounce(() => setQuery(query), debounceTime) + setQuery(newQuery); } return { From ef119b1083e32d81d021e0a6045ffce5ef9dd34b Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Thu, 3 Oct 2024 13:53:11 +0200 Subject: [PATCH 6/7] feat(suggest): implement no results state --- .../semantic-ui/js/oarepo_ui/forms/hooks.js | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js index 1f63553e..5168f6e0 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/forms/hooks.js @@ -22,9 +22,7 @@ import { decode } from "html-entities"; import sanitizeHtml from "sanitize-html"; import { getValidTagsForEditor } from "@js/oarepo_ui"; import { DEFAULT_SUGGESTION_SIZE } from "./constants"; -import { withCancel } from "react-invenio-forms"; import queryString from "query-string"; -import { Message } from "semantic-ui-react"; export const extractFEErrorMessages = (obj) => { const errorMessages = []; @@ -520,6 +518,7 @@ export const useSuggestionApi = ({ const [suggestions, setSuggestions] = useState(_initialSuggestions); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [noResults, setNoResults] = useState(false); const [query, setQuery] = useState(""); // Inspired by: https://dev.to/alexdrocks/using-lodash-debounce-with-react-hooks-for-an-async-data-fetching-input-2p4g const [didMount, setDidMount] = useState(false); @@ -554,6 +553,7 @@ export const useSuggestionApi = ({ function fetchSuggestions (cancelToken) { setLoading(true); + setNoResults(false); setSuggestions(initialSuggestions); setError(null); @@ -574,6 +574,10 @@ export const useSuggestionApi = ({ }) .then((res) => { const searchHits = res?.data?.hits?.hits; + if (searchHits.length === 0) { + setNoResults(true); + } + const serializedSuggestions = serializeSuggestions(searchHits); setSuggestions(_uniqBy(serializedSuggestions, "value")); }) @@ -585,21 +589,25 @@ export const useSuggestionApi = ({ }); } - function executeSearch (searchQuery) { - const newQuery = preSearchChange(searchQuery); - // If there is no query change, then keep prevState suggestions - if (query === newQuery) { - return; - } + const executeSearch = React.useCallback( + (searchQuery) => { + const newQuery = preSearchChange(searchQuery); + // If there is no query change, then keep prevState suggestions + if (query === newQuery) { + return; + } - setQuery(newQuery); - } + setQuery(newQuery); + }, + [query] + ); return { suggestions, error, loading, query, + noResults, executeSearch, }; }; From 6401ddc07047be6d3ae418e48b39b4a909fdb538 Mon Sep 17 00:00:00 2001 From: Miroslav Bauer Date: Tue, 8 Oct 2024 12:19:57 +0200 Subject: [PATCH 7/7] chore(release): release version 5.2.14 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0c219df5..7cf70da6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = oarepo-ui -version = 5.2.12 +version = 5.2.14 description = UI module for invenio 3.5+ long_description = file: README.md long_description_content_type = text/markdown