From 2660cac8a8419757b859bf348703ceb15a76df11 Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Tue, 5 Dec 2023 19:46:27 -0700 Subject: [PATCH] Allow infinite scrolling of coaches. --- assets/js/react/components/FadeIn.tsx | 33 +++++-- .../js/react/components/FallbackMessage.tsx | 4 +- assets/js/react/components/SelectLanguage.tsx | 4 +- assets/js/react/pages/Search.tsx | 86 +++++++++++++------ assets/js/react/utils/queries.ts | 29 +++++-- lib/boardwise/coaches.ex | 11 ++- lib/boardwise/coaches/coach.ex | 1 + .../controllers/coach_controller.ex | 22 ++++- test/boardwise/coaches_test.exs | 4 +- 9 files changed, 139 insertions(+), 55 deletions(-) diff --git a/assets/js/react/components/FadeIn.tsx b/assets/js/react/components/FadeIn.tsx index 6f4c68f..f28046d 100644 --- a/assets/js/react/components/FadeIn.tsx +++ b/assets/js/react/components/FadeIn.tsx @@ -1,10 +1,8 @@ import * as React from "react" -import { motion, useReducedMotion } from "framer-motion" +import { MotionProps, motion, useReducedMotion } from "framer-motion" const FadeInStaggerContext = React.createContext(false) -const viewport = { once: true, margin: "0px 0px -200px" } - export function FadeIn({ ...props }) { let shouldReduceMotion = useReducedMotion() let isInStaggerGroup = React.useContext(FadeInStaggerContext) @@ -21,23 +19,42 @@ export function FadeIn({ ...props }) { : { initial: "hidden", whileInView: "visible", - viewport, + viewport: { + once: true, + margin: "0px 0px -200px", + }, })} {...props} /> ) } -export function FadeInStagger({ faster = false, ...props }) { +export type FadeInStaggerProps = { + faster?: boolean + className?: string +} & MotionProps + +export const FadeInStagger = React.forwardRef(function FadeInStagger( + props: FadeInStaggerProps, + ref: React.ForwardedRef +) { + const { faster = false, ...other } = props + + // Consider dropping framer-motion: + // https://github.com/framer/motion/issues/776 return ( ) -} +}) diff --git a/assets/js/react/components/FallbackMessage.tsx b/assets/js/react/components/FallbackMessage.tsx index 518ec39..987bea5 100644 --- a/assets/js/react/components/FallbackMessage.tsx +++ b/assets/js/react/components/FallbackMessage.tsx @@ -3,10 +3,10 @@ import clsx from "clsx" import { FadeIn } from "./FadeIn" -export type FallbackMessageProps = React.ComponentPropsWithoutRef<"div"> & { +export type FallbackMessageProps = { title: string body: string -} +} & React.ComponentPropsWithoutRef<"div"> export const FallbackMessage = React.forwardRef(function FallbackMessage( props: FallbackMessageProps, diff --git a/assets/js/react/components/SelectLanguage.tsx b/assets/js/react/components/SelectLanguage.tsx index b528c81..35d9441 100644 --- a/assets/js/react/components/SelectLanguage.tsx +++ b/assets/js/react/components/SelectLanguage.tsx @@ -4,7 +4,7 @@ import { SelectProps } from "@mui/base/Select" import type { Language } from "../types/Language" import { Select, Option } from "./Select" -import { useFetchLanguages } from "../utils/queries" +import { useLanguagesQuery } from "../utils/queries" export type SelectLanguageProps = SelectProps<{}, boolean> @@ -15,7 +15,7 @@ export const SelectLanguage = React.forwardRef(function SelectLanguage( const id = React.useId() const [options, setOptions] = React.useState(null) const { defaultValue, ...other } = props - const { isLoading, data } = useFetchLanguages() + const { isLoading, data } = useLanguagesQuery() React.useEffect(() => { if (data) { diff --git a/assets/js/react/pages/Search.tsx b/assets/js/react/pages/Search.tsx index f6131ae..a2be564 100644 --- a/assets/js/react/pages/Search.tsx +++ b/assets/js/react/pages/Search.tsx @@ -1,17 +1,39 @@ import * as React from "react" +import type { SearchParams } from "../types/SearchParams" + import { Container } from "../components/Container" -import { FadeIn, FadeInStagger } from "../components/FadeIn" +import { FadeIn } from "../components/FadeIn" import { FallbackMessage } from "../components/FallbackMessage" import { FilterModal } from "../components/FilterModal" import { FilterScroll } from "../components/FilterScroll" import { Loading } from "../components/Loading" import { SearchResult } from "../components/SearchResult" import { defaultSearchParams } from "../types/SearchParams" -import { useFetchCoaches } from "../utils/queries" +import { useCoachesInfiniteQuery } from "../utils/queries" -function SearchResults() { - const { isLoading, isError, data } = useFetchCoaches() +function SearchResults({ searchParams }: { searchParams: SearchParams }) { + const { isLoading, isError, data, fetchNextPage, hasNextPage, isFetching } = + useCoachesInfiniteQuery(searchParams) + + const resultsRef = React.useRef(null) + + const handleScroll = React.useCallback(() => { + if (!resultsRef.current) { + return + } + const resultRect = resultsRef.current.getBoundingClientRect() + if (!isFetching && hasNextPage && resultRect.bottom < 800) { + fetchNextPage() + } + }, [isFetching, hasNextPage, resultsRef]) + + React.useEffect(() => { + window.addEventListener("scroll", handleScroll, { passive: true }) + return () => { + window.removeEventListener("scroll", handleScroll) + } + }, [isFetching, hasNextPage, resultsRef]) if (isLoading) { return @@ -28,28 +50,38 @@ function SearchResults() { } return ( - - {data?.map((coach, index) => ( - - - - ))} - + <> +
+ {data?.pages.map((group, index) => ( + + {group.map((coach) => ( + + + + ))} + + ))} +
+ {hasNextPage ? : null} + ) } @@ -73,7 +105,7 @@ export function Search() { setModalOpen(false) }} /> - + ) } diff --git a/assets/js/react/utils/queries.ts b/assets/js/react/utils/queries.ts index f57fb20..33a7ccf 100644 --- a/assets/js/react/utils/queries.ts +++ b/assets/js/react/utils/queries.ts @@ -1,22 +1,35 @@ import axios from "axios" -import { useQuery } from "@tanstack/react-query" +import { useQuery, useInfiniteQuery } from "@tanstack/react-query" import type { Coach } from "../types/Coach" import type { Language } from "../types/Language" +import type { SearchParams } from "../types/SearchParams" -export const useFetchCoaches = () => { - return useQuery({ - queryKey: ["api", "coaches"], - queryFn: async () => { - const response = await axios.get<{ data: Coach[] }>("/api/coaches/") +export const useCoachesInfiniteQuery = (searchParams: SearchParams) => { + return useInfiniteQuery({ + queryKey: ["coaches", searchParams], + queryFn: async ({ pageParam = 1 }) => { + const response = await axios.get<{ data: Coach[] }>("/api/coaches/", { + params: { + page_no: pageParam, + page_size: 15, + }, + }) return response.data.data }, + initialPageParam: 1, + getNextPageParam: (lastPage, _pages, lastPageParam) => { + if (lastPage.length === 0) { + return undefined + } + return lastPageParam + 1 + }, }) } -export const useFetchLanguages = () => { +export const useLanguagesQuery = () => { return useQuery({ - queryKey: ["api", "languages"], + queryKey: ["languages"], queryFn: async () => { const response = await axios.get<{ data: Language[] }>("/api/languages/") return response.data.data diff --git a/lib/boardwise/coaches.ex b/lib/boardwise/coaches.ex index d4aa51c..cf019b5 100644 --- a/lib/boardwise/coaches.ex +++ b/lib/boardwise/coaches.ex @@ -11,17 +11,20 @@ defmodule BoardWise.Coaches do @prefix "coach_scraper" @doc """ - Returns the list of coaches. + Return the list of coaches at the given page, based on page size. ## Examples - iex> list_coaches() + iex> page_coaches(1, 25) [%Coach{}, ...] """ - def list_coaches do + def page_coaches(page_no, page_size) do + offset = (page_no - 1) * page_size + Coach - |> limit(6) + |> limit(^page_size) + |> offset(^offset) |> Repo.all(prefix: @prefix) end diff --git a/lib/boardwise/coaches/coach.ex b/lib/boardwise/coaches/coach.ex index a61be17..f145f8f 100644 --- a/lib/boardwise/coaches/coach.ex +++ b/lib/boardwise/coaches/coach.ex @@ -33,6 +33,7 @@ defmodule BoardWise.Coaches.Coach do :username, :name, :image_url, + :languages, :rapid, :blitz, :bullet diff --git a/lib/boardwise_web/controllers/coach_controller.ex b/lib/boardwise_web/controllers/coach_controller.ex index 6b6a16c..b361c8f 100644 --- a/lib/boardwise_web/controllers/coach_controller.ex +++ b/lib/boardwise_web/controllers/coach_controller.ex @@ -1,10 +1,28 @@ defmodule BoardWiseWeb.CoachController do use BoardWiseWeb, :controller + require Logger alias BoardWise.Coaches - def index(conn, _params) do - coaches = Coaches.list_coaches() + plug :fetch_query_params + + def index(conn, params) do + page_no = get_integer_param(params, "page_no", 1) + page_size = get_integer_param(params, "page_size", 10) + coaches = Coaches.page_coaches(page_no, page_size) render(conn, :index, coaches: coaches) end + + defp get_integer_param(params, key, default) do + val = Map.get(params, key) + + if is_nil(val) do + default + else + case Integer.parse(val) do + {parsed, ""} -> parsed + _ -> default + end + end + end end diff --git a/test/boardwise/coaches_test.exs b/test/boardwise/coaches_test.exs index 7c9ce6d..b2c8fbc 100644 --- a/test/boardwise/coaches_test.exs +++ b/test/boardwise/coaches_test.exs @@ -10,9 +10,9 @@ defmodule BoardWise.CoachesTest do @invalid_attrs %{blitz: nil, bullet: nil, rapid: nil, site: nil, username: nil} - test "list_coaches/0 returns all coaches" do + test "page_coaches/2 returns all coaches" do coach = coach_fixture() - assert Coaches.list_coaches() == [coach] + assert Coaches.page_coaches(1, 1000) == [coach] end test "get_coach!/1 returns the coach with given id" do