User search (#1468)

* Initial

* Search users from API

* Better feeling search

* Fix TODO

* Search via url, discord id & id

* Load initial user

* UserSearch on admin page

* UserSearch on tournaments page

* UserSearch for badges

* Plus suggestions

* Vod page

* Remove unused code

* Fix test
This commit is contained in:
Kalle 2023-08-26 22:10:01 +03:00 committed by GitHub
parent 9b746969cd
commit fd7d1ea2dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 397 additions and 193 deletions

View File

@ -2,8 +2,8 @@ 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 type { GearType } from "~/db/types";
import { useAllEventsWithMapPools } from "~/hooks/swr";
import { useTranslation } from "~/hooks/useTranslation";
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
@ -170,70 +170,6 @@ export function Combobox<
);
}
// 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)}
/>
);
}
export function WeaponCombobox({
id,
required,

View File

@ -0,0 +1,140 @@
import { Combobox } from "@headlessui/react";
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useDebounce } from "react-use";
import type { UserSearchLoaderData } from "~/routes/u";
import { Avatar } from "./Avatar";
import { useTranslation } from "~/hooks/useTranslation";
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
export function UserSearch({
inputName,
onChange,
initialUserId,
id,
className,
userIdsToOmit,
required,
}: {
inputName: string;
onChange?: (user: UserSearchUserItem) => void;
initialUserId?: number;
id?: string;
className?: string;
userIdsToOmit?: Set<number>;
required?: boolean;
}) {
const { t } = useTranslation();
const [selectedUser, setSelectedUser] =
React.useState<UserSearchUserItem | null>(null);
const queryFetcher = useFetcher<UserSearchLoaderData>();
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
const [query, setQuery] = React.useState("");
useDebounce(
() => {
if (!query) return;
queryFetcher.load(`/u?q=${query}&limit=6`);
},
1000,
[query]
);
// load initial user
React.useEffect(() => {
if (
!initialUserId ||
initialUserFetcher.state !== "idle" ||
initialUserFetcher.data
) {
return;
}
initialUserFetcher.load(`/u?q=${initialUserId}`);
}, [initialUserId, initialUserFetcher]);
React.useEffect(() => {
if (!initialUserFetcher.data) return;
setSelectedUser(initialUserFetcher.data.users[0]);
}, [initialUserFetcher.data]);
const allUsers = queryFetcher.data?.users ?? [];
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
const noMatches = queryFetcher.data && users.length === 0;
const initialSelectionIsLoading = Boolean(
initialUserId && !initialUserFetcher.data
);
return (
<div className="combobox-wrapper">
{selectedUser && inputName ? (
<input type="hidden" name={inputName} value={selectedUser.id} />
) : null}
<Combobox
value={selectedUser}
onChange={(newUser) => {
setSelectedUser(newUser);
onChange?.(newUser!);
}}
disabled={initialSelectionIsLoading}
>
<Combobox.Input
placeholder={
initialSelectionIsLoading
? t("actions.loading")
: "Search via name or ID..."
}
onChange={(event) => setQuery(event.target.value)}
displayValue={(user: UserSearchUserItem) => user?.discordName ?? ""}
className={clsx("combobox-input", className)}
data-1p-ignore
data-testid={`${inputName}-combobox-input`}
id={id}
required={required}
/>
<Combobox.Options
className={clsx("combobox-options", {
empty: noMatches,
hidden: !queryFetcher.data,
})}
>
{noMatches ? (
<div className="combobox-no-matches">
{t("forms.errors.noSearchMatches")}{" "}
<span className="combobox-emoji">🤔</span>
</div>
) : null}
{users.map((user, i) => (
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
{({ active }) => (
<li
className={clsx("combobox-item", { active })}
data-testid={`combobox-option-${i}`}
>
<Avatar user={user} size="xs" />
<div>
<div className="stack xs horizontal items-center">
<span className="combobox-username">
{user.discordName}
</span>{" "}
{user.plusTier ? (
<span className="text-xxs">+{user.plusTier}</span>
) : null}
</div>
{user.discordUniqueName ? (
<div className="text-xs">{user.discordUniqueName}</div>
) : null}
</div>
</li>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
</div>
);
}

View File

@ -22,6 +22,7 @@ import findAllPatronsSql from "./findAllPatrons.sql";
import findAllPlusMembersSql from "./findAllPlusMembers.sql";
import findByIdentifierSql from "./findByIdentifier.sql";
import searchSql from "./search.sql";
import searchExactSql from "./searchExact.sql";
import updateByDiscordIdSql from "./updateByDiscordId.sql";
import updateDiscordIdSql from "./updateDiscordId.sql";
import updateProfileSql from "./updateProfile.sql";
@ -239,24 +240,71 @@ export const updateResultHighlights = sql.transaction(
);
const searchStm = sql.prepare(searchSql);
export function search(input: string) {
export function search({ input, limit }: { input: string; limit: number }) {
const searchString = `%${input}%`;
return searchStm.all({
discordName: searchString,
inGameName: searchString,
twitter: searchString,
}) as Array<
Pick<
User,
| "discordId"
| "discordAvatar"
| "discordName"
| "discordDiscriminator"
| "customUrl"
| "inGameName"
return (
searchStm.all({
discordName: searchString,
inGameName: searchString,
discordUniqueName: searchString,
twitter: searchString,
limit,
}) as Array<
Pick<
UserWithPlusTier,
| "id"
| "discordId"
| "discordAvatar"
| "discordName"
| "discordDiscriminator"
| "customUrl"
| "inGameName"
| "discordUniqueName"
| "showDiscordUniqueName"
| "plusTier"
>
>
>;
).map((user) => ({
...user,
discordUniqueName: user.showDiscordUniqueName
? user.discordUniqueName
: undefined,
}));
}
const searchExactStm = sql.prepare(searchExactSql);
export function searchExact(args: {
discordId?: User["discordId"];
customUrl?: User["customUrl"];
id?: User["id"];
}) {
return (
searchExactStm.all({
discordId: args.discordId ?? null,
customUrl: args.customUrl ?? null,
id: args.id ?? null,
}) as Array<
Pick<
UserWithPlusTier,
| "id"
| "discordId"
| "discordAvatar"
| "discordName"
| "discordDiscriminator"
| "customUrl"
| "inGameName"
| "discordUniqueName"
| "showDiscordUniqueName"
| "plusTier"
>
>
).map((user) => ({
...user,
discordUniqueName: user.showDiscordUniqueName
? user.discordUniqueName
: undefined,
}));
}
const wipePlusTiersStm = sql.prepare(wipePlusTiersSql);

View File

@ -1,16 +1,21 @@
select
"id",
"discordName",
"discordId",
"discordAvatar",
"discordDiscriminator",
"discordUniqueName",
"showDiscordUniqueName",
"customUrl",
"inGameName"
"inGameName",
"PlusTier"."tier" as "plusTier"
from
"User"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
where
"discordName" like @discordName
or "inGameName" like @inGameName
or "discordUniqueName" like @discordUniqueName
or "twitter" like @twitter
order by
case
@ -18,4 +23,4 @@ order by
else "PlusTier"."tier"
end asc
limit
25
@limit

View File

@ -0,0 +1,18 @@
select
"id",
"discordName",
"discordId",
"discordAvatar",
"discordDiscriminator",
"discordUniqueName",
"showDiscordUniqueName",
"customUrl",
"inGameName",
"PlusTier"."tier" as "plusTier"
from
"User"
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
where
"discordId" = @discordId
or "customUrl" = @customUrl
or "id" = @id

View File

@ -13,7 +13,7 @@ import * as React from "react";
import { useFetcher } from "react-router-dom";
import invariant from "tiny-invariant";
import { Button } from "~/components/Button";
import { Combobox, UserCombobox } from "~/components/Combobox";
import { Combobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -41,6 +41,7 @@ import { addNewArt, editArt } from "../queries/addNewArt.server";
import { findArtById } from "../queries/findArtById.server";
import { previewUrl } from "../art-utils";
import { allArtTags } from "../queries/allArtTags.server";
import { UserSearch } from "~/components/UserSearch";
export const handle: SendouRouteHandle = {
i18n: ["art"],
@ -419,12 +420,11 @@ function LinkedUsers() {
{users.map(({ inputId, userId }, i) => {
return (
<div key={inputId} className="stack horizontal sm mb-2 items-center">
<UserCombobox
<UserSearch
inputName="user"
onChange={(event) => {
if (!event) return;
onChange={(newUser) => {
const newUsers = clone(users);
newUsers[i] = { ...newUsers[i], userId: Number(event.value) };
newUsers[i] = { ...newUsers[i], userId: newUser.id };
setUsers(newUsers);
}}

View File

@ -17,7 +17,6 @@ import {
validateCanCheckIn,
} from "../tournament-utils";
import { SubmitButton } from "~/components/SubmitButton";
import { UserCombobox } from "~/components/Combobox";
import { adminActionSchema } from "../tournament-schemas.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import invariant from "tiny-invariant";
@ -37,6 +36,7 @@ import {
import { Redirect } from "~/components/Redirect";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { findMapPoolByTeamId } from "~/features/tournament-bracket";
import { UserSearch } from "~/components/UserSearch";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
@ -118,7 +118,7 @@ export const action: ActionFunction = async ({ request, params }) => {
validate(team, "Invalid team id");
const previousTeam = teams.find((t) =>
t.members.some((m) => m.userId === data["user[value]"])
t.members.some((m) => m.userId === data.userId)
);
if (hasTournamentStarted(event.id)) {
@ -131,7 +131,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}
joinTeam({
userId: data["user[value]"],
userId: data.userId,
newTeamId: team.id,
previousTeamId: previousTeam?.id,
// this team is not checked in so we can simply delete it
@ -329,7 +329,7 @@ function AdminActions() {
{selectedAction.inputs.includes("USER") ? (
<div>
<label htmlFor="user">User</label>
<UserCombobox inputName="user" id="user" />
<UserSearch inputName="userId" id="user" />
</div>
) : null}
<SubmitButton

View File

@ -58,7 +58,7 @@ export const adminActionSchema = z.union([
z.object({
_action: _action("ADD_MEMBER"),
teamId: id,
"user[value]": id,
userId: id,
}),
z.object({
_action: _action("REMOVE_MEMBER"),

View File

@ -7,7 +7,7 @@ import { Form, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { z } from "zod";
import { Button } from "~/components/Button";
import { UserCombobox, WeaponCombobox } from "~/components/Combobox";
import { WeaponCombobox } from "~/components/Combobox";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@ -41,6 +41,7 @@ import { videoMatchTypes, VOD } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
import type { VideoBeingAdded, VideoMatchBeingAdded } from "../vods-types";
import { canAddVideo, canEditVideo, vodToVideoBeingAdded } from "../vods-utils";
import { UserSearch } from "~/components/UserSearch";
export const handle: SendouRouteHandle = {
i18n: ["vods", "calendar"],
@ -387,14 +388,14 @@ function TransformingPlayerInput({
required
/>
) : (
<UserCombobox
<UserSearch
id="pov"
inputName="team-player"
initialUserId={match.povUserId}
onChange={(selected) =>
onChange={(newUser) =>
onChange({
...match,
povUserId: selected?.value ? Number(selected.value) : undefined,
povUserId: newUser.id,
})
}
required

View File

@ -2,10 +2,8 @@ import useSWRImmutable from "swr/immutable";
import type { WeaponUsageLoaderData } from "~/features/sendouq/routes/weapon-usage";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import type { EventsWithMapPoolsLoaderData } from "~/routes/calendar/map-pool-events";
import type { UsersLoaderData } from "~/routes/users";
import {
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,
GET_ALL_USERS_ROUTE,
getWeaponUsage,
} from "~/utils/urls";
@ -14,19 +12,6 @@ const fetcher = async (url: string) => {
return res.json();
};
export function useUsers() {
const { data, error } = useSWRImmutable<UsersLoaderData>(
GET_ALL_USERS_ROUTE,
fetcher
);
return {
users: data?.users,
isLoading: !error && !data,
isError: error,
};
}
export function useAllEventsWithMapPools() {
const { data, error } = useSWRImmutable<EventsWithMapPoolsLoaderData>(
GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE,

View File

@ -9,9 +9,9 @@ import * as React from "react";
import { z } from "zod";
import { Button } from "~/components/Button";
import { Catcher } from "~/components/Catcher";
import { UserCombobox } from "~/components/Combobox";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
import { db } from "~/db";
import { makeArtist } from "~/features/art";
import {
@ -38,29 +38,29 @@ export const meta: V2_MetaFunction = () => {
const adminActionSchema = z.union([
z.object({
_action: _action("MIGRATE"),
"old-user[value]": z.preprocess(actualNumber, z.number().positive()),
"new-user[value]": z.preprocess(actualNumber, z.number().positive()),
"old-user": z.preprocess(actualNumber, z.number().positive()),
"new-user": z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("REFRESH"),
}),
z.object({
_action: _action("FORCE_PATRON"),
"user[value]": z.preprocess(actualNumber, z.number().positive()),
user: z.preprocess(actualNumber, z.number().positive()),
patronTier: z.preprocess(actualNumber, z.number()),
patronTill: z.string(),
}),
z.object({
_action: _action("VIDEO_ADDER"),
"user[value]": z.preprocess(actualNumber, z.number().positive()),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("ARTIST"),
"user[value]": z.preprocess(actualNumber, z.number().positive()),
user: z.preprocess(actualNumber, z.number().positive()),
}),
z.object({
_action: _action("LINK_PLAYER"),
"user[value]": z.preprocess(actualNumber, z.number().positive()),
user: z.preprocess(actualNumber, z.number().positive()),
playerId: z.preprocess(actualNumber, z.number().positive()),
}),
]);
@ -77,8 +77,8 @@ export const action: ActionFunction = async ({ request }) => {
switch (data._action) {
case "MIGRATE": {
db.users.migrate({
oldUserId: data["old-user[value]"],
newUserId: data["new-user[value]"],
oldUserId: data["old-user"],
newUserId: data["new-user"],
});
break;
}
@ -88,7 +88,7 @@ export const action: ActionFunction = async ({ request }) => {
}
case "FORCE_PATRON": {
db.users.forcePatron({
id: data["user[value]"],
id: data["user"],
patronSince: dateToDatabaseTimestamp(new Date()),
patronTier: data.patronTier,
patronTill: dateToDatabaseTimestamp(new Date(data.patronTill)),
@ -96,16 +96,16 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "ARTIST": {
makeArtist(data["user[value]"]);
makeArtist(data["user"]);
break;
}
case "VIDEO_ADDER": {
db.users.makeVideoAdder(data["user[value]"]);
db.users.makeVideoAdder(data["user"]);
break;
}
case "LINK_PLAYER": {
db.users.linkPlayer({
userId: data["user[value]"],
userId: data["user"],
playerId: data.playerId,
});
@ -169,11 +169,9 @@ function Impersonate() {
<h2>Impersonate user</h2>
<div>
<label>User to log in as</label>
<UserCombobox
<UserSearch
inputName="user"
onChange={(selected) =>
setUserId(selected?.value ? Number(selected.value) : undefined)
}
onChange={(newUser) => setUserId(newUser.id)}
/>
</div>
<div className="stack horizontal md">
@ -209,20 +207,16 @@ function MigrateUser() {
<div className="stack horizontal md">
<div>
<label>Old user</label>
<UserCombobox
<UserSearch
inputName="old-user"
onChange={(selected) =>
setOldUserId(selected?.value ? Number(selected.value) : undefined)
}
onChange={(newUser) => setOldUserId(newUser.id)}
/>
</div>
<div>
<label>New user</label>
<UserCombobox
<UserSearch
inputName="new-user"
onChange={(selected) =>
setNewUserId(selected?.value ? Number(selected.value) : undefined)
}
onChange={(newUser) => setNewUserId(newUser.id)}
/>
</div>
</div>
@ -249,7 +243,7 @@ function LinkPlayer() {
<div className="stack horizontal md">
<div>
<label>User</label>
<UserCombobox inputName="user" />
<UserSearch inputName="user" />
</div>
<div>
<label>Player ID</label>
@ -274,7 +268,7 @@ function GiveArtist() {
<div className="stack horizontal md">
<div>
<label>User</label>
<UserCombobox inputName="user" />
<UserSearch inputName="user" />
</div>
</div>
<div className="stack horizontal md">
@ -295,7 +289,7 @@ function GiveVideoAdder() {
<div className="stack horizontal md">
<div>
<label>User</label>
<UserCombobox inputName="user" />
<UserSearch inputName="user" />
</div>
</div>
<div className="stack horizontal md">
@ -316,7 +310,7 @@ function ForcePatron() {
<div className="stack horizontal md">
<div>
<label>User</label>
<UserCombobox inputName="user" />
<UserSearch inputName="user" />
</div>
<div>

View File

@ -4,7 +4,6 @@ import { Form, useMatches, useOutletContext } from "@remix-run/react";
import * as React from "react";
import { z } from "zod";
import { Button, LinkButton } from "~/components/Button";
import { UserCombobox } from "~/components/Combobox";
import { Dialog } from "~/components/Dialog";
import { TrashIcon } from "~/components/icons/Trash";
import { Label } from "~/components/Label";
@ -26,6 +25,7 @@ import {
safeJSONParse,
} from "~/utils/zod";
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "../$id";
import { UserSearch } from "~/components/UserSearch";
const editBadgeActionSchema = z.union([
z.object({
@ -131,15 +131,13 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
<h3 className="badges-edit__small-header">Managers</h3>
<div className="text-center my-4">
<Label className="stack vertical items-center">Add new manager</Label>
<UserCombobox
<UserSearch
className="mx-auto"
inputName="new-manager"
onChange={(user) => {
if (!user) return;
setManagers([
...managers,
{ discordFullName: user.label, id: Number(user.value) },
{ discordFullName: user.discordName, id: user.id },
]);
}}
userIdsToOmit={userIdsToOmitFromCombobox}
@ -208,17 +206,15 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
<h3 className="badges-edit__small-header">Owners</h3>
<div className="text-center my-4">
<Label className="stack items-center">Add new owner</Label>
<UserCombobox
<UserSearch
className="mx-auto"
inputName="new-owner"
onChange={(user) => {
if (!user) return;
setOwners([
...owners,
{
discordFullName: user.label,
id: Number(user.value),
discordFullName: user.discordName,
id: user.id,
count: 1,
},
]);

View File

@ -9,11 +9,11 @@ import clsx from "clsx";
import * as React from "react";
import { z } from "zod";
import { Button } from "~/components/Button";
import { UserCombobox } from "~/components/Combobox";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { UserSearch } from "~/components/UserSearch";
import { CALENDAR_EVENT_RESULT } from "~/constants";
import { db } from "~/db";
import type { User } from "~/db/types";
@ -412,16 +412,11 @@ function Players({
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
/>
) : (
<UserCombobox
<UserSearch
id={formId}
inputName="team-player"
initialUserId={player.id}
onChange={(selected) =>
handleInputChange(
i,
selected?.value ? Number(selected?.value) : NEW_PLAYER.id
)
}
onChange={(newUser) => handleInputChange(i, newUser.id)}
/>
)}
</div>

View File

@ -16,7 +16,6 @@ import {
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
PLUS_TIERS,
} from "~/constants";
import { UserCombobox } from "~/components/Combobox";
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
@ -33,6 +32,7 @@ import { FormMessage } from "~/components/FormMessage";
import { atOrError } from "~/utils/arrays";
import { requireUser, useUser } from "~/modules/auth";
import { SubmitButton } from "~/components/SubmitButton";
import { UserSearch } from "~/components/UserSearch";
const commentActionSchema = z.object({
tier: z.preprocess(
@ -46,7 +46,7 @@ const commentActionSchema = z.object({
trimmedString,
z.string().min(1).max(PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH)
),
"user[value]": z.preprocess(actualNumber, z.number().positive()),
userId: z.preprocess(actualNumber, z.number().positive()),
});
export const action: ActionFunction = async ({ request }) => {
@ -55,9 +55,7 @@ export const action: ActionFunction = async ({ request }) => {
schema: commentActionSchema,
});
const suggested = badRequestIfFalsy(
db.users.findByIdentifier(data["user[value]"])
);
const suggested = badRequestIfFalsy(db.users.findByIdentifier(data.userId));
const user = await requireUser(request);
@ -148,7 +146,16 @@ export default function PlusNewSuggestionModalPage() {
</div>
<div>
<label htmlFor="user">Suggested user</label>
<UserCombobox inputName="user" onChange={setSelectedUser} required />
<UserSearch
inputName="userId"
onChange={(user) =>
setSelectedUser({
plusTier: user.plusTier,
value: String(user.id),
})
}
required
/>
{selectedUserErrorMessage ? (
<FormMessage type="error">{selectedUserErrorMessage}</FormMessage>
) : null}

View File

@ -1,6 +1,6 @@
import type { LinksFunction, LoaderArgs } from "@remix-run/node";
import type { LinksFunction, LoaderArgs, SerializeFrom } from "@remix-run/node";
import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix";
import { parseSearchParams, type SendouRouteHandle } from "~/utils/remix";
import { navIconUrl, userPage, USER_SEARCH_PAGE } from "~/utils/urls";
import styles from "~/styles/u.css";
import { Input } from "~/components/Input";
@ -12,6 +12,8 @@ import * as React from "react";
import { Avatar } from "~/components/Avatar";
import { discordFullName } from "~/utils/strings";
import { useTranslation } from "~/hooks/useTranslation";
import { z } from "zod";
import { queryToUserIdentifier } from "~/utils/users";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@ -26,13 +28,29 @@ export const handle: SendouRouteHandle = {
}),
};
export type UserSearchLoaderData = SerializeFrom<typeof loader>;
const searchParamsSchema = z.object({
q: z.string().max(100).default(""),
limit: z.coerce.number().int().min(1).max(25).default(25),
});
export const loader = ({ request }: LoaderArgs) => {
const url = new URL(request.url);
const input = url.searchParams.get("q");
const { q, limit } = parseSearchParams({
request,
schema: searchParamsSchema,
});
if (!input) return null;
if (!q) return null;
return { users: db.users.search(input), input };
const identifier = queryToUserIdentifier(q);
return {
users: identifier
? db.users.searchExact(identifier)
: db.users.search({ input: q, limit }),
input: q,
};
};
export default function UserSearchPage() {

View File

@ -1,9 +1,6 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import type { ActionFunction } from "@remix-run/node";
import { db } from "~/db";
import type { UserWithPlusTier } from "~/db/types";
import { canAccessLohiEndpoint } from "~/permissions";
import { discordFullName } from "~/utils/strings";
export const action: ActionFunction = async ({ request }) => {
if (!canAccessLohiEndpoint(request)) {
@ -15,20 +12,3 @@ export const action: ActionFunction = async ({ request }) => {
return null;
};
export interface UsersLoaderData {
users: ({
discordFullName: string;
} & Pick<UserWithPlusTier, "id" | "discordId" | "plusTier">)[];
}
export const loader: LoaderFunction = () => {
return json<UsersLoaderData>({
users: db.users.findAll().map((u) => ({
id: u.id,
discordFullName: discordFullName(u),
discordId: u.discordId,
plusTier: u.plusTier,
})),
});
};

View File

@ -615,6 +615,13 @@ dialog::backdrop {
background-color: var(--theme-transparent);
}
.combobox-username {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article > p {
padding-block: var(--s-2-5);
}

View File

@ -27,6 +27,7 @@ export async function selectUser({
await combobox.clear();
await combobox.type(userName);
await expect(page.getByTestId("combobox-option-0")).toBeVisible();
await page.keyboard.press("Enter");
}

View File

@ -118,7 +118,6 @@ export const FRONT_GIRL_PATH = "/static-assets/img/layout/front-girl";
export const FRONT_BOY_BG_PATH = "/static-assets/img/layout/front-boy-bg";
export const FRONT_GIRL_BG_PATH = "/static-assets/img/layout/front-girl-bg";
export const GET_ALL_USERS_ROUTE = "/users";
export const GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE = "/calendar/map-pool-events";
interface UserLinkArgs {

44
app/utils/users.test.ts Normal file
View File

@ -0,0 +1,44 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import { queryToUserIdentifier } from "./users";
const QueryToUserIdentifier = suite("queryToUserIdentifier()");
QueryToUserIdentifier("returns null if no match", () => {
assert.equal(queryToUserIdentifier("foo"), null);
});
QueryToUserIdentifier("gets custom url from url", () => {
assert.equal(queryToUserIdentifier("https://sendou.ink/u/sendou"), {
customUrl: "sendou",
});
});
QueryToUserIdentifier("gets discord id from url", () => {
assert.equal(
queryToUserIdentifier("https://sendou.ink/u/79237403620945920"),
{
discordId: "79237403620945920",
}
);
});
QueryToUserIdentifier("gets custom url from url (without https://)", () => {
assert.equal(queryToUserIdentifier("sendou.ink/u/sendou"), {
customUrl: "sendou",
});
});
QueryToUserIdentifier("gets discord id", () => {
assert.equal(queryToUserIdentifier("79237403620945920"), {
discordId: "79237403620945920",
});
});
QueryToUserIdentifier("gets id", () => {
assert.equal(queryToUserIdentifier("1"), {
id: 1,
});
});
QueryToUserIdentifier.run();

View File

@ -1,5 +1,6 @@
import type { User } from "~/db/types";
import { isAdmin } from "~/permissions";
import { isCustomUrl } from "./urls";
export function isAtLeastFiveDollarTierPatreon(
user?: Pick<User, "patronTier" | "id">
@ -8,3 +9,32 @@ export function isAtLeastFiveDollarTierPatreon(
return isAdmin(user) || (user.patronTier && user.patronTier >= 2);
}
const urlRegExp = new RegExp("(https://)?sendou.ink/u/(.+)");
const DISCORD_ID_MIN_LENGTH = 17;
export function queryToUserIdentifier(
query: string
): { id: number } | { discordId: string } | { customUrl: string } | null {
const match = query.match(urlRegExp);
if (match) {
const [, , identifier] = match;
if (isCustomUrl(identifier)) {
return { customUrl: identifier };
}
return { discordId: identifier };
}
// = it's numeric
if (!isCustomUrl(query)) {
if (query.length >= DISCORD_ID_MIN_LENGTH) {
return { discordId: query };
}
return { id: Number(query) };
}
return null;
}