diff --git a/src/app/WardrobePage/Item.js b/src/app/WardrobePage/Item.js index e2ba6d5..a2dc7e5 100644 --- a/src/app/WardrobePage/Item.js +++ b/src/app/WardrobePage/Item.js @@ -303,17 +303,21 @@ function LinkOrButton({ href, component = Button, ...props }) { * ItemListContainer is a container for Item components! Wrap your Item * components in this to ensure a consistent list layout. */ -export function ItemListContainer({ children }) { - return {children}; +export function ItemListContainer({ children, ...props }) { + return ( + + {children} + + ); } /** * ItemListSkeleton is a placeholder for when an ItemListContainer and its * Items are loading. */ -export function ItemListSkeleton({ count }) { +export function ItemListSkeleton({ count, ...props }) { return ( - + {Array.from({ length: count }).map((_, i) => ( ))} diff --git a/src/app/WardrobePage/ItemsAndSearchPanels.js b/src/app/WardrobePage/ItemsAndSearchPanels.js index 9eeb9af..9956fbe 100644 --- a/src/app/WardrobePage/ItemsAndSearchPanels.js +++ b/src/app/WardrobePage/ItemsAndSearchPanels.js @@ -38,7 +38,7 @@ function ItemsAndSearchPanels({ - + - - - + ) : ( - + @@ -82,15 +81,15 @@ function SearchResults({ query, outfitState, dispatchToOutfit, - scrollContainerRef, firstSearchResultRef, onMoveFocusUpToQuery, }) { - const { loading, loadingMore, error, items, fetchMore } = useSearchResults( + const [currentPageNumber, setCurrentPageNumber] = React.useState(1); + const { loading, error, items, numTotalPages } = useSearchResults( query, - outfitState + outfitState, + currentPageNumber ); - useScrollTracker(scrollContainerRef, 300, fetchMore); // This will save the `wornItemIds` when the SearchResults first mounts, and // keep it saved even after the outfit changes. We use this to try to restore @@ -123,14 +122,10 @@ function SearchResults({ } }, []); + const searchPanelBackground = useColorModeValue("white", "gray.900"); + // If the results aren't ready, we have some special case UI! - if (loading) { - return ( - - - - ); - } else if (error) { + if (error) { return ( We hit an error trying to load your search results{" "} @@ -140,24 +135,30 @@ function SearchResults({ Try again? ); - } else if (items.length === 0) { - return ( - - We couldn't find any matching items{" "} - - 🤔 - {" "} - Try again? - - ); } // Finally, render the item list, with checkboxes and Item components! // We also render some extra skeleton items at the bottom during infinite // scroll loading. return ( - <> - + + + null} + /> + + {items.map((item, index) => ( ))} - {loadingMore && } - + {loading && ( + + )} + {!loading && items.length === 0 && ( + + We couldn't find any matching items{" "} + + 🤔 + {" "} + Try again? + + )} + ); } @@ -245,9 +261,13 @@ function SearchResultItem({ * useSearchResults manages the actual querying and state management of search! * It's hefty, infinite-scroll pagination is a bit of a thing! */ -function useSearchResults(query, outfitState) { +function useSearchResults( + query, + outfitState, + currentPageNumber, + { skip = false } = {} +) { const { speciesId, colorId } = outfitState; - const [isEndOfResults, setIsEndOfResults] = React.useState(false); // We debounce the search query, so that we don't resend a new query whenever // the user types anything. @@ -256,12 +276,6 @@ function useSearchResults(query, outfitState) { initialValue: emptySearchQuery, }); - // When the query changes, we should update our impression of whether we've - // reached the end! - React.useEffect(() => { - setIsEndOfResults(false); - }, [query]); - // NOTE: This query should always load ~instantly, from the client cache. const { data: zoneData } = useQuery(gql` query SearchPanelZones { @@ -277,13 +291,11 @@ function useSearchResults(query, outfitState) { : []; const filterToZoneIds = filterToZones.map((z) => z.id); + const currentPageIndex = currentPageNumber - 1; + const offset = currentPageIndex * SEARCH_PER_PAGE; + // Here's the actual GQL query! At the bottom we have more config than usual! - const { - loading: loadingGQL, - error, - data, - fetchMore: fetchMoreGQL, - } = useQuery( + const { loading: loadingGQL, error, data } = useQuery( gql` query SearchPanel( $query: String! @@ -294,6 +306,7 @@ function useSearchResults(query, outfitState) { $speciesId: ID! $colorId: ID! $offset: Int! + $perPage: Int! ) { itemSearch: itemSearchV2( query: $query @@ -302,11 +315,8 @@ function useSearchResults(query, outfitState) { currentUserOwnsOrWants: $currentUserOwnsOrWants zoneIds: $zoneIds ) { - query - zones { - id - } - items(offset: $offset, limit: 50) { + numTotalItems + items(offset: $offset, limit: $perPage) { # TODO: De-dupe this from useOutfitState? id name @@ -346,139 +356,32 @@ function useSearchResults(query, outfitState) { itemKind: debouncedQuery.filterToItemKind, currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants, zoneIds: filterToZoneIds, - offset: 0, speciesId, colorId, + offset, + perPage: SEARCH_PER_PAGE, }, context: { sendAuth: true }, skip: - !debouncedQuery.value && - !debouncedQuery.filterToItemKind && - !debouncedQuery.filterToZoneLabel && - !debouncedQuery.filterToCurrentUserOwnsOrWants, - notifyOnNetworkStatusChange: true, - onCompleted: (d) => { - // This is called each time the query completes, including on - // `fetchMore`, with the extended results. But, on the first time, this - // logic can tell us whether we're at the end of the list, by counting - // whether there was <30. We also have to check in `fetchMore`! - const items = d && d.itemSearch && d.itemSearch.items; - if (items && items.length < 30) { - setIsEndOfResults(true); - } - }, + skip || + (!debouncedQuery.value && + !debouncedQuery.filterToItemKind && + !debouncedQuery.filterToZoneLabel && + !debouncedQuery.filterToCurrentUserOwnsOrWants), onError: (e) => { console.error("Error loading search results", e); }, + // Return `numTotalItems` from the GQL cache while waiting for next page! + returnPartialData: true, } ); - // Smooth over the data a bit, so that we can use key fields with confidence! - const result = data?.itemSearch; - const resultValue = result?.query; - const zoneStr = [...filterToZoneIds].sort().join(","); - const resultZoneStr = (result?.zones || []) - .map((z) => z.id) - .sort() - .join(","); - const queriesMatch = resultValue === query.value && resultZoneStr === zoneStr; - const items = result?.items || []; + const loading = debouncedQuery !== query || loadingGQL; + const items = data?.itemSearch?.items ?? []; + const numTotalItems = data?.itemSearch?.numTotalItems ?? null; + const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE); - // Okay, what kind of loading state is this? - let loading; - let loadingMore; - if (loadingGQL && items.length > 0 && queriesMatch) { - // If we already have items for this query, but we're also loading GQL, - // then we're `loadingMore`. - loading = false; - loadingMore = true; - } else if (loadingGQL || query !== debouncedQuery) { - // Otherwise, if we're loading GQL or the user has changed the query, we're - // just `loading`. - loading = true; - loadingMore = false; - } else { - // Otherwise, we're not loading at all! - loading = false; - loadingMore = false; - } - - // When SearchResults calls this, we'll resend the query, with the `offset` - // increased. We'll append the results to the original query! - const fetchMore = React.useCallback(() => { - if (!loadingGQL && !error && !isEndOfResults) { - fetchMoreGQL({ - variables: { - offset: items.length, - }, - updateQuery: (prev, { fetchMoreResult }) => { - // Note: This is a bit awkward because, if the results count ends on - // a multiple of 30, the user will see a flash of loading before - // getting told it's actually the end. Ah well :/ - // - // We could maybe make this more rigorous later with - // react-virtualized to have a better scrollbar anyway, and then - // we'd need to return the total result count... a bit annoying to - // potentially double the query runtime? We'd need to see how slow it - // actually makes things. - if (fetchMoreResult.itemSearch.items.length < 30) { - setIsEndOfResults(true); - } - - return { - ...prev, - itemSearch: { - ...(prev?.itemSearch || {}), - items: [ - ...(prev?.itemSearch?.items || []), - ...(fetchMoreResult?.itemSearch?.items || []), - ], - }, - }; - }, - }).catch((e) => { - console.error("Error loading more search results pages", e); - }); - } - }, [loadingGQL, error, isEndOfResults, fetchMoreGQL, items.length]); - - return { loading, loadingMore, error, items, fetchMore }; -} - -/** - * useScrollTracker watches for the given scroll container to scroll near the - * bottom, then fires a callback. We use this to fetch more search results! - */ -function useScrollTracker(scrollContainerRef, threshold, onScrolledToBottom) { - const onScroll = React.useCallback( - (e) => { - const topEdgeScrollPosition = e.target.scrollTop; - const bottomEdgeScrollPosition = - topEdgeScrollPosition + e.target.clientHeight; - const remainingScrollDistance = - e.target.scrollHeight - bottomEdgeScrollPosition; - if (remainingScrollDistance < threshold) { - onScrolledToBottom(); - } - }, - [onScrolledToBottom, threshold] - ); - - React.useLayoutEffect(() => { - const scrollContainer = scrollContainerRef.current; - - if (!scrollContainer) { - return; - } - - scrollContainer.addEventListener("scroll", onScroll); - - return () => { - if (scrollContainer) { - scrollContainer.removeEventListener("scroll", onScroll); - } - }; - }, [onScroll, scrollContainerRef]); + return { loading, error, items, numTotalPages }; } /** diff --git a/src/app/components/PaginationToolbar.js b/src/app/components/PaginationToolbar.js index 9d016e5..4c0b8de 100644 --- a/src/app/components/PaginationToolbar.js +++ b/src/app/components/PaginationToolbar.js @@ -22,6 +22,11 @@ function PaginationToolbar({ goToPageNumber(currentPageNumber - 1) + : undefined + } _disabled={{ cursor: isLoading ? "wait" : "not-allowed", opacity: 0.4, @@ -30,7 +35,7 @@ function PaginationToolbar({ > ← Prev - {numTotalPages && ( + {numTotalPages > 0 && ( Page @@ -46,6 +51,11 @@ function PaginationToolbar({ )} goToPageNumber(currentPageNumber + 1) + : undefined + } _disabled={{ cursor: isLoading ? "wait" : "not-allowed", opacity: 0.4,