Apply initial ordering to search results.

main
Joshua Potter 2023-12-06 16:24:40 -07:00
parent e927eab560
commit 286b3dd31d
7 changed files with 155 additions and 43 deletions

View File

@ -12,8 +12,8 @@ import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage"
import { Slider } from "./Slider" import { Slider } from "./Slider"
import { import {
FIDE_RATING_MIN, FIDE_RATING_MIN as RATING_MIN,
FIDE_RATING_MAX, FIDE_RATING_MAX as RATING_MAX,
SearchParams, SearchParams,
} from "../types/SearchParams" } from "../types/SearchParams"
@ -55,8 +55,18 @@ export function FilterModal({
}: FilterModalProps) { }: FilterModalProps) {
const idPrefix = React.useId() const idPrefix = React.useId()
const { watch, reset, control, register, setValue, handleSubmit } = const {
useForm<SearchParams>({ defaultValues }) watch,
reset,
control,
register,
setValue,
handleSubmit,
formState: { errors },
} = useForm<SearchParams>({
mode: "onChange",
defaultValues,
})
// Default values are processed immediately despite the modal not being open // Default values are processed immediately despite the modal not being open
// at the start. Furthermore, values are preserved after closing and // at the start. Furthermore, values are preserved after closing and
@ -80,7 +90,9 @@ export function FilterModal({
} }
const registerRating = register("rating") const registerRating = register("rating")
const registerModes = register("modes") const registerModes = register("modes", {
required: "Please select at least one mode.",
})
return ( return (
<Modal <Modal
@ -91,7 +103,11 @@ export function FilterModal({
as: "form", as: "form",
title: "Filters", title: "Filters",
footer: ( footer: (
<Button className="float-right py-2" type="submit"> <Button
className="float-right py-2"
type="submit"
disabled={Object.keys(errors).length > 0}
>
Search coaches Search coaches
</Button> </Button>
), ),
@ -119,7 +135,7 @@ export function FilterModal({
</Field> </Field>
<Field> <Field>
<Label htmlFor={`${idPrefix}-rating`}>FIDE Rating:</Label> <Label htmlFor={`${idPrefix}-rating`}>Rating:</Label>
<p className="py-2 text-sm"> <p className="py-2 text-sm">
Find coaches that have a rating within the specified range. Keep in Find coaches that have a rating within the specified range. Keep in
mind, a higher rating does not necessarily mean a better coach{" "} mind, a higher rating does not necessarily mean a better coach{" "}
@ -141,14 +157,11 @@ export function FilterModal({
setValue("rating.1", newValue[1]) setValue("rating.1", newValue[1])
}} }}
step={10} step={10}
min={FIDE_RATING_MIN} min={RATING_MIN}
max={FIDE_RATING_MAX} max={RATING_MAX}
marks={computeStepLabels( marks={computeStepLabels(RATING_MIN, RATING_MAX, 7, 50).map(
FIDE_RATING_MIN, (s) => ({ value: s, label: `${s}` })
FIDE_RATING_MAX, )}
7,
50
).map((s) => ({ value: s, label: `${s}` }))}
/> />
)} )}
/> />
@ -169,12 +182,13 @@ export function FilterModal({
</div> </div>
</Field> </Field>
<FieldSet className="text-sm text-neutral-600"> <FieldSet error={errors?.modes?.message}>
<p className="py-2"> <Label htmlFor={`${idPrefix}-rating`}>Mode:</Label>
<p className="py-2 text-sm">
Prefer a specific game mode? We{"'"}ll prioritize coaches that Prefer a specific game mode? We{"'"}ll prioritize coaches that
specialize in the modes selected. specialize in the modes selected.
</p> </p>
<div className="grid grid-cols-3 pt-3"> <div className="grid grid-cols-3 pt-2 text-sm">
{(Object.keys(Mode) as Mode[]).map((m) => ( {(Object.keys(Mode) as Mode[]).map((m) => (
<div key={m} className="col-span-1 flex items-center gap-x-2"> <div key={m} className="col-span-1 flex items-center gap-x-2">
<CheckBox value={m} {...registerModes} /> <CheckBox value={m} {...registerModes} />

View File

@ -14,3 +14,18 @@ export const defaultSearchParams: SearchParams = {
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET], modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
languages: [], languages: [],
} }
export function toQueryParams(p: SearchParams) {
const queryParams: { [key: string]: any } = {}
for (const mode of p.modes) {
queryParams[`${mode.toLowerCase()}_gte`] = p.rating[0]
queryParams[`${mode.toLowerCase()}_lte`] = p.rating[1]
}
if (p.languages.length > 0) {
queryParams["languages"] = p.languages.join(",")
}
return queryParams
}

View File

@ -3,27 +3,30 @@ import { useQuery, useInfiniteQuery } from "@tanstack/react-query"
import type { Coach } from "../types/Coach" import type { Coach } from "../types/Coach"
import type { Language } from "../types/Language" import type { Language } from "../types/Language"
import type { SearchParams } from "../types/SearchParams" import { type SearchParams, toQueryParams } from "../types/SearchParams"
export const useCoachesInfiniteQuery = (searchParams: SearchParams) => { export const useCoachesInfiniteQuery = (searchParams: SearchParams) => {
const queryParams = toQueryParams(searchParams)
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: ["coaches", searchParams], queryKey: ["coaches", queryParams],
queryFn: async ({ pageParam = 1 }) => { queryFn: async ({ pageParam = 1 }) => {
const response = await axios.get<{ data: Coach[] }>("/api/coaches/", { const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
params: { params: {
page_no: pageParam, page_no: pageParam,
page_size: 15, page_size: 15,
...queryParams,
}, },
}) })
return response.data.data return response.data.data
}, },
initialPageParam: 1, getNextPageParam: (lastPage, _allPages, lastPageParam) => {
getNextPageParam: (lastPage, _pages, lastPageParam) => {
if (lastPage.length === 0) { if (lastPage.length === 0) {
return undefined return undefined
} }
return lastPageParam + 1 return lastPageParam + 1
}, },
initialPageParam: 1,
}) })
} }

View File

@ -7,24 +7,71 @@ defmodule BoardWise.Coaches do
alias BoardWise.Repo alias BoardWise.Repo
alias BoardWise.Coaches.Coach alias BoardWise.Coaches.Coach
alias BoardWise.Coaches.QueryParams
@prefix "coach_scraper" @prefix "coach_scraper"
defmacrop rating_fragment(field, gte, lte) do
quote do
fragment(
"""
CASE
WHEN ? IS NULL THEN 0
WHEN ? IS NULL THEN 0
WHEN ? >= ? AND ? <= ? THEN 5
ELSE 0
END
""",
type(unquote(gte), :integer),
type(unquote(lte), :integer),
unquote(field),
type(unquote(gte), :integer),
unquote(field),
type(unquote(lte), :integer)
)
end
end
@doc """ @doc """
Return the list of coaches at the given page, based on page size. Return the list of coaches according to the specified params.
## Examples ## Examples
iex> page_coaches(1, 25) iex> list_coaches(%QueryParams{...})
[%Coach{}, ...] [%Coach{}, ...]
""" """
def page_coaches(page_no, page_size) do def list_coaches(%QueryParams{
offset = (page_no - 1) * page_size :rapid_gte => rapid_gte,
:rapid_lte => rapid_lte,
:blitz_gte => blitz_gte,
:blitz_lte => blitz_lte,
:bullet_gte => bullet_gte,
:bullet_lte => bullet_lte,
:languages => languages,
:page_no => page_no,
:page_size => page_size
}) do
Coach Coach
|> select([c], c)
|> select_merge(
[c],
%{
score:
fragment(
"""
? + ? + ?
""",
rating_fragment(c.rapid, ^rapid_gte, ^rapid_lte),
rating_fragment(c.blitz, ^blitz_gte, ^blitz_lte),
rating_fragment(c.bullet, ^bullet_gte, ^bullet_lte)
)
|> selected_as(:score)
}
)
|> order_by(desc: selected_as(:score))
|> limit(^page_size) |> limit(^page_size)
|> offset(^offset) |> offset(^((page_no - 1) * page_size))
|> Repo.all(prefix: @prefix) |> Repo.all(prefix: @prefix)
end end

View File

@ -15,14 +15,20 @@ defmodule BoardWise.Coaches.Coach do
import Ecto.Changeset import Ecto.Changeset
schema "export" do schema "export" do
# required fields
field :site, :string field :site, :string
field :username, :string field :username, :string
# optional fields
field :name, :string field :name, :string
field :image_url, :string field :image_url, :string
field :languages, {:array, :string} field :languages, {:array, :string}
field :blitz, :integer field :blitz, :integer
field :bullet, :integer field :bullet, :integer
field :rapid, :integer field :rapid, :integer
# virtual fields
field :score, :integer, virtual: true
end end
@doc false @doc false

View File

@ -0,0 +1,13 @@
defmodule BoardWise.Coaches.QueryParams do
defstruct [
:rapid_gte,
:rapid_lte,
:blitz_gte,
:blitz_lte,
:bullet_gte,
:bullet_lte,
:languages,
page_no: 1,
page_size: 15
]
end

View File

@ -1,28 +1,42 @@
defmodule BoardWiseWeb.CoachController do defmodule BoardWiseWeb.CoachController do
use BoardWiseWeb, :controller use BoardWiseWeb, :controller
require Logger
alias BoardWise.Coaches alias BoardWise.Coaches
alias BoardWise.Coaches.QueryParams
plug :fetch_query_params
def index(conn, params) do def index(conn, params) do
page_no = get_integer_param(params, "page_no", 1) query_params =
page_size = get_integer_param(params, "page_size", 10) %QueryParams{}
coaches = Coaches.page_coaches(page_no, page_size) |> override_param(:rapid_gte, params, :integer)
|> override_param(:rapid_lte, params, :integer)
|> override_param(:blitz_gte, params, :integer)
|> override_param(:blitz_lte, params, :integer)
|> override_param(:bullet_gte, params, :integer)
|> override_param(:bullet_lte, params, :integer)
|> override_param(:languages, params, :strlist)
|> override_param(:page_no, params, :integer)
|> override_param(:page_size, params, :integer)
# Ensure we never attempt to query too large of a response all at once.
query_params = %{query_params | page_size: Enum.min([query_params.page_size, 25])}
coaches = Coaches.list_coaches(query_params)
render(conn, :index, coaches: coaches) render(conn, :index, coaches: coaches)
end end
defp get_integer_param(params, key, default) do defp override_param(query_params, key, params, type) do
val = Map.get(params, key) case Map.get(params, Atom.to_string(key)) do
nil ->
query_params
if is_nil(val) do val when type == :strlist ->
default %{query_params | key => String.split(val, ",")}
else
case Integer.parse(val) do val when type == :integer ->
{parsed, ""} -> parsed case Integer.parse(val) do
_ -> default {parsed, ""} -> %{query_params | key => parsed}
end _ -> query_params
end
end end
end end
end end