From 6afd09a7364189b54b6ce2cf06ec13e773147f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Ka=CC=88gy?= Date: Thu, 6 Jun 2024 11:58:58 +0200 Subject: [PATCH] refactor content search to use react query --- components/content-search/index.tsx | 330 +++++++--------------------- components/content-search/types.ts | 53 +++++ package-lock.json | 25 +++ package.json | 1 + 4 files changed, 154 insertions(+), 255 deletions(-) create mode 100644 components/content-search/types.ts diff --git a/components/content-search/index.tsx b/components/content-search/index.tsx index 27a89001..4a1effed 100644 --- a/components/content-search/index.tsx +++ b/components/content-search/index.tsx @@ -4,9 +4,24 @@ import { useState, useRef, useEffect, useCallback } from '@wordpress/element'; import { addQueryArgs } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; import styled from '@emotion/styled'; +import {useMergeRefs} from '@wordpress/compose'; import SearchItem, { Suggestion } from './SearchItem'; import { StyledComponentContext } from '../styled-components-context'; +import type { QueryCache, SearchResult, ContentSearchProps } from './types'; + +import { + QueryClient, + QueryClientProvider, + useQuery, + useInfiniteQuery, + QueryFunction, + } from '@tanstack/react-query'; +import { useOnClickOutside } from '../../hooks/use-on-click-outside'; + + +const queryClient = new QueryClient(); + const NAMESPACE = 'tenup-content-search'; // Equalize height of list icons to match loader in order to reduce jumping. @@ -47,57 +62,6 @@ const StyledSearchControl = styled(SearchControl)` width: 100%; `; -interface QueryCache { - results: SearchResult[] | null; - controller: null | number | AbortController; - currentPage: number | null; - totalPages: number | null; -} - -interface SearchResult { - id: number; - title: string; - url: string; - type: string; - subtype: string; - link?: string; - name?: string; -} - -interface QueryArgs { - perPage: number; - page: number; - contentTypes: string[]; - mode: string; - keyword: string; -} - -interface RenderItemComponentProps { - item: Suggestion; - onSelect: () => void; - searchTerm?: string; - isSelected?: boolean; - id?: string; - contentTypes: string[]; - renderType?: (suggestion: Suggestion) => string; -} - -interface ContentSearchProps { - onSelectItem: (item: Suggestion) => void; - placeholder?: string; - label?: string; - hideLabelFromVision?: boolean; - contentTypes?: string[]; - mode?: 'post' | 'user' | 'term'; - perPage?: number; - queryFilter?: (query: string, args: QueryArgs) => string; - excludeItems?: { - id: number; - }[]; - renderItemType?: (props: Suggestion) => string; - renderItem?: (props: RenderItemComponentProps) => JSX.Element; - fetchInitialResults?: boolean; -} const ContentSearch: React.FC = ({ onSelectItem = () => { @@ -116,13 +80,9 @@ const ContentSearch: React.FC = ({ fetchInitialResults, }) => { const [searchString, setSearchString] = useState(''); - const [searchQueries, setSearchQueries] = useState<{[key: string]: QueryCache}>({}); const [selectedItem, setSelectedItem] = useState(null); - const [currentPage, setCurrentPage] = useState(1); const [isFocused, setIsFocused] = useState(false); - const mounted = useRef(true); - const searchContainer = useRef(null); const filterResults = useCallback( @@ -233,214 +193,65 @@ const ContentSearch: React.FC = ({ [mode, filterResults], ); - /** - * handleSearchStringChange - * - * Using the keyword and the list of tags that are linked to the parent - * block search for posts/terms/users that match and return them to the - * autocomplete component. - * - * @param {string} keyword search query string - * @param {number} page page query string - */ - const handleSearchStringChange = (keyword: string, page: number) => { - // Reset page and query on empty keyword. - if (keyword.trim() === '') { - setCurrentPage(1); - } - - const preparedQuery = prepareSearchQuery(keyword, page); - - // Only do query if not cached or previously errored/cancelled. - if (!searchQueries[preparedQuery] || searchQueries[preparedQuery].controller === 1) { - setSearchQueries((queries) => { - // New queries. - const newQueries: {[key: string]: QueryCache} = {}; - - // Remove errored or cancelled queries. - Object.keys(queries).forEach((query) => { - if (queries[query].controller !== 1) { - newQueries[query] = queries[query]; - } - }); - - newQueries[preparedQuery] = { - results: null, - controller: null, - currentPage: page, - totalPages: null, - }; - - return newQueries; - }); - } - - setCurrentPage(page); - - setSearchString(keyword); - }; - - const handleLoadMore = () => { - handleSearchStringChange(searchString, currentPage + 1); - }; - - useEffect(() => { - // Trigger initial fetch if enabled. - if (fetchInitialResults) { - handleSearchStringChange('', 1); - } - - return () => { - mounted.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - Object.keys(searchQueries).forEach((searchQueryString) => { - const searchQuery = searchQueries[searchQueryString]; - - if (searchQueryString !== prepareSearchQuery(searchString, currentPage)) { - if (searchQuery.controller && typeof searchQuery.controller === 'object') { - searchQuery.controller.abort(); - } - } else if (searchQuery.results === null && searchQuery.controller === null) { - const controller = new AbortController(); - - apiFetch({ + const clickOutsideRef = useOnClickOutside(() => { + setIsFocused(false); + }); + + const mergedRef = useMergeRefs([searchContainer, clickOutsideRef]); + + const { + status, + data, + error, + isFetching, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + } = useInfiniteQuery( + { + queryKey: ['search', searchString, contentTypes.join(','), mode, perPage], + queryFn: async ({ pageParam = 1 }) => { + const searchQueryString = prepareSearchQuery(searchString, pageParam); + const response = await apiFetch({ path: searchQueryString, - signal: controller.signal, parse: false, - }) - .then((results: Response) => { - const totalPages = parseInt( - ( results.headers && results.headers.get('X-WP-TotalPages') ) || '0', - 10, - ); - - // Parse, because we set parse to false to get the headers. - results.json().then((results: SearchResult[]) => { - if (mounted.current === false) { - return; - } - const normalizedResults = normalizeResults(results); - - setSearchQueries((queries) => { - const newQueries = { ...queries }; - - if (typeof newQueries[searchQueryString] === 'undefined') { - newQueries[searchQueryString] = { - results: null, - controller: null, - totalPages: null, - currentPage: null, - }; - } - - newQueries[searchQueryString].results = normalizedResults; - newQueries[searchQueryString].totalPages = totalPages; - newQueries[searchQueryString].controller = 0; - - return newQueries; - }); - }); - }) - .catch((error) => { - // fetch_error means the request was aborted - if (error.code !== 'fetch_error') { - setSearchQueries((queries) => { - const newQueries = { ...queries }; - - if (typeof newQueries[searchQueryString] === 'undefined') { - newQueries[searchQueryString] = { - results: null, - controller: null, - totalPages: null, - currentPage: null, - }; - } + }); - newQueries[searchQueryString].controller = 1; - newQueries[searchQueryString].results = []; + const totalPages = parseInt( + ( response.headers && response.headers.get('X-WP-TotalPages') ) || '0', + 10, + ); - return newQueries; - }); - } - }); + const results = await response.json(); + const normalizedResults = normalizeResults(results); - setSearchQueries((queries) => { - const newQueries = { ...queries }; + const hasNextPage = totalPages > pageParam; + const hasPreviousPage = pageParam > 1; - newQueries[searchQueryString].controller = controller; + return { + results: normalizedResults, + nextPage: hasNextPage ? pageParam + 1 : undefined, + previousPage: hasPreviousPage ? pageParam - 1 : undefined, + }; + }, + getNextPageParam: (lastPage) => lastPage.nextPage, + getPreviousPageParam: (firstPage) => firstPage.previousPage, + initialPageParam: 1 + } + ); - return newQueries; - }); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchQueries, searchString, currentPage]); - - let searchResults: SearchResult[] | null = null; - let isLoading = true; - let showLoadMore = false; - - for (let i = 1; i <= currentPage; i++) { - // eslint-disable-next-line no-loop-func - Object.keys(searchQueries).forEach((searchQueryString) => { - const searchQuery = searchQueries[searchQueryString]; - - if (searchQueryString === prepareSearchQuery(searchString, i)) { - if (searchQuery.results !== null) { - if (searchResults === null) { - searchResults = []; - } - - searchResults = searchResults.concat(searchQuery.results); - - // If on last page, maybe show load more button - if (i === currentPage) { - isLoading = false; - - if ( searchQuery.totalPages && searchQuery.currentPage && searchQuery.totalPages > searchQuery.currentPage) { - showLoadMore = true; - } - } - } else if (searchQuery.controller === 1 && i === currentPage) { - isLoading = false; - showLoadMore = false; - } - } - }); - } + const searchResults = data?.pages.map((page) => page?.results).flat() || undefined; - if (searchResults !== null) { - searchResults = filterResults(searchResults); - } const hasSearchString = !!searchString.length; const hasSearchResults = searchResults && !!searchResults.length; const hasInitialResults = fetchInitialResults && isFocused; - // Add event listener to close search results when clicking outside of the search container. - useEffect(() => { - document.addEventListener('mouseup', (e: MouseEvent) => { - // Bail if anywhere inside search container is clicked. - if ( - searchContainer.current?.contains(e.target as Node) - ) { - return; - } - - setIsFocused(false); - }); - }, []); - return ( - - + { - handleSearchStringChange(newSearchString, 1); + setSearchString(newSearchString); }} label={label} hideLabelFromVision={hideLabelFromVision} @@ -454,9 +265,9 @@ const ContentSearch: React.FC = ({ {hasSearchString || hasInitialResults ? ( <> - {isLoading && currentPage === 1 && } + {status === 'pending' && } - {!isLoading && !hasSearchResults && ( + {!!error || (!isFetching && !hasSearchResults) && (
  • = ({
  • )} { - (!isLoading || currentPage > 1) && + status === 'success' && searchResults && searchResults.map((item, index) => { - if (!item.title.length) { + if (!item || !item.title.length) { return null; } @@ -514,20 +325,29 @@ const ContentSearch: React.FC = ({ })}
    - {!isLoading && hasSearchResults && showLoadMore && ( + {hasSearchResults && hasNextPage && ( - )} - {isLoading && currentPage > 1 && } + {isFetchingNextPage && } ) : null}
    + ); +}; + +const ContentSearchWrapper: React.FC = (props) => { + return ( + + + + ); }; -export { ContentSearch }; +export { ContentSearchWrapper as ContentSearch}; diff --git a/components/content-search/types.ts b/components/content-search/types.ts new file mode 100644 index 00000000..763c47b3 --- /dev/null +++ b/components/content-search/types.ts @@ -0,0 +1,53 @@ +import type { Suggestion } from './SearchItem'; + +export interface QueryCache { + results: SearchResult[] | null; + controller: null | number | AbortController; + currentPage: number | null; + totalPages: number | null; +} + +export interface SearchResult { + id: number; + title: string; + url: string; + type: string; + subtype: string; + link?: string; + name?: string; +} + +export interface QueryArgs { + perPage: number; + page: number; + contentTypes: string[]; + mode: string; + keyword: string; +} + +export interface RenderItemComponentProps { + item: Suggestion; + onSelect: () => void; + searchTerm?: string; + isSelected?: boolean; + id?: string; + contentTypes: string[]; + renderType?: (suggestion: Suggestion) => string; +} + +export interface ContentSearchProps { + onSelectItem: (item: Suggestion) => void; + placeholder?: string; + label?: string; + hideLabelFromVision?: boolean; + contentTypes?: string[]; + mode?: 'post' | 'user' | 'term'; + perPage?: number; + queryFilter?: (query: string, args: QueryArgs) => string; + excludeItems?: { + id: number; + }[]; + renderItemType?: (props: Suggestion) => string; + renderItem?: (props: RenderItemComponentProps) => JSX.Element; + fetchInitialResults?: boolean; +} diff --git a/package-lock.json b/package-lock.json index d8b06d11..89f568af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@emotion/styled": "^11.11.5", "@floating-ui/react-dom": "^2.0.9", "@leeoniya/ufuzzy": "^1.0.14", + "@tanstack/react-query": "^5.40.1", "@wordpress/icons": "^9.48.0", "array-move": "^4.0.0", "prop-types": "^15.8.1", @@ -5935,6 +5936,30 @@ "integrity": "sha512-oocsqY7g0cR+Gur5jRQLSrX2OtpMLMse1I10JQBm8CdGMrDkh1Mg2gjsiquMHRtBs4Qwu5wgEp5GgIYHk4SNPw==", "dev": true }, + "node_modules/@tanstack/query-core": { + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.40.0.tgz", + "integrity": "sha512-eD8K8jsOIq0Z5u/QbvOmfvKKE/XC39jA7yv4hgpl/1SRiU+J8QCIwgM/mEHuunQsL87dcvnHqSVLmf9pD4CiaA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.40.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.40.1.tgz", + "integrity": "sha512-gOcmu+gpFd2taHrrgMM9RemLYYEDYfsCqszxCC0xtx+csDa4R8t7Hr7SfWXQP13S2sF+mOxySo/+FNXJFYBqcA==", + "dependencies": { + "@tanstack/query-core": "5.40.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", diff --git a/package.json b/package.json index 2763b431..40ab3e2e 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@emotion/styled": "^11.11.5", "@floating-ui/react-dom": "^2.0.9", "@leeoniya/ufuzzy": "^1.0.14", + "@tanstack/react-query": "^5.40.1", "@wordpress/icons": "^9.48.0", "array-move": "^4.0.0", "prop-types": "^15.8.1",