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

View File

@ -14,3 +14,18 @@ export const defaultSearchParams: SearchParams = {
modes: [Mode.RAPID, Mode.BLITZ, Mode.BULLET],
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 { Language } from "../types/Language"
import type { SearchParams } from "../types/SearchParams"
import { type SearchParams, toQueryParams } from "../types/SearchParams"
export const useCoachesInfiniteQuery = (searchParams: SearchParams) => {
const queryParams = toQueryParams(searchParams)
return useInfiniteQuery({
queryKey: ["coaches", searchParams],
queryKey: ["coaches", queryParams],
queryFn: async ({ pageParam = 1 }) => {
const response = await axios.get<{ data: Coach[] }>("/api/coaches/", {
params: {
page_no: pageParam,
page_size: 15,
...queryParams,
},
})
return response.data.data
},
initialPageParam: 1,
getNextPageParam: (lastPage, _pages, lastPageParam) => {
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
if (lastPage.length === 0) {
return undefined
}
return lastPageParam + 1
},
initialPageParam: 1,
})
}

View File

@ -7,24 +7,71 @@ defmodule BoardWise.Coaches do
alias BoardWise.Repo
alias BoardWise.Coaches.Coach
alias BoardWise.Coaches.QueryParams
@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 """
Return the list of coaches at the given page, based on page size.
Return the list of coaches according to the specified params.
## Examples
iex> page_coaches(1, 25)
iex> list_coaches(%QueryParams{...})
[%Coach{}, ...]
"""
def page_coaches(page_no, page_size) do
offset = (page_no - 1) * page_size
def list_coaches(%QueryParams{
: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
|> 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)
|> offset(^offset)
|> offset(^((page_no - 1) * page_size))
|> Repo.all(prefix: @prefix)
end

View File

@ -15,14 +15,20 @@ defmodule BoardWise.Coaches.Coach do
import Ecto.Changeset
schema "export" do
# required fields
field :site, :string
field :username, :string
# optional fields
field :name, :string
field :image_url, :string
field :languages, {:array, :string}
field :blitz, :integer
field :bullet, :integer
field :rapid, :integer
# virtual fields
field :score, :integer, virtual: true
end
@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,27 +1,41 @@
defmodule BoardWiseWeb.CoachController do
use BoardWiseWeb, :controller
require Logger
alias BoardWise.Coaches
plug :fetch_query_params
alias BoardWise.Coaches.QueryParams
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)
query_params =
%QueryParams{}
|> 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)
end
defp get_integer_param(params, key, default) do
val = Map.get(params, key)
defp override_param(query_params, key, params, type) do
case Map.get(params, Atom.to_string(key)) do
nil ->
query_params
if is_nil(val) do
default
else
val when type == :strlist ->
%{query_params | key => String.split(val, ",")}
val when type == :integer ->
case Integer.parse(val) do
{parsed, ""} -> parsed
_ -> default
{parsed, ""} -> %{query_params | key => parsed}
_ -> query_params
end
end
end