diff --git a/app/components/Button.tsx b/app/components/Button.tsx index cd9ca2869..d63b7361d 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -67,7 +67,9 @@ type LinkButtonProps = Pick< ButtonProps, "variant" | "children" | "className" | "size" | "testId" | "icon" > & - Pick & { "data-cy"?: string } & { + Pick & { + "data-cy"?: string; + } & { isExternal?: boolean; }; @@ -79,9 +81,9 @@ export function LinkButton({ to, prefetch, isExternal, - state, testId, icon, + preventScrollReset, }: LinkButtonProps) { if (isExternal) { return ( @@ -119,7 +121,7 @@ export function LinkButton({ to={to} data-testid={testId} prefetch={prefetch} - state={state} + preventScrollReset={preventScrollReset} > {icon && React.cloneElement(icon, { diff --git a/app/components/ConditionalScrollRestoration.tsx b/app/components/ConditionalScrollRestoration.tsx deleted file mode 100644 index 66d8e2d6f..000000000 --- a/app/components/ConditionalScrollRestoration.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// temporary workaround before Remix has React Router 6.4 -// https://github.com/remix-run/remix/issues/186#issuecomment-1178395835 - -import { ScrollRestoration, useLocation } from "@remix-run/react"; -import * as React from "react"; - -export function ConditionalScrollRestoration() { - const isFirstRenderRef = React.useRef(true); - const location = useLocation(); - - React.useEffect(() => { - isFirstRenderRef.current = false; - }, []); - - if ( - !isFirstRenderRef.current && - location.state != null && - typeof location.state === "object" && - (location.state as { scroll: boolean }).scroll === false - ) { - return null; - } - - return location.pathname} />; -} diff --git a/app/components/Label.tsx b/app/components/Label.tsx index fc13eedfb..89382874b 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -41,7 +41,7 @@ export function Label({ } function lengthWarning(valueLimits: NonNullable) { - if (valueLimits.current >= valueLimits.max) return "error"; + if (valueLimits.current > valueLimits.max) return "error"; if (valueLimits.current / valueLimits.max >= 0.9) return "warning"; return; diff --git a/app/components/NewTabs.tsx b/app/components/NewTabs.tsx index 16224ab38..1c7a87338 100644 --- a/app/components/NewTabs.tsx +++ b/app/components/NewTabs.tsx @@ -7,6 +7,7 @@ interface NewTabsProps { label: string; number?: number; hidden?: boolean; + disabled?: boolean; }[]; content: { key: string; @@ -57,6 +58,7 @@ export function NewTabs(args: NewTabsProps) { key={tab.label} className="tab__button" data-testid={`tab-${tab.label}`} + disabled={tab.disabled} > {tab.label} {typeof tab.number === "number" && tab.number !== 0 && ( diff --git a/app/components/UserSearch.tsx b/app/components/UserSearch.tsx index 4c6371dbc..765029fb3 100644 --- a/app/components/UserSearch.tsx +++ b/app/components/UserSearch.tsx @@ -9,130 +9,140 @@ import { Avatar } from "./Avatar"; 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`); +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; + } +>( + ( + { + inputName, + onChange, + initialUserId, + id, + className, + userIdsToOmit, + required, + onBlur, }, - 1000, - [query], - ); + ref, + ) => { + const { t } = useTranslation(); + const [selectedUser, setSelectedUser] = + React.useState(null); + const queryFetcher = useFetcher(); + const initialUserFetcher = useFetcher(); + const [query, setQuery] = React.useState(""); - // load initial user - React.useEffect(() => { - if ( - !initialUserId || - initialUserFetcher.state !== "idle" || - initialUserFetcher.data - ) { - return; - } + useDebounce( + () => { + if (!query) return; + queryFetcher.load(`/u?q=${query}&limit=6`); + }, + 1000, + [query], + ); - initialUserFetcher.load(`/u?q=${initialUserId}`); - }, [initialUserId, initialUserFetcher]); - React.useEffect(() => { - if (!initialUserFetcher.data) return; + React.useEffect(() => { + if ( + !initialUserId || + initialUserFetcher.state !== "idle" || + initialUserFetcher.data + ) { + return; + } + initialUserFetcher.load(`/u?q=${initialUserId}`); + }, [initialUserId, initialUserFetcher]); - setSelectedUser(initialUserFetcher.data.users[0]); - }, [initialUserFetcher.data]); + React.useEffect(() => { + if (!initialUserFetcher.data) return; + setSelectedUser(initialUserFetcher.data.users[0]); + }, [initialUserFetcher.data]); - const allUsers = queryFetcher.data?.users ?? []; + 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, + ); - 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?.username ?? ""} - className={clsx("combobox-input", className)} - data-1p-ignore - data-testid={`${inputName}-combobox-input`} - id={id} - required={required} - /> - + {selectedUser && inputName ? ( + + ) : null} + { + setSelectedUser(newUser); + onChange?.(newUser!); + }} + disabled={initialSelectionIsLoading} > - {noMatches ? ( -
- {t("forms.errors.noSearchMatches")}{" "} - 🤔 -
- ) : null} - {users.map((user, i) => ( - - {({ active }) => ( -
  • - -
    -
    - {user.username}{" "} - {user.plusTier ? ( - +{user.plusTier} + 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}
    - {user.discordUniqueName ? ( -
    {user.discordUniqueName}
    - ) : null} -
  • - - )} - - ))} - - - - ); -} + + )} + + ))} + + + + ); + }, +); diff --git a/app/components/form/AddFieldButton.tsx b/app/components/form/AddFieldButton.tsx new file mode 100644 index 000000000..d7c1f47ef --- /dev/null +++ b/app/components/form/AddFieldButton.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "../Button"; +import { PlusIcon } from "../icons/Plus"; + +export function AddFieldButton({ + onClick, +}: { + onClick: () => void; +}) { + const { t } = useTranslation(["common"]); + + return ( + + ); +} diff --git a/app/components/form/FormFieldset.tsx b/app/components/form/FormFieldset.tsx new file mode 100644 index 000000000..0d2edecde --- /dev/null +++ b/app/components/form/FormFieldset.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; +import { RemoveFieldButton } from "./RemoveFieldButton"; + +export function FormFieldset({ + title, + children, + onRemove, +}: { title: string; children: React.ReactNode; onRemove: () => void }) { + return ( +
    + {title} +
    + {children} + +
    + +
    +
    +
    + ); +} diff --git a/app/components/form/MyForm.tsx b/app/components/form/MyForm.tsx new file mode 100644 index 000000000..5dbf6e30f --- /dev/null +++ b/app/components/form/MyForm.tsx @@ -0,0 +1,56 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFetcher } from "@remix-run/react"; +import * as React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { z } from "zod"; +import type { ActionError } from "~/utils/remix"; +import { SubmitButton } from "../SubmitButton"; + +export function MyForm({ + schema, + defaultValues, + title, + children, +}: { + schema: T; + defaultValues?: z.infer; + title?: string; + children: React.ReactNode; +}) { + const { t } = useTranslation(["common"]); + const fetcher = useFetcher(); + const methods = useForm>({ + resolver: zodResolver(schema), + defaultValues, + }); + + React.useEffect(() => { + if (!fetcher.data?.isError) return; + + const error = fetcher.data as ActionError; + + methods.setError(error.field as any, { + message: error.msg, + }); + }, [fetcher.data, methods.setError]); + + const onSubmit = React.useCallback( + methods.handleSubmit((values) => + fetcher.submit(values, { method: "post", encType: "application/json" }), + ), + [], + ); + + return ( + + + {title ?

    {title}

    : null} + {children} + + {t("common:actions.submit")} + +
    +
    + ); +} diff --git a/app/components/form/RemoveFieldButton.tsx b/app/components/form/RemoveFieldButton.tsx new file mode 100644 index 000000000..704d62692 --- /dev/null +++ b/app/components/form/RemoveFieldButton.tsx @@ -0,0 +1,14 @@ +import { Button } from "../Button"; +import { TrashIcon } from "../icons/Trash"; + +export function RemoveFieldButton({ onClick }: { onClick: () => void }) { + return ( +