Tournament select field

This commit is contained in:
Timothy Armes
2023-10-16 18:07:38 +02:00
committed by Owen Rees
parent a0f9d4dd4e
commit db369a3979
15 changed files with 518 additions and 88 deletions
+187
View File
@@ -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>
);
};
+10 -4
View File
@@ -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>
);
}}
/>
);
};