diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e4ab4ea --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +MONGODB_URI= +DISCORD_WEBHOOK_URL= +RESULTS_SCRAPER_URL= +GMAIL_USER= +GMAIL_PASS= diff --git a/app/[locale]/elo/FetchResultsForm.tsx b/app/[locale]/elo/FetchResultsForm.tsx deleted file mode 100644 index 8a552cc..0000000 --- a/app/[locale]/elo/FetchResultsForm.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { useState } from "react"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { useTranslations } from "next-intl"; -import { FormProvider, useForm } from "react-hook-form"; -import { z } from "zod"; - -import { TextField } from "@/components/form/TextField"; -import { fetchTournamentResultsSchema } from "@/schemas"; -import { trpc } from "@/utils/trpc"; - -type FetchResultsFormValues = z.infer; - -export const FetchResultsForm = () => { - const t = useTranslations("Elo"); - const fetchResults = trpc.fetchTournamentResults.useMutation(); - - const form = useForm({ - resolver: zodResolver(fetchTournamentResultsSchema), - }); - - const onSubmit = async (input: FetchResultsFormValues) => { - try { - const result = await fetchResults.mutateAsync(input); - console.log(result); - } catch (err: unknown) { - console.log(err); - } - }; - - return ( - -
-
- - -
-
-
- ); -}; diff --git a/app/[locale]/elo/ManualEloForm.tsx b/app/[locale]/elo/ManualEloForm.tsx new file mode 100644 index 0000000..5bcc320 --- /dev/null +++ b/app/[locale]/elo/ManualEloForm.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { FormProvider, useFieldArray, useForm } from "react-hook-form"; +import { IoAdd, IoCloseOutline } from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { SelectField } from "@/components/form/SelectField"; +import { TextField } from "@/components/form/TextField"; +import { getNewRating } from "@/utils/eloCalculator"; + +const resultsSchema = z.object({ + currentElo: z.number().int().positive(), + kFactor: z.enum(["40", "30", "20", "15", "10"]), + games: z.array( + z.object({ + opponentElo: z.number().int().positive(), + result: z.enum(["win", "draw", "loss"]), + }), + ), +}); + +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +type EloFormValues = DeepPartial>; + +export const ManualEloForm = () => { + const t = useTranslations("Elo"); + + const form = useForm({ + resolver: zodResolver(resultsSchema), + defaultValues: { + kFactor: "20", + games: [{}], + }, + }); + + const { + fields: gameFields, + append: appendGame, + remove: removeGame, + } = useFieldArray({ + control: form.control, + name: "games", + }); + + const onSubmit = async (data: EloFormValues) => {}; + + const [currentElo, kFactor, games] = form.watch([ + "currentElo", + "kFactor", + "games", + ]); + + type Deltas = { + rating: number; + deltas: { rating: number; delta: number | undefined }[]; + }; + + const calculations = !Number.isNaN(currentElo) + ? (games ?? []).reduce( + (acc, game) => { + if (!Number.isNaN(game?.opponentElo) && game?.result) { + const result = + game?.result === "win" ? 1 : game?.result === "loss" ? 0 : 0.5; + + const { delta } = getNewRating( + currentElo!, + game.opponentElo!, + result, + parseInt(kFactor!, 10), + ); + return { + rating: acc.rating + delta, + deltas: [...acc.deltas, { rating: acc.rating + delta, delta }], + }; + } + + return { + rating: acc.rating, + deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }], + }; + }, + { rating: currentElo!, deltas: [] }, + ) + : { rating: currentElo!, deltas: [] }; + + const deltas = calculations.deltas; + + return ( + +
+
+ + + ({ + value: k, + label: k, + }))} + isClearable={false} + required + /> +
+ +

+ {t("resultsTitle")} +

+ +
+ {gameFields.map((game, i) => { + return ( +
+
+
+ + +
+ ({ + value: result, + label: t("gameResult", { result }), + }))} + isClearable={false} + required + /> + {gameFields.length > 1 && ( + + )} +
+
+ + {gameFields.length > 1 && ( + + )} +
+ + {deltas[i]?.delta !== undefined && ( +
1 && "mr-10", + i === deltas.length - 1 && "font-bold", + )} + > + {Math.round(deltas[i]!.rating)} ( + 0 && "text-success", + deltas[i].delta! < 0 && "text-error", + )} + > + {deltas[i]!.delta! >= 0 ? "+" : ""} + {deltas[i].delta!} + + ) +
+ )} +
+ ); + })} +
+ +
+ +
+
+
+ ); +}; diff --git a/app/[locale]/elo/TournamentResults.tsx b/app/[locale]/elo/TournamentResults.tsx new file mode 100644 index 0000000..0db56be --- /dev/null +++ b/app/[locale]/elo/TournamentResults.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { last } from "lodash"; +import { useTranslations } from "next-intl"; +import { twMerge } from "tailwind-merge"; + +import { getNewRating } from "@/utils/eloCalculator"; +import { TournamentResultsData } from "@/utils/trpc"; + +type TournamentResultsProps = { + results: TournamentResultsData; + playerId: string; + kFactor: number; +}; + +export const TournamentResults = ({ + results, + playerId, + kFactor, +}: TournamentResultsProps) => { + const t = useTranslations("Elo"); + + const playerResults = results?.find((p) => p.id === playerId); + if (!playerResults) { + return null; + } + + type Deltas = { + rating: number; + deltas: { + rating: number; + delta: number | undefined; + opponentName: string | null; + opponentElo: number | null; + result: number | null; + lostByForfeit: boolean; + wonByForfeit: boolean; + estimated: boolean; + national: boolean; + colour: string; + }[]; + }; + + const currentElo = playerResults.elo; + + const calculations = !Number.isNaN(currentElo) + ? (playerResults.results ?? []).reduce( + (acc, game) => { + const info = { + opponentName: game.opponent, + opponentElo: game.elo, + lostByForfeit: game.lostByForfeit, + wonByForfeit: game.wonByForfeit, + estimated: game.estimated, + national: game.national, + colour: game.colour, + result: game.result, + }; + + if ( + !game.wonByForfeit && + !game.lostByForfeit && + !game.estimated && + !game.national && + game.result !== null + ) { + const { delta } = getNewRating( + currentElo!, + game.elo!, + game.result as 1 | 0 | 0.5, + kFactor, + ); + return { + rating: acc.rating + delta, + deltas: [ + ...acc.deltas, + { rating: acc.rating + delta, delta, ...info }, + ], + }; + } + + return { + rating: acc.rating, + deltas: [ + ...acc.deltas, + { rating: acc.rating, delta: undefined, ...info }, + ], + }; + }, + { rating: currentElo!, deltas: [] }, + ) + : { rating: currentElo!, deltas: [] }; + + const deltas = calculations.deltas; + const totalDelta = Math.round( + deltas.reduce((acc, delta) => acc + (delta.delta ?? 0), 0), + ); + + const noChangeCount = (deltas ?? []).filter( + ({ wonByForfeit, lostByForfeit, estimated, national }) => + wonByForfeit || lostByForfeit || estimated || national, + ).length; + + if (playerResults.estimated || playerResults.national) + return
{t("needRating")}
; + + return ( + <> +
+ {t("initialRating", { rating: Math.round(playerResults.elo) })} +
+ + + {deltas.map((delta, i) => { + const { estimated, national, lostByForfeit, wonByForfeit } = delta; + const opponentElo = delta.opponentElo + ? ` (${delta.opponentElo} ${ + estimated ? "E" : national ? "N" : "F" + })` + : ""; + + const opponentName = delta.opponentName ? ( + + + {delta.opponentName} + + {opponentElo} + + ) : ( + "-" + ); + + const playedWhite = delta.colour === "white"; + + const whitePlayer = playedWhite ? playerResults.name : opponentName; + const blackPlayer = !playedWhite ? playerResults.name : opponentName; + const noChange = + wonByForfeit || lostByForfeit || estimated || national; + + const forfeit = delta.opponentName === null; + + let result = ""; + if (wonByForfeit) { + result = t("wonByForfeit"); + } else if (lostByForfeit) { + result = t("lostByForfeit"); + } else if (delta.result === 0.5) { + result = t("draw"); + } else if (playedWhite && delta.result === 1) { + result = t("whiteWin"); + } else result = t("blackWin"); + + return ( + + {forfeit ? ( + + ) : ( + + )} + + + + ); + })} +
{t("forfeit")} + {whitePlayer} + {t("vs")} + {blackPlayer} + + {!forfeit && result} + + {noChange ? ( + "-" + ) : ( + 0 && "text-success", + deltas[i].delta! < 0 && "text-error", + )} + > + {deltas[i]!.delta! >= 0 ? "+" : ""} + {deltas[i].delta!} + + )} +
+ + {noChangeCount > 0 && ( +
+ {t("noChangeInfo", { noChangeCount })} +
+ )} + + {deltas.length > 0 && ( +
+ {t.rich("finalRating", { + rating: Math.round(last(deltas)!.rating), + delta: () => ( + 0 && "text-success", + totalDelta! < 0 && "text-error", + )} + > + {totalDelta >= 0 ? "+" : ""} + {totalDelta} + + ), + })} +
+ )} + + ); +}; diff --git a/app/[locale]/elo/page.tsx b/app/[locale]/elo/page.tsx index 6d9d9e1..1f62c0b 100644 --- a/app/[locale]/elo/page.tsx +++ b/app/[locale]/elo/page.tsx @@ -1,99 +1,85 @@ "use client"; +import { useState } from "react"; + import { zodResolver } from "@hookform/resolvers/zod"; +import { isEmpty } from "lodash"; import { useTranslations } from "next-intl"; -import { FormProvider, useFieldArray, useForm } from "react-hook-form"; -import { IoAdd, IoCloseOutline } from "react-icons/io5"; -import { twMerge } from "tailwind-merge"; +import Link from "next/link"; +import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; +import { Spinner } from "@/components/Spinner"; import { SelectField } from "@/components/form/SelectField"; import { TextField } from "@/components/form/TextField"; -import { getNewRating } from "@/utils/eloCalculator"; +import { fetchTournamentResultsSchema } from "@/schemas"; +import { trpc } from "@/utils/trpc"; -import { FetchResultsForm } from "./FetchResultsForm"; +import { ManualEloForm } from "./ManualEloForm"; +import { TournamentResults } from "./TournamentResults"; -const resultsSchema = z.object({ - currentElo: z.number().int().positive(), +const formSchema = fetchTournamentResultsSchema.extend({ + player: z.string().optional(), kFactor: z.enum(["40", "30", "20", "15", "10"]), - games: z.array( - z.object({ - opponentElo: z.number().int().positive(), - result: z.enum(["win", "draw", "loss"]), - }), - ), }); -type DeepPartial = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; - -type EloFormValues = DeepPartial>; +type FetchResultsFormValues = z.infer; export default function Elo() { const t = useTranslations("Elo"); + type TranslationKey = Parameters[0]; - const form = useForm({ - resolver: zodResolver(resultsSchema), + const [formInput, setFormInput] = useState({ + url: "", + kFactor: "20", + }); + + const hasUrl = !isEmpty(formInput?.url.trim()); + + const { + data: allResults, + isFetching, + error, + } = trpc.fetchTournamentResults.useQuery( + { + url: formInput?.url.trim(), + }, + { + refetchOnWindowFocus: false, + staleTime: Infinity, + cacheTime: 10 * 60 * 1000, + enabled: hasUrl, + retry: false, + }, + ); + + const playerOptions = (allResults ?? []).map((player) => ({ + value: player.id, + label: player.name, + })); + + const form = useForm({ + resolver: zodResolver(fetchTournamentResultsSchema), defaultValues: { kFactor: "20", - games: [{}], }, }); - const { - fields: gameFields, - append: appendGame, - remove: removeGame, - } = useFieldArray({ - control: form.control, - name: "games", - }); + const [player, kFactor] = form.watch(["player", "kFactor"]); + const playerResults = allResults?.find((p) => p.id === player); - const onSubmit = async (data: EloFormValues) => {}; - - const [currentElo, kFactor, games] = form.watch([ - "currentElo", - "kFactor", - "games", - ]); - - type Deltas = { - rating: number; - deltas: { rating: number; delta: number | undefined }[]; + const onSubmit = async (input: FetchResultsFormValues) => { + setFormInput(input); }; - const calculations = !Number.isNaN(currentElo) - ? (games ?? []).reduce( - (acc, game) => { - if (!Number.isNaN(game?.opponentElo) && game?.result) { - const result = - game?.result === "win" ? 1 : game?.result === "loss" ? 0 : 0.5; + const clearForm = () => { + form.setValue("url", ""); + setFormInput({ ...formInput, url: "" }); + }; - const { delta } = getNewRating( - currentElo!, - game.opponentElo!, - result, - parseInt(kFactor!, 10), - ); - return { - rating: acc.rating + delta, - deltas: [...acc.deltas, { rating: acc.rating + delta, delta }], - }; - } - - return { - rating: acc.rating, - deltas: [...acc.deltas, { rating: acc.rating, delta: undefined }], - }; - }, - { rating: currentElo!, deltas: [] }, - ) - : { rating: currentElo!, deltas: [] }; - - const deltas = calculations.deltas; + if (error) { + console.error(error); + } return (
@@ -108,124 +94,101 @@ export default function Elo() { {t("info")}

-
- -
+ {hasUrl && !isFetching && !error && ( +
+ +
+ )} -
-
+ +
- - ({ - value: k, - label: k, - }))} - required - /> -
- -

- {t("resultsTitle")} -

- -
- {gameFields.map((game, i) => { - return ( -
-
-
- - -
- ({ - value: result, - label: t("gameResult", { result }), - }))} - required - /> - {gameFields.length > 1 && ( - - )} -
-
- - {gameFields.length > 1 && ( - - )} -
- - {deltas[i]?.delta !== undefined && ( -
1 && "mr-10", - i === deltas.length - 1 && "font-bold", - )} - > - {Math.round(deltas[i]!.rating)} ( - 0 && "text-success", - deltas[i].delta! < 0 && "text-error", - )} - > - {deltas[i]!.delta! >= 0 ? "+" : ""} - {deltas[i].delta!} - - ) -
- )} -
- ); - })} -
- -
+ + {isFetching && ( +
+ +
+ )} + + {error && ( +
+ {error.message.startsWith("ERR_") + ? t(error.message as TranslationKey) + : t.rich("unknownError", { + contact: (chunks) => ( + + {chunks} + + ), + })} +
+ )} + + {!isFetching && playerOptions.length > 0 && ( +
+ + + ({ + value: k, + label: k, + }))} + isClearable={false} + required + /> +
+ )} -
+ {hasUrl && !isFetching && player && playerResults && !error && ( + + )} + + {((!hasUrl && !isFetching) || !!error) && ( + <> +
+
+
+ {t("useManualLabel")} +
+
+ + + + )}
); diff --git a/app/server/procedures/fetchTournamentResults.ts b/app/server/procedures/fetchTournamentResults.ts index f3c613a..f4872b5 100644 --- a/app/server/procedures/fetchTournamentResults.ts +++ b/app/server/procedures/fetchTournamentResults.ts @@ -1,19 +1,156 @@ +import { z } from "zod"; + import { fetchTournamentResultsSchema } from "@/schemas"; import { errorLog } from "@/utils/logger"; import { publicProcedure } from "../trpc"; +const scrapedSchema = z.array( + z.object({ + id: z.string(), + name: z.string(), + elo: z.string(), + results: z.array( + z.object({ + colour: z.enum(["B", "N", ""]), + result: z.string(), + opponent_name: z.string().nullable(), + opponent_elo: z.string().nullable(), + }), + ), + }), +); + +const outputSchema = z.array( + z.object({ + id: z.string(), + name: z.string(), + elo: z.number(), + estimated: z.boolean(), + national: z.boolean(), + results: z.array( + z.object({ + colour: z.enum(["white", "black", ""]), + result: z.number().nullable(), + lostByForfeit: z.boolean(), + wonByForfeit: z.boolean(), + opponent: z.string().nullable(), + elo: z.number().nullable(), + estimated: z.boolean(), + national: z.boolean(), + }), + ), + }), +); + +const getEloDetails = (elo: string | null) => { + if (elo === null || elo === "") { + return { elo: 0, estimated: false, national: false }; + } + return { + elo: parseInt(elo), + estimated: elo.endsWith("E"), + national: elo.endsWith("N"), + }; +}; + +const getResultDetails = ( + results: z.infer[number]["results"][number], +) => { + if (results.opponent_name === null) { + return { result: 0, lostByForfeit: true, wonByForfeit: false }; + } + + if (results.result === "<") { + return { result: 0, lostByForfeit: true, wonByForfeit: false }; + } + + if (results.result === ">") { + return { result: 1, lostByForfeit: false, wonByForfeit: true }; + } + + return { + result: parseFloat(results.result), + lostByForfeit: false, + wonByForfeit: false, + }; +}; + +const reportFetchError = async (url: string, error: any) => { + if (typeof process.env.DISCORD_WEBHOOK_URL === "string") { + await fetch(process.env.DISCORD_WEBHOOK_URL as string, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + embeds: [ + { + title: "Error Fetching Tournament Results", + fields: [ + { name: "url", value: url }, + { + name: "error.message", + value: "message" in error ? error.message : "", + }, + { name: "error", value: JSON.stringify(error, null, 2) }, + ], + }, + ], + }), + }); + } +}; + export const fetchTournamentResults = publicProcedure .input(fetchTournamentResultsSchema) - .mutation(async ({ input }) => { + .output(outputSchema) + .query(async ({ input }) => { try { - console.log(input.url); + // Depending on which page the user copied the URL from, we need to extract the tournament ID - // Fetch the tournament results using Python + // http://echecs.asso.fr/FicheTournoi.aspx?Ref=59975 + // http://echecs.asso.fr/Resultats.aspx?URL=Tournois/Id/59975/59975&Action=Ls - return true; + const tournamentId = + input.url.match(/Ref=(\d+)/)?.[1] ?? input.url.match(/Id\/(\d+)/)?.[1]; + + if (!tournamentId) { + throw new Error("ERR_NO_TOURNAMENT_ID"); + } + + const rawResults = await fetch( + `${process.env.RESULTS_SCRAPER_URL}${tournamentId}`, + ); + + const results = await rawResults.json(); + + if ("detail" in results && results.detail === "Not found") { + throw new Error("ERR_TOURNAMENT_NOT_FOUND"); + } + + const parsedResults = scrapedSchema.parse(results); + return parsedResults.map[number]>( + (player) => ({ + id: player.id, + name: player.name, + ...getEloDetails(player.elo), + results: player.results.map((result) => ({ + colour: + result.colour === "B" + ? "white" + : result.colour === "N" + ? "black" + : "", + opponent: result.opponent_name, + ...getResultDetails(result), + ...getEloDetails(result.opponent_elo), + })), + }), + ); } catch (error) { - errorLog(error); + reportFetchError(input.url, error); + errorLog(JSON.stringify(error, null, 2)); throw error; } }); diff --git a/components/Spinner.tsx b/components/Spinner.tsx new file mode 100644 index 0000000..2ccaba5 --- /dev/null +++ b/components/Spinner.tsx @@ -0,0 +1,22 @@ +export const Spinner = () => { + return ( +
+ +
+ ); +}; diff --git a/components/form/SelectField.tsx b/components/form/SelectField.tsx index 45098e8..6501356 100644 --- a/components/form/SelectField.tsx +++ b/components/form/SelectField.tsx @@ -1,200 +1,174 @@ -import { Fragment, useRef } from "react"; +import React from "react"; -import { Listbox, Transition } from "@headlessui/react"; +import { flatMap, get, isArray, isNil, isObject } from "lodash"; import { useTranslations } from "next-intl"; import { Controller, useFormContext } from "react-hook-form"; -import { - IoChevronDown, - IoCloseOutline, - IoSearchOutline, -} from "react-icons/io5"; +import Select, { + ActionMeta, + ClassNamesConfig, + GroupBase, + OnChangeValue, + Props, +} from "react-select"; import { twMerge } from "tailwind-merge"; import { Field, FieldProps } from "./Field"; -export type SelectOption = { - value: string; - label: React.ReactNode; - selectedLabel?: React.ReactNode; +export type BaseOption = { + value: T; + label: string | JSX.Element; disabled?: boolean; + data?: D; }; -type SelectFieldProps = FieldProps & { - required?: boolean; - placeholder?: string; - noOptionsMessage?: string; - options: SelectOption[]; - searchable?: boolean; - listboxClassName?: string; - dropdownClassName?: string; - searchValue?: string; - onChangeSearchValue?: (value: string) => void; - onOptionSelected?: (option: SelectOption) => void; -}; +export const classNames = ( + hasError: boolean, +): ClassNamesConfig> => ({ + container: () => "w-full", + valueContainer: () => "text-gray-900 dark:text-white", + indicatorsContainer: () => "flex items-center self-stretch shrink-0", + clearIndicator: () => + "pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white", + dropdownIndicator: () => + "pointer-events-none flex items-center pr-2 text-gray-900 dark:text-white", + indicatorSeparator: () => "w-px text-gray-900 dark:text-white", + control: (state) => + twMerge( + "group flex w-full items-center justify-between rounded-lg border p-3 text-sm", + "border-gray-300 bg-gray-50 text-gray-900 shadow-sm focus-within:border-primary-500 focus-within:ring-primary-500", + "dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus-within:border-primary-500 dark:focus-within:ring-primary-500", -export const SelectField = ({ + hasError && "ring-1 ring-error", + state.isDisabled && "cursor-not-allowed", + state.isFocused && "border-primary ring ring-primary ring-opacity-50", + ), + multiValue: () => "bg-fieldGray border rounded-lg flex space-x-1 pl-1 m-1", + multiValueLabel: () => "", + multiValueRemove: () => "items-center px-1 hover:text-primary", + placeholder: () => + "block truncate pr-2 placeholder-gray dark:placeholder-gray-400", + menu: () => + twMerge( + "!z-10 mt-2 rounded-lg border border-gray-200 bg-white p-1", + "border-gray-300 bg-gray-50 text-gray-900", + "dark:border-gray-600 dark:bg-gray-700 dark:text-white", + ), + groupHeading: () => "ml-3 mt-2 mb-1 text-textGray text-sm uppercase", + option: ({ isFocused, isSelected, isDisabled }) => + twMerge( + "px-3 py-2 hover:cursor-pointer", + isDisabled && "opacity-50", + isFocused && "hover:bg-primary-500 hover:text-white", + ), + noOptionsMessage: () => + "text-textGray p-2 bg-gray-50 border border-dashed border-gray-200 rounded-sm", +}); + +export type SelectFieldProps< + IsMulti extends boolean = false, + T = string, + D = unknown, +> = FieldProps & + Omit< + Props, IsMulti, GroupBase>>, + "onChange" | "value" | "classNames" + > & { + required?: boolean; + }; + +export const SelectField = < + IsMulti extends boolean = false, + T = string, + D = unknown, +>({ name, - label, className, labelClassName, childrenWrapperClassName, - hideErrorMessage = false, - + label, + hideErrorMessage, required, + placeholder, - noOptionsMessage, options, - searchable, - searchValue = "", - onChangeSearchValue, - onOptionSelected, - listboxClassName, - dropdownClassName, -}: SelectFieldProps) => { - const at = useTranslations("App"); + ...selectProps +}: SelectFieldProps) => { + const t = useTranslations("App"); const form = useFormContext(); - // We need to keep track of the selected option to be able to continue to - // display it when the user searches for something else - const curOption = useRef(null); + const { + formState: { errors }, + } = form; + + const hasError = !!get(errors, name)?.message; + + const isGroup = ( + option: BaseOption | GroupBase>, + ): option is GroupBase> => + isObject(option) && "options" in option; + + const flattenedOptions = flatMap(options, (option) => { + return isGroup(option) ? option.options : option; + }); + + const valueToOption = (value: T): BaseOption => + flattenedOptions.find((o) => o.value === value) ?? { value, label: "" }; return ( -
- { - const selectedOption = - curOption.current?.value === value - ? curOption.current - : options.find((option) => option.value === value); + { + const onSelectChange = ( + newValue: OnChangeValue, IsMulti>, + actionMeta: ActionMeta>, + ) => { + if (isNil(newValue)) onChange(null); + else if (isArray(newValue)) { + onChange(newValue.map((option) => option.value)); + } else onChange((newValue as BaseOption).value); + }; - return ( - { - curOption.current = - options.find((option) => option.value === value) ?? null; - onChangeSearchValue?.(""); - onChange(value); - onOptionSelected?.(curOption.current!); - }} - > - {({ open }) => ( -
- - - {selectedOption?.selectedLabel ?? - selectedOption?.label ?? - placeholder ?? - at("selectPlaceholder")} - - - - + const optionValue = isNil(value) + ? null + : isArray(value) + ? value.map(valueToOption) + : valueToOption(value); - -
ul]:outline-none", - "border-gray-300 bg-gray-50 text-gray-900", - "dark:border-gray-600 dark:bg-gray-700 dark:text-white", - - dropdownClassName, - )} - > - - {searchable && ( -
- - onChangeSearchValue(e.target.value) - : undefined - } - placeholder={at("searchPlaceholder")} - type="search" - /> - {searchValue.trim() !== "" && ( - - )} -
- )} - - {options.length === 0 ? ( -
- {noOptionsMessage ?? at("noOptionsMessage")} -
- ) : ( -
- {options.map((option) => ( - - {option.label} - - ))} -
- )} -
-
-
-
- )} -
- ); - }} - /> -
+ return ( +