From fd7d1ea2dc8cc5cc1392c3e7d22c2f2943d5b2f7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 26 Aug 2023 22:10:01 +0300 Subject: [PATCH] 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 --- app/components/Combobox.tsx | 68 +-------- app/components/UserSearch.tsx | 140 ++++++++++++++++++ app/db/models/users/queries.server.ts | 78 ++++++++-- app/db/models/users/search.sql | 9 +- app/db/models/users/searchExact.sql | 18 +++ app/features/art/routes/art.new.tsx | 10 +- .../tournament/routes/to.$id.admin.tsx | 8 +- .../tournament/tournament-schemas.server.ts | 2 +- app/features/vods/routes/vods.new.tsx | 9 +- app/hooks/swr.ts | 15 -- app/routes/admin.tsx | 52 +++---- app/routes/badges/$id/edit.tsx | 16 +- app/routes/calendar/$id/report-winners.tsx | 11 +- app/routes/plus/suggestions/new.tsx | 19 ++- app/routes/u.tsx | 30 +++- app/routes/users.tsx | 22 +-- app/styles/common.css | 7 + app/utils/playwright.ts | 1 + app/utils/urls.ts | 1 - app/utils/users.test.ts | 44 ++++++ app/utils/users.ts | 30 ++++ 21 files changed, 397 insertions(+), 193 deletions(-) create mode 100644 app/components/UserSearch.tsx create mode 100644 app/db/models/users/searchExact.sql create mode 100644 app/utils/users.test.ts diff --git a/app/components/Combobox.tsx b/app/components/Combobox.tsx index bd7bfd22c..cc824da9f 100644 --- a/app/components/Combobox.tsx +++ b/app/components/Combobox.tsx @@ -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>, - "inputName" | "onChange" | "className" | "id" | "required" -> & { userIdsToOmit?: Set; 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 ( -
{t("errors.genericReload")}
- ); - } - - return ( - - ); -} - export function WeaponCombobox({ id, required, diff --git a/app/components/UserSearch.tsx b/app/components/UserSearch.tsx new file mode 100644 index 000000000..0c1b5dac6 --- /dev/null +++ b/app/components/UserSearch.tsx @@ -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["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; + required?: boolean; +}) { + const { t } = useTranslation(); + const [selectedUser, setSelectedUser] = + React.useState(null); + const queryFetcher = useFetcher(); + const initialUserFetcher = useFetcher(); + 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 ( +
+ {selectedUser && inputName ? ( + + ) : null} + { + setSelectedUser(newUser); + onChange?.(newUser!); + }} + disabled={initialSelectionIsLoading} + > + 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} + /> + + {noMatches ? ( +
+ {t("forms.errors.noSearchMatches")}{" "} + 🤔 +
+ ) : null} + {users.map((user, i) => ( + + {({ active }) => ( +
  • + +
    +
    + + {user.discordName} + {" "} + {user.plusTier ? ( + +{user.plusTier} + ) : null} +
    + {user.discordUniqueName ? ( +
    {user.discordUniqueName}
    + ) : null} +
    +
  • + )} +
    + ))} +
    +
    +
    + ); +} diff --git a/app/db/models/users/queries.server.ts b/app/db/models/users/queries.server.ts index 921f13104..31e63884d 100644 --- a/app/db/models/users/queries.server.ts +++ b/app/db/models/users/queries.server.ts @@ -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); diff --git a/app/db/models/users/search.sql b/app/db/models/users/search.sql index dace7d3a6..0d321e2ae 100644 --- a/app/db/models/users/search.sql +++ b/app/db/models/users/search.sql @@ -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 diff --git a/app/db/models/users/searchExact.sql b/app/db/models/users/searchExact.sql new file mode 100644 index 000000000..ac65eaea4 --- /dev/null +++ b/app/db/models/users/searchExact.sql @@ -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 diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx index 52f7de651..f37fe2362 100644 --- a/app/features/art/routes/art.new.tsx +++ b/app/features/art/routes/art.new.tsx @@ -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 (
    - { - 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); }} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 11c52b955..6a081759b 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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") ? (
    - +
    ) : null} ) : ( - + onChange={(newUser) => onChange({ ...match, - povUserId: selected?.value ? Number(selected.value) : undefined, + povUserId: newUser.id, }) } required diff --git a/app/hooks/swr.ts b/app/hooks/swr.ts index ae55844b6..c9943a6a3 100644 --- a/app/hooks/swr.ts +++ b/app/hooks/swr.ts @@ -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( - GET_ALL_USERS_ROUTE, - fetcher - ); - - return { - users: data?.users, - isLoading: !error && !data, - isError: error, - }; -} - export function useAllEventsWithMapPools() { const { data, error } = useSWRImmutable( GET_ALL_EVENTS_WITH_MAP_POOLS_ROUTE, diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index b3880955a..d307044aa 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -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() {

    Impersonate user

    - - setUserId(selected?.value ? Number(selected.value) : undefined) - } + onChange={(newUser) => setUserId(newUser.id)} />
    @@ -209,20 +207,16 @@ function MigrateUser() {
    - - setOldUserId(selected?.value ? Number(selected.value) : undefined) - } + onChange={(newUser) => setOldUserId(newUser.id)} />
    - - setNewUserId(selected?.value ? Number(selected.value) : undefined) - } + onChange={(newUser) => setNewUserId(newUser.id)} />
    @@ -249,7 +243,7 @@ function LinkPlayer() {
    - +
    @@ -274,7 +268,7 @@ function GiveArtist() {
    - +
    @@ -295,7 +289,7 @@ function GiveVideoAdder() {
    - +
    @@ -316,7 +310,7 @@ function ForcePatron() {
    - +
    diff --git a/app/routes/badges/$id/edit.tsx b/app/routes/badges/$id/edit.tsx index a14c49816..828b3cdce 100644 --- a/app/routes/badges/$id/edit.tsx +++ b/app/routes/badges/$id/edit.tsx @@ -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 }) {

    Managers

    - { - 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 }) {

    Owners

    - { - if (!user) return; - setOwners([ ...owners, { - discordFullName: user.label, - id: Number(user.value), + discordFullName: user.discordName, + id: user.id, count: 1, }, ]); diff --git a/app/routes/calendar/$id/report-winners.tsx b/app/routes/calendar/$id/report-winners.tsx index 13dc40034..ac950946b 100644 --- a/app/routes/calendar/$id/report-winners.tsx +++ b/app/routes/calendar/$id/report-winners.tsx @@ -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} /> ) : ( - - handleInputChange( - i, - selected?.value ? Number(selected?.value) : NEW_PLAYER.id - ) - } + onChange={(newUser) => handleInputChange(i, newUser.id)} /> )}
    diff --git a/app/routes/plus/suggestions/new.tsx b/app/routes/plus/suggestions/new.tsx index 22425baa6..d8826acd7 100644 --- a/app/routes/plus/suggestions/new.tsx +++ b/app/routes/plus/suggestions/new.tsx @@ -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() {
    - + + setSelectedUser({ + plusTier: user.plusTier, + value: String(user.id), + }) + } + required + /> {selectedUserErrorMessage ? ( {selectedUserErrorMessage} ) : null} diff --git a/app/routes/u.tsx b/app/routes/u.tsx index 00c486570..dab9af3ac 100644 --- a/app/routes/u.tsx +++ b/app/routes/u.tsx @@ -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; + +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() { diff --git a/app/routes/users.tsx b/app/routes/users.tsx index 89ecf6530..0e4d8a0db 100644 --- a/app/routes/users.tsx +++ b/app/routes/users.tsx @@ -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)[]; -} - -export const loader: LoaderFunction = () => { - return json({ - users: db.users.findAll().map((u) => ({ - id: u.id, - discordFullName: discordFullName(u), - discordId: u.discordId, - plusTier: u.plusTier, - })), - }); -}; diff --git a/app/styles/common.css b/app/styles/common.css index 3be24439b..99bdbaff3 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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); } diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index 59287dde0..c09d37396 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -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"); } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 02bc4eba2..b94e6fba2 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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 { diff --git a/app/utils/users.test.ts b/app/utils/users.test.ts new file mode 100644 index 000000000..d08e01023 --- /dev/null +++ b/app/utils/users.test.ts @@ -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(); diff --git a/app/utils/users.ts b/app/utils/users.ts index 42afbaef8..33d46d16c 100644 --- a/app/utils/users.ts +++ b/app/utils/users.ts @@ -1,5 +1,6 @@ import type { User } from "~/db/types"; import { isAdmin } from "~/permissions"; +import { isCustomUrl } from "./urls"; export function isAtLeastFiveDollarTierPatreon( user?: Pick @@ -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; +}