mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 13:45:27 -05:00
Select improvements (#2654)
This commit is contained in:
@@ -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 [
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -233,7 +233,7 @@ function PovFormField() {
|
||||
onChange={(newUser) =>
|
||||
onChange({
|
||||
type: "USER",
|
||||
userId: newUser.id,
|
||||
userId: newUser?.id,
|
||||
})
|
||||
}
|
||||
onBlur={onBlur}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
1841
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user