Select improvements (#2654)

This commit is contained in:
Kalle
2025-12-03 20:47:42 +02:00
committed by GitHub
parent 265585d4cd
commit 683b2ed76d
17 changed files with 1068 additions and 1015 deletions

View File

@@ -11,6 +11,8 @@ import type { AnyWeapon } from "~/features/build-analyzer";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { filterWeapon } from "~/modules/in-game-lists/utils";
import {
mainWeaponIds,
nonDamagingSpecialWeaponIds,
SPLAT_BOMB_ID,
specialWeaponIds,
subWeaponIds,
@@ -46,7 +48,6 @@ interface WeaponSelectProps<
quickSelectWeaponsIds?: Array<MainWeaponId>;
}
// TODO: fix selected value disappears when filtered out. This is because `items` is filtered in a controlled manner and the selected key might not be included in the filtered items.
export function WeaponSelect<
Clearable extends boolean | undefined = undefined,
IncludeSubSpecial extends boolean | undefined = undefined,
@@ -63,10 +64,11 @@ export function WeaponSelect<
quickSelectWeaponsIds,
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
const { t } = useTranslation(["common"]);
const { items, filterValue, setFilterValue } = useFilteredWeaponItems({
const { items, filterValue, setFilterValue } = useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
});
const filter = useWeaponFilter();
const isControlled = value !== undefined;
@@ -113,6 +115,7 @@ export function WeaponSelect<
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
filter={filter}
>
{({ key, items: weapons, name, idx }) => (
<SendouSelectItemSection
@@ -178,7 +181,40 @@ export function WeaponSelect<
);
}
function useFilteredWeaponItems({
function useWeaponFilter() {
const { t } = useTranslation(["weapons"]);
const weaponNameToWeaponMap = (() => {
const map = new Map<string, AnyWeapon>();
for (const id of mainWeaponIds) {
map.set(t(`weapons:MAIN_${id}`), { id, type: "MAIN" });
}
for (const id of subWeaponIds) {
map.set(t(`weapons:SUB_${id}`), { id, type: "SUB" });
}
for (const id of specialWeaponIds) {
map.set(t(`weapons:SPECIAL_${id}`), { id, type: "SPECIAL" });
}
return map;
})();
return (value: string, searchValue: string) => {
const weapon = weaponNameToWeaponMap.get(value);
if (!weapon) return false;
return filterWeapon({
weapon,
weaponName: value,
searchTerm: searchValue,
});
};
}
function useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
}: {
@@ -192,58 +228,41 @@ function useFilteredWeaponItems({
const showQuickSelectWeapons =
filterValue === "" && quickSelectWeaponsIds?.length;
const filteredItems = () => {
if (showQuickSelectWeapons) {
return [
{
idx: 0,
key: "quick-select" as const,
name: t("common:forms.weaponSearch.quickSelect"),
items: items
.flatMap((c) =>
c.items
.map((item) => (item.weapon.type === "MAIN" ? item : null))
.filter((val) => val !== null),
)
.filter((item) =>
quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId),
)
.sort((a, b) => {
const aIdx = quickSelectWeaponsIds.indexOf(
a.weapon.id as MainWeaponId,
);
const bIdx = quickSelectWeaponsIds.indexOf(
b.weapon.id as MainWeaponId,
);
return aIdx - bIdx;
}),
},
];
}
if (showQuickSelectWeapons) {
const quickSelectCategory = {
idx: 0,
key: "quick-select" as const,
name: t("common:forms.weaponSearch.quickSelect"),
items: items
.flatMap((c) =>
c.items
.map((item) => (item.weapon.type === "MAIN" ? item : null))
.filter((val) => val !== null),
)
.filter((item) =>
quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId),
)
.sort((a, b) => {
const aIdx = quickSelectWeaponsIds.indexOf(
a.weapon.id as MainWeaponId,
);
const bIdx = quickSelectWeaponsIds.indexOf(
b.weapon.id as MainWeaponId,
);
return aIdx - bIdx;
}),
};
return !filterValue
? items
: items
.map((category) => {
const filteredItems = category.items.filter((item) =>
filterWeapon({
weapon: item.weapon,
weaponName: item.name,
searchTerm: filterValue,
}),
);
return {
...category,
items: filteredItems,
};
})
.filter((category) => category.items.length > 0)
.map((category, idx) => ({ ...category, idx }));
};
return {
// not too sure why we need to type cast here.. was working fine before refactoring
items: [quickSelectCategory] as typeof items,
filterValue,
setFilterValue,
};
}
return {
items: filteredItems(),
items,
filterValue,
setFilterValue,
};
@@ -288,14 +307,17 @@ function useAllWeaponCategories(withSubSpecial = false) {
name: "specials" as const,
key: "specials",
idx: 1,
items: specialWeaponIds.map((id) => ({
name: t(`weapons:SPECIAL_${id}`),
weapon: {
anyWeaponId: `SPECIAL_${id}`,
id,
type: "SPECIAL" as const,
},
})),
items: specialWeaponIds
// currently no use-case exists to select big bubbler or tacticooler
.filter((id) => !nonDamagingSpecialWeaponIds.includes(id))
.map((id) => ({
name: t(`weapons:SPECIAL_${id}`),
weapon: {
anyWeaponId: `SPECIAL_${id}`,
id,
type: "SPECIAL" as const,
},
})),
};
return [

View File

@@ -1,6 +1,10 @@
import clsx from "clsx";
import * as React from "react";
import type { ListBoxItemProps, SelectProps } from "react-aria-components";
import type {
AutocompleteProps,
ListBoxItemProps,
SelectProps,
} from "react-aria-components";
import {
Autocomplete,
Button,
@@ -45,6 +49,7 @@ export interface SendouSelectProps<T extends object>
/** Callback for when the search input value changes. When defined `items` has to be filtered on the caller side (automatic filtering in component disabled). */
onSearchInputChange?: (value: string) => void;
clearable?: boolean;
filter?: AutocompleteProps<object>["filter"];
}
/**
@@ -74,6 +79,7 @@ export function SendouSelect<T extends object>({
onSearchInputChange,
clearable = false,
className,
filter,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
@@ -106,7 +112,7 @@ export function SendouSelect<T extends object>({
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<Popover className={clsx(popoverClassName, styles.popover)}>
<Autocomplete
filter={isControlled ? undefined : contains}
filter={filter ? filter : isControlled ? undefined : contains}
inputValue={searchInputValue}
onInputChange={onSearchInputChange}
>

View File

@@ -33,13 +33,13 @@ type TournamentSearchItem = NonNullable<
>["tournaments"][number];
interface TournamentSearchProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialTournamentId?: number;
onChange?: (tournament: TournamentSearchItem) => void;
onChange?: (tournament: TournamentSearchItem | null) => void;
}
export const TournamentSearch = React.forwardRef(function TournamentSearch<
@@ -72,6 +72,21 @@ export const TournamentSearch = React.forwardRef(function TournamentSearch<
}
};
// clear if selected user is not in the new filtered items
React.useEffect(() => {
if (
selectedKey &&
selectedKey !== initialTournamentId &&
!list.items.some(
(tournament) =>
typeof tournament.id === "number" && tournament.id === selectedKey,
)
) {
setSelectedKey(null);
onChange?.(null);
}
}, [list.items, selectedKey, onChange, initialTournamentId]);
return (
<Select
name={name}

View File

@@ -30,13 +30,13 @@ import userSearchStyles from "./UserSearch.module.css";
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
interface UserSearchProps<T extends object>
extends Omit<SelectProps<T>, "children"> {
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialUserId?: number;
onChange?: (user: UserSearchUserItem) => void;
onChange?: (user: UserSearchUserItem | null) => void;
}
export const UserSearch = React.forwardRef(function UserSearch<
@@ -54,15 +54,28 @@ export const UserSearch = React.forwardRef(function UserSearch<
ref?: React.Ref<HTMLButtonElement>,
) {
const [selectedKey, setSelectedKey] = React.useState(initialUserId ?? null);
const { initialUser, ...list } = useUserSearch(setSelectedKey, initialUserId);
const { initialUser, items, ...list } = useUserSearch(
setSelectedKey,
initialUserId,
);
const onSelectionChange = (userId: number) => {
setSelectedKey(userId);
onChange?.(
list.items.find((user) => user.id === userId) as UserSearchUserItem,
);
onChange?.(items.find((user) => user.id === userId) as UserSearchUserItem);
};
// clear if selected user is not in the new filtered items
React.useEffect(() => {
if (
selectedKey &&
selectedKey !== initialUserId &&
!items.some((user) => user.id === selectedKey)
) {
setSelectedKey(null);
onChange?.(null);
}
}, [items, selectedKey, onChange, initialUserId]);
return (
<Select
name={name}
@@ -102,9 +115,7 @@ export const UserSearch = React.forwardRef(function UserSearch<
</Button>
</SearchField>
<ListBox
items={[initialUser, ...list.items].filter(
(user) => user !== undefined,
)}
items={[initialUser, ...items].filter((user) => user !== undefined)}
className={selectStyles.listBox}
>
{(item) => <UserItem item={item as UserSearchUserItem} />}

View File

@@ -28,7 +28,7 @@ export function UserSearchFormField<T extends FieldValues>({
name={name}
render={({ field: { onChange, onBlur, value, ref } }) => (
<UserSearch
onChange={(newUser) => onChange(newUser.id)}
onChange={(newUser) => onChange(newUser?.id)}
initialUserId={value}
onBlur={onBlur}
ref={ref}

View File

@@ -145,7 +145,7 @@ function Impersonate() {
<h2>Impersonate user</h2>
<UserSearch
label="User to log in as"
onChange={(newUser) => setUserId(newUser.id)}
onChange={(newUser) => setUserId(newUser?.id)}
/>
<div className="stack horizontal md">
<SendouButton type="submit" isDisabled={!userId}>
@@ -174,12 +174,12 @@ function MigrateUser() {
<UserSearch
label="Old user"
name="old-user"
onChange={(newUser) => setOldUserId(newUser.id)}
onChange={(newUser) => setOldUserId(newUser?.id)}
/>
<UserSearch
label="New user"
name="new-user"
onChange={(newUser) => setNewUserId(newUser.id)}
onChange={(newUser) => setNewUserId(newUser?.id)}
/>
</div>
<div className="stack horizontal md">

View File

@@ -338,7 +338,7 @@ function LinkedUsers() {
name="user"
onChange={(newUser) => {
const newUsers = structuredClone(users);
newUsers[i] = { ...newUsers[i], userId: newUser.id };
newUsers[i] = { ...newUsers[i], userId: newUser?.id };
setUsers(newUsers);
}}

View File

@@ -61,6 +61,7 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
className="text-center mx-auto"
name="new-manager"
onChange={(user) => {
if (!user) return;
if (managers.some((m) => m.id === user.id)) {
return;
}
@@ -127,6 +128,7 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
name="new-owner"
key={userInputKey}
onChange={(user) => {
if (!user) return;
setOwners((previousOwners) => {
const existingOwner = previousOwners.find(
(o) => o.id === user.id,

View File

@@ -13,7 +13,6 @@ import {
mainWeaponIds,
nonBombSubWeaponIds,
nonDamagingSpecialWeaponIds,
specialWeaponIds,
subWeaponIds,
weaponCategories,
weaponIdToBaseWeaponId,
@@ -208,11 +207,7 @@ export function validatedAnyWeaponFromSearchParams(
if (rawWeapon?.startsWith("SPECIAL_")) {
const id = Number(rawWeapon.replace("SPECIAL_", ""));
if (
!specialWeaponIds
.filter((id) => !nonDamagingSpecialWeaponIds.includes(id))
.includes(id as any)
) {
if (nonDamagingSpecialWeaponIds.includes(id)) {
return DEFAULT_ANY_WEAPON;
}

View File

@@ -278,7 +278,10 @@ function Players({
id={formId}
name="team-player"
initialUserId={player.id}
onChange={(newUser) => handleInputChange(i, newUser.id)}
onChange={(newUser) => {
if (!newUser) return;
handleInputChange(i, newUser.id);
}}
/>
)}
</div>

View File

@@ -79,7 +79,7 @@ export function WithFormField({ usersTeams }: FromFormFieldProps) {
onChange({
mode: "PICKUP",
users: value.users.map((u, j) =>
j === i ? user.id : u,
j === i ? user?.id : u,
),
})
}

View File

@@ -273,7 +273,7 @@ function TournamentSearchFormField() {
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={value ?? undefined}
onChange={(tournament) => onChange(tournament.id)}
onChange={(tournament) => onChange(tournament?.id)}
/>
)}
/>

View File

@@ -233,7 +233,7 @@ function PovFormField() {
onChange={(newUser) =>
onChange({
type: "USER",
userId: newUser.id,
userId: newUser?.id,
})
}
onBlur={onBlur}

View File

@@ -63,6 +63,7 @@ export const weaponAltNames = new Map<MainWeaponId, string[] | string>()
.set(5041, "letras")
.set(6010, "tent")
.set(6011, "tent")
.set(6012, "tent")
.set(6022, ["kcover", "emberz"])
.set(7010, "bow")
.set(7011, "bow")

View File

@@ -11,12 +11,11 @@ import {
import { newVodPage, VODS_PAGE, vodVideoPage } from "~/utils/urls";
const chooseVideoDate = async (page: Page) => {
await page.getByTestId("open-calendar-button").click();
await page
.getByTestId("choose-date-button")
.filter({ has: page.locator(`text="1"`) })
.first()
.click();
.getByRole("spinbutton", { name: "year, Video date *" })
.fill("2024");
await page.getByRole("spinbutton", { name: "month, Video date *" }).fill("5");
await page.getByRole("spinbutton", { name: "day, Video date *" }).fill("15");
};
test.describe("VoDs page", () => {

1841
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -68,7 +68,7 @@
"p-limit": "^6.2.0",
"partysocket": "^1.1.3",
"react": "^18.3.1",
"react-aria-components": "^1.10.0",
"react-aria-components": "^1.13.0",
"react-charts": "^3.0.0-beta.57",
"react-compiler-runtime": "^19.1.0-rc.2",
"react-dom": "^18.3.1",