sendou.ink/app/components/Combobox.tsx
Kalle e7bbb565be
SendouQ (#1455)
* Tables

* Clocks

* Maplist preference selector

* Fix SSR

* Nav icon

* RankedOrScrim

* Map pool

* Create group

* Redirect logic

* Persist map pool

* Advance from preparing page

* Rename query

* Fix merge

* Fix migration order

* Seed groups

* Find looking groups SQL

* Renders something

* More UI work

* Back to 30min

* Likes/dislikes

* Always return own group

* Fix like order

* 3 tc/rm/cb -> 2

* Show only 3 weapons

* Pass group size

* Handle both liked and liked by same group

* Fix SQL

* Group preference frontend work

* Morphing

* Styling

* Don't show group controls if not manager

* Give/remove manager

* Leave group

* Leave with confirm

* Delete likes when morphing groups

* Clocks consistency

* Remove bad invariant

* Persist settings to local storage

* Fix initial value flashing

* Fix never resolving loading indicator

* REFRESH_GROUP

* Flip animations

* Tweaks

* Auto refresh logic

* Groups of 4 seed

* Reduce throwing

* Load full groups initial

* Create match

* Match UI initial

* Score reporter initial

* Push footer down on match page

* Score reporter knows when set ended

* Score reporting untested

* Show score after report

* Align better

* Look again with same group functionality

* More migrations

* Team on match page

* Show confirmer before reporting score

* Report weapons

* Report weapos again by admin + skill changing

* Handle no tiebreaker given to MapPool

* Remove unranked

* Remove support for "team id skill"

* no-wrap -> nowrap

* Preparing page work

* Use common GroupCard component

* Add some metas

* MemberAdder in looking page

* Fix GroupCard actions

* Fix SZ only map list including other modes

* Add season info

* Prompt login

* Joining team

* Manage group on preparing page

* Manage group on preparing page

* Seed past matches

* Add to seed

* No map list preference when full group + fix expiry

* Fix skill matchesCount calculation

* Tiers initial work

* Some progress on tiers

* Tiering logic

* MMR in group cards

* Name to challenge

* Team MMR

* Big team rank icons

* Adjust todos

* Match score report with confirm

* Allow regular members to report score

* Handle reporting weapons edge cases

* Add tier images

* Improve GroupCard spacing

* Refactor looking page

* Looking mobile UI

* Calculate skill only for current season

* Divide groups visually when reporting weapons

* Fix match page weapons sorting

* Add cache to user skills+tier calculation

* Admin report match score

* Initial leaderboard

* Cached leaderboard

* Weapon category lb's

* Populate SkillTeamUser in SendouQ

* Team leaderboard filtered down

* Add TODOs

* Seasons initlal

* Season weapons initial

* Weapons stylized

* Show rest weapons as +

* Hide peak if same as current

* Load matches SQL initial

* Season matches UI initial

* Take user id in account

* Add weapons

* Paginated matches

* Fix pages count logic

* Scroll top on data change

* Day headers for matches

* Link from user page to user seasons page

* Summarize maps + ui initial

* Map stats

* Player info tabs

* MMR chart

* Chart adjustments

* Handle basing team MMR on player MMR

* Set initial MMR

* Add info about discord to match page

* Season support to tournaments

* Get tournament skills as well for the graph

* WIP

* New team rating logic + misc other

* tiered -> tiered.server

* Update season starting time

* TODOs

* Add rules page

* Hide elements correctly when off-season

* Fix crash when only one player with skill

* How-to video

* Fix StartRank showing when not logged in

* Make user leaderboard the default

* Make Skill season non-nullable

* Add suggested pass to match

* Add rule

* identifierToUserIds helper

* Fix tiers not showing
2023-08-12 22:42:54 +03:00

458 lines
12 KiB
TypeScript

import { Combobox as HeadlessCombobox } from "@headlessui/react";
import clsx from "clsx";
import Fuse from "fuse.js";
import * as React from "react";
import type { GearType, UserWithPlusTier } from "~/db/types";
import { useAllEventsWithMapPools, useUsers } from "~/hooks/swr";
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
clothesGearIds,
headGearIds,
mainWeaponIds,
shoesGearIds,
subWeaponIds,
weaponCategories,
} from "~/modules/in-game-lists";
import {
nonBombSubWeaponIds,
nonDamagingSpecialWeaponIds,
specialWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { type SerializedMapPoolEvent } from "~/routes/calendar/map-pool-events";
import type { Unpacked } from "~/utils/types";
import {
gearImageUrl,
mainWeaponImageUrl,
specialWeaponImageUrl,
subWeaponImageUrl,
} from "~/utils/urls";
import { Image } from "./Image";
const MAX_RESULTS_SHOWN = 6;
interface ComboboxBaseOption {
label: string;
value: string;
imgPath?: string;
}
type ComboboxOption<T> = ComboboxBaseOption & T;
interface ComboboxProps<T> {
options: ComboboxOption<T>[];
inputName: string;
placeholder: string;
className?: string;
id?: string;
isLoading?: boolean;
required?: boolean;
value?: ComboboxOption<T> | null;
initialValue: ComboboxOption<T> | null;
onChange?: (selectedOption: ComboboxOption<T> | null) => void;
fullWidth?: boolean;
nullable?: true;
fuseOptions?: Fuse.IFuseOptions<ComboboxOption<T>>;
}
export function Combobox<T extends Record<string, string | null | number>>({
options,
inputName,
placeholder,
value,
initialValue,
onChange,
required,
className,
id,
nullable,
isLoading = false,
fullWidth = false,
fuseOptions = {},
}: ComboboxProps<T>) {
const { t } = useTranslation();
const [_selectedOption, setSelectedOption] = React.useState<Unpacked<
typeof options
> | null>(initialValue);
const [query, setQuery] = React.useState("");
const filteredOptions = (() => {
if (!query) return [];
const fuse = new Fuse(options, {
...fuseOptions,
keys: [...Object.keys(options[0] ?? {})],
});
return fuse
.search(query)
.slice(0, MAX_RESULTS_SHOWN)
.map((res) => res.item);
})();
const noMatches = filteredOptions.length === 0;
const displayValue = (option: Unpacked<typeof options>) => {
return option?.label ?? "";
};
const selectedOption = value ?? _selectedOption;
return (
<div className="combobox-wrapper">
<HeadlessCombobox
value={selectedOption}
onChange={(selected) => {
onChange?.(selected);
setSelectedOption(selected);
}}
name={inputName}
disabled={!selectedOption && isLoading}
// TODO: remove hack that prevents TS from freaking out. probably related: https://github.com/tailwindlabs/headlessui/issues/1895
nullable={nullable as true}
>
<HeadlessCombobox.Input
onChange={(event) => setQuery(event.target.value)}
placeholder={isLoading ? t("actions.loading") : placeholder}
className={clsx("combobox-input", className, {
fullWidth,
})}
defaultValue={initialValue}
displayValue={displayValue}
data-testid={`${inputName}-combobox-input`}
id={id}
required={required}
/>
<HeadlessCombobox.Options
className={clsx("combobox-options", {
empty: noMatches,
fullWidth,
hidden: !query,
})}
>
{isLoading ? (
<div className="combobox-no-matches">{t("actions.loading")}</div>
) : noMatches ? (
<div className="combobox-no-matches">
{t("forms.errors.noSearchMatches")}{" "}
<span className="combobox-emoji">🤔</span>
</div>
) : (
filteredOptions.map((option) => (
<HeadlessCombobox.Option
key={option.value}
value={option}
as={React.Fragment}
>
{({ active }) => (
<li className={clsx("combobox-item", { active })}>
{option.imgPath && (
<Image
alt=""
path={option.imgPath}
width={24}
height={24}
/>
)}
{option.label}
</li>
)}
</HeadlessCombobox.Option>
))
)}
</HeadlessCombobox.Options>
</HeadlessCombobox>
</div>
);
}
// Reference for Fuse options: https://fusejs.io/api/options.html
const USER_COMBOBOX_FUSE_OPTIONS = {
threshold: 0.42, // Empirically determined value to get an exact match for a Discord ID
};
export function UserCombobox({
inputName,
initialUserId,
onChange,
userIdsToOmit,
className,
required,
id,
}: Pick<
ComboboxProps<Pick<UserWithPlusTier, "discordId" | "plusTier">>,
"inputName" | "onChange" | "className" | "id" | "required"
> & { userIdsToOmit?: Set<number>; initialUserId?: number }) {
const { t } = useTranslation();
const { users, isLoading, isError } = useUsers();
const options = React.useMemo(() => {
if (!users) return [];
const data = userIdsToOmit
? users.filter((user) => !userIdsToOmit.has(user.id))
: users;
return data.map((u) => ({
label: u.discordFullName,
value: String(u.id),
discordId: u.discordId,
plusTier: u.plusTier,
}));
}, [users, userIdsToOmit]);
const initialValue = React.useMemo(() => {
if (!initialUserId) return;
return options.find((o) => o.value === String(initialUserId));
}, [options, initialUserId]);
if (isError) {
return (
<div className="text-sm text-error">{t("errors.genericReload")}</div>
);
}
return (
<Combobox
inputName={inputName}
options={options}
placeholder="Sendou#4059"
isLoading={isLoading}
initialValue={initialValue ?? null}
onChange={onChange}
className={className}
id={id}
required={required}
fuseOptions={USER_COMBOBOX_FUSE_OPTIONS}
// reload after users have loaded
key={String(!!initialValue)}
/>
);
}
// TODO: [object Object] flickers when server rendered with initialValue
export function WeaponCombobox({
id,
required,
className,
inputName,
onChange,
initialWeaponId,
weaponIdsToOmit,
fullWidth,
nullable,
value,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
| "inputName"
| "onChange"
| "className"
| "id"
| "required"
| "fullWidth"
| "nullable"
> & {
initialWeaponId?: (typeof mainWeaponIds)[number];
weaponIdsToOmit?: Set<MainWeaponId>;
value?: MainWeaponId | null;
}) {
const { t } = useTranslation("weapons");
const idToWeapon = (id: (typeof mainWeaponIds)[number]) => ({
value: String(id),
label: t(`MAIN_${id}`),
imgPath: mainWeaponImageUrl(id),
});
return (
<Combobox
inputName={inputName}
options={mainWeaponIds
.filter((id) => !weaponIdsToOmit?.has(id))
.map(idToWeapon)}
value={typeof value === "number" ? idToWeapon(value) : null}
initialValue={
typeof initialWeaponId === "number" ? idToWeapon(initialWeaponId) : null
}
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
onChange={onChange}
className={className}
id={id}
required={required}
fullWidth={fullWidth}
nullable={nullable}
/>
);
}
export function AllWeaponCombobox({
id,
inputName,
onChange,
fullWidth,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
"inputName" | "onChange" | "id" | "fullWidth"
>) {
const { t } = useTranslation("weapons");
const options = () => {
const result: ComboboxProps<
Record<string, string | null | number>
>["options"] = [];
for (const mainWeaponId of mainWeaponIds) {
result.push({
value: `MAIN_${mainWeaponId}`,
label: t(`MAIN_${mainWeaponId}`),
imgPath: mainWeaponImageUrl(mainWeaponId),
});
}
for (const subWeaponId of subWeaponIds) {
if (nonBombSubWeaponIds.includes(subWeaponId)) continue;
result.push({
value: `SUB_${subWeaponId}`,
label: t(`SUB_${subWeaponId}`),
imgPath: subWeaponImageUrl(subWeaponId),
});
}
for (const specialWeaponId of specialWeaponIds) {
if (nonDamagingSpecialWeaponIds.includes(specialWeaponId)) continue;
result.push({
value: `SPECIAL_${specialWeaponId}`,
label: t(`SPECIAL_${specialWeaponId}`),
imgPath: specialWeaponImageUrl(specialWeaponId),
});
}
return result;
};
return (
<Combobox
inputName={inputName}
options={options()}
initialValue={null}
placeholder={t(`MAIN_${weaponCategories[0].weaponIds[0]}`)}
onChange={onChange}
id={id}
fullWidth={fullWidth}
/>
);
}
export function GearCombobox({
id,
required,
className,
inputName,
onChange,
gearType,
initialGearId,
}: Pick<
ComboboxProps<ComboboxBaseOption>,
"inputName" | "onChange" | "className" | "id" | "required"
> & { gearType: GearType; initialGearId?: number }) {
const { t } = useTranslation("gear");
const translationPrefix =
gearType === "HEAD" ? "H" : gearType === "CLOTHES" ? "C" : "S";
const ids =
gearType === "HEAD"
? headGearIds
: gearType === "CLOTHES"
? clothesGearIds
: shoesGearIds;
const idToGear = (id: (typeof ids)[number]) => ({
value: String(id),
label: t(`${translationPrefix}_${id}` as any),
imgPath: gearImageUrl(gearType, id),
});
return (
<Combobox
inputName={inputName}
options={ids.map(idToGear)}
placeholder={idToGear(ids[0]).label}
initialValue={initialGearId ? idToGear(initialGearId as any) : null}
onChange={onChange}
className={className}
id={id}
required={required}
/>
);
}
const mapPoolEventToOption = (
e: SerializedMapPoolEvent
): ComboboxOption<Pick<SerializedMapPoolEvent, "serializedMapPool">> => ({
serializedMapPool: e.serializedMapPool,
label: e.name,
value: e.id.toString(),
});
type MapPoolEventsComboboxProps = Pick<
ComboboxProps<Pick<SerializedMapPoolEvent, "serializedMapPool">>,
"inputName" | "className" | "id" | "required"
> & {
initialEvent?: SerializedMapPoolEvent;
onChange: (event: SerializedMapPoolEvent | null) => void;
};
export function MapPoolEventsCombobox({
id,
required,
className,
inputName,
onChange,
initialEvent,
}: MapPoolEventsComboboxProps) {
const { t } = useTranslation();
const { events, isLoading, isError } = useAllEventsWithMapPools();
const options = React.useMemo(
() => (events ? events.map(mapPoolEventToOption) : []),
[events]
);
// this is important so that we don't trigger the reset to the initialEvent every time
const initialOption = React.useMemo(
() => initialEvent && mapPoolEventToOption(initialEvent),
[initialEvent]
);
if (isError) {
return (
<div className="text-sm text-error">{t("errors.genericReload")}</div>
);
}
return (
<Combobox
inputName={inputName}
options={isLoading && initialOption ? [initialOption] : options}
placeholder={t("actions.search")}
initialValue={initialOption ?? null}
onChange={(e) => {
onChange(
e && {
id: parseInt(e.value, 10),
name: e.label,
serializedMapPool: e.serializedMapPool,
}
);
}}
className={className}
id={id}
required={required}
isLoading={isLoading}
fullWidth
/>
);
}