mirror of
https://github.com/TheRealOwenRees/echecsfrance.git
synced 2026-07-23 04:26:57 +00:00
Tournament select field
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { get, isArray, isNil } from "lodash";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Controller,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { ActionMeta, GroupBase, OnChangeValue, Props } from "react-select";
|
||||
import AsyncSelect from "react-select/async";
|
||||
|
||||
import { Prettify } from "@/types";
|
||||
|
||||
import { Field, GenericFieldProps } from "./Field";
|
||||
import { BaseOption, classNames } from "./SelectField";
|
||||
|
||||
export type AsyncSelectFieldProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
IsMulti extends boolean = false,
|
||||
T = string,
|
||||
D = unknown,
|
||||
> = Prettify<
|
||||
GenericFieldProps<TFieldValues, TFieldName> &
|
||||
Omit<
|
||||
Props<BaseOption<T, D>, IsMulti, GroupBase<BaseOption<T, D>>>,
|
||||
"options" | "onChange" | "value" | "classNames"
|
||||
> & {
|
||||
required?: boolean;
|
||||
loadOption: (id: T) => Promise<BaseOption<T, D> | undefined>;
|
||||
loadOptions: (searchString?: string) => Promise<BaseOption<T, D>[]>;
|
||||
separators?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
export const AsyncSelectField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
IsMulti extends boolean = false,
|
||||
T = string,
|
||||
D = unknown,
|
||||
>({
|
||||
name,
|
||||
control,
|
||||
className,
|
||||
label,
|
||||
hideErrorMessage,
|
||||
required,
|
||||
|
||||
loadOption,
|
||||
loadOptions,
|
||||
|
||||
placeholder,
|
||||
separators,
|
||||
...selectProps
|
||||
}: AsyncSelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
|
||||
const t = useTranslations("App");
|
||||
const [loadingValues, setLoadingValues] = useState(false);
|
||||
const [loadedInitialValues, setLoadedInitialValues] = useState(false);
|
||||
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||
const valueOptions = useRef<BaseOption<T, D>[]>([]);
|
||||
const searchOptions = useRef<BaseOption<T, D>[]>([]);
|
||||
const form = useFormContext();
|
||||
const value = useWatch({ control: form.control, name });
|
||||
|
||||
// Fetch the option(s) for the current value(s) when mounted
|
||||
useEffect(() => {
|
||||
const doLoad = async () => {
|
||||
try {
|
||||
const values = !value ? [] : isArray(value) ? value : [value];
|
||||
|
||||
const unFetchedValues = values.filter(
|
||||
(v) =>
|
||||
valueOptions.current.find((o) => o.value === value) === undefined,
|
||||
);
|
||||
|
||||
if (unFetchedValues.length > 0) {
|
||||
setLoadingValues(true);
|
||||
const options = await Promise.all(
|
||||
unFetchedValues.map((v: T) => {
|
||||
return loadOption(v);
|
||||
}),
|
||||
);
|
||||
|
||||
valueOptions.current = [
|
||||
...valueOptions.current,
|
||||
...options.filter((vo): vo is BaseOption<T, D> => vo !== undefined),
|
||||
];
|
||||
|
||||
setLoadingValues(false);
|
||||
|
||||
// This forced a state update, which enures that the select is updated once the query has finished
|
||||
setLoadedInitialValues(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
setLoadingValues(false);
|
||||
}
|
||||
};
|
||||
|
||||
doLoad();
|
||||
}, [value, setLoadingValues, loadOption]);
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
const hasError = !!get(errors, name)?.message;
|
||||
|
||||
const valueToOption = (value: T) =>
|
||||
[...valueOptions.current, ...searchOptions.current].find(
|
||||
(o) => o.value === value,
|
||||
) ?? { value, label: "" };
|
||||
|
||||
const fetchOptions = async (searchString?: string) => {
|
||||
setLoadingOptions(true);
|
||||
const options = await loadOptions(searchString);
|
||||
searchOptions.current = options;
|
||||
setLoadingOptions(false);
|
||||
return options;
|
||||
};
|
||||
|
||||
return (
|
||||
<Field {...{ name, control, className, label, hideErrorMessage, required }}>
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const onSelectChange = (
|
||||
newValue: OnChangeValue<BaseOption<T, D>, IsMulti>,
|
||||
actionMeta: ActionMeta<BaseOption<T, D>>,
|
||||
) => {
|
||||
console.log(newValue, actionMeta);
|
||||
if (isNil(newValue) || actionMeta.action === "clear") {
|
||||
onChange(null);
|
||||
valueOptions.current = [];
|
||||
} else if (isArray(newValue)) {
|
||||
onChange(newValue.map((option) => option.value));
|
||||
valueOptions.current = newValue;
|
||||
} else {
|
||||
onChange((newValue as BaseOption<T, D>).value);
|
||||
valueOptions.current = [newValue as BaseOption<T, D>];
|
||||
}
|
||||
};
|
||||
|
||||
const optionValue =
|
||||
isNil(value) || value === ""
|
||||
? null
|
||||
: isArray(value)
|
||||
? // @ts-ignore - this is too complex for TS to understand
|
||||
value.map(valueToOption)
|
||||
: valueToOption(value);
|
||||
|
||||
return (
|
||||
<AsyncSelect<BaseOption<T, D>, IsMulti>
|
||||
isClearable
|
||||
value={optionValue}
|
||||
onChange={onSelectChange}
|
||||
loadOptions={fetchOptions}
|
||||
defaultOptions
|
||||
isLoading={loadingOptions || loadingValues}
|
||||
noOptionsMessage={() => t("noOptionsMessage")}
|
||||
placeholder={placeholder ?? t("selectPlaceholder")}
|
||||
unstyled
|
||||
styles={{
|
||||
input: (base) => ({
|
||||
...base,
|
||||
"input:focus": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
}),
|
||||
}}
|
||||
classNames={classNames<BaseOption<T, D>, IsMulti>(
|
||||
hasError,
|
||||
separators ?? false,
|
||||
)}
|
||||
{...selectProps}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
@@ -29,12 +29,12 @@ export type BaseOption<T = string, D = unknown> = {
|
||||
|
||||
export const classNames = <Option, IsMulti extends boolean = false>(
|
||||
hasError: boolean,
|
||||
separators: boolean,
|
||||
): ClassNamesConfig<Option, IsMulti, GroupBase<Option>> => ({
|
||||
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",
|
||||
clearIndicator: () => "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",
|
||||
@@ -55,7 +55,7 @@ export const classNames = <Option, IsMulti extends boolean = false>(
|
||||
"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",
|
||||
"!z-30 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",
|
||||
),
|
||||
@@ -63,6 +63,7 @@ export const classNames = <Option, IsMulti extends boolean = false>(
|
||||
option: ({ isFocused, isDisabled }) =>
|
||||
twMerge(
|
||||
"px-3 py-2 hover:cursor-pointer",
|
||||
separators && "border-b border-gray-200",
|
||||
isDisabled && "opacity-50",
|
||||
isFocused && "hover:bg-primary-500 hover:text-white",
|
||||
),
|
||||
@@ -83,6 +84,7 @@ export type SelectFieldProps<
|
||||
"onChange" | "value" | "classNames" | "name"
|
||||
> & {
|
||||
required?: boolean;
|
||||
separators?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -103,6 +105,7 @@ export const SelectField = <
|
||||
required,
|
||||
|
||||
placeholder,
|
||||
separators,
|
||||
options,
|
||||
...selectProps
|
||||
}: SelectFieldProps<TFieldValues, TFieldName, IsMulti, T, D>) => {
|
||||
@@ -176,7 +179,10 @@ export const SelectField = <
|
||||
},
|
||||
}),
|
||||
}}
|
||||
classNames={classNames<BaseOption<T, D>, IsMulti>(hasError)}
|
||||
classNames={classNames<BaseOption<T, D>, IsMulti>(
|
||||
hasError,
|
||||
separators ?? false,
|
||||
)}
|
||||
{...selectProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { FieldPath, FieldValues } from "react-hook-form";
|
||||
|
||||
import { TimeControlColours } from "@/constants";
|
||||
import { SearchedTournament, trpc } from "@/utils/trpc";
|
||||
|
||||
import { AsyncSelectField, AsyncSelectFieldProps } from "./AsyncSelectField";
|
||||
|
||||
type TournamentSelectFieldProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = Omit<
|
||||
AsyncSelectFieldProps<
|
||||
TFieldValues,
|
||||
TFieldName,
|
||||
false,
|
||||
string,
|
||||
SearchedTournament
|
||||
>,
|
||||
"loadOptions" | "loadOption" | "separators"
|
||||
>;
|
||||
|
||||
export const TournamentSelectField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...rest
|
||||
}: TournamentSelectFieldProps<TFieldValues, TFieldName>) => {
|
||||
const at = useTranslations("App");
|
||||
const client = trpc.useContext();
|
||||
|
||||
const loadOption = async (ffeId: string) => {
|
||||
const tournament = await client.getTournamentDetails.fetch({ ffeId });
|
||||
|
||||
return !tournament
|
||||
? undefined
|
||||
: {
|
||||
value: tournament.ffeId,
|
||||
label: tournament.tournament,
|
||||
data: tournament,
|
||||
};
|
||||
};
|
||||
|
||||
const loadOptions = async (searchValue?: string) => {
|
||||
const tournaments = await client.searchTournaments.fetch({
|
||||
searchValue: searchValue ?? "",
|
||||
});
|
||||
|
||||
return (tournaments ?? []).map((tournament) => ({
|
||||
value: tournament.ffeId,
|
||||
label: tournament.tournament,
|
||||
data: tournament,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<AsyncSelectField
|
||||
{...rest}
|
||||
isSearchable
|
||||
separators
|
||||
loadOption={loadOption}
|
||||
loadOptions={loadOptions}
|
||||
isClearable={true}
|
||||
formatOptionLabel={(option, context) => {
|
||||
if (context.context === "value") return option.label;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 text-xs uppercase">
|
||||
<div className="flex-1">{option.data?.town}</div>
|
||||
<div
|
||||
className="ml-2 rounded-sm px-1 py-0.5 align-middle text-[9px]/[11px] uppercase text-white"
|
||||
style={{
|
||||
background: `${TimeControlColours[option.data!.timeControl]}`,
|
||||
}}
|
||||
>
|
||||
{at("timeControlEnum", { tc: option.data?.timeControl })}
|
||||
</div>
|
||||
<div className="text-xs">{option.data?.date}</div>
|
||||
</div>
|
||||
|
||||
<div>{option.data?.tournament}</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user