diff --git a/assets/js/react/components/FilterModal.tsx b/assets/js/react/components/FilterModal.tsx index 980f3b3..b3cff6d 100644 --- a/assets/js/react/components/FilterModal.tsx +++ b/assets/js/react/components/FilterModal.tsx @@ -6,6 +6,7 @@ import { Field } from "./FieldSet" import { Input } from "./Input" import { Label } from "./Label" import { Modal } from "./Modal" +import { SelectLanguage, SelectLanguageProps } from "./SelectLanguage" import { Slider } from "./Slider" import { @@ -63,6 +64,19 @@ export function FilterModal({ // Registration + const proxyLanguages = register("languages") + const registerLanguages: Pick< + SelectLanguageProps, + "defaultValue" | "onChange" + > = { + ...proxyLanguages, + defaultValue: defaultValues.languages, + onChange: (event, value) => { + event && proxyLanguages.onChange(event) + setValue("languages", (value ?? []) as string[]) + }, + } + const controlFIDERating = register("fideRating") return ( @@ -82,6 +96,25 @@ export function FilterModal({ }} >
+ + +

+ Select languages you prefer communicating in. We{"'"}ll prioritize + finding coaches that can speak fluently in at least one of your + selections. +

+ +
+

diff --git a/assets/js/react/components/FilterScroll.tsx b/assets/js/react/components/FilterScroll.tsx index 57c43f2..da460d5 100644 --- a/assets/js/react/components/FilterScroll.tsx +++ b/assets/js/react/components/FilterScroll.tsx @@ -4,6 +4,7 @@ import clsx from "clsx" import type { SearchParams } from "../types/SearchParams" import FilterIcon from "../icons/Filter" +import EnglishIcon from "../icons/English" import RightArrowIcon from "../icons/RightArrow" import RisingGraphIcon from "../icons/RisingGraph" import { Button } from "./Button" @@ -19,11 +20,27 @@ const filters: FilterOption[] = [ { title: "FIDE 2000+", Icon: RisingGraphIcon, - enable: (q) => { - q.fideRating[0] = Math.max(2000, q.fideRating[0]) - return q + enable: (p) => { + p.fideRating[0] = Math.max(2000, p.fideRating[0]) + return p }, - isEnabled: (q) => q.fideRating[0] >= 2000, + isEnabled: (p) => p.fideRating[0] >= 2000, + }, + { + title: "English Speaking", + Icon: EnglishIcon, + enable: (p) => { + for (const lang of ["en-US", "en-GB"]) { + if (!p.languages.includes(lang)) { + p.languages.push(lang) + } + } + return p + }, + // Using `||` doesn't match how `enable` works but this is probably closer + // to how people would expect the filter to operate. + isEnabled: (p) => + p.languages.includes("en-US") || p.languages.includes("en-GB"), }, ] diff --git a/assets/js/react/components/Select.tsx b/assets/js/react/components/Select.tsx new file mode 100644 index 0000000..7fe57b1 --- /dev/null +++ b/assets/js/react/components/Select.tsx @@ -0,0 +1,105 @@ +import * as React from "react" +import clsx from "clsx" + +import { + Select as BaseSelect, + SelectProps, + SelectOwnerState, +} from "@mui/base/Select" +import { + Option as BaseOption, + OptionProps, + OptionOwnerState, +} from "@mui/base/Option" + +import { FieldContext } from "./FieldSet" +import { resolveSlotProps } from "../utils/props" +import { sameWidth } from "../utils/popperjs" + +export const Option = React.forwardRef>( + function Option(props, ref) { + const rootSlotProps = (ownerState: OptionOwnerState) => { + const resolved = resolveSlotProps(props.slotProps?.root, ownerState) + return { + ...resolved, + className: clsx( + "list-none p-2 rounded-lg cursor-default last-of-type:border-b-0", + ownerState.disabled + ? "text-slate-400" + : "hover:bg-slate-100 hover:text-slate-900", + { + "font-bold": ownerState.selected, + "bg-slate-100 text-slate-900": + ownerState.selected || ownerState.highlighted, + }, + resolved?.className + ), + } + } + + return ( + + ) + } +) + +export const Select = React.forwardRef(function Select< + TValue extends {}, + Multiple extends boolean, +>( + props: SelectProps, + ref: React.ForwardedRef +) { + const fieldContext = React.useContext(FieldContext) + const { disabled = fieldContext?.disabled, slotProps, ...other } = props + + const rootSlotProps = (ownerState: SelectOwnerState) => { + const resolved = resolveSlotProps(slotProps?.root, ownerState) + return { + ...resolved, + className: clsx( + "text-sm box-border px-3 py-2 rounded-lg text-left bg-white border border-solid text-slate-900 transition-all hover:bg-slate-50 outline-0 shadow shadow-slate-100 after:float-right", + ownerState.open ? 'after:content-["▴"]' : 'after:content-["▾"]', + ownerState.disabled ? "opacity-60" : "", + fieldContext?.error + ? "border-amber-800 focus-visible:outline focus-visible:outline-1 focus-visible:outline-amber-800" + : "border-slate-300", + resolved?.className + ), + } + } + + const listboxSlotProps = (ownerState: SelectOwnerState) => { + const resolved = resolveSlotProps(slotProps?.listbox, ownerState) + return { + ...resolved, + className: clsx( + "text-sm p-1.5 my-3 rounded-xl h-60 overflow-auto outline-0 bg-white border border-solid border-slate-200 text-slate-900 shadow shadow-slate-100", + resolved?.className + ), + } + } + + const popperSlotProps = (ownerState: SelectOwnerState) => { + const resolved = resolveSlotProps(slotProps?.popper, ownerState) + return { + ...resolved, + className: clsx("z-[1000]", resolved?.className), + modifiers: [sameWidth], + } + } + + return ( + + ) +}) diff --git a/assets/js/react/components/SelectLanguage.tsx b/assets/js/react/components/SelectLanguage.tsx new file mode 100644 index 0000000..983d0b0 --- /dev/null +++ b/assets/js/react/components/SelectLanguage.tsx @@ -0,0 +1,44 @@ +import * as React from "react" +import { SelectProps } from "@mui/base/Select" + +import { Select, Option } from "./Select" +import { useFetchLanguages } from "../utils/queries" + +export type SelectLanguageProps = SelectProps<{}, boolean> + +export const SelectLanguage = React.forwardRef(function SelectLanguage( + props: SelectLanguageProps, + ref: React.ForwardedRef +) { + const id = React.useId() + const [options, setOptions] = React.useState([ + { value: "", label: "Loading..." }, + ]) + const { defaultValue, ...other } = props + const { isLoading, data } = useFetchLanguages() + + React.useEffect(() => { + if (!data) { + return + } + setOptions(data.map((row) => ({ value: row.code, label: row.name }))) + }, [data]) + + return ( + + ) +}) diff --git a/assets/js/react/icons/English.tsx b/assets/js/react/icons/English.tsx new file mode 100644 index 0000000..1504562 --- /dev/null +++ b/assets/js/react/icons/English.tsx @@ -0,0 +1,30 @@ +import * as React from "react" + +const SvgComponent = ({ ...props }) => ( + + + + +) + +export default SvgComponent diff --git a/assets/js/react/pages/Search.tsx b/assets/js/react/pages/Search.tsx index c24caa0..37a3e5c 100644 --- a/assets/js/react/pages/Search.tsx +++ b/assets/js/react/pages/Search.tsx @@ -1,8 +1,4 @@ import * as React from "react" -import axios from "axios" -import { useQuery } from "@tanstack/react-query" - -import type { Coach } from "../types/Coach" import { Container } from "../components/Container" import { FadeIn, FadeInStagger } from "../components/FadeIn" @@ -12,15 +8,10 @@ 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" function SearchResults() { - const { isLoading, isError, data } = useQuery({ - queryKey: ["coaches"], - queryFn: async () => { - const response = await axios.get<{ data: Coach[] }>("/api/coaches/") - return response.data.data - }, - }) + const { isLoading, isError, data } = useFetchCoaches() if (isLoading) { return diff --git a/assets/js/react/types/Coach.ts b/assets/js/react/types/Coach.ts index 1e18501..8bd72bb 100644 --- a/assets/js/react/types/Coach.ts +++ b/assets/js/react/types/Coach.ts @@ -3,6 +3,7 @@ export type Coach = { username: string name: string | null image_url: string | null + languages: string[] | null rapid: number | null blitz: number | null bullet: number | null diff --git a/assets/js/react/types/Language.ts b/assets/js/react/types/Language.ts new file mode 100644 index 0000000..eefc960 --- /dev/null +++ b/assets/js/react/types/Language.ts @@ -0,0 +1,4 @@ +export type Language = { + name: string + code: string +} diff --git a/assets/js/react/types/SearchParams.ts b/assets/js/react/types/SearchParams.ts index 847d99d..743a657 100644 --- a/assets/js/react/types/SearchParams.ts +++ b/assets/js/react/types/SearchParams.ts @@ -1,5 +1,6 @@ export type SearchParams = { fideRating: [number, number] + languages: string[] } export const FIDE_RATING_MIN = 1500 @@ -7,4 +8,5 @@ export const FIDE_RATING_MAX = 3200 export const defaultSearchParams: SearchParams = { fideRating: [FIDE_RATING_MIN, FIDE_RATING_MAX], + languages: [], } diff --git a/assets/js/react/utils/popperjs.ts b/assets/js/react/utils/popperjs.ts new file mode 100644 index 0000000..50f66b8 --- /dev/null +++ b/assets/js/react/utils/popperjs.ts @@ -0,0 +1,16 @@ +import { type Modifier } from "@popperjs/core" + +export const sameWidth: Partial> = { + name: "sameWidth", + phase: "beforeWrite", + enabled: true, + requires: ["computeStyles"], + fn: ({ state }) => { + state.styles.popper.width = `${state.rects.reference.width}px` + }, + effect: ({ state }) => { + if ("offsetWidth" in state.elements.reference) { + state.elements.popper.style.width = `${state.elements.reference.offsetWidth}px` + } + }, +} diff --git a/assets/js/react/utils/queries.ts b/assets/js/react/utils/queries.ts new file mode 100644 index 0000000..f57fb20 --- /dev/null +++ b/assets/js/react/utils/queries.ts @@ -0,0 +1,25 @@ +import axios from "axios" +import { useQuery } from "@tanstack/react-query" + +import type { Coach } from "../types/Coach" +import type { Language } from "../types/Language" + +export const useFetchCoaches = () => { + return useQuery({ + queryKey: ["api", "coaches"], + queryFn: async () => { + const response = await axios.get<{ data: Coach[] }>("/api/coaches/") + return response.data.data + }, + }) +} + +export const useFetchLanguages = () => { + return useQuery({ + queryKey: ["api", "languages"], + queryFn: async () => { + const response = await axios.get<{ data: Language[] }>("/api/languages/") + return response.data.data + }, + }) +} diff --git a/assets/package-lock.json b/assets/package-lock.json index f56f65a..132afb8 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@mui/base": "^5.0.0-beta.25", + "@popperjs/core": "^2.11.8", "@tanstack/react-query": "^5.12.2", "axios": "^1.6.2", "clsx": "^2.0.0", diff --git a/assets/package.json b/assets/package.json index ca357ae..dd74f3c 100644 --- a/assets/package.json +++ b/assets/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "dependencies": { "@mui/base": "^5.0.0-beta.25", + "@popperjs/core": "^2.11.8", "@tanstack/react-query": "^5.12.2", "axios": "^1.6.2", "clsx": "^2.0.0", diff --git a/lib/boardwise/coaches.ex b/lib/boardwise/coaches.ex index 9bb1a09..d4aa51c 100644 --- a/lib/boardwise/coaches.ex +++ b/lib/boardwise/coaches.ex @@ -22,7 +22,6 @@ defmodule BoardWise.Coaches do def list_coaches do Coach |> limit(6) - |> where(site: "lichess") |> Repo.all(prefix: @prefix) end diff --git a/lib/boardwise/coaches/coach.ex b/lib/boardwise/coaches/coach.ex index 43f5c7c..a61be17 100644 --- a/lib/boardwise/coaches/coach.ex +++ b/lib/boardwise/coaches/coach.ex @@ -19,6 +19,7 @@ defmodule BoardWise.Coaches.Coach do field :username, :string field :name, :string field :image_url, :string + field :languages, {:array, :string} field :blitz, :integer field :bullet, :integer field :rapid, :integer diff --git a/lib/boardwise/languages.ex b/lib/boardwise/languages.ex new file mode 100644 index 0000000..a19b6c0 --- /dev/null +++ b/lib/boardwise/languages.ex @@ -0,0 +1,106 @@ +defmodule BoardWise.Languages do + @moduledoc """ + The Languages context. + """ + + import Ecto.Query, warn: false + alias BoardWise.Repo + + alias BoardWise.Languages.Language + + @prefix "coach_scraper" + + @doc """ + Returns the list of languages. + + ## Examples + + iex> list_languages() + [%Language{}, ...] + + """ + def list_languages do + Repo.all(Language, prefix: @prefix) + end + + @doc """ + Gets a single language. + + Raises `Ecto.NoResultsError` if the Language does not exist. + + ## Examples + + iex> get_language!(123) + %Language{} + + iex> get_language!(456) + ** (Ecto.NoResultsError) + + """ + def get_language!(id), do: Repo.get!(Language, id, prefix: @prefix) + + @doc """ + Creates a language. + + ## Examples + + iex> create_language(%{field: value}) + {:ok, %Language{}} + + iex> create_language(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_language(attrs \\ %{}) do + %Language{} + |> Language.changeset(attrs) + |> Repo.insert(prefix: @prefix) + end + + @doc """ + Updates a language. + + ## Examples + + iex> update_language(language, %{field: new_value}) + {:ok, %Language{}} + + iex> update_language(language, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_language(%Language{} = language, attrs) do + language + |> Language.changeset(attrs) + |> Repo.update(prefix: @prefix) + end + + @doc """ + Deletes a language. + + ## Examples + + iex> delete_language(language) + {:ok, %Language{}} + + iex> delete_language(language) + {:error, %Ecto.Changeset{}} + + """ + def delete_language(%Language{} = language) do + Repo.delete(language, prefix: @prefix) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking language changes. + + ## Examples + + iex> change_language(language) + %Ecto.Changeset{data: %Language{}} + + """ + def change_language(%Language{} = language, attrs \\ %{}) do + Language.changeset(language, attrs) + end +end diff --git a/lib/boardwise/languages/language.ex b/lib/boardwise/languages/language.ex new file mode 100644 index 0000000..d29f030 --- /dev/null +++ b/lib/boardwise/languages/language.ex @@ -0,0 +1,17 @@ +defmodule BoardWise.Languages.Language do + use Ecto.Schema + import Ecto.Changeset + + schema "languages" do + field :code, :string + field :name, :string + end + + @doc false + def changeset(language, attrs) do + language + |> cast(attrs, [:code, :name]) + |> validate_required([:code, :name]) + |> unique_constraint(:code_unique, name: :code_unique) + end +end diff --git a/lib/boardwise_web/controllers/coach_json.ex b/lib/boardwise_web/controllers/coach_json.ex index 513f636..a1c8d97 100644 --- a/lib/boardwise_web/controllers/coach_json.ex +++ b/lib/boardwise_web/controllers/coach_json.ex @@ -14,6 +14,7 @@ defmodule BoardWiseWeb.CoachJSON do username: coach.username, name: coach.name, image_url: coach.image_url, + languages: coach.languages, rapid: coach.rapid, blitz: coach.blitz, bullet: coach.bullet diff --git a/lib/boardwise_web/controllers/language_controller.ex b/lib/boardwise_web/controllers/language_controller.ex new file mode 100644 index 0000000..2f74708 --- /dev/null +++ b/lib/boardwise_web/controllers/language_controller.ex @@ -0,0 +1,10 @@ +defmodule BoardWiseWeb.LanguageController do + use BoardWiseWeb, :controller + + alias BoardWise.Languages + + def index(conn, _params) do + langs = Languages.list_languages() + render(conn, :index, langs: langs) + end +end diff --git a/lib/boardwise_web/controllers/language_json.ex b/lib/boardwise_web/controllers/language_json.ex new file mode 100644 index 0000000..8287a1a --- /dev/null +++ b/lib/boardwise_web/controllers/language_json.ex @@ -0,0 +1,17 @@ +defmodule BoardWiseWeb.LanguageJSON do + alias BoardWise.Languages.Language + + @doc """ + Renders a list of coaches. + """ + def index(%{langs: langs}) do + %{data: for(lang <- langs, do: data(lang))} + end + + defp data(%Language{} = lang) do + %{ + code: lang.code, + name: lang.name + } + end +end diff --git a/lib/boardwise_web/router.ex b/lib/boardwise_web/router.ex index ba622cd..07ab82c 100644 --- a/lib/boardwise_web/router.ex +++ b/lib/boardwise_web/router.ex @@ -26,6 +26,7 @@ defmodule BoardWiseWeb.Router do pipe_through :api get "/coaches", CoachController, :index + get "/languages", LanguageController, :index end # Other scopes may use custom stacks. diff --git a/priv/repo/migrations/20231205212321_languages.exs b/priv/repo/migrations/20231205212321_languages.exs new file mode 100644 index 0000000..4408bbf --- /dev/null +++ b/priv/repo/migrations/20231205212321_languages.exs @@ -0,0 +1,11 @@ +defmodule BoardWise.Repo.Migrations.Languages do + use Ecto.Migration + + @prefix "coach_scraper" + + def change do + alter table(:export, prefix: @prefix) do + add :languages, {:array, :string} + end + end +end diff --git a/priv/repo/migrations/20231205220353_create_languages.exs b/priv/repo/migrations/20231205220353_create_languages.exs new file mode 100644 index 0000000..d0faf84 --- /dev/null +++ b/priv/repo/migrations/20231205220353_create_languages.exs @@ -0,0 +1,19 @@ +defmodule BoardWise.Repo.Migrations.CreateLanguages do + use Ecto.Migration + + @prefix "coach_scraper" + + def change do + create table(:languages, prefix: @prefix) do + add :code, :string, null: false + add :name, :string, null: false + end + + create unique_index( + :languages, + [:code], + prefix: @prefix, + name: "code_unique" + ) + end +end diff --git a/test/boardwise/languages_test.exs b/test/boardwise/languages_test.exs new file mode 100644 index 0000000..fc97180 --- /dev/null +++ b/test/boardwise/languages_test.exs @@ -0,0 +1,61 @@ +defmodule BoardWise.LanguagesTest do + use BoardWise.DataCase + + alias BoardWise.Languages + + describe "languages" do + alias BoardWise.Languages.Language + + import BoardWise.LanguagesFixtures + + @invalid_attrs %{code: nil, name: nil} + + test "list_languages/0 returns all languages" do + language = language_fixture() + assert Languages.list_languages() == [language] + end + + test "get_language!/1 returns the language with given id" do + language = language_fixture() + assert Languages.get_language!(language.id) == language + end + + test "create_language/1 with valid data creates a language" do + valid_attrs = %{code: "some code", name: "some name"} + + assert {:ok, %Language{} = language} = Languages.create_language(valid_attrs) + assert language.code == "some code" + assert language.name == "some name" + end + + test "create_language/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Languages.create_language(@invalid_attrs) + end + + test "update_language/2 with valid data updates the language" do + language = language_fixture() + update_attrs = %{code: "some updated code", name: "some updated name"} + + assert {:ok, %Language{} = language} = Languages.update_language(language, update_attrs) + assert language.code == "some updated code" + assert language.name == "some updated name" + end + + test "update_language/2 with invalid data returns error changeset" do + language = language_fixture() + assert {:error, %Ecto.Changeset{}} = Languages.update_language(language, @invalid_attrs) + assert language == Languages.get_language!(language.id) + end + + test "delete_language/1 deletes the language" do + language = language_fixture() + assert {:ok, %Language{}} = Languages.delete_language(language) + assert_raise Ecto.NoResultsError, fn -> Languages.get_language!(language.id) end + end + + test "change_language/1 returns a language changeset" do + language = language_fixture() + assert %Ecto.Changeset{} = Languages.change_language(language) + end + end +end diff --git a/test/support/fixtures/languages_fixtures.ex b/test/support/fixtures/languages_fixtures.ex new file mode 100644 index 0000000..da9c865 --- /dev/null +++ b/test/support/fixtures/languages_fixtures.ex @@ -0,0 +1,21 @@ +defmodule BoardWise.LanguagesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `BoardWise.Languages` context. + """ + + @doc """ + Generate a language. + """ + def language_fixture(attrs \\ %{}) do + {:ok, language} = + attrs + |> Enum.into(%{ + code: "some code", + name: "some name" + }) + |> BoardWise.Languages.create_language() + + language + end +end