From 4d730e5d8b31e23560c6ffd64b8bb28b88ee514b Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 12 May 2025 22:53:35 +0300 Subject: [PATCH] New user search & dialog (#2270) * From scrims * wip * wip * wip * wip * WIP * wip * wip * wip * wip * wip * import ordering --- app/components/Dialog.tsx | 114 -------- app/components/FormWithConfirm.tsx | 24 +- app/components/UserSearch.tsx | 150 ----------- app/components/elements/BottomTexts.tsx | 23 ++ app/components/elements/Button.tsx | 4 +- app/components/elements/DatePicker.tsx | 8 +- app/components/elements/Dialog.module.css | 90 +++++++ app/components/elements/Dialog.tsx | 144 ++++++++++ app/components/elements/FieldError.tsx | 2 +- app/components/elements/Select.module.css | 4 + app/components/elements/Select.tsx | 18 +- app/components/elements/Toast.module.css | 6 +- app/components/elements/UserSearch.module.css | 57 ++++ app/components/elements/UserSearch.tsx | 248 ++++++++++++++++++ app/components/form/MyForm.tsx | 19 +- app/components/form/UserSearchFormField.tsx | 2 +- .../layout/LogInButtonContainer.tsx | 66 ++--- app/components/layout/NavDialog.tsx | 13 +- app/db/seed/index.ts | 160 ++++++----- app/db/tables.ts | 74 ++---- app/features/admin/routes/admin.tsx | 79 ++---- app/features/art/components/ArtGrid.tsx | 40 +-- app/features/art/routes/art.new.tsx | 4 +- app/features/art/routes/art.tsx | 1 - .../associations/routes/associations.new.tsx | 13 +- .../badges/routes/badges.$id.edit.tsx | 146 +++++------ .../routes/calendar.$id.report-winners.tsx | 4 +- .../actions/plus.suggestions.new.server.ts | 31 ++- .../plus-suggestions-utils.ts | 31 +-- ...plus.suggestions.comment.$tier.$userId.tsx | 24 +- .../routes/plus.suggestions.new.tsx | 92 +------ .../routes/plus.suggestions.tsx | 4 +- .../scrims/components/WithFormField.tsx | 45 ++-- app/features/scrims/routes/scrims.new.tsx | 2 +- app/features/scrims/routes/scrims.tsx | 11 +- .../components/AddPrivateNoteDialog.tsx | 22 +- .../sendouq-match/routes/q.match.$id.tsx | 1 - app/features/sendouq/routes/q.tsx | 19 +- app/features/team/routes/t.tsx | 30 +-- app/features/team/team.css | 5 - .../components/Bracket/Swiss.tsx | 5 +- .../components/BracketMapListDialog.tsx | 12 +- .../OrganizerMatchMapListDialog.tsx | 14 +- .../tournament-bracket/tournament-bracket.css | 4 - .../routes/org.$slug.edit.tsx | 2 +- .../tournament/actions/to.$id.admin.server.ts | 5 + .../tournament/routes/to.$id.admin.tsx | 62 ++--- .../tournament/routes/to.$id.seeds.tsx | 12 +- .../user-page/routes/u.$identifier.builds.tsx | 12 +- app/features/vods/routes/vods.new.tsx | 55 ++-- app/root.tsx | 16 +- app/styles/badges.css | 4 - app/styles/common.css | 34 +-- app/styles/layout.css | 10 +- app/styles/plus.css | 10 - app/utils/kysely.server.ts | 5 +- app/utils/playwright.ts | 13 +- e2e/badges.spec.ts | 2 +- e2e/tournament-bracket.spec.ts | 2 +- locales/en/common.json | 2 + locales/en/q.json | 1 - locales/es-ES/q.json | 1 - locales/es-US/q.json | 1 - locales/fr-EU/q.json | 1 - locales/it/q.json | 1 - locales/ja/q.json | 1 - locales/pt-BR/q.json | 1 - locales/ru/q.json | 1 - locales/zh/q.json | 1 - 69 files changed, 1093 insertions(+), 1027 deletions(-) delete mode 100644 app/components/Dialog.tsx delete mode 100644 app/components/UserSearch.tsx create mode 100644 app/components/elements/BottomTexts.tsx create mode 100644 app/components/elements/Dialog.module.css create mode 100644 app/components/elements/Dialog.tsx create mode 100644 app/components/elements/UserSearch.module.css create mode 100644 app/components/elements/UserSearch.tsx diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx deleted file mode 100644 index 6a0449123..000000000 --- a/app/components/Dialog.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from "react"; -import invariant from "~/utils/invariant"; - -// TODO: use react aria components - -export function Dialog({ - children, - isOpen, - close, - className, - closeOnAnyClick, -}: { - children: React.ReactNode; - isOpen: boolean; - close?: () => void; - className?: string; - closeOnAnyClick?: boolean; -}) { - const ref = useDOMSync(isOpen); - useControlledEsc({ ref, isOpen, close }); - - // https://stackoverflow.com/a/26984690 - const closeOnOutsideClick = close - ? (event: React.MouseEvent) => { - if (closeOnAnyClick) return close(); - const rect: DOMRect = ref.current.getBoundingClientRect(); - - // https://stackoverflow.com/a/77402711 - const isFirefoxSelectClick = event.clientY === 0 && event.clientX === 0; - if (isFirefoxSelectClick) return; - - const isInDialog = - rect.top <= event.clientY && - event.clientY <= rect.top + rect.height && - rect.left <= event.clientX && - event.clientX <= rect.left + rect.width; - if (!isInDialog) { - close(); - } - } - : undefined; - - return ( - - {children} - - ); -} - -function useDOMSync(isOpen: boolean) { - const ref = React.useRef(null); - - React.useEffect(() => { - const dialog = ref.current; - - if (dialog.open && isOpen) return; - if (!dialog.open && !isOpen) return; - - const html = document.getElementsByTagName("html")[0]; - invariant(html); - - if (isOpen) { - dialog.showModal(); - html.classList.add("lock-scroll"); - } else { - dialog.close(); - html.classList.remove("lock-scroll"); - } - - return () => { - dialog.close(); - html.classList.remove("lock-scroll"); - }; - }, [isOpen]); - - return ref; -} - -function useControlledEsc({ - ref, - isOpen, - close, -}: { - ref: React.MutableRefObject; - isOpen: boolean; - close?: () => void; -}) { - React.useEffect(() => { - const dialog = ref.current; - if (!dialog) return; - - const preventDefault = (event: KeyboardEvent) => { - event.preventDefault(); - }; - dialog.addEventListener("cancel", preventDefault); - - return () => { - dialog.removeEventListener("cancel", preventDefault); - }; - }, [ref]); - - React.useEffect(() => { - if (!isOpen || !close) return; - - const closeOnEsc = (event: KeyboardEvent) => { - if (event.key === "Escape") { - close(); - } - }; - - document.addEventListener("keydown", closeOnEsc); - return () => document.removeEventListener("keydown", closeOnEsc); - }, [isOpen, close]); -} diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index 0723e5a93..83bb35df4 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -2,10 +2,10 @@ import { type FetcherWithComponents, useFetcher } from "@remix-run/react"; import * as React from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { SendouDialog } from "~/components/elements/Dialog"; import { useIsMounted } from "~/hooks/useIsMounted"; import invariant from "~/utils/invariant"; -import { Button, type ButtonProps } from "./Button"; -import { Dialog } from "./Dialog"; +import type { ButtonProps } from "./Button"; import { SubmitButton } from "./SubmitButton"; export function FormWithConfirm({ @@ -13,11 +13,9 @@ export function FormWithConfirm({ children, dialogHeading, submitButtonText, - cancelButtonText, action, submitButtonTestId = "submit-button", submitButtonVariant = "destructive", - cancelButtonVariant, fetcher: _fetcher, }: { fields?: ( @@ -27,11 +25,9 @@ export function FormWithConfirm({ children: React.ReactNode; dialogHeading: string; submitButtonText?: string; - cancelButtonText?: string; action?: string; submitButtonTestId?: string; submitButtonVariant?: ButtonProps["variant"]; - cancelButtonVariant?: ButtonProps["variant"]; fetcher?: FetcherWithComponents; }) { const componentsFetcher = useFetcher(); @@ -73,10 +69,15 @@ export function FormWithConfirm({ document.body, ) : null} - +
-

{dialogHeading}

-
+

{dialogHeading}

+
{submitButtonText ?? t("common:actions.delete")} -
-
+ {React.cloneElement(children, { // @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode onClick: openDialog, // TODO: when SendouButton has overtaken Button, this line can be removed diff --git a/app/components/UserSearch.tsx b/app/components/UserSearch.tsx deleted file mode 100644 index 64eebdd8b..000000000 --- a/app/components/UserSearch.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { Combobox } from "@headlessui/react"; -import { useFetcher } from "@remix-run/react"; -import clsx from "clsx"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useDebounce } from "react-use"; -import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server"; -import { Avatar } from "./Avatar"; - -type UserSearchUserItem = NonNullable["users"][number]; - -export const UserSearch = React.forwardRef< - HTMLInputElement, - { - inputName?: string; - onChange?: (user: UserSearchUserItem) => void; - initialUserId?: number; - id?: string; - className?: string; - userIdsToOmit?: Set; - required?: boolean; - onBlur?: React.FocusEventHandler; - disabled?: boolean; - } ->( - ( - { - inputName, - onChange, - initialUserId, - id, - className, - userIdsToOmit, - required, - onBlur, - disabled, - }, - ref, - ) => { - 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], - ); - - 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={disabled || initialSelectionIsLoading} - > - setQuery(event.target.value)} - displayValue={(user: UserSearchUserItem) => user?.username ?? ""} - className={clsx("combobox-input", className)} - data-1p-ignore - data-testid={`${inputName}-combobox-input`} - id={id} - required={required} - onBlur={onBlur} - /> - - {noMatches ? ( -
- {t("forms.errors.noSearchMatches")}{" "} - 🤔 -
- ) : null} - {users.map((user, i) => ( - - {({ active }) => ( -
  • - -
    -
    - - {user.username} - {" "} - {user.plusTier ? ( - +{user.plusTier} - ) : null} -
    - {user.discordUniqueName ? ( -
    {user.discordUniqueName}
    - ) : null} -
    -
  • - )} -
    - ))} -
    -
    -
    - ); - }, -); diff --git a/app/components/elements/BottomTexts.tsx b/app/components/elements/BottomTexts.tsx new file mode 100644 index 000000000..72a75984c --- /dev/null +++ b/app/components/elements/BottomTexts.tsx @@ -0,0 +1,23 @@ +import { SendouFieldError } from "~/components/elements/FieldError"; +import { SendouFieldMessage } from "~/components/elements/FieldMessage"; + +export function SendouBottomTexts({ + bottomText, + errorText, +}: { + bottomText?: string; + errorText?: string; +}) { + return ( + <> + {errorText ? ( + {errorText} + ) : ( + + )} + {bottomText && !errorText ? ( + {bottomText} + ) : null} + + ); +} diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 859f6c651..54a6476a2 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -17,7 +17,7 @@ type ButtonVariant = | "minimal-success" | "minimal-destructive"; -interface MyDatePickerProps extends ReactAriaButtonProps { +interface SendouButtonProps extends ReactAriaButtonProps { variant?: ButtonVariant; size?: "miniscule" | "small" | "medium" | "big"; icon?: JSX.Element; @@ -31,7 +31,7 @@ export function SendouButton({ className, icon, ...rest -}: MyDatePickerProps) { +}: SendouButtonProps) { const variantClassname = variant ? variantToClassname(variant) : null; return ( diff --git a/app/components/elements/DatePicker.tsx b/app/components/elements/DatePicker.tsx index 823f0c49c..2d1def797 100644 --- a/app/components/elements/DatePicker.tsx +++ b/app/components/elements/DatePicker.tsx @@ -14,6 +14,7 @@ import { Popover, DatePicker as ReactAriaDatePicker, } from "react-aria-components"; +import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { type FormFieldSize, formFieldSizeToClassName, @@ -21,8 +22,6 @@ import { import { ArrowLeftIcon } from "../icons/ArrowLeft"; import { ArrowRightIcon } from "../icons/ArrowRight"; import { CalendarIcon } from "../icons/Calendar"; -import { SendouFieldError } from "./FieldError"; -import { SendouFieldMessage } from "./FieldMessage"; import { SendouLabel } from "./Label"; interface SendouDatePickerProps @@ -52,10 +51,7 @@ export function SendouDatePicker({ - {errorText && {errorText}} - {bottomText && !errorText ? ( - {bottomText} - ) : null} + diff --git a/app/components/elements/Dialog.module.css b/app/components/elements/Dialog.module.css new file mode 100644 index 000000000..88069a19e --- /dev/null +++ b/app/components/elements/Dialog.module.css @@ -0,0 +1,90 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 10; + overflow-y: auto; + background-color: rgba(0, 0, 0, 0.25); + display: flex; + min-height: 100%; + align-items: center; + justify-content: center; + padding: 1rem; + text-align: center; + backdrop-filter: blur(10px); /* Adjust blur value as needed */ +} + +.fullScreenOverlay { + padding: 0; + display: initial; +} + +.overlay[data-entering] { + animation: fade-in 300ms ease-out; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal { + width: 100%; + max-width: 28rem; + overflow: hidden; + border-radius: 1rem; + background-color: var(--bg-lighter-solid); + border: 2.5px solid var(--border); + padding: var(--s-6); + text-align: left; + vertical-align: middle; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px + rgba(0, 0, 0, 0.05); +} + +.fullScreenModal { + min-width: 100vw; + min-height: 100vh; + border-radius: 0; +} + +.modal[data-entering] { + animation: zoom-in-95 300ms ease-out; +} + +@keyframes zoom-in-95 { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.dialog { + outline: none; + position: relative; +} + +.headingContainer { + border-bottom: 2px solid var(--border); + padding-block-end: var(--s-2); + margin-block-end: var(--s-4); + display: flex; + justify-content: space-between; + align-items: center; + margin-block-start: -3px; +} + +.noHeading { + margin-block-start: -14px; +} + +.heading { + font-size: var(--fonts-lg); +} diff --git a/app/components/elements/Dialog.tsx b/app/components/elements/Dialog.tsx new file mode 100644 index 000000000..a12d351e7 --- /dev/null +++ b/app/components/elements/Dialog.tsx @@ -0,0 +1,144 @@ +import type { ModalOverlayProps } from "react-aria-components"; +import { + Dialog, + DialogTrigger, + Heading, + ModalOverlay, +} from "react-aria-components"; +import { Modal } from "react-aria-components"; + +import { useNavigate } from "@remix-run/react"; +import clsx from "clsx"; +import { SendouButton } from "~/components/elements/Button"; +import { CrossIcon } from "~/components/icons/Cross"; +import styles from "./Dialog.module.css"; + +interface SendouDialogProps extends ModalOverlayProps { + trigger?: React.ReactNode; + children?: React.ReactNode; + heading?: string; + showHeading?: boolean; + onClose?: () => void; + /** When closing the modal which URL to navigate to */ + onCloseTo?: string; + overlayClassName?: string; + "aria-label"?: string; + /** If true, the modal takes over the full screen with the content below hidden */ + isFullScreen?: boolean; +} + +/** + * This component allows you to create a dialog with a customizable trigger and content. + * It supports both controlled and uncontrolled modes for managing the dialog's open state. + * + * @example + * // Example usage with implicit isOpen + * return ( + * + * This is the dialog content. + * + * ); + * + * @example + * // Example usage with a SendouButton as the trigger + * return ( + * Open Dialog} + * > + * This is the dialog content. + * + * ); + */ +export function SendouDialog({ + trigger, + children, + ...rest +}: SendouDialogProps) { + if (!trigger) { + const props = + typeof rest.isOpen === "boolean" ? rest : { isOpen: true, ...rest }; + return {children}; + } + + return ( + + {trigger} + {children} + + ); +} + +function DialogModal({ + children, + heading, + showHeading = true, + className, + ...rest +}: Omit) { + const navigate = useNavigate(); + + const showCloseButton = rest.onClose || rest.onCloseTo; + const onClose = () => { + if (rest.onCloseTo) { + navigate(rest.onCloseTo); + } else if (rest.onClose) { + rest.onClose(); + } + }; + + const onOpenChange = (isOpen: boolean) => { + if (!isOpen) { + if (rest.onCloseTo) { + navigate(rest.onCloseTo); + } else if (rest.onClose) { + rest.onClose(); + } + } + }; + + return ( + + + + {showHeading ? ( +
    + {heading ? ( + + {heading} + + ) : null} + {showCloseButton ? ( + } + variant="minimal-destructive" + className="ml-auto" + slot="close" + onPress={onClose} + /> + ) : null} +
    + ) : null} + {children} +
    +
    +
    + ); +} diff --git a/app/components/elements/FieldError.tsx b/app/components/elements/FieldError.tsx index 5b5fdef3c..2162de14e 100644 --- a/app/components/elements/FieldError.tsx +++ b/app/components/elements/FieldError.tsx @@ -1,6 +1,6 @@ import { FieldError as ReactAriaFieldError } from "react-aria-components"; -export function SendouFieldError({ children }: { children: React.ReactNode }) { +export function SendouFieldError({ children }: { children?: React.ReactNode }) { return ( {children} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 07e4112a5..6cd40b1c1 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -123,6 +123,10 @@ border: none; } +[data-empty] .searchClearButton { + visibility: hidden; +} + .noResults { font-size: var(--fonts-md); font-weight: var(--bold); diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index b38c93d32..0db421653 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -1,13 +1,8 @@ import clsx from "clsx"; -import type { - ListBoxItemProps, - SelectProps, - ValidationResult, -} from "react-aria-components"; +import type { ListBoxItemProps, SelectProps } from "react-aria-components"; import { Autocomplete, Button, - FieldError, Input, Label, ListBox, @@ -17,11 +12,11 @@ import { SearchField, Select, SelectValue, - Text, Virtualizer, useFilter, } from "react-aria-components"; import { useTranslation } from "react-i18next"; +import { SendouBottomTexts } from "~/components/elements/BottomTexts"; import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown"; import { CrossIcon } from "../icons/Cross"; import { SearchIcon } from "../icons/Search"; @@ -31,7 +26,8 @@ interface SendouSelectProps extends Omit, "children"> { label?: string; description?: string; - errorMessage?: string | ((validation: ValidationResult) => string); + errorText?: string; + bottomText?: string; items?: Iterable; children: React.ReactNode | ((item: T) => React.ReactNode); search?: { @@ -42,7 +38,8 @@ interface SendouSelectProps export function SendouSelect({ label, description, - errorMessage, + errorText, + bottomText, children, items, search, @@ -60,8 +57,7 @@ export function SendouSelect({ - {description && {description}} - {errorMessage} + {search ? ( diff --git a/app/components/elements/Toast.module.css b/app/components/elements/Toast.module.css index b339c1255..9afd4fa3f 100644 --- a/app/components/elements/Toast.module.css +++ b/app/components/elements/Toast.module.css @@ -3,9 +3,9 @@ gap: 8px; display: flex; position: fixed; - top: 55px; - right: 8px; - z-index: 1; + top: 10px; + right: 10px; + z-index: 10; } .toast { diff --git a/app/components/elements/UserSearch.module.css b/app/components/elements/UserSearch.module.css new file mode 100644 index 000000000..104c4012e --- /dev/null +++ b/app/components/elements/UserSearch.module.css @@ -0,0 +1,57 @@ +.item { + font-size: var(--fonts-xsm); + font-weight: var(--semi-bold); + white-space: pre-wrap; + padding: var(--s-1-5); + border-radius: var(--rounded-sm); + height: 33px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + display: flex; + align-items: center; + gap: var(--s-2); +} + +.popover { + min-height: 250px; +} + +.itemTextsContainer { + line-height: 1.1; +} + +.selectValue { + max-width: calc(var(--select-width) - 55px); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; + gap: var(--s-2); +} + +button:disabled .selectValue { + color: var(--text-lighter); + font-style: italic; +} + +.placeholder { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + color: var(--text-lighter); + text-align: center; + display: grid; + place-items: center; + height: 162px; + margin-block: var(--s-4); +} + +.itemAdditionalText { + font-size: var(--fonts-xxsm); + color: var(--text-lighter); +} + +button .itemAdditionalText { + display: none; +} diff --git a/app/components/elements/UserSearch.tsx b/app/components/elements/UserSearch.tsx new file mode 100644 index 000000000..8db24252b --- /dev/null +++ b/app/components/elements/UserSearch.tsx @@ -0,0 +1,248 @@ +import { useFetcher } from "@remix-run/react"; +import clsx from "clsx"; +import * as React from "react"; +import { + Button, + Input, + type Key, + ListBox, + ListBoxItem, + Popover, + SearchField, + Select, + type SelectProps, + SelectValue, +} from "react-aria-components"; +import { Autocomplete } from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { useDebounce } from "react-use"; +import { SendouBottomTexts } from "~/components/elements/BottomTexts"; +import { SendouLabel } from "~/components/elements/Label"; +import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown"; +import { CrossIcon } from "~/components/icons/Cross"; +import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server"; +import { Avatar } from "../Avatar"; +import { SearchIcon } from "../icons/Search"; + +import selectStyles from "./Select.module.css"; +import userSearchStyles from "./UserSearch.module.css"; + +type UserSearchUserItem = NonNullable["users"][number]; + +interface UserSearchProps + extends Omit, "children"> { + name?: string; + label?: string; + bottomText?: string; + errorText?: string; + initialUserId?: number; + onChange?: (user: UserSearchUserItem) => void; +} + +export const UserSearch = React.forwardRef(function UserSearch< + T extends object, +>( + { + name, + label, + bottomText, + errorText, + initialUserId, + onChange, + ...rest + }: UserSearchProps, + ref?: React.Ref, +) { + const [selectedKey, setSelectedKey] = React.useState(initialUserId ?? null); + const { initialUser, ...list } = useUserSearch(setSelectedKey, initialUserId); + + const onSelectionChange = (userId: number) => { + setSelectedKey(userId); + onChange?.( + list.items.find((user) => user.id === userId) as UserSearchUserItem, + ); + }; + + return ( + + + + user !== undefined, + )} + className={selectStyles.listBox} + > + {(item) => } + + + + + ); +}); + +function UserItem({ + item, +}: { + item: + | UserSearchUserItem + | { + id: "NO_RESULTS"; + } + | { + id: "PLACEHOLDER"; + }; +}) { + const { t } = useTranslation(["common"]); + + // for some reason the `renderEmptyState` on ListBox is not working + // so doing this as a workaround + if (typeof item.id === "string") { + return ( + + {item.id === "PLACEHOLDER" + ? t("common:forms.userSearch.placeholder") + : t("common:forms.userSearch.noResults")} + + ); + } + + const additionalText = () => { + const plusServer = item.plusTier ? `+${item.plusTier}` : ""; + const profileUrl = item.customUrl ? `/u/${item.customUrl}` : ""; + + if (plusServer && profileUrl) { + return `${plusServer} • ${profileUrl}`; + } + + if (plusServer) { + return plusServer; + } + + if (profileUrl) { + return profileUrl; + } + + return ""; + }; + + return ( + + clsx(userSearchStyles.item, { + [selectStyles.itemFocused]: isFocused, + [selectStyles.itemSelected]: isSelected, + }) + } + data-testid="user-search-item" + > + +
    + {item.username} + {additionalText() ? ( +
    + {additionalText()} +
    + ) : null} +
    +
    + ); +} + +function useUserSearch( + setSelectedKey: (userId: number | null) => void, + initialUserId?: number, +) { + const [filterText, setFilterText] = React.useState(""); + + const queryFetcher = useFetcher(); + const initialUserFetcher = useFetcher(); + + React.useEffect(() => { + if ( + !initialUserId || + initialUserFetcher.state !== "idle" || + initialUserFetcher.data + ) { + return; + } + initialUserFetcher.load(`/u?q=${initialUserId}`); + }, [initialUserId, initialUserFetcher]); + + React.useEffect(() => { + if (initialUserId !== undefined) { + setSelectedKey(initialUserId); + } + }, [initialUserId, setSelectedKey]); + + useDebounce( + () => { + if (!filterText) return; + queryFetcher.load(`/u?q=${filterText}&limit=6`); + setSelectedKey(null); + }, + 500, + [filterText], + ); + + const items = () => { + // data fetched for the query user has currently typed + if (queryFetcher.data && queryFetcher.data.query === filterText) { + if (queryFetcher.data.users.length === 0) { + return [{ id: "NO_RESULTS" }]; + } + return queryFetcher.data.users; + } + + return [{ id: "PLACEHOLDER" }]; + }; + + const initialUser = initialUserFetcher.data?.users[0]; + + return { + filterText, + setFilterText, + items: items(), + initialUser, + }; +} diff --git a/app/components/form/MyForm.tsx b/app/components/form/MyForm.tsx index c4260d4b3..14637ddf7 100644 --- a/app/components/form/MyForm.tsx +++ b/app/components/form/MyForm.tsx @@ -6,22 +6,20 @@ import { useTranslation } from "react-i18next"; import type { z } from "zod"; import { logger } from "~/utils/logger"; import type { ActionError } from "~/utils/remix.server"; -import { Button, LinkButton } from "../Button"; +import { LinkButton } from "../Button"; import { SubmitButton } from "../SubmitButton"; export function MyForm({ schema, defaultValues, - title, + heading, children, - handleCancel, cancelLink, }: { schema: T; defaultValues?: DefaultValues>; - title?: string; + heading?: string; children: React.ReactNode; - handleCancel?: () => void; cancelLink?: string; }) { const { t } = useTranslation(["common"]); @@ -55,21 +53,12 @@ export function MyForm({ return ( - {title ?

    {title}

    : null} + {heading ?

    {heading}

    : null} {children}
    {t("common:actions.submit")} - {handleCancel ? ( - - ) : null} {cancelLink ? ( ({ label, diff --git a/app/components/layout/LogInButtonContainer.tsx b/app/components/layout/LogInButtonContainer.tsx index ed13a43d4..529c7af14 100644 --- a/app/components/layout/LogInButtonContainer.tsx +++ b/app/components/layout/LogInButtonContainer.tsx @@ -1,25 +1,19 @@ import { useSearchParams } from "@remix-run/react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import { SendouDialog } from "~/components/elements/Dialog"; import { useIsMounted } from "~/hooks/useIsMounted"; import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls"; -import { Button } from "../Button"; -import { Dialog } from "../Dialog"; export function LogInButtonContainer({ children, }: { children: React.ReactNode; }) { - const isMounted = useIsMounted(); const { t } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams(); + const isMounted = useIsMounted(); + const [searchParams] = useSearchParams(); const authError = searchParams.get("authError"); - const closeAuthErrorDialog = () => { - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.delete("authError"); - setSearchParams(newSearchParams); - }; return ( <> @@ -29,40 +23,34 @@ export function LogInButtonContainer({ {authError != null && isMounted && createPortal( - +
    - - + {authError === "aborted" ? ( + <>{t("auth.errors.discordPermissions")} + ) : ( + <> + {t("auth.errors.unknown")}{" "} + + {SENDOU_INK_DISCORD_URL} + + + )}
    -
    , + , document.body, )} ); } - -function AuthenticationErrorHelp({ errorCode }: { errorCode: string }) { - const { t } = useTranslation(); - - switch (errorCode) { - case "aborted": - return ( - <> -

    {t("auth.errors.aborted")}

    - {t("auth.errors.discordPermissions")} - - ); - default: - return ( - <> -

    {t("auth.errors.failed")}

    - {t("auth.errors.unknown")}{" "} - - {SENDOU_INK_DISCORD_URL} - - - ); - } -} diff --git a/app/components/layout/NavDialog.tsx b/app/components/layout/NavDialog.tsx index 25503c5a3..ca22eaa2f 100644 --- a/app/components/layout/NavDialog.tsx +++ b/app/components/layout/NavDialog.tsx @@ -1,11 +1,11 @@ import { Link } from "@remix-run/react"; import { useTranslation } from "react-i18next"; +import { SendouDialog } from "~/components/elements/Dialog"; import { navItems } from "~/components/layout/nav-items"; import { useUser } from "~/features/auth/core/user"; import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls"; import { Avatar } from "../Avatar"; import { Button } from "../Button"; -import { Dialog } from "../Dialog"; import { Image } from "../Image"; import { CrossIcon } from "../icons/Cross"; import { LogOutIcon } from "../icons/LogOut"; @@ -23,7 +23,12 @@ export function NavDialog({ } return ( - +
    {user ? ( -
    +
    ) : null} -
    + ); } diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index c1dcc29d1..a48f50bd0 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -185,6 +185,8 @@ export async function seed(variation?: SeedVariation | null) { count++; + faker.seed(5800); + await seedFunc(); } @@ -349,7 +351,7 @@ async function userProfiles() { for (let id = 2; id < 500; id++) { if (id === ADMIN_ID || id === NZAP_TEST_ID) continue; - if (Math.random() < 0.25) continue; // 75% have bio + if (faker.number.float(1) < 0.25) continue; // 75% have bio sql .prepare( @@ -361,15 +363,16 @@ async function userProfiles() { faker.helpers.arrayElement([1, 1, 1, 2, 3, 4]), "\n\n", ), - country: Math.random() > 0.5 ? faker.location.countryCode() : null, + country: + faker.number.float(1) > 0.5 ? faker.location.countryCode() : null, }); } for (let id = 2; id < 500; id++) { if (id === ADMIN_ID || id === NZAP_TEST_ID) continue; - if (Math.random() < 0.15) continue; // 85% have weapons + if (faker.number.float(1) < 0.15) continue; // 85% have weapons - const weapons = R.shuffle(mainWeaponIds); + const weapons = faker.helpers.shuffle(mainWeaponIds); for (let j = 0; j < faker.helpers.arrayElement([1, 2, 3, 4, 5]); j++) { sql @@ -390,24 +393,24 @@ async function userProfiles() { userId: id, weaponSplId: weapons.pop()!, order: j + 1, - isFavorite: Math.random() > 0.8 ? 1 : 0, + isFavorite: faker.number.float(1) > 0.8 ? 1 : 0, }); } } for (let id = 1; id < 500; id++) { - const defaultLanguages = Math.random() > 0.1 ? ["en"] : []; - if (Math.random() > 0.9) defaultLanguages.push("es"); - if (Math.random() > 0.9) defaultLanguages.push("fr"); - if (Math.random() > 0.9) defaultLanguages.push("de"); - if (Math.random() > 0.9) defaultLanguages.push("it"); - if (Math.random() > 0.9) defaultLanguages.push("ja"); + const defaultLanguages = faker.number.float(1) > 0.1 ? ["en"] : []; + if (faker.number.float(1) > 0.9) defaultLanguages.push("es"); + if (faker.number.float(1) > 0.9) defaultLanguages.push("fr"); + if (faker.number.float(1) > 0.9) defaultLanguages.push("de"); + if (faker.number.float(1) > 0.9) defaultLanguages.push("it"); + if (faker.number.float(1) > 0.9) defaultLanguages.push("ja"); await QSettingsRepository.updateVoiceChat({ languages: defaultLanguages, userId: id, vc: - Math.random() > 0.2 + faker.number.float(1) > 0.2 ? "YES" : faker.helpers.arrayElement(["YES", "NO", "LISTEN_ONLY"]), }); @@ -416,13 +419,13 @@ async function userProfiles() { const randomPreferences = (): UserMapModePreferences => { const modes: UserMapModePreferences["modes"] = modesShort.flatMap((mode) => { - if (Math.random() > 0.5 && mode !== "SZ") return []; + if (faker.number.float(1) > 0.5 && mode !== "SZ") return []; const criteria = mode === "SZ" ? 0.2 : 0.5; return { mode, - preference: Math.random() > criteria ? "PREFER" : "AVOID", + preference: faker.number.float(1) > criteria ? "PREFER" : "AVOID", }; }); @@ -434,7 +437,8 @@ const randomPreferences = (): UserMapModePreferences => { return { mode, - stages: R.shuffle(stageIds) + stages: faker.helpers + .shuffle(stageIds) .filter((stageId) => !BANNED_MAPS[mode].includes(stageId)) .slice(0, AMOUNT_OF_MAPS_IN_POOL_PER_MODE), }; @@ -444,7 +448,7 @@ const randomPreferences = (): UserMapModePreferences => { async function userMapModePreferences() { for (let id = 1; id < 500; id++) { - if (id !== ADMIN_ID && Math.random() < 0.2) continue; // 80% have maps && admin always + if (id !== ADMIN_ID && faker.number.float(1) < 0.2) continue; // 80% have maps && admin always await db .updateTable("User") @@ -459,12 +463,11 @@ async function userMapModePreferences() { async function userQWeaponPool() { for (let id = 1; id < 500; id++) { if (id === 2) continue; // no weapons for N-ZAP - if (Math.random() < 0.2) continue; // 80% have weapons + if (faker.number.float(1) < 0.2) continue; // 80% have weapons - const weapons = R.shuffle(mainWeaponIds).slice( - 0, - faker.helpers.arrayElement([1, 2, 3, 4]), - ); + const weapons = faker.helpers + .shuffle(mainWeaponIds) + .slice(0, faker.helpers.arrayElement([1, 2, 3, 4])); await db .updateTable("User") @@ -603,7 +606,7 @@ function syncPlusTiers() { } function getAvailableBadgeIds() { - return R.shuffle( + return faker.helpers.shuffle( (sql.prepare(`select "id" from "Badge"`).all() as any[]).map((b) => b.id), ); } @@ -624,7 +627,7 @@ function badgesToUsers() { ); for (const id of availableBadgeIds) { - userIds = R.shuffle(userIds); + userIds = faker.helpers.shuffle(userIds); for ( let i = 0; i < @@ -715,7 +718,7 @@ function calendarEvents() { const userIds = userIdsInRandomOrder(); for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) { - const shuffledTags = R.shuffle(Object.keys(persistedTags)); + const shuffledTags = faker.helpers.shuffle(Object.keys(persistedTags)); sql .prepare( @@ -749,7 +752,7 @@ function calendarEvents() { bracketUrl: faker.internet.url(), authorId: id === 1 ? NZAP_TEST_ID : (userIds.pop() ?? null), tags: - Math.random() > 0.2 + faker.number.float(1) > 0.2 ? shuffledTags .slice( 0, @@ -761,7 +764,7 @@ function calendarEvents() { : null, }); - const twoDayEvent = Math.random() > 0.9; + const twoDayEvent = faker.number.float(1) > 0.9; const startTime = id % 2 === 0 ? faker.date.soon({ days: 42 }) @@ -810,7 +813,7 @@ function calendarEvents() { function calendarEventBadges() { for (let eventId = 1; eventId <= AMOUNT_OF_CALENDAR_EVENTS; eventId++) { - if (Math.random() > 0.25) continue; + if (faker.number.float(1) > 0.25) continue; const availableBadgeIds = getAvailableBadgeIds(); @@ -847,7 +850,7 @@ async function calendarEventResults() { for (const eventId of eventIdsOfPast) { // event id = 1 needs to be without results for e2e tests - if (Math.random() < 0.3 || eventId === 1) continue; + if (faker.number.float(1) < 0.3 || eventId === 1) continue; await CalendarRepository.upsertReportedScores({ eventId, @@ -862,7 +865,7 @@ async function calendarEventResults() { ) .fill(null) .map(() => { - const withStringName = Math.random() < 0.2; + const withStringName = faker.number.float(1) < 0.2; return { name: withStringName ? faker.person.firstName() : null, @@ -1314,9 +1317,9 @@ function calendarEventWithToToolsTeams( if ( event !== "SOS" && event !== "LUTI" && - (Math.random() < 0.8 || id === 1) + (faker.number.float(1) < 0.8 || id === 1) ) { - const shuffledPairs = R.shuffle(availablePairs.slice()); + const shuffledPairs = faker.helpers.shuffle(availablePairs.slice()); let SZ = 0; let TC = 0; @@ -1399,7 +1402,7 @@ function tournamentSubs() { .run({ userId: id, tournamentId: 1, - canVc: Number(Math.random() > 0.5), + canVc: Number(faker.number.float(1) > 0.5), bestWeapons: nullFilledArray( faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]), ) @@ -1414,7 +1417,7 @@ function tournamentSubs() { }) .join(","), okWeapons: - Math.random() > 0.5 + faker.number.float(1) > 0.5 ? null : nullFilledArray( faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]), @@ -1429,7 +1432,7 @@ function tournamentSubs() { } }) .join(","), - message: Math.random() > 0.5 ? null : faker.lorem.paragraph(), + message: faker.number.float(1) > 0.5 ? null : faker.lorem.paragraph(), visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL", }); } @@ -1438,19 +1441,21 @@ function tournamentSubs() { } const randomAbility = (legalTypes: AbilityType[]) => { - const randomOrderAbilities = R.shuffle([...abilities]); + const randomOrderAbilities = faker.helpers.shuffle([...abilities]); return randomOrderAbilities.find((a) => legalTypes.includes(a.type))!.name; }; -const adminWeaponPool = mainWeaponIds.filter(() => Math.random() > 0.8); +const adminWeaponPool = mainWeaponIds.filter(() => faker.number.float(1) > 0.8); async function adminBuilds() { for (let i = 0; i < 50; i++) { - const randomOrderHeadGear = R.shuffle(headGearIds.slice()); - const randomOrderClothesGear = R.shuffle(clothesGearIds.slice()); - const randomOrderShoesGear = R.shuffle(shoesGearIds.slice()); + const randomOrderHeadGear = faker.helpers.shuffle(headGearIds.slice()); + const randomOrderClothesGear = faker.helpers.shuffle( + clothesGearIds.slice(), + ); + const randomOrderShoesGear = faker.helpers.shuffle(shoesGearIds.slice()); // filter out sshot to prevent test flaking - const randomOrderWeaponIds = R.shuffle( + const randomOrderWeaponIds = faker.helpers.shuffle( adminWeaponPool.filter((id) => id !== 40).slice(), ); @@ -1460,7 +1465,8 @@ async function adminBuilds() { )}`, ownerId: ADMIN_ID, private: 0, - description: Math.random() < 0.75 ? faker.lorem.paragraph() : null, + description: + faker.number.float(1) < 0.75 ? faker.lorem.paragraph() : null, headGearSplId: randomOrderHeadGear[0], clothesGearSplId: randomOrderClothesGear[0], shoesGearSplId: randomOrderShoesGear[0], @@ -1470,8 +1476,8 @@ async function adminBuilds() { .fill(null) .map(() => randomOrderWeaponIds.pop()!), modes: - Math.random() < 0.75 - ? modesShort.filter(() => Math.random() < 0.5) + faker.number.float(1) < 0.75 + ? modesShort.filter(() => faker.number.float(1) < 0.5) : null, abilities: [ [ @@ -1509,12 +1515,14 @@ async function manySplattershotBuilds() { for (let i = 0; i < 499; i++) { const SPLATTERSHOT_ID = 40; - const randomOrderHeadGear = R.shuffle(headGearIds.slice()); - const randomOrderClothesGear = R.shuffle(clothesGearIds.slice()); - const randomOrderShoesGear = R.shuffle(shoesGearIds.slice()); - const randomOrderWeaponIds = R.shuffle(mainWeaponIds.slice()).filter( - (id) => id !== SPLATTERSHOT_ID, + const randomOrderHeadGear = faker.helpers.shuffle(headGearIds.slice()); + const randomOrderClothesGear = faker.helpers.shuffle( + clothesGearIds.slice(), ); + const randomOrderShoesGear = faker.helpers.shuffle(shoesGearIds.slice()); + const randomOrderWeaponIds = faker.helpers + .shuffle(mainWeaponIds.slice()) + .filter((id) => id !== SPLATTERSHOT_ID); const ownerId = users.pop()!; @@ -1524,7 +1532,8 @@ async function manySplattershotBuilds() { faker.word.noun(), )}`, ownerId, - description: Math.random() < 0.75 ? faker.lorem.paragraph() : null, + description: + faker.number.float(1) < 0.75 ? faker.lorem.paragraph() : null, headGearSplId: randomOrderHeadGear[0], clothesGearSplId: randomOrderClothesGear[0], shoesGearSplId: randomOrderShoesGear[0], @@ -1536,8 +1545,8 @@ async function manySplattershotBuilds() { i === 0 ? SPLATTERSHOT_ID : randomOrderWeaponIds.pop()!, ), modes: - Math.random() < 0.75 - ? modesShort.filter(() => Math.random() < 0.5) + faker.number.float(1) < 0.75 + ? modesShort.filter(() => faker.number.float(1) < 0.5) : null, abilities: [ [ @@ -1930,7 +1939,8 @@ function arts() { ).id, authorId: userId, isShowcase: i === 0 ? 1 : 0, - description: Math.random() > 0.5 ? faker.lorem.paragraph() : null, + description: + faker.number.float(1) > 0.5 ? faker.lorem.paragraph() : null, }) as Tables["Art"]; if (i === 1) { @@ -1960,7 +1970,7 @@ function commissionsOpen() { const allUsers = userIdsInRandomOrder(); for (const userId of allUsers) { - if (Math.random() > 0.5) { + if (faker.number.float(1) > 0.5) { updateCommissionStm.run({ commissionsOpen: 1, commissionText: faker.lorem.paragraph(), @@ -2019,15 +2029,15 @@ const randomMapList = ( ): TournamentMapListMap[] => { const szOnly = faker.helpers.arrayElement([true, false]); - let modePattern = R.shuffle([...modesShort]).filter( - () => Math.random() > 0.15, - ); + let modePattern = faker.helpers + .shuffle([...modesShort]) + .filter(() => faker.number.float(1) > 0.15); if (modePattern.length === 0) { - modePattern = R.shuffle([...rankedModesShort]); + modePattern = faker.helpers.shuffle([...rankedModesShort]); } const mapList: TournamentMapListMap[] = []; - const stageIdsShuffled = R.shuffle([...stageIds]); + const stageIdsShuffled = faker.helpers.shuffle([...stageIds]); for (let i = 0; i < 7; i++) { const mode = modePattern.pop()!; @@ -2050,7 +2060,7 @@ const AMOUNT_OF_USERS_WITH_SKILLS = 100; async function playedMatches() { const _groupMembers = (() => { return new Array(AMOUNT_OF_USERS_WITH_SKILLS).fill(null).map(() => { - const users = R.shuffle( + const users = faker.helpers.shuffle( userIdsInAscendingOrderById().slice(0, AMOUNT_OF_USERS_WITH_SKILLS), ); @@ -2061,14 +2071,14 @@ async function playedMatches() { userIdsInAscendingOrderById() .slice(0, AMOUNT_OF_USERS_WITH_SKILLS) .map((id) => { - const weapons = R.shuffle([...mainWeaponIds]); + const weapons = faker.helpers.shuffle([...mainWeaponIds]); return [id, weapons[0]]; }), ); let matchDate = new Date(Date.UTC(2023, 9, 15, 0, 0, 0, 0)); for (let i = 0; i < MATCHES_COUNT; i++) { - const groupMembers = R.shuffle([..._groupMembers]); + const groupMembers = faker.helpers.shuffle([..._groupMembers]); const groupAlphaMembers = groupMembers.pop()!; invariant(groupAlphaMembers, "groupAlphaMembers not found"); @@ -2131,7 +2141,7 @@ async function playedMatches() { id: match.id, }); - if (Math.random() > 0.95) { + if (faker.number.float(1) > 0.95) { // increment date by 1 day matchDate = new Date(matchDate.getTime() + 1000 * 60 * 60 * 24); } @@ -2175,7 +2185,9 @@ async function playedMatches() { reportScore({ matchId: match.id, reportedByUserId: - Math.random() > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0], + faker.number.float(1) > 0.5 + ? groupAlphaMembers[0] + : groupBravoMembers[0], winners, }); addSkills({ @@ -2193,7 +2205,7 @@ async function playedMatches() { })(); // -> add weapons for 90% of matches - if (Math.random() > 0.9) continue; + if (faker.number.float(1) > 0.9) continue; const users = [...groupAlphaMembers, ...groupBravoMembers]; const mapsWithUsers = users.flatMap((u) => finishedMatch.mapList.map((m) => ({ map: m, user: u })), @@ -2202,13 +2214,13 @@ async function playedMatches() { addReportedWeapons( mapsWithUsers.map((mu) => { const weapon = () => { - if (Math.random() < 0.9) return defaultWeapons[mu.user]; - if (Math.random() > 0.5) + if (faker.number.float(1) < 0.9) return defaultWeapons[mu.user]; + if (faker.number.float(1) > 0.5) return ( mainWeaponIds.find((id) => id > defaultWeapons[mu.user]) ?? 0 ); - const shuffled = R.shuffle([...mainWeaponIds]); + const shuffled = faker.helpers.shuffle([...mainWeaponIds]); return shuffled[0]; }; @@ -2265,7 +2277,7 @@ async function scrimPosts() { const allUsers = userIdsInRandomOrder(true); const date = () => { - const isNow = Math.random() > 0.5; + const isNow = faker.number.float(1) > 0.5; if (isNow) { return databaseTimestampNow(); @@ -2284,7 +2296,7 @@ async function scrimPosts() { }; const team = () => { - const hasTeam = Math.random() > 0.5; + const hasTeam = faker.number.float(1) > 0.5; if (!hasTeam) { return null; @@ -2294,7 +2306,7 @@ async function scrimPosts() { }; const divRange = () => { - const hasDivRange = Math.random() > 0.2; + const hasDivRange = faker.number.float(1) > 0.2; if (!hasDivRange) { return null; @@ -2331,7 +2343,9 @@ async function scrimPosts() { minDiv: divs?.minDiv, teamId: team(), text: - Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null, + faker.number.float(1) > 0.5 + ? faker.lorem.sentences({ min: 1, max: 5 }) + : null, visibility: null, users: users(), }); @@ -2340,7 +2354,9 @@ async function scrimPosts() { const adminPostId = await ScrimPostRepository.insert({ at: date(), text: - Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null, + faker.number.float(1) > 0.5 + ? faker.lorem.sentences({ min: 1, max: 5 }) + : null, visibility: null, users: users() .map((u) => ({ ...u, isOwner: 0 })) diff --git a/app/db/tables.ts b/app/db/tables.ts index 26c9585df..ed3406b94 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -2,6 +2,7 @@ import type { ColumnType, GeneratedAlways, Insertable, + JSONColumnType, Selectable, SqlBool, Updateable, @@ -23,8 +24,9 @@ import type { ModeShort, StageId, } from "~/modules/in-game-lists"; +import type { JSONColumnTypeNullable } from "~/utils/kysely.server"; -export type Generated = T extends ColumnType +type Generated = T extends ColumnType ? ColumnType : ColumnType; @@ -35,7 +37,7 @@ export interface Team { bannerImgId: number | null; bio: string | null; createdAt: Generated; - css: ColumnType | null, string | null, string | null>; + css: JSONColumnTypeNullable>; customUrl: string; deletedAt: number | null; id: GeneratedAlways; @@ -100,7 +102,7 @@ export interface Build { description: string | null; headGearSplId: number; id: GeneratedAlways; - modes: ColumnType; + modes: JSONColumnTypeNullable; ownerId: number; private: number | null; shoesGearSplId: number; @@ -146,11 +148,7 @@ export interface CalendarEvent { organizationId: number | null; avatarImgId: number | null; // TODO: remove in migration - avatarMetadata: ColumnType< - CalendarEventAvatarMetadata | null, - string | null, - string | null - >; + avatarMetadata: JSONColumnTypeNullable; } export interface CalendarEventBadge { @@ -250,7 +248,7 @@ export interface GroupMatch { chatCode: string | null; createdAt: Generated; id: GeneratedAlways; - memento: ColumnType; + memento: JSONColumnTypeNullable; reportedAt: number | null; reportedByUserId: number | null; } @@ -474,21 +472,13 @@ export interface CastedMatchesInfo { } export interface Tournament { - settings: ColumnType; + settings: JSONColumnType; id: GeneratedAlways; mapPickingStyle: TournamentMapPickingStyle; /** Maps prepared ahead of time for rounds. Follows settings.bracketProgression order. Null in the spot if not defined yet for that bracket. */ - preparedMaps: ColumnType< - (PreparedMaps | null)[] | null, - string | null, - string | null - >; - castTwitchAccounts: ColumnType; - castedMatchesInfo: ColumnType< - CastedMatchesInfo | null, - string | null, - string | null - >; + preparedMaps: JSONColumnTypeNullable<(PreparedMaps | null)[]>; + castTwitchAccounts: JSONColumnTypeNullable; + castedMatchesInfo: JSONColumnTypeNullable; rules: string | null; /** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */ parentTournamentId: number | null; @@ -548,8 +538,8 @@ export interface TournamentMatch { groupId: number; id: GeneratedAlways; number: number; - opponentOne: ColumnType; - opponentTwo: ColumnType; + opponentOne: JSONColumnType; + opponentTwo: JSONColumnType; roundId: number; stageId: number; status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus]; @@ -616,7 +606,7 @@ export interface TournamentRound { id: GeneratedAlways; number: number; stageId: number; - maps: ColumnType; + maps: JSONColumnTypeNullable; } // when updating this also update `defaultBracketSettings` in tournament-utils.ts @@ -682,11 +672,7 @@ export interface TournamentTeam { seed: number | null; /** For formats that have many starting brackets, where should the team start? */ startingBracketIdx: number | null; - activeRosterUserIds: ColumnType< - number[] | null, - string | null, - string | null - >; + activeRosterUserIds: JSONColumnTypeNullable; tournamentId: number; teamId: number | null; avatarImgId: number | null; @@ -714,7 +700,7 @@ export interface TournamentOrganization { name: string; slug: string; description: string | null; - socials: ColumnType; + socials: JSONColumnTypeNullable; avatarImgId: number | null; } @@ -744,7 +730,7 @@ export interface TournamentOrganizationSeries { organizationId: number; name: string; description: string | null; - substringMatches: ColumnType; + substringMatches: JSONColumnType; showLeaderboard: Generated; } @@ -821,7 +807,7 @@ export interface User { commissionsOpen: Generated; commissionText: string | null; country: string | null; - css: ColumnType | null, string | null, string | null>; + css: JSONColumnTypeNullable>; customUrl: string | null; discordAvatar: string | null; discordId: string; @@ -849,16 +835,12 @@ export interface User { battlefy: string | null; vc: Generated<"YES" | "NO" | "LISTEN_ONLY">; youtubeId: string | null; - mapModePreferences: ColumnType< - UserMapModePreferences | null, - string | null, - string | null - >; + mapModePreferences: JSONColumnTypeNullable; qWeaponPool: ColumnType; plusSkippedForSeasonNth: number | null; noScreen: Generated; - buildSorting: ColumnType; - preferences: ColumnType; + buildSorting: JSONColumnTypeNullable; + preferences: JSONColumnTypeNullable; } /** Represents User joined with PlusTier table */ @@ -946,11 +928,7 @@ export interface ScrimPost { /** Lowest LUTI div accepted */ minDiv: number | null; /** Who sees the post */ - visibility: ColumnType< - AssociationVisibility | null, - string | null, - string | null - >; + visibility: JSONColumnTypeNullable; /** Any additional info */ text: string | null; /** The key to access the scrim chat, used after scrim is scheduled with another team */ @@ -999,11 +977,7 @@ export interface AssociationMember { export interface Notification { id: GeneratedAlways; type: NotificationValue["type"]; - meta: ColumnType< - Record | null, - string | null, - string | null - >; + meta: JSONColumnTypeNullable>; pictureUrl: string | null; createdAt: GeneratedAlways; } @@ -1025,7 +999,7 @@ export interface NotificationSubscription { export interface NotificationUserSubscription { id: GeneratedAlways; userId: number; - subscription: ColumnType; + subscription: JSONColumnType; } export type Tables = { [P in keyof DB]: Selectable }; diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx index 5d517df66..88f11ff56 100644 --- a/app/features/admin/routes/admin.tsx +++ b/app/features/admin/routes/admin.tsx @@ -15,7 +15,7 @@ import { Input } from "~/components/Input"; import { Main } from "~/components/Main"; import { NewTabs } from "~/components/NewTabs"; import { SubmitButton } from "~/components/SubmitButton"; -import { UserSearch } from "~/components/UserSearch"; +import { UserSearch } from "~/components/elements/UserSearch"; import { SearchIcon } from "~/components/icons/Search"; import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants"; import { useHasRole } from "~/modules/permissions/hooks"; @@ -144,13 +144,10 @@ function Impersonate() { reloadDocument >

    Impersonate user

    -
    - - setUserId(newUser.id)} - /> -
    + setUserId(newUser.id)} + />
    +
    + +
    ); @@ -139,30 +122,25 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {

    Owners

    -
    - - { - setOwners((previousOwners) => { - const existingOwner = previousOwners.find( - (o) => o.id === user.id, + { + setOwners((previousOwners) => { + const existingOwner = previousOwners.find( + (o) => o.id === user.id, + ); + if (existingOwner) { + return previousOwners.map((o) => + o.id === user.id ? { ...o, count: o.count + 1 } : o, ); - if (existingOwner) { - return previousOwners.map((o) => - o.id === user.id ? { ...o, count: o.count + 1 } : o, - ); - } - return [...previousOwners, { count: 1, ...user }]; - }); - }} - /> -
    + } + return [...previousOwners, { count: 1, ...user }]; + }); + }} + />
      {owners.map((owner) => ( @@ -214,16 +192,16 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) { name="ownerIds" value={JSON.stringify(countArrayToDuplicatedIdsArray(owners))} /> - +
      + +
    ); } diff --git a/app/features/calendar/routes/calendar.$id.report-winners.tsx b/app/features/calendar/routes/calendar.$id.report-winners.tsx index 595b96def..746126e6d 100644 --- a/app/features/calendar/routes/calendar.$id.report-winners.tsx +++ b/app/features/calendar/routes/calendar.$id.report-winners.tsx @@ -8,7 +8,7 @@ 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 { UserSearch } from "~/components/elements/UserSearch"; import { CALENDAR_EVENT_RESULT } from "~/constants"; import type { SendouRouteHandle } from "~/utils/remix.server"; import type { Unpacked } from "~/utils/types"; @@ -277,7 +277,7 @@ function Players({ ) : ( handleInputChange(i, newUser.id)} /> diff --git a/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts index db1700a20..4277ca94d 100644 --- a/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts +++ b/app/features/plus-suggestions/actions/plus.suggestions.new.server.ts @@ -12,23 +12,30 @@ import { badRequestIfFalsy, errorToastIfFalsy, parseRequestPayload, + unauthorizedIfFalsy, } from "~/utils/remix.server"; import { plusSuggestionPage } from "~/utils/urls"; import { firstCommentActionSchema } from "../plus-suggestions-schemas"; -import { canSuggestNewUserBE } from "../plus-suggestions-utils"; +import { + canSuggestNewUser, + playerAlreadyMember, + playerAlreadySuggested, +} from "../plus-suggestions-utils"; export const action: ActionFunction = async ({ request }) => { + const user = await requireUser(request); + const data = await parseRequestPayload({ request, schema: firstCommentActionSchema, }); + unauthorizedIfFalsy(user.plusTier && user.plusTier <= data.tier); + const suggested = badRequestIfFalsy( await UserRepository.findLeanById(data.userId), ); - const user = await requireUser(request); - const votingMonthYear = rangeToMonthYear( badRequestIfFalsy(nextNonCompletedVoting(new Date())), ); @@ -36,13 +43,25 @@ export const action: ActionFunction = async ({ request }) => { await PlusSuggestionRepository.findAllByMonth(votingMonthYear); errorToastIfFalsy( - canSuggestNewUserBE({ - user, + !playerAlreadySuggested({ + suggestions, suggested, targetPlusTier: data.tier, + }), + "This user has already been suggested", + ); + + errorToastIfFalsy( + !playerAlreadyMember({ suggested, targetPlusTier: data.tier }), + "This user is already a member of this tier", + ); + + errorToastIfFalsy( + canSuggestNewUser({ + user, suggestions, }), - "No permissions to make this suggestion", + "Can't make a suggestion right now", ); await PlusSuggestionRepository.create({ diff --git a/app/features/plus-suggestions/plus-suggestions-utils.ts b/app/features/plus-suggestions/plus-suggestions-utils.ts index 5e996a6a6..e6d1a4b40 100644 --- a/app/features/plus-suggestions/plus-suggestions-utils.ts +++ b/app/features/plus-suggestions/plus-suggestions-utils.ts @@ -131,14 +131,14 @@ function suggestionHasNoOtherComments({ throw new Error(`Invalid suggestion id: ${suggestionId}`); } -interface CanSuggestNewUserFEArgs { +interface CanSuggestNewUserArgs { user?: Pick; suggestions: PlusSuggestionRepository.FindAllByMonthItem[]; } -export function canSuggestNewUserFE({ +export function canSuggestNewUser({ user, suggestions, -}: CanSuggestNewUserFEArgs) { +}: CanSuggestNewUserArgs) { const votingActive = process.env.NODE_ENV === "test" ? false : isVotingActive(); @@ -152,24 +152,6 @@ export function canSuggestNewUserFE({ ]); } -interface CanSuggestNewUserBEArgs extends CanSuggestNewUserFEArgs { - suggested: Pick; - targetPlusTier: NonNullable; -} -export function canSuggestNewUserBE({ - user, - suggestions, - suggested, - targetPlusTier, -}: CanSuggestNewUserBEArgs) { - return allTruthy([ - canSuggestNewUserFE({ user, suggestions }), - !playerAlreadySuggested({ suggestions, suggested, targetPlusTier }), - targetPlusTierIsSmallerOrEqual({ user, targetPlusTier }), - !playerAlreadyMember({ suggested, targetPlusTier }), - ]); -} - function isPlusServerMember(user?: Pick) { return Boolean(user?.plusTier); } @@ -177,14 +159,17 @@ function isPlusServerMember(user?: Pick) { export function playerAlreadyMember({ suggested, targetPlusTier, -}: Pick) { +}: { + suggested: Pick; + targetPlusTier: NonNullable; +}) { return suggested.plusTier && suggested.plusTier <= targetPlusTier; } function hasUserSuggestedThisMonth({ user, suggestions, -}: Pick) { +}: Pick) { return suggestions.some( (suggestion) => suggestion.suggestions[0].author.id === user?.id, ); diff --git a/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx b/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx index 53171f2fa..ea2eb497f 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.comment.$tier.$userId.tsx @@ -1,7 +1,7 @@ import { Form, useMatches, useParams } from "@remix-run/react"; -import { Button, LinkButton } from "~/components/Button"; -import { Dialog } from "~/components/Dialog"; +import { Button } from "~/components/Button"; import { Redirect } from "~/components/Redirect"; +import { SendouDialog } from "~/components/elements/Dialog"; import { PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants"; import { useUser } from "~/features/auth/core/user"; import { atOrError } from "~/utils/arrays"; @@ -42,26 +42,18 @@ export default function PlusCommentModalPage() { } return ( - +
    -

    - {userBeingCommented.suggested.username}'s +{tierSuggestedTo}{" "} - suggestion -

    -
    +
    - - Cancel -
    -
    + ); } diff --git a/app/features/plus-suggestions/routes/plus.suggestions.new.tsx b/app/features/plus-suggestions/routes/plus.suggestions.new.tsx index 47383ac0e..c51a5c965 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.new.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.new.tsx @@ -1,25 +1,18 @@ import { Form, useMatches } from "@remix-run/react"; import * as React from "react"; -import { LinkButton } from "~/components/Button"; -import { Dialog } from "~/components/Dialog"; -import { FormMessage } from "~/components/FormMessage"; import { Label } from "~/components/Label"; import { Redirect } from "~/components/Redirect"; import { SubmitButton } from "~/components/SubmitButton"; -import { UserSearch } from "~/components/UserSearch"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { UserSearch } from "~/components/elements/UserSearch"; import { PLUS_TIERS, PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH, } from "~/constants"; -import type { UserWithPlusTier } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { atOrError } from "~/utils/arrays"; import { plusSuggestionPage } from "~/utils/urls"; -import { - canSuggestNewUserFE, - playerAlreadyMember, - playerAlreadySuggested, -} from "../plus-suggestions-utils"; +import { canSuggestNewUser } from "../plus-suggestions-utils"; import type { PlusSuggestionsLoaderData } from "./plus.suggestions"; import { action } from "../actions/plus.suggestions.new.server"; @@ -29,11 +22,6 @@ export default function PlusNewSuggestionModalPage() { const user = useUser(); const matches = useMatches(); const data = atOrError(matches, -2).data as PlusSuggestionsLoaderData; - const [selectedUser, setSelectedUser] = React.useState<{ - /** User id */ - value: string; - plusTier: number | null; - } | null>(null); const tierOptions = PLUS_TIERS.filter((tier) => { // user will be redirected anyway @@ -47,7 +35,7 @@ export default function PlusNewSuggestionModalPage() { if ( !data.suggestions || - !canSuggestNewUserFE({ + !canSuggestNewUser({ user, suggestions: data.suggestions, }) || @@ -56,18 +44,12 @@ export default function PlusNewSuggestionModalPage() { return ; } - const selectedUserErrorMessage = getSelectedUserErrorMessage({ - suggested: selectedUser - ? { id: Number(selectedUser.value), plusTier: selectedUser.plusTier } - : undefined, - suggestions: data.suggestions, - targetPlusTier, - }); - return ( - +
    -

    Adding a new suggestion

    -
    -

    - {t("q:privateNote.header", { name: aboutUser.username })} -

    -