Form system refactor from react-hook-form to one schema per form across the stack (#2735)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kalle 2026-01-18 18:21:19 +02:00 committed by GitHub
parent a004cf33b7
commit c20701d98c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
242 changed files with 11673 additions and 3795 deletions

6
.beans.yml Normal file
View File

@ -0,0 +1,6 @@
beans:
path: .beans
prefix: sendou.ink-2-
id_length: 4
default_status: todo
default_type: task

View File

@ -1,7 +1,4 @@
{
"enabledPlugins": {
"code-review@claude-plugins-official": true
},
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "beans prime" }] }
@ -9,5 +6,8 @@
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "beans prime" }] }
]
},
"enabledPlugins": {
"code-review@claude-plugins-official": true
}
}

View File

@ -13,7 +13,7 @@
- `npm run test:unit:browser` runs all unit tests and browser tests
- `npm run test:e2e` runs all e2e tests
- `npm run test:e2e:flaky-detect` runs all e2e tests and repeats each 10 times
- `npm run i18n:sync` syncs translation jsons with English and should always be run after adding new text to an English translation file
- `npm run i18n:sync` syncs translation jsons with English
## Typescript
@ -68,3 +68,15 @@
## Unit testing
- library used for unit testing is Vitest
- Vitest browser mode can be used to write tests for components
## Testing in Chrome
- some pages need authentication, you should impersonate "Sendou" user which can be done on the /admin page
## i18n
- by default everything should be translated via i18next
- some a11y labels or text that should not normally be encountered by user (example given, error message by server) can be english
- before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json)
- add only English translation and use `npm run i18n:sync` to initialize other jsons with empty string ready for translators

View File

@ -5,15 +5,21 @@ export function FormMessage({
children,
type,
className,
spaced = true,
id,
}: {
children: React.ReactNode;
type: "error" | "info";
className?: string;
spaced?: boolean;
id?: string;
}) {
return (
<div
id={id}
className={clsx(
{ "info-message": type === "info", "error-message": type === "error" },
{ "no-margin": !spaced },
className,
)}
>

View File

@ -1,5 +1,5 @@
.selectWidthWider {
--select-width: 250px;
--select-width: 100%;
}
.item {

View File

@ -46,6 +46,8 @@ interface WeaponSelectProps<
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
export function WeaponSelect<
@ -62,11 +64,20 @@ export function WeaponSelect<
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
const { t } = useTranslation(["common"]);
const selectedWeaponId: MainWeaponId | null =
typeof value === "number"
? (value as MainWeaponId)
: value && typeof value === "object" && value.type === "MAIN"
? (value.id as MainWeaponId)
: null;
const { items, filterValue, setFilterValue } = useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
});
const filter = useWeaponFilter();
@ -97,9 +108,10 @@ export function WeaponSelect<
aria-label={
!label ? t("common:forms.weaponSearch.placeholder") : undefined
}
isDisabled={isDisabled}
items={items}
label={label}
placeholder={t("common:forms.weaponSearch.placeholder")}
placeholder={placeholder ?? t("common:forms.weaponSearch.placeholder")}
search={{
placeholder: t("common:forms.weaponSearch.search.placeholder"),
}}
@ -217,9 +229,11 @@ function useWeaponFilter() {
function useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
}: {
includeSubSpecial: boolean | undefined;
quickSelectWeaponsIds?: Array<MainWeaponId>;
selectedWeaponId?: MainWeaponId | null;
}) {
const items = useAllWeaponCategories(includeSubSpecial);
const [filterValue, setFilterValue] = React.useState("");
@ -229,6 +243,11 @@ function useWeaponItems({
filterValue === "" && quickSelectWeaponsIds?.length;
if (showQuickSelectWeapons) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof selectedWeaponId === "number") {
weaponIdsToInclude.add(selectedWeaponId);
}
const quickSelectCategory = {
idx: 0,
key: "quick-select" as const,
@ -240,7 +259,7 @@ function useWeaponItems({
.filter((val) => val !== null),
)
.filter((item) =>
quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId),
weaponIdsToInclude.has(item.weapon.id as MainWeaponId),
)
.sort((a, b) => {
const aIdx = quickSelectWeaponsIds.indexOf(

View File

@ -4,18 +4,20 @@ import { SendouFieldMessage } from "~/components/elements/FieldMessage";
export function SendouBottomTexts({
bottomText,
errorText,
errorId,
}: {
bottomText?: string;
errorText?: string;
errorId?: string;
}) {
return (
<>
{errorText ? (
<SendouFieldError>{errorText}</SendouFieldError>
<SendouFieldError id={errorId}>{errorText}</SendouFieldError>
) : (
<SendouFieldError />
)}
{bottomText && !errorText ? (
{bottomText ? (
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
) : null}
</>

View File

@ -1,4 +1,3 @@
import clsx from "clsx";
import {
Button,
DateInput,
@ -12,10 +11,7 @@ import {
} from "react-aria-components";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouCalendar } from "~/components/elements/Calendar";
import {
type FormFieldSize,
formFieldSizeToClassName,
} from "../form/form-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { CalendarIcon } from "../icons/Calendar";
import { SendouLabel } from "./Label";
@ -24,29 +20,52 @@ interface SendouDatePickerProps<T extends DateValue>
label: string;
bottomText?: string;
errorText?: string;
size?: FormFieldSize;
errorId?: string;
}
export function SendouDatePicker<T extends DateValue>({
label,
errorText,
errorId,
bottomText,
size,
isRequired,
...rest
}: SendouDatePickerProps<T>) {
const isMounted = useIsMounted();
if (!isMounted) {
return (
<div>
<SendouLabel required={isRequired}>{label}</SendouLabel>
<input type="text" disabled />
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
</div>
);
}
return (
<ReactAriaDatePicker {...rest} validationBehavior="aria">
<ReactAriaDatePicker
{...rest}
validationBehavior="aria"
aria-label={label}
isInvalid={!!errorText}
>
<SendouLabel required={isRequired}>{label}</SendouLabel>
<Group
className={clsx("react-aria-Group", formFieldSizeToClassName(size))}
>
<Group className="react-aria-Group">
<DateInput>{(segment) => <DateSegment segment={segment} />}</DateInput>
<Button data-testid="open-calendar-button">
<CalendarIcon />
</Button>
</Group>
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
<Popover>
<Dialog>
<SendouCalendar />

View File

@ -1,8 +1,14 @@
import { FieldError as ReactAriaFieldError } from "react-aria-components";
export function SendouFieldError({ children }: { children?: React.ReactNode }) {
export function SendouFieldError({
children,
id,
}: {
children?: React.ReactNode;
id?: string;
}) {
return (
<ReactAriaFieldError className="error-message">
<ReactAriaFieldError className="error-message" id={id}>
{children}
</ReactAriaFieldError>
);

View File

@ -12,7 +12,7 @@
align-items: center;
justify-content: space-between;
gap: var(--s-1-5);
min-width: var(--select-width);
width: var(--select-width);
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
@ -50,8 +50,7 @@
.popover {
padding: var(--s-1);
min-width: var(--select-width);
max-width: var(--select-width);
width: var(--trigger-width);
border: 2px solid var(--border);
border-radius: var(--rounded);
background-color: var(--bg-darker);

View File

@ -82,7 +82,7 @@ export const UserSearch = React.forwardRef(function UserSearch<
placeholder=""
selectedKey={selectedKey}
onSelectionChange={onSelectionChange as (key: Key | null) => void}
aria-label="User search"
{...(label ? {} : { "aria-label": "User search" })}
{...rest}
>
{label ? (

View File

@ -1,21 +0,0 @@
import { useTranslation } from "react-i18next";
import { SendouButton } from "../elements/Button";
import { PlusIcon } from "../icons/Plus";
export function AddFieldButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
icon={<PlusIcon />}
aria-label="Add form field"
size="small"
variant="minimal"
onPress={onClick}
className="self-start"
data-testid="add-field-button"
>
{t("common:actions.add")}
</SendouButton>
);
}

View File

@ -1,92 +0,0 @@
import type { CalendarDateTime } from "@internationalized/date";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { dateToDateValue, dayMonthYearToDateValue } from "../../utils/dates";
import type { DayMonthYear } from "../../utils/zod";
import { SendouDatePicker } from "../elements/DatePicker";
import type { FormFieldSize } from "./form-utils";
export function DateFormField<T extends FieldValues>({
label,
name,
bottomText,
required,
size,
granularity = "day",
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
required?: boolean;
size?: FormFieldSize;
granularity?: "day" | "minute";
}) {
const methods = useFormContext();
return (
<Controller
name={name}
control={methods.control}
render={({
field: { name, value, onChange, onBlur /*, ref*/ }, // TODO: figure out where ref goes (to focus on error) and put it there
fieldState: { invalid, error },
}) => {
const getValue = () => {
const originalValue = value as DayMonthYear | Date | null;
if (!originalValue) return null;
if (originalValue instanceof Date) {
return dateToDateValue(originalValue);
}
return dayMonthYearToDateValue(originalValue as DayMonthYear);
};
return (
<SendouDatePicker
label={label}
granularity={granularity}
isRequired={required}
errorText={error?.message as string | undefined}
value={getValue()}
size={size}
isInvalid={invalid}
name={name}
onBlur={onBlur}
onChange={(value) => {
if (value) {
if (granularity === "minute") {
onChange(
new Date(
value.year,
value.month - 1,
value.day,
(value as CalendarDateTime).hour,
(value as CalendarDateTime).minute,
),
);
} else {
onChange({
day: value.day,
month: value.month - 1,
year: value.year,
});
}
}
if (!value) {
onChange(null);
}
}}
bottomText={bottomText}
/>
);
}}
/>
);
}

View File

@ -1,25 +0,0 @@
import type * as React from "react";
import { RemoveFieldButton } from "./RemoveFieldButton";
export function FormFieldset({
title,
children,
onRemove,
}: {
title: string;
children: React.ReactNode;
onRemove: () => void;
}) {
return (
<fieldset className="w-min">
<legend>{title}</legend>
<div className="stack sm">
{children}
<div className="mt-4 stack items-center">
<RemoveFieldButton onClick={onRemove} />
</div>
</div>
</fieldset>
);
}

View File

@ -1,54 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils";
export function InputFormField<T extends FieldValues>({
label,
name,
bottomText,
placeholder,
required,
size = "small",
type,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
placeholder?: string;
required?: boolean;
size?: FormFieldSize;
type?: React.HTMLInputTypeAttribute;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
<input
id={id}
placeholder={placeholder}
type={type}
{...methods.register(name)}
className={formFieldSizeToClassName(size)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@ -1,126 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
interface InputGroupFormFieldProps<T extends FieldValues> {
label: string;
name: FieldPath<T>;
bottomText?: string;
direction?: "horizontal" | "vertical";
type: "checkbox" | "radio";
values: Array<{
label: string;
value: string;
}>;
}
export function InputGroupFormField<T extends FieldValues>({
label,
name,
bottomText,
values,
type,
direction = "vertical",
}: InputGroupFormFieldProps<T>) {
const methods = useFormContext();
return (
<Controller
name={name}
control={methods.control}
render={({
field: { name, value, onChange, ref },
fieldState: { error },
}) => {
const handleCheckboxChange =
(name: string) => (newChecked: boolean) => {
const newValue = newChecked
? [...(value || []), name]
: value?.filter((v: string) => v !== name);
onChange(newValue);
};
const handleRadioChange = (name: string) => () => {
onChange(name);
};
return (
<div>
<fieldset
className={clsx("stack sm", {
"horizontal md": direction === "horizontal",
})}
ref={ref}
>
<legend>{label}</legend>
{values.map((checkbox) => {
const isChecked = value?.includes(checkbox.value);
return (
<GroupInput
key={checkbox.value}
type={type}
name={name}
checked={isChecked}
onChange={
type === "checkbox"
? handleCheckboxChange(checkbox.value)
: handleRadioChange(checkbox.value)
}
>
{checkbox.label}
</GroupInput>
);
})}
</fieldset>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}}
/>
);
}
function GroupInput({
children,
name,
checked,
onChange,
type,
}: {
children: React.ReactNode;
name: string;
checked: boolean;
onChange: (newChecked: boolean) => void;
type: "checkbox" | "radio";
}) {
const id = React.useId();
return (
<div className="stack horizontal sm items-center">
<input
type={type}
id={id}
name={name}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<label htmlFor={id} className="mb-0">
{children}
</label>
</div>
);
}

View File

@ -1,14 +0,0 @@
import { SendouButton } from "../elements/Button";
import { TrashIcon } from "../icons/Trash";
export function RemoveFieldButton({ onClick }: { onClick: () => void }) {
return (
<SendouButton
icon={<TrashIcon />}
aria-label="Remove form field"
size="small"
variant="minimal-destructive"
onPress={onClick}
/>
);
}

View File

@ -1,56 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { type FormFieldSize, formFieldSizeToClassName } from "./form-utils";
export function SelectFormField<T extends FieldValues>({
label,
name,
values,
bottomText,
size,
required,
}: {
label: string;
name: FieldPath<T>;
values: Array<{ value: string | number; label: string }>;
bottomText?: string;
size?: FormFieldSize;
required?: boolean;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
<select
{...methods.register(name)}
id={id}
className={formFieldSizeToClassName(size)}
>
{values.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@ -1,80 +0,0 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as React from "react";
import { type DefaultValues, FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { z } from "zod";
import { logger } from "~/utils/logger";
import type { ActionError } from "~/utils/remix.server";
import { LinkButton } from "../elements/Button";
import { SubmitButton } from "../SubmitButton";
export function SendouForm<T extends z.ZodTypeAny>({
schema,
defaultValues,
heading,
children,
cancelLink,
submitButtonTestId,
}: {
schema: T;
defaultValues?: DefaultValues<z.infer<T>>;
heading?: string;
children: React.ReactNode;
cancelLink?: string;
submitButtonTestId?: string;
}) {
const { t } = useTranslation(["common"]);
const fetcher = useFetcher<any>();
const methods = useForm({
resolver: standardSchemaResolver(schema as any),
defaultValues,
});
if (methods.formState.isSubmitted && methods.formState.errors) {
logger.error(methods.formState.errors);
}
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 as Parameters<typeof fetcher.submit>[0], {
method: "post",
encType: "application/json",
}),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
{heading ? <h1 className="text-lg">{heading}</h1> : null}
{children}
<div className="stack horizontal lg justify-between mt-6 w-full">
<SubmitButton state={fetcher.state} testId={submitButtonTestId}>
{t("common:actions.submit")}
</SubmitButton>
{cancelLink ? (
<LinkButton
variant="minimal-destructive"
to={cancelLink}
size="small"
>
{t("common:actions.cancel")}
</LinkButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
);
}

View File

@ -1,46 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
useWatch,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
export function TextAreaFormField<T extends FieldValues>({
label,
name,
bottomText,
maxLength,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
maxLength: number;
}) {
const methods = useFormContext();
const value = useWatch({ name }) ?? "";
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label
htmlFor={id}
valueLimits={{ current: value.length, max: maxLength }}
>
{label}
</Label>
<textarea id={id} {...methods.register(name)} />
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@ -1,81 +0,0 @@
import {
type FieldPath,
type FieldValues,
useFieldArray,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { AddFieldButton } from "./AddFieldButton";
import { RemoveFieldButton } from "./RemoveFieldButton";
export function TextArrayFormField<T extends FieldValues>({
label,
name,
bottomText,
/** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */
format = "plain",
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
format?: "plain" | "object";
}) {
const {
register,
formState: { errors },
clearErrors,
} = useFormContext();
const { fields, append, remove } = useFieldArray({
name,
});
const rootError = errors[name]?.root;
return (
<div>
<Label>{label}</Label>
<div className="stack md">
{fields.map((field, index) => {
// @ts-expect-error
const error = errors[name]?.[index]?.value;
return (
<div key={field.id}>
<div className="stack horizontal md">
<input
{...register(
format === "plain"
? `${name}.${index}`
: `${name}.${index}.value`,
)}
/>
<RemoveFieldButton
onClick={() => {
remove(index);
clearErrors(`${name}.root`);
}}
/>
</div>
{error && (
<FormMessage type="error">
{error.message as string}
</FormMessage>
)}
</div>
);
})}
<AddFieldButton
// @ts-expect-error
onClick={() => append(format === "plain" ? "" : { value: "" })}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>
)}
{bottomText && !rootError ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
</div>
);
}

View File

@ -1,49 +0,0 @@
import * as React from "react";
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { SendouSwitch } from "../elements/Switch";
export function ToggleFormField<T extends FieldValues>({
label,
name,
bottomText,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
render={({ field: { value, onChange } }) => (
<SendouSwitch
id={id}
isSelected={value ?? false}
onChange={onChange}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@ -1,47 +0,0 @@
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { UserSearch } from "../elements/UserSearch";
export function UserSearchFormField<T extends FieldValues>({
label,
name,
bottomText,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
}) {
const methods = useFormContext();
const error = get(methods.formState.errors, name);
return (
<div>
<Controller
control={methods.control}
name={name}
render={({ field: { onChange, onBlur, value, ref } }) => (
<UserSearch
onChange={(newUser) => onChange(newUser?.id)}
initialUserId={value}
onBlur={onBlur}
ref={ref}
label={label}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@ -1,11 +0,0 @@
import clsx from "clsx";
export type FormFieldSize = "extra-small" | "small" | "medium";
export function formFieldSizeToClassName(size?: FormFieldSize) {
return clsx({
"input__extra-small": size === "extra-small",
input__small: size === "small",
input__medium: size === "medium",
});
}

View File

@ -309,13 +309,11 @@ describe("Account migration", () => {
it("two accounts with teams results in an error", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 1,
isMainTeam: true,
});
await TeamRepository.create({
customUrl: "team-2",
name: "Team 2",
ownerUserId: 2,
isMainTeam: true,
@ -335,7 +333,6 @@ describe("Account migration", () => {
it("deletes past team membership status of the new user", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 2,
isMainTeam: true,
@ -354,7 +351,6 @@ describe("Account migration", () => {
it("handles old user member of the same team as new user (old user has left the team, new user current)", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 2,
isMainTeam: true,

View File

@ -9,13 +9,13 @@ import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { cache } from "~/utils/cache.server";
import { logger } from "~/utils/logger";
import { parseRequestPayload } from "~/utils/remix.server";
const E2E_SEEDS_DIR = "e2e/seeds";
const seedSchema = z.object({
variation: z.enum(SEED_VARIATIONS).nullish(),
source: z.enum(["e2e"]).nullish(),
});
export type SeedVariation = NonNullable<
@ -27,7 +27,7 @@ export const action: ActionFunction = async ({ request }) => {
throw new Response(null, { status: 400 });
}
const { variation } = await parseRequestPayload({
const { variation, source } = await parseRequestPayload({
request,
schema: seedSchema,
});
@ -38,16 +38,14 @@ export const action: ActionFunction = async ({ request }) => {
`db-seed-${variationName}.sqlite3`,
);
if (!fs.existsSync(preSeededDbPath)) {
// Fall back to slow seed if pre-seeded db doesn't exist
logger.warn(
`Pre-seeded database not found for variation "${variationName}", falling back to seeding via code.`,
);
const { seed } = await import("~/db/seed");
await seed(variation);
} else {
const usePreSeeded = source === "e2e" && fs.existsSync(preSeededDbPath);
if (usePreSeeded) {
restoreFromPreSeeded(preSeededDbPath);
adjustSeedDatesToCurrent(variationName);
} else {
const { seed } = await import("~/db/seed");
await seed(variation);
}
clearAllTournamentDataCache();

View File

@ -2,6 +2,8 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { LimitReachedError } from "~/utils/errors";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
@ -137,6 +139,22 @@ export function insert({ userId, ...associationArgs }: InsertArgs) {
.insertInto("AssociationMember")
.values({ userId, associationId: association.id, role: "ADMIN" })
.execute();
const { count, patronTier } = await trx
.selectFrom("AssociationMember")
.innerJoin("User", "User.id", "AssociationMember.userId")
.select((eb) => [eb.fn.countAll<number>().as("count"), "User.patronTier"])
.where("AssociationMember.userId", "=", userId)
.executeTakeFirstOrThrow();
const maxCount =
(patronTier ?? 0) >= 2
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
if (count > maxCount) {
throw new LimitReachedError("Max amount of associations reached");
}
});
}

View File

@ -1,36 +1,33 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { requireUser } from "~/features/auth/core/user.server";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { LimitReachedError } from "~/utils/errors";
import { associationsPage } from "~/utils/urls";
import * as AssociationRepository from "../AssociationRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: createNewAssociationSchema,
});
const associationCount = (
await AssociationRepository.findByMemberUserId(user.id)
).actual;
const maxAssociationCount = user.roles.includes("SUPPORTER")
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
if (associationCount.length >= maxAssociationCount) {
return actionError<typeof createNewAssociationSchema>({
msg: `Regular users can only be a member of ${maxAssociationCount} associations (supporters ${ASSOCIATION.MAX_COUNT_SUPPORTER})`,
field: "name",
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
await AssociationRepository.insert({
name: data.name,
userId: user.id,
});
try {
await AssociationRepository.insert({
name: result.data.name,
userId: user.id,
});
} catch (error) {
if (error instanceof LimitReachedError) {
return { fieldErrors: { name: "forms:errors.maxAssociationsReached" } };
}
throw error;
}
return redirect(associationsPage());
};

View File

@ -1,9 +1,13 @@
import { z } from "zod";
import { _action, id, inviteCode, safeStringSchema } from "~/utils/zod";
import { textFieldRequired } from "~/form/fields";
import { _action, id, inviteCode } from "~/utils/zod";
import { ASSOCIATION } from "./associations-constants";
export const createNewAssociationSchema = z.object({
name: safeStringSchema({ max: 100 }),
name: textFieldRequired({
label: "labels.name",
maxLength: 100,
}),
});
const removeMemberSchema = z.object({

View File

@ -1,19 +1,15 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { SendouForm } from "~/form/SendouForm";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage } from "~/utils/urls";
import { action } from "../actions/associations.new.server";
export { action };
type FormFields = z.infer<typeof createNewAssociationSchema>;
export const handle: SendouRouteHandle = {
i18n: "scrims",
i18n: ["scrims"],
};
export default function AssociationsNewPage() {
@ -24,16 +20,8 @@ export default function AssociationsNewPage() {
heading={t("scrims:associations.forms.title")}
onCloseTo={associationsPage()}
>
<SendouForm
schema={createNewAssociationSchema}
defaultValues={{
name: "",
}}
>
<InputFormField<FormFields>
label={t("scrims:associations.forms.name.title")}
name="name"
/>
<SendouForm schema={createNewAssociationSchema}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
</SendouDialog>
);

View File

@ -46,23 +46,29 @@ export function BadgesSelector({
</div>
)}
{showSelect ? (
<select
onBlur={onBlur}
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>
{options
.filter((badge) => !selectedBadges.includes(badge.id))
.map((badge) => (
<option key={badge.id} value={badge.id}>
{badge.displayName}
</option>
))}
</select>
options.length === 0 ? (
<div className="text-warning text-xs">
{t("common:badges.selector.noneAvailable")}
</div>
) : (
<select
onBlur={() => onBlur?.()}
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>
{options
.filter((badge) => !selectedBadges.includes(badge.id))
.map((badge) => (
<option key={badge.id} value={badge.id}>
{badge.displayName}
</option>
))}
</select>
)
) : null}
</div>
);

View File

@ -10,8 +10,10 @@ import type {
ModeShort,
} from "~/modules/in-game-lists/types";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import { LimitReachedError } from "~/utils/errors";
import invariant from "~/utils/invariant";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { BUILD } from "./builds-constants";
import { sortAbilities } from "./core/ability-sorting.server";
export async function allByUserId(
@ -91,9 +93,9 @@ interface CreateArgs {
title: TablesInsertable["Build"]["title"];
description: TablesInsertable["Build"]["description"];
modes: Array<ModeShort> | null;
headGearSplId: TablesInsertable["Build"]["headGearSplId"];
clothesGearSplId: TablesInsertable["Build"]["clothesGearSplId"];
shoesGearSplId: TablesInsertable["Build"]["shoesGearSplId"];
headGearSplId: number | null;
clothesGearSplId: number | null;
shoesGearSplId: number | null;
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
abilities: BuildAbilitiesTuple;
private: TablesInsertable["Build"]["private"];
@ -120,9 +122,9 @@ async function createInTrx({
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
)
: null,
headGearSplId: args.headGearSplId,
clothesGearSplId: args.clothesGearSplId,
shoesGearSplId: args.shoesGearSplId,
headGearSplId: args.headGearSplId ?? -1,
clothesGearSplId: args.clothesGearSplId ?? -1,
shoesGearSplId: args.shoesGearSplId ?? -1,
private: args.private,
})
.returningAll()
@ -179,7 +181,19 @@ async function createInTrx({
}
export async function create(args: CreateArgs) {
return db.transaction().execute(async (trx) => createInTrx({ args, trx }));
return db.transaction().execute(async (trx) => {
await createInTrx({ args, trx });
const { count } = await trx
.selectFrom("Build")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("ownerId", "=", args.ownerId)
.executeTakeFirstOrThrow();
if (count > BUILD.MAX_COUNT) {
throw new LimitReachedError("Max amount of builds reached");
}
});
}
export async function update(args: CreateArgs & { id: number }) {
@ -193,6 +207,16 @@ export function deleteById(id: number) {
return db.deleteFrom("Build").where("id", "=", id).execute();
}
export async function ownerIdById(buildId: number) {
const result = await db
.selectFrom("Build")
.select("ownerId")
.where("id", "=", buildId)
.executeTakeFirstOrThrow();
return result.ownerId;
}
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
return db
.selectFrom("BuildAbility")

View File

@ -60,10 +60,6 @@ export const PATCHES = [
];
export const BUILD = {
TITLE_MIN_LENGTH: 1,
TITLE_MAX_LENGTH: 50,
DESCRIPTION_MAX_LENGTH: 280,
MAX_WEAPONS_COUNT: 5,
MAX_COUNT: 250,
} as const;

View File

@ -40,7 +40,6 @@ export const action: ActionFunction = async ({ request }) => {
const data = await parseFormData({
formData,
schema: newCalendarEventActionSchema,
parseAsync: true,
});
const isEditing = Boolean(data.eventToEditId);

View File

@ -3,7 +3,15 @@ import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import "~/styles/calendar-new.css";
import {
array,
checkboxGroup,
numberFieldOptional,
radioGroup,
textFieldOptional,
toggle,
userSearchOptional,
} from "~/form/fields";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import {
@ -50,21 +58,102 @@ export const calendarFiltersSearchParamsSchema = z.object({
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
});
const TAGS_TO_OMIT: CalendarEventTag[] = [
"CARDS",
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",
];
const filterTags = CALENDAR_EVENT.TAGS.filter(
(tag) => !TAGS_TO_OMIT.includes(tag),
);
const tagItems = filterTags.map((tag) => ({
label: `options.tag.${tag}` as const,
value: tag,
}));
export const calendarFiltersFormSchema = z
.object({
preferredStartTime: preferredStartTime,
tagsIncluded: z.array(calendarEventTagSchema),
tagsExcluded: z.array(calendarEventTagSchema),
isSendou: z.boolean(),
isRanked: z.boolean(),
orgsIncluded: calendarFiltersPlainStringArr,
orgsExcluded: calendarFiltersPlainStringArr,
authorIdsExcluded: calendarFiltersIdsArr,
games: calendarFilterGamesArr,
preferredVersus: preferredVersus,
modes: modeArr,
modesExact: z.boolean(),
minTeamCount: z.coerce.number().int().nonnegative(),
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
{ label: "modes.TC", value: "TC" },
{ label: "modes.RM", value: "RM" },
{ label: "modes.CB", value: "CB" },
{ label: () => "Salmon Run", value: "SR" },
{ label: () => "Tricolor", value: "TB" },
],
minLength: 1,
}),
modesExact: toggle({
label: "labels.modesExact",
bottomText: "bottomTexts.modesExact",
}),
games: checkboxGroup({
label: "labels.games",
items: [
{ label: "options.game.S1", value: "S1" },
{ label: "options.game.S2", value: "S2" },
{ label: "options.game.S3", value: "S3" },
],
minLength: 1,
}),
preferredVersus: checkboxGroup({
label: "labels.vs",
items: [
{ label: () => "4v4", value: "4v4" },
{ label: () => "3v3", value: "3v3" },
{ label: () => "2v2", value: "2v2" },
{ label: () => "1v1", value: "1v1" },
],
minLength: 1,
}),
preferredStartTime: radioGroup({
label: "labels.startTime",
items: [
{ label: "options.startTime.any", value: "ANY" },
{ label: "options.startTime.eu", value: "EU" },
{ label: "options.startTime.na", value: "NA" },
{ label: "options.startTime.au", value: "AU" },
],
}),
tagsIncluded: checkboxGroup({
label: "labels.tagsIncluded",
items: tagItems,
}),
tagsExcluded: checkboxGroup({
label: "labels.tagsExcluded",
items: tagItems,
}),
isSendou: toggle({ label: "labels.onlySendouEvents" }),
isRanked: toggle({ label: "labels.onlyRankedEvents" }),
minTeamCount: numberFieldOptional({
label: "labels.minTeamCount",
}),
orgsIncluded: array({
label: "labels.orgsIncluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
orgsExcluded: array({
label: "labels.orgsExcluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
authorIdsExcluded: array({
label: "labels.authorIdsExcluded",
field: userSearchOptional({}),
max: 10,
}),
})
.superRefine((filters, ctx) => {
if (

View File

@ -1,21 +1,16 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { FilterFilledIcon } from "~/components/icons/FilterFilled";
import { SubmitButton } from "~/components/SubmitButton";
import type { CalendarEventTag } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import { TagsFormField } from "~/features/calendar/components/TagsFormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
type FormValues = z.infer<typeof calendarFiltersFormSchema>;
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
const { t } = useTranslation(["calendar"]);
@ -47,18 +42,6 @@ export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
);
}
const TAGS_TO_OMIT: Array<CalendarEventTag> = [
"CARDS",
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",
] as const;
function FiltersForm({
filters,
closeDialog,
@ -67,156 +50,58 @@ function FiltersForm({
closeDialog: () => void;
}) {
const user = useUser();
const { t } = useTranslation(["game-misc", "calendar"]);
const methods = useForm({
resolver: standardSchemaResolver(calendarFiltersFormSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const { t } = useTranslation(["calendar"]);
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: CalendarFilters) => {
const handleApply = (values: FormValues) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
prev.set("filters", JSON.stringify(values));
return prev;
});
closeDialog();
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values as CalendarFilters);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(values as Parameters<typeof fetcher.submit>[0], {
method: "post",
encType: "application/json",
}),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.modes")}
name={"modes" as const}
values={[
{ label: t("game-misc:MODE_LONG_TW"), value: "TW" },
{ label: t("game-misc:MODE_LONG_SZ"), value: "SZ" },
{ label: t("game-misc:MODE_LONG_TC"), value: "TC" },
{ label: t("game-misc:MODE_LONG_RM"), value: "RM" },
{ label: t("game-misc:MODE_LONG_CB"), value: "CB" },
{ label: t("game-misc:MODE_LONG_SR"), value: "SR" },
{ label: t("game-misc:MODE_LONG_TB"), value: "TB" },
]}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.exactModes")}
name={"modesExact" as const}
bottomText={t("calendar:filter.exactModesBottom")}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.games")}
name={"games" as const}
values={[
{ label: t("game-misc:GAME_S1"), value: "S1" },
{ label: t("game-misc:GAME_S2"), value: "S2" },
{ label: t("game-misc:GAME_S3"), value: "S3" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.vs")}
name={"preferredVersus" as const}
values={[
{ label: "4v4", value: "4v4" },
{ label: "3v3", value: "3v3" },
{ label: "2v2", value: "2v2" },
{ label: "1v1", value: "1v1" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="radio"
label={t("calendar:filter.startTime")}
name={"preferredStartTime" as const}
values={[
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
{ label: t("calendar:filter.startTime.na"), value: "NA" },
{ label: t("calendar:filter.startTime.au"), value: "AU" },
]}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsIncluded")}
name={"tagsIncluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsExcluded")}
name={"tagsExcluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isSendou")}
name={"isSendou" as const}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isRanked")}
name={"isRanked" as const}
/>
<InputFormField<CalendarFilters>
label={t("calendar:filter.minTeamCount")}
type="number"
name={"minTeamCount" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsIncluded")}
name={"orgsIncluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsExcluded")}
name={"orgsExcluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.authorIdsExcluded")}
name={"authorIdsExcluded" as const}
bottomText={t("calendar:filter.authorIdsExcludedBottom")}
/>
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("calendar:filter.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("calendar:filter.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
<SendouForm
schema={calendarFiltersFormSchema}
defaultValues={filters as unknown as FormValues}
onApply={handleApply}
submitButtonText={t("calendar:filter.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="modes" />
<FormField name="modesExact" />
<FormField name="games" />
<FormField name="preferredVersus" />
<FormField name="preferredStartTime" />
<FormField name="tagsIncluded" />
<FormField name="tagsExcluded" />
<FormField name="isSendou" />
<FormField name="isRanked" />
<FormField name="minTeamCount" />
<FormField name="orgsIncluded" />
<FormField name="orgsExcluded" />
<FormField name="authorIdsExcluded" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["calendar"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
return (
<SendouButton
variant="outlined"
onPress={() => submitToServer(values as CalendarFilters)}
isDisabled={fetcherState !== "idle"}
>
{t("calendar:filter.applyAndDefault")}
</SendouButton>
);
}

View File

@ -1,101 +0,0 @@
import * as React from "react";
import { Tag, TagGroup, TagList } from "react-aria-components";
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import type { CalendarEventTag } from "~/db/tables";
import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants";
import { tags as allTags } from "../calendar-constants";
import styles from "./TagsFormField.module.css";
export function TagsFormField<T extends FieldValues>({
label,
name,
bottomText,
tagsToOmit,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
tagsToOmit?: Array<CalendarEventTag>;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div className="w-full">
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
render={({ field: { onChange, value, ref } }) => (
<SelectableTags
selectedTags={value}
onSelectionChange={onChange}
tagsToOmit={tagsToOmit}
ref={ref}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}
const SelectableTags = React.forwardRef<
HTMLDivElement,
{
selectedTags: Array<CalendarEventTag>;
tagsToOmit?: Array<CalendarEventTag>;
onSelectionChange: (selectedTags: Array<CalendarEventTag>) => void;
}
>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
const { t } = useTranslation();
const availableTags = tagsToOmit
? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
: CALENDAR_EVENT.TAGS;
return (
<TagGroup
className={styles.tagGroup}
selectionMode="multiple"
selectedKeys={selectedTags}
onSelectionChange={(newSelection) =>
onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
}
aria-label="Select tags"
ref={ref}
>
<TagList className={styles.tagList}>
{availableTags.map((tag) => {
return (
<Tag
key={tag}
id={tag}
className={styles.tag}
style={{ "--tag-color": allTags[tag].color }}
>
{t(`tag.name.${tag}`)}
</Tag>
);
})}
</TagList>
</TagGroup>
);
});

View File

@ -31,14 +31,12 @@ const createImage = async ({
const createTeam = async (ownerUserId: number) => {
teamCounter++;
const customUrl = `team-${teamCounter}`;
await TeamRepository.create({
const createdTeam = await TeamRepository.create({
name: `Team ${teamCounter}`,
customUrl,
ownerUserId,
isMainTeam: true,
});
const team = await TeamRepository.findByCustomUrl(customUrl);
const team = await TeamRepository.findByCustomUrl(createdTeam.customUrl);
if (!team) throw new Error("Team not found after creation");
return team;
};

View File

@ -19,8 +19,10 @@ export const list =
!process.env.NODE_ENV ||
IS_E2E_TEST_RUN ||
// this gets checked when the project is running
// import.meta.env is undefined when Playwright bundles test code
(process.env.NODE_ENV === "development" &&
import.meta.env.VITE_PROD_MODE !== "true")
(typeof import.meta.env === "undefined" ||
import.meta.env.VITE_PROD_MODE !== "true"))
? ([
{
nth: 0,

View File

@ -5,35 +5,42 @@ import type { Tables } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
actionError,
errorToast,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { scrimsPage } from "~/utils/urls";
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { SCRIM } from "../scrims-constants";
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import {
type fromSchema,
type newRequestSchema,
type RANGE_END_OPTIONS,
scrimsNewActionSchema,
scrimsNewFormSchema,
} from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
import { serializeLutiDiv } from "../scrims-utils";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: scrimsNewActionSchema,
schema: scrimsNewFormSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
if (data.from.mode === "PICKUP") {
if (data.from.users.includes(user.id)) {
return actionError<typeof newRequestSchema>({
@ -55,11 +62,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
? resolveRangeEndToDate(data.at, data.rangeEnd)
: null;
const resolvedDivs = data.divs ? resolveDivs(data.divs) : null;
await ScrimPostRepository.insert({
at: dateToDatabaseTimestamp(data.at),
rangeEnd: rangeEndDate ? dateToDatabaseTimestamp(rangeEndDate) : null,
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
maxDiv: resolvedDivs?.[0] ? serializeLutiDiv(resolvedDivs[0]) : null,
minDiv: resolvedDivs?.[1] ? serializeLutiDiv(resolvedDivs[1]) : null,
text: data.postText,
managedByAnyone: data.managedByAnyone,
maps:
@ -214,3 +223,18 @@ function resolveRangeEndToDate(
}
}
}
function resolveDivs(
divs: [LutiDiv | null, LutiDiv | null],
): [LutiDiv | null, LutiDiv | null] {
const [max, min] = divs;
if (!max || !min) return divs;
const maxIndex = LUTI_DIVS.indexOf(max);
const minIndex = LUTI_DIVS.indexOf(min);
if (minIndex < maxIndex) {
return [min, max];
}
return divs;
}

View File

@ -1,104 +0,0 @@
import type * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Label } from "~/components/Label";
import { FormMessage } from "../../../components/FormMessage";
import { LUTI_DIVS } from "../scrims-constants";
import type { LutiDiv } from "../scrims-types";
export function LutiDivsFormField() {
const methods = useFormContext();
const error = methods.formState.errors.divs;
return (
<div>
<Controller
control={methods.control}
name="divs"
render={({ field: { onChange, onBlur, value } }) => (
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
type LutiDivEdit = {
max: LutiDiv | null;
min: LutiDiv | null;
};
function LutiDivsSelector({
value,
onChange,
onBlur,
}: {
value: LutiDivEdit | null;
onChange: (value: LutiDivEdit | null) => void;
onBlur: () => void;
}) {
const { t } = useTranslation(["scrims"]);
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.max
? { min: newValue, max: value?.max ?? null }
: null,
);
};
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.min
? { max: newValue, min: value?.min ?? null }
: null,
);
};
return (
<div className="stack horizontal sm">
<div>
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
<select
id="max-div"
value={value?.max ?? ""}
onChange={onChangeMax}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
<select
id="min-div"
value={value?.min ?? ""}
onChange={onChangeMin}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
</div>
);
}

View File

@ -1,17 +1,17 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { FilterFilledIcon } from "~/components/icons/FilterFilled";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import { scrimsFiltersSchema } from "../scrims-schemas";
import { LutiDivsFormField } from "./LutiDivsFormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { scrimsFiltersFormSchema } from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
type FormValues = z.infer<typeof scrimsFiltersFormSchema>;
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
const { t } = useTranslation(["scrims"]);
@ -44,6 +44,26 @@ export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
);
}
function filtersToFormValues(filters: ScrimFilters): FormValues {
return {
weekdayTimes: filters.weekdayTimes,
weekendTimes: filters.weekendTimes,
divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null],
};
}
function formValuesToFilters(values: FormValues): ScrimFilters {
const [max, min] = values.divs ?? [null, null];
return {
weekdayTimes: values.weekdayTimes,
weekendTimes: values.weekendTimes,
divs:
max || min
? { max: max as LutiDiv | null, min: min as LutiDiv | null }
: null,
};
}
function FiltersForm({
filters,
closeDialog,
@ -53,96 +73,56 @@ function FiltersForm({
}) {
const user = useUser();
const { t } = useTranslation(["scrims"]);
const methods = useForm({
resolver: standardSchemaResolver(scrimsFiltersSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: ScrimFilters) => {
const defaultValues = filtersToFormValues(filters);
const handleApply = (values: FormValues) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
prev.set("filters", JSON.stringify(formValuesToFilters(values)));
return prev;
});
closeDialog();
};
return (
<SendouForm
schema={scrimsFiltersFormSchema}
defaultValues={defaultValues}
onApply={handleApply}
submitButtonText={t("scrims:filters.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="weekdayTimes" />
<FormField name="weekendTimes" />
<FormField name="divs" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["scrims"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
const handlePress = () => {
submitToServer({
_action: "PERSIST_SCRIM_FILTERS",
filters: formValuesToFilters(values as FormValues),
});
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values as ScrimFilters);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(
// @ts-expect-error TODO: fix
{
_action: "PERSIST_SCRIM_FILTERS",
filters: values as Parameters<typeof fetcher.submit>[0],
},
{
method: "post",
encType: "application/json",
},
),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<input type="hidden" name="_action" value="PERSIST_SCRIM_FILTERS" />
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayStart")}
name={"weekdayTimes.start" as const}
type="time"
size="extra-small"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayEnd")}
name={"weekdayTimes.end" as const}
type="time"
size="extra-small"
/>
</div>
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendStart")}
name={"weekendTimes.start" as const}
type="time"
size="extra-small"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendEnd")}
name={"weekendTimes.end" as const}
type="time"
size="extra-small"
/>
</div>
<LutiDivsFormField />
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("scrims:filters.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("scrims:filters.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
<SendouButton
variant="outlined"
onPress={handlePress}
isDisabled={fetcherState !== "idle"}
>
{t("scrims:filters.applyAndDefault")}
</SendouButton>
);
}

View File

@ -2,16 +2,14 @@ import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import type { NewRequestFormFields } from "../routes/scrims";
import { SCRIM } from "../scrims-constants";
import { newRequestSchema } from "../scrims-schemas";
import { scrimRequestFormSchema } from "../scrims-schemas";
import type { ScrimPost } from "../scrims-types";
import { generateTimeOptions } from "../scrims-utils";
import { WithFormField } from "./WithFormField";
@ -32,7 +30,7 @@ export function ScrimRequestModal({
databaseTimestampToDate(post.at),
databaseTimestampToDate(post.rangeEnd),
).map((timestamp) => ({
value: timestamp,
value: String(timestamp),
label: formatTime(new Date(timestamp)),
}))
: [];
@ -40,9 +38,8 @@ export function ScrimRequestModal({
return (
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
<SendouForm
schema={newRequestSchema}
schema={scrimRequestFormSchema}
defaultValues={{
_action: "NEW_REQUEST",
scrimPostId: post.id,
from:
data.teams.length > 0
@ -54,32 +51,31 @@ export function ScrimRequestModal({
) as unknown as number[],
},
message: "",
at: post.rangeEnd ? (timeOptions[0]?.value as unknown as Date) : null,
at: post.rangeEnd && timeOptions[0] ? timeOptions[0].value : null,
}}
>
<div className="font-semi-bold text-lighter italic">
{new Intl.ListFormat(i18n.language).format(
post.users.map((u) => u.username),
)}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
{post.rangeEnd ? (
<SelectFormField<NewRequestFormFields>
name="at"
label={t("scrims:requestModal.at.label")}
bottomText={t("scrims:requestModal.at.explanation")}
values={timeOptions}
/>
) : null}
<TextAreaFormField<NewRequestFormFields>
name="message"
label={t("scrims:requestModal.message.label")}
maxLength={SCRIM.REQUEST_MESSAGE_MAX_LENGTH}
/>
{({ FormField }) => (
<>
<div className="font-semi-bold text-lighter italic">
{new Intl.ListFormat(i18n.language).format(
post.users.map((u) => u.username),
)}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<FormField name="from">
{(props: CustomFieldRenderProps) => (
<WithFormField usersTeams={data.teams} {...props} />
)}
</FormField>
{post.rangeEnd ? (
<FormField name="at" options={timeOptions} />
) : null}
<FormField name="message" />
</>
)}
</SendouForm>
</SendouDialog>
);

View File

@ -1,107 +1,117 @@
import { Controller, useFormContext } from "react-hook-form";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { useUser } from "~/features/auth/core/user";
import { SCRIM } from "~/features/scrims/scrims-constants";
import {
FormFieldWrapper,
useTranslatedTexts,
} from "~/form/fields/FormFieldWrapper";
import { errorMessageId } from "~/form/utils";
import { nullFilledArray } from "~/utils/arrays";
import type { CommonUser } from "~/utils/kysely.server";
import type { NewRequestFormFields } from "../routes/scrims";
import type { fromSchema } from "../scrims-schemas";
interface FromFormFieldProps {
type FromValue = z.infer<typeof fromSchema>;
interface WithFormFieldProps {
usersTeams: Array<{
id: number;
name: string;
members: Array<CommonUser>;
}>;
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}
export function WithFormField({ usersTeams }: FromFormFieldProps) {
export function WithFormField({
usersTeams,
name,
value,
onChange,
error,
}: WithFormFieldProps) {
const { t } = useTranslation(["scrims"]);
const user = useUser();
const methods = useFormContext<NewRequestFormFields>();
const id = React.useId();
const { translatedError } = useTranslatedTexts({ error });
const fromValue = value as FromValue | null;
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.value === "PICKUP") {
onChange({
mode: "PICKUP",
users: nullFilledArray(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER),
});
return;
}
onChange({ teamId: Number(e.target.value), mode: "TEAM" });
};
const handleUserChange = (
selectedUser: { id: number } | null,
index: number,
) => {
if (!fromValue || fromValue.mode !== "PICKUP") return;
onChange({
mode: "PICKUP",
users: fromValue.users.map((u, j) =>
j === index ? selectedUser?.id : u,
),
});
};
const selectValue = fromValue?.mode === "TEAM" ? fromValue.teamId : "PICKUP";
return (
<div>
<Label htmlFor="with">{t("scrims:forms.with.title")}</Label>
<Controller
control={methods.control}
name="from"
render={({ field: { onChange, onBlur, value }, fieldState }) => {
const setTeam = (teamId: number) => {
onChange({ teamId, mode: "TEAM" });
};
const error =
(fieldState.error as any)?.users ?? fieldState.error?.root;
return (
<div>
<select
id="with"
className="w-max"
value={value.mode === "TEAM" ? value.teamId : "PICKUP"}
onChange={(e) => {
if (e.target.value === "PICKUP") {
onChange({
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
),
});
return;
}
setTeam(Number(e.target.value));
}}
onBlur={onBlur}
>
{usersTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
</select>
{value.mode === "PICKUP" ? (
<div className="stack md mt-4">
<UserSearch
initialUserId={user!.id}
isDisabled
label={t("scrims:forms.with.user", { nth: 1 })}
/>
{value.users.map((userId, i) => (
<UserSearch
key={i}
initialUserId={userId}
onChange={(user) =>
onChange({
mode: "PICKUP",
users: value.users.map((u, j) =>
j === i ? user?.id : u,
),
})
}
isRequired={i < 3}
label={t("scrims:forms.with.user", { nth: i + 2 })}
/>
))}
{error ? (
<FormMessage type="error">
{error.message as string}
</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.with.explanation")}
</FormMessage>
)}
</div>
) : null}
</div>
);
}}
/>
</div>
<FormFieldWrapper
id={id}
name={name}
label={t("scrims:forms.with.title")}
error={fromValue?.mode === "TEAM" ? error : undefined}
>
<select id={id} value={selectValue} onChange={handleSelectChange}>
{usersTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
</select>
{fromValue?.mode === "PICKUP" ? (
<div className="stack md mt-4">
<UserSearch
initialUserId={user!.id}
isDisabled
label={t("scrims:forms.with.user", { nth: 1 })}
/>
{fromValue.users.map((userId, i) => (
<UserSearch
key={i}
initialUserId={userId}
onChange={(selectedUser) => handleUserChange(selectedUser, i)}
isRequired={i < 3}
label={t("scrims:forms.with.user", { nth: i + 2 })}
/>
))}
{translatedError ? (
<FormMessage type="error" id={errorMessageId(name)}>
{translatedError}
</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.with.explanation")}
</FormMessage>
)}
</div>
) : null}
</FormFieldWrapper>
);
}

View File

@ -2,21 +2,18 @@ import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import type { z } from "zod";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { Image } from "~/components/Image";
import { AlertIcon } from "~/components/icons/Alert";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import TimePopover from "~/components/TimePopover";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { SCRIM } from "~/features/scrims/scrims-constants";
import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { SendouForm } from "~/form/SendouForm";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SerializeFrom } from "~/utils/remix";
@ -117,23 +114,13 @@ export default function ScrimPage() {
);
}
type FormFields = z.infer<typeof cancelScrimSchema>;
function CancelScrimForm() {
const { t } = useTranslation(["scrims"]);
return (
<SendouForm
schema={cancelScrimSchema}
defaultValues={{ reason: "" }}
submitButtonTestId="cancel-scrim-submit"
>
<TextAreaFormField<FormFields>
name="reason"
label={t("cancelModal.scrim.reasonLabel")}
maxLength={SCRIM.CANCEL_REASON_MAX_LENGTH}
bottomText={t("scrims:cancelModal.scrim.reasonExplanation")}
/>
{({ FormField }) => <FormField name="reason" />}
</SendouForm>
);
}

View File

@ -0,0 +1,4 @@
.datePickerFullWidth {
--input-width: 100%;
width: 100%;
}

View File

@ -9,9 +9,9 @@ import {
} from "~/utils/Test";
import { action } from "../actions/scrims.new.server";
import { loader } from "../loaders/scrims.server";
import type { scrimsNewActionSchema } from "../scrims-schemas";
import type { scrimsNewFormSchema } from "../scrims-schemas";
const newScrimAction = wrappedAction<typeof scrimsNewActionSchema>({
const newScrimAction = wrappedAction<typeof scrimsNewFormSchema>({
action,
isJsonSubmission: true,
});
@ -24,7 +24,7 @@ const defaultNewScrimPostArgs: Parameters<typeof newScrimAction>[0] = {
at: new Date(),
rangeEnd: null,
baseVisibility: "PUBLIC",
divs: { min: null, max: null },
divs: [null, null],
from: {
mode: "PICKUP",
users: [1, 3, 4],

View File

@ -1,36 +1,33 @@
import type { CalendarDateTime } from "@internationalized/date";
import * as React from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import type { z } from "zod";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import { DateFormField } from "~/components/form/DateFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { Label } from "~/components/Label";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { errorMessageId } from "~/form/utils";
import { nullFilledArray } from "~/utils/arrays";
import { dateToDateValue } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.new.server";
import { LutiDivsFormField } from "../components/LutiDivsFormField";
import { WithFormField } from "../components/WithFormField";
import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server";
import { SCRIM } from "../scrims-constants";
import {
MAX_SCRIM_POST_TEXT_LENGTH,
RANGE_END_OPTIONS,
scrimsNewActionSchema,
} from "../scrims-schemas";
import { scrimsNewFormSchema } from "../scrims-schemas";
import styles from "./scrims.new.module.css";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
type FormFields = z.infer<typeof scrimsNewActionSchema>;
type FormFields = z.infer<typeof scrimsNewFormSchema>;
const DEFAULT_NOT_FOUND_VISIBILITY = {
at: null,
@ -44,13 +41,12 @@ export default function NewScrimPage() {
return (
<Main>
<SendouForm
schema={scrimsNewActionSchema}
heading={t("scrims:forms.title")}
schema={scrimsNewFormSchema}
title={t("scrims:forms.title")}
defaultValues={{
postText: "",
at: new Date(),
rangeEnd: null,
divs: null,
baseVisibility: "PUBLIC",
notFoundVisibility: DEFAULT_NOT_FOUND_VISIBILITY,
from:
@ -67,66 +63,39 @@ export default function NewScrimPage() {
mapsTournamentId: null,
}}
>
<WithFormField usersTeams={data.teams} />
{({ FormField }) => (
<>
<FormField name="from">
{(props: CustomFieldRenderProps) => (
<WithFormField usersTeams={data.teams} {...props} />
)}
</FormField>
<DateFormField<FormFields>
size="medium"
label={t("scrims:forms.when.title")}
name="at"
bottomText={t("scrims:forms.when.explanation")}
granularity="minute"
/>
<SelectFormField<FormFields>
size="medium"
label={t("scrims:forms.rangeEnd.title")}
name="rangeEnd"
bottomText={t("scrims:forms.rangeEnd.explanation")}
values={[
{
value: "",
label: t("scrims:forms.rangeEnd.notFlexible"),
},
...RANGE_END_OPTIONS.map((option) => ({
value: option,
label: t(`scrims:forms.rangeEnd.${option}`),
})),
]}
/>
<FormField name="at" />
<FormField name="rangeEnd" />
<BaseVisibilityFormField associations={data.associations} />
<FormField name="baseVisibility">
{(props: CustomFieldRenderProps) => (
<BaseVisibilityFormField
associations={data.associations}
{...props}
/>
)}
</FormField>
<NotFoundVisibilityFormField associations={data.associations} />
<NotFoundVisibilityFormField associations={data.associations} />
<LutiDivsFormField />
<FormField name="divs" />
<SelectFormField<FormFields>
label={t("scrims:forms.maps.title")}
name="maps"
values={[
{
value: "NO_PREFERENCE",
label: t("scrims:forms.maps.noPreference"),
},
{ value: "SZ", label: t("scrims:forms.maps.szOnly") },
{ value: "RANKED", label: t("scrims:forms.maps.rankedOnly") },
{ value: "ALL", label: t("scrims:forms.maps.allModes") },
{ value: "TOURNAMENT", label: t("scrims:forms.maps.tournament") },
]}
/>
<FormField name="maps" />
<TournamentSearchFormField />
<TournamentSearchFormField />
<TextAreaFormField<FormFields>
label={t("scrims:forms.text.title")}
name="postText"
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
/>
<FormField name="postText" />
<ToggleFormField<FormFields>
label={t("scrims:forms.managedByAnyone.title")}
name="managedByAnyone"
bottomText={t("scrims:forms.managedByAnyone.explanation")}
/>
<FormField name="managedByAnyone" />
</>
)}
</SendouForm>
</Main>
);
@ -134,20 +103,30 @@ export default function NewScrimPage() {
function BaseVisibilityFormField({
associations,
name,
value,
onChange,
error,
}: {
associations: ScrimsNewLoaderData["associations"];
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}) {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const error = methods.formState.errors.baseVisibility;
const id = React.useId();
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
return (
<div>
<Label htmlFor="visibility">{t("scrims:forms.visibility.title")}</Label>
<FormFieldWrapper
id={id}
name={name}
label={t("scrims:forms.visibility.title")}
error={error}
>
{noAssociations ? (
<FormMessage type="info">
{t("scrims:forms.visibility.noneAvailable")}
@ -155,15 +134,12 @@ function BaseVisibilityFormField({
) : (
<AssociationSelect
associations={associations}
id="visibility"
{...methods.register("baseVisibility")}
id={id}
value={String(value)}
onChange={(e) => onChange(e.target.value)}
/>
)}
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
</FormFieldWrapper>
);
}
@ -172,68 +148,112 @@ function NotFoundVisibilityFormField({
}: {
associations: ScrimsNewLoaderData["associations"];
}) {
const { t } = useTranslation(["scrims"]);
const baseVisibility = useWatch<FormFields>({
name: "baseVisibility",
});
const date = useWatch<FormFields>({ name: "notFoundVisibility.at" }) ?? "";
const methods = useFormContext<FormFields>();
const { t } = useTranslation(["scrims", "forms"]);
const { values, setValue, clientErrors, serverErrors } =
useFormFieldContext();
const baseVisibility = values.baseVisibility as string;
const notFoundVisibility =
values.notFoundVisibility as FormFields["notFoundVisibility"];
React.useEffect(() => {
const prevBaseVisibility = React.useRef(baseVisibility);
if (prevBaseVisibility.current !== baseVisibility) {
prevBaseVisibility.current = baseVisibility;
if (baseVisibility === "PUBLIC") {
methods.setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
}
}, [baseVisibility, methods.setValue]);
}
const error = methods.formState.errors.notFoundVisibility;
const error =
serverErrors.notFoundVisibility ?? clientErrors.notFoundVisibility;
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
if (noAssociations || baseVisibility === "PUBLIC") return null;
const handleDateChange = (val: CalendarDateTime | null) => {
if (val) {
const date = new Date(
val.year,
val.month - 1,
val.day,
val.hour,
val.minute,
);
setValue("notFoundVisibility", {
...notFoundVisibility,
at: date,
});
} else {
setValue("notFoundVisibility", {
...notFoundVisibility,
at: null,
});
}
};
const handleAssociationChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setValue("notFoundVisibility", {
...notFoundVisibility,
forAssociation: e.target.value,
});
};
const dateValue = notFoundVisibility.at
? dateToDateValue(new Date(notFoundVisibility.at))
: null;
return (
<div>
<div className="stack horizontal sm">
<DateFormField<FormFields>
label={t("scrims:forms.notFoundVisibility.title")}
name="notFoundVisibility.at"
granularity="minute"
/>
{date ? (
<div>
<div className={styles.datePickerFullWidth}>
<SendouDatePicker
label={t("scrims:forms.notFoundVisibility.title")}
granularity="minute"
errorText={error ? t(`forms:${error}` as never) : undefined}
errorId={errorMessageId("notFoundVisibility")}
value={dateValue}
onChange={handleDateChange}
bottomText={
notFoundVisibility.at
? undefined
: t("scrims:forms.notFoundVisibility.explanation")
}
/>
</div>
{notFoundVisibility.at ? (
<div className="w-full">
<Label htmlFor="not-found-visibility">
{t("scrims:forms.visibility.title")}
</Label>
<AssociationSelect
associations={associations}
id="not-found-visibility"
{...methods.register("notFoundVisibility.forAssociation")}
value={String(notFoundVisibility.forAssociation)}
onChange={handleAssociationChange}
/>
</div>
) : null}
</div>
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.notFoundVisibility.explanation")}
</FormMessage>
)}
</div>
);
}
const AssociationSelect = React.forwardRef<
HTMLSelectElement,
{
associations: ScrimsNewLoaderData["associations"];
} & React.SelectHTMLAttributes<HTMLSelectElement>
>(({ associations, ...rest }, ref) => {
function AssociationSelect({
associations,
id,
value,
onChange,
}: {
associations: ScrimsNewLoaderData["associations"];
id: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}) {
const { t } = useTranslation(["scrims"]);
return (
<select ref={ref} {...rest}>
<select id={id} className="w-full" value={value} onChange={onChange}>
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
{associations.virtual.map((association) => (
<option key={association} value={association}>
@ -247,40 +267,42 @@ const AssociationSelect = React.forwardRef<
))}
</select>
);
});
}
function TournamentSearchFormField() {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const maps = useWatch<FormFields>({ name: "maps" });
const { values, setValue, clientErrors, serverErrors } =
useFormFieldContext();
const maps = values.maps as string;
const mapsTournamentId = values.mapsTournamentId as number | null;
const error = methods.formState.errors.mapsTournamentId;
const error = serverErrors.mapsTournamentId ?? clientErrors.mapsTournamentId;
const prevMaps = React.useRef(maps);
React.useEffect(() => {
if (maps !== "TOURNAMENT") {
methods.setValue("mapsTournamentId", null);
if (prevMaps.current !== maps) {
prevMaps.current = maps;
if (maps !== "TOURNAMENT") {
setValue("mapsTournamentId", null);
}
}
}, [maps, methods]);
}, [maps, setValue]);
if (maps !== "TOURNAMENT") return null;
return (
<div>
<Controller
control={methods.control}
name="mapsTournamentId"
render={({ field: { onChange, value } }) => (
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={value ?? undefined}
onChange={(tournament) => onChange(tournament?.id)}
/>
)}
<FormFieldWrapper
id="mapsTournamentId"
name="mapsTournamentId"
error={error}
>
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={mapsTournamentId ?? undefined}
onChange={(tournament) =>
setValue("mapsTournamentId", tournament?.id ?? null)
}
/>
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
) : null}
</div>
</FormFieldWrapper>
);
}

View File

@ -1,5 +1,19 @@
import { add, sub } from "date-fns";
import { z } from "zod";
import {
customField,
datetimeRequired,
dualSelectOptional,
idConstant,
select,
selectDynamicOptional,
selectOptional,
stringConstant,
textAreaOptional,
textAreaRequired,
timeRangeOptional,
toggle,
} from "~/form/fields";
import {
_action,
date,
@ -23,11 +37,11 @@ const fromUsers = z.preprocess(
z
.array(id)
.min(3, {
message: "Must have at least 3 users excluding yourself",
message: "forms:errors.minUsersExcludingYourself",
})
.max(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER)
.refine(noDuplicates, {
message: "Users must be unique",
message: "forms:errors.usersMustBeUnique",
}),
);
@ -58,7 +72,11 @@ const cancelRequestSchema = z.object({
});
export const cancelScrimSchema = z.object({
reason: z.string().trim().min(1).max(SCRIM.CANCEL_REASON_MAX_LENGTH),
reason: textAreaRequired({
label: "labels.scrimCancelReason",
bottomText: "bottomTexts.scrimCancelReasonHelp",
maxLength: SCRIM.CANCEL_REASON_MAX_LENGTH,
}),
});
const timeRangeSchema = z.object({
@ -81,7 +99,7 @@ export const divsSchema = z
return true;
},
{
message: "Both min and max div must be set or neither",
message: "forms:errors.divBothOrNeither",
},
)
.transform((divs) => {
@ -97,12 +115,46 @@ export const divsSchema = z
return divs;
});
export const scrimsFiltersSchema = z.object({
const scrimsFiltersSchema = z.object({
weekdayTimes: timeRangeSchema.nullable().catch(null),
weekendTimes: timeRangeSchema.nullable().catch(null),
divs: divsSchema.nullable().catch(null),
});
const divsFormField = dualSelectOptional({
fields: [
{
label: "labels.scrimMaxDiv",
items: LUTI_DIVS.map((div) => ({ label: () => div, value: div })),
},
{
label: "labels.scrimMinDiv",
items: LUTI_DIVS.map((div) => ({ label: () => div, value: div })),
},
],
validate: {
func: ([max, min]) => {
if ((max && !min) || (!max && min)) return false;
return true;
},
message: "errors.divBothOrNeither",
},
});
export const scrimsFiltersFormSchema = z.object({
weekdayTimes: timeRangeOptional({
label: "labels.weekdayTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
weekendTimes: timeRangeOptional({
label: "labels.weekendTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
divs: divsFormField,
});
export const scrimsFiltersSearchParamsObject = z.object({
filters: z
.preprocess(safeJSONParse, scrimsFiltersSchema)
@ -122,7 +174,7 @@ export const scrimsActionSchema = z.union([
persistScrimFiltersSchema,
]);
export const MAX_SCRIM_POST_TEXT_LENGTH = 500;
const MAX_SCRIM_POST_TEXT_LENGTH = 500;
export const RANGE_END_OPTIONS = [
"+30min",
@ -133,73 +185,98 @@ export const RANGE_END_OPTIONS = [
"+3hours",
] as const;
export const scrimsNewActionSchema = z
export const scrimRequestFormSchema = z.object({
_action: stringConstant("NEW_REQUEST"),
scrimPostId: idConstant(),
from: customField({ initialValue: null }, fromSchema),
message: textAreaOptional({
label: "labels.scrimRequestMessage",
maxLength: SCRIM.REQUEST_MESSAGE_MAX_LENGTH,
}),
at: selectDynamicOptional({
label: "labels.scrimRequestStartTime",
bottomText: "bottomTexts.scrimRequestStartTime",
}),
});
const rangeEndItems = [
{ label: "options.scrimFlexibility.notFlexible" as const, value: "" },
{ label: "options.scrimFlexibility.+30min" as const, value: "+30min" },
{ label: "options.scrimFlexibility.+1hour" as const, value: "+1hour" },
{ label: "options.scrimFlexibility.+1.5hours" as const, value: "+1.5hours" },
{ label: "options.scrimFlexibility.+2hours" as const, value: "+2hours" },
{ label: "options.scrimFlexibility.+2.5hours" as const, value: "+2.5hours" },
{ label: "options.scrimFlexibility.+3hours" as const, value: "+3hours" },
] as const;
const mapsItems = [
{ label: "options.scrimMaps.noPreference" as const, value: "NO_PREFERENCE" },
{ label: "options.scrimMaps.szOnly" as const, value: "SZ" },
{ label: "options.scrimMaps.rankedOnly" as const, value: "RANKED" },
{ label: "options.scrimMaps.allModes" as const, value: "ALL" },
{ label: "options.scrimMaps.tournament" as const, value: "TOURNAMENT" },
] as const;
export const scrimsNewFormSchema = z
.object({
at: z.preprocess(
date,
z
.date()
.refine(
(date) => {
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{
message: "Date can not be in the past",
},
)
.refine(
(date) => {
if (date > add(new Date(), { days: 15 })) return false;
return true;
},
{
message: "Date can not be more than 2 weeks in the future",
},
),
),
rangeEnd: z
.preprocess(
(val) => (val === "" ? null : val),
z.enum(RANGE_END_OPTIONS).nullable(),
)
.catch(null),
baseVisibility: associationIdentifierSchema,
notFoundVisibility: z.object({
at: z
.preprocess(date, z.date())
.nullish()
.refine(
(date) => {
if (!date) return true;
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{
message: "Date can not be in the past",
},
),
forAssociation: associationIdentifierSchema,
at: datetimeRequired({
label: "labels.start",
bottomText: "bottomTexts.scrimStart",
min: sub(new Date(), { days: 1 }),
max: add(new Date(), { days: 15 }),
minMessage: "errors.dateInPast",
maxMessage: "errors.dateTooFarInFuture",
}),
divs: divsSchema.nullable(),
from: fromSchema,
postText: z.preprocess(
falsyToNull,
z.string().max(MAX_SCRIM_POST_TEXT_LENGTH).nullable(),
rangeEnd: selectOptional({
label: "labels.scrimStartFlexibility",
bottomText: "bottomTexts.scrimStartFlexibility",
items: [...rangeEndItems],
}),
baseVisibility: customField(
{ initialValue: "PUBLIC" },
associationIdentifierSchema,
),
notFoundVisibility: customField(
{ initialValue: { at: null, forAssociation: "PUBLIC" } },
z.object({
at: z
.preprocess(date, z.date())
.nullish()
.refine(
(date) => {
if (!date) return true;
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{ message: "errors.dateInPast" },
),
forAssociation: associationIdentifierSchema,
}),
),
divs: divsFormField,
from: customField({ initialValue: null }, fromSchema),
postText: textAreaOptional({
label: "labels.text",
maxLength: MAX_SCRIM_POST_TEXT_LENGTH,
}),
managedByAnyone: toggle({
label: "labels.scrimManagedByAnyone",
bottomText: "bottomTexts.scrimManagedByAnyone",
}),
maps: select({
label: "labels.scrimMaps",
items: [...mapsItems],
}),
mapsTournamentId: customField(
{ initialValue: null },
z.preprocess(falsyToNull, id.nullable()),
),
managedByAnyone: z.boolean(),
maps: z.enum(["NO_PREFERENCE", "SZ", "RANKED", "ALL", "TOURNAMENT"]),
mapsTournamentId: z.preprocess(falsyToNull, id.nullable()),
})
.superRefine((post, ctx) => {
if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament must be selected when maps is tournament",
message: "forms:errors.tournamentMustBeSelected",
code: z.ZodIssueCode.custom,
});
}
@ -207,17 +284,18 @@ export const scrimsNewActionSchema = z
if (post.maps !== "TOURNAMENT" && post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament should only be selected when maps is tournament",
message: "forms:errors.tournamentOnlyWhenMapsIsTournament",
code: z.ZodIssueCode.custom,
});
}
if (
post.notFoundVisibility.at &&
post.notFoundVisibility.forAssociation === post.baseVisibility
) {
ctx.addIssue({
path: ["notFoundVisibility"],
message: "Not found visibility must be different from base visibility",
message: "forms:errors.visibilityMustBeDifferent",
code: z.ZodIssueCode.custom,
});
}
@ -225,16 +303,15 @@ export const scrimsNewActionSchema = z
if (post.baseVisibility === "PUBLIC" && post.notFoundVisibility.at) {
ctx.addIssue({
path: ["notFoundVisibility"],
message:
"Not found visibility can not be set if base visibility is public",
message: "forms:errors.visibilityNotAllowedWhenPublic",
code: z.ZodIssueCode.custom,
});
}
if (post.notFoundVisibility.at && post.notFoundVisibility.at < post.at) {
if (post.notFoundVisibility.at && post.notFoundVisibility.at > post.at) {
ctx.addIssue({
path: ["notFoundVisibility", "at"],
message: "Date can not be before the scrim date",
path: ["notFoundVisibility"],
message: "forms:errors.dateAfterScrimDate",
code: z.ZodIssueCode.custom,
});
}
@ -242,7 +319,7 @@ export const scrimsNewActionSchema = z
if (post.notFoundVisibility.at && post.at < new Date()) {
ctx.addIssue({
path: ["notFoundVisibility"],
message: "Can not be set if looking for scrim now",
message: "forms:errors.canNotSetIfLookingNow",
code: z.ZodIssueCode.custom,
});
}

View File

@ -1,5 +1,7 @@
import { db } from "~/db/sql";
import type { QWeaponPool, Tables, UserMapModePreferences } from "~/db/tables";
import type { Tables, UserMapModePreferences } from "~/db/tables";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
@ -18,7 +20,9 @@ export async function settingsByUserId(userId: number) {
return {
...preferences,
languages: preferences.languages?.split(","),
languages: preferences.languages?.split(",") as
| UnifiedLanguageCode[]
| undefined,
};
}
@ -74,13 +78,20 @@ export function updateVoiceChat(args: {
export function updateSendouQWeaponPool(args: {
userId: number;
weaponPool: QWeaponPool[];
weaponPool: WeaponPoolItem[];
}) {
return db
.updateTable("User")
.set({
qWeaponPool:
args.weaponPool.length > 0 ? JSON.stringify(args.weaponPool) : null,
args.weaponPool.length > 0
? JSON.stringify(
args.weaponPool.map((wpn) => ({
weaponSplId: wpn.id,
isFavorite: Number(wpn.isFavorite),
})),
)
: null,
})
.where("User.id", "=", args.userId)
.execute();

View File

@ -34,13 +34,6 @@ export const action = async ({ request }: { request: Request }) => {
});
break;
}
case "UPDATE_NO_SCREEN": {
await QSettingsRepository.updateNoScreen({
userId: user.id,
noScreen: Number(data.noScreen),
});
break;
}
case "REMOVE_TRUST": {
await QSettingsRepository.deleteTrustedUser({
trustGiverUserId: user.id,

View File

@ -1,19 +1,10 @@
import { z } from "zod";
import { languagesUnified } from "~/modules/i18n/config";
import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "./q-settings-constants";
import {
_action,
checkboxValueToBoolean,
id,
modeShort,
noDuplicates,
qWeapon,
safeJSONParse,
stageId,
} from "~/utils/zod";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
SENDOUQ_WEAPON_POOL_MAX_SIZE,
} from "./q-settings-constants";
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "./q-settings-schemas";
const preference = z.enum(["AVOID", "PREFER"]).optional();
export const settingsActionSchema = z.union([
@ -41,30 +32,8 @@ export const settingsActionSchema = z.union([
),
),
}),
z.object({
_action: _action("UPDATE_VC"),
vc: z.enum(["YES", "NO", "LISTEN_ONLY"]),
languages: z.preprocess(
safeJSONParse,
z
.array(z.string())
.refine(noDuplicates)
.refine((val) =>
val.every((lang) => languagesUnified.some((l) => l.code === lang)),
),
),
}),
z.object({
_action: _action("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: z.preprocess(
safeJSONParse,
z.array(qWeapon).max(SENDOUQ_WEAPON_POOL_MAX_SIZE),
),
}),
z.object({
_action: _action("UPDATE_NO_SCREEN"),
noScreen: z.preprocess(checkboxValueToBoolean, z.boolean()),
}),
updateVoiceChatSchema,
updateWeaponPoolSchema,
z.object({
_action: _action("REMOVE_TRUST"),
userToRemoveTrustFromId: id,

View File

@ -0,0 +1,38 @@
import { z } from "zod";
import {
checkboxGroup,
radioGroup,
stringConstant,
weaponPool,
} from "~/form/fields";
import { languagesUnified } from "~/modules/i18n/config";
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "./q-settings-constants";
export const updateWeaponPoolSchema = z.object({
_action: stringConstant("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: weaponPool({
label: "labels.weaponPool",
maxCount: SENDOUQ_WEAPON_POOL_MAX_SIZE,
}),
});
const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({
label: () => lang.name,
value: lang.code,
}));
export const updateVoiceChatSchema = z.object({
_action: stringConstant("UPDATE_VC"),
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
languages: checkboxGroup({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
});

View File

@ -5,38 +5,34 @@ import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage, WeaponImage } from "~/components/Image";
import { CrossIcon } from "~/components/icons/Cross";
import { ModeImage } from "~/components/Image";
import { MapIcon } from "~/components/icons/Map";
import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled";
import { PuzzleIcon } from "~/components/icons/Puzzle";
import { SpeakerFilledIcon } from "~/components/icons/SpeakerFilled";
import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
import { UsersIcon } from "~/components/icons/Users";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
import type { Preference, UserMapModePreferences } from "~/db/tables";
import {
soundCodeToLocalStorageKey,
soundVolume,
} from "~/features/chat/chat-utils";
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { languagesUnified } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
navIconUrl,
SENDOUQ_PAGE,
SENDOUQ_SETTINGS_PAGE,
SETTINGS_PAGE,
soundPath,
} from "~/utils/urls";
import { action } from "../actions/q.settings.server";
@ -44,10 +40,11 @@ import { BANNED_MAPS } from "../banned-maps";
import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker";
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
import { loader } from "../loaders/q.settings.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "../q-settings-constants";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
SENDOUQ_WEAPON_POOL_MAX_SIZE,
} from "../q-settings-constants";
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "../q-settings-schemas";
export { loader, action };
import "../q-settings.css";
@ -252,8 +249,8 @@ function MapPicker() {
}
function VoiceChat() {
const { t } = useTranslation(["common", "q"]);
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
return (
<details>
@ -263,128 +260,34 @@ function VoiceChat() {
<MicrophoneFilledIcon />
</div>
</summary>
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
<VoiceChatAbility />
<Languages />
<div>
<SubmitButton
size="big"
className="mt-2 mx-auto"
_action="UPDATE_VC"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateVoiceChatSchema}
defaultValues={{
vc: data.settings.vc,
languages: data.settings.languages ?? [],
}}
>
{({ FormField }) => (
<>
<FormField name="vc" />
<FormField name="languages" />
</>
)}
</SendouForm>
</div>
</details>
);
}
function VoiceChatAbility() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const label = (vc: Tables["User"]["vc"]) => {
switch (vc) {
case "YES":
return t("q:settings.voiceChat.canVC.yes");
case "NO":
return t("q:settings.voiceChat.canVC.no");
case "LISTEN_ONLY":
return t("q:settings.voiceChat.canVC.listenOnly");
default:
assertUnreachable(vc);
}
};
return (
<div className="stack">
<label>{t("q:settings.voiceChat.canVC.header")}</label>
{(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
return (
<div key={option} className="stack sm horizontal items-center">
<input
type="radio"
name="vc"
id={option}
value={option}
required
defaultChecked={data.settings.vc === option}
/>
<label htmlFor={option} className="mb-0 text-main-forced">
{label(option)}
</label>
</div>
);
})}
</div>
);
}
function Languages() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.settings.languages ?? []);
return (
<div className="stack">
<input type="hidden" name="languages" value={JSON.stringify(value)} />
<label>{t("q:settings.voiceChat.languages.header")}</label>
<select
className="w-max"
onChange={(e) => {
const newLanguages = [...value, e.target.value].sort((a, b) =>
a.localeCompare(b),
);
setValue(newLanguages);
}}
>
<option value="">
{t("q:settings.voiceChat.languages.placeholder")}
</option>
{languagesUnified
.filter((lang) => !value.includes(lang.code))
.map((option) => {
return (
<option key={option.code} value={option.code}>
{option.name}
</option>
);
})}
</select>
<div className="mt-2">
{value.map((code) => {
const name = languagesUnified.find((l) => l.code === code)?.name;
return (
<div key={code} className="stack horizontal items-center sm">
{name}{" "}
<SendouButton
icon={<CrossIcon />}
variant="minimal-destructive"
onPress={() => {
const newLanguages = value.filter(
(codeInArr) => codeInArr !== code,
);
setValue(newLanguages);
}}
/>
</div>
);
})}
</div>
</div>
);
}
function WeaponPool() {
const { t } = useTranslation(["common", "q"]);
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const [weapons, setWeapons] = React.useState(data.settings.qWeaponPool ?? []);
const fetcher = useFetcher();
const latestWeapon = weapons[weapons.length - 1]?.weaponSplId ?? null;
const defaultWeaponPool = (data.settings.qWeaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
}));
return (
<details>
@ -393,94 +296,16 @@ function WeaponPool() {
<span>{t("q:settings.weaponPool.header")}</span> <PuzzleIcon />
</div>
</summary>
<fetcher.Form method="post" className="mb-4 stack items-center">
<input
type="hidden"
name="weaponPool"
value={JSON.stringify(weapons)}
/>
<div className="q-settings__weapon-pool-select-container">
{weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
<WeaponSelect
onChange={(weaponSplId) => {
setWeapons([
...weapons,
{
weaponSplId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon ?? "empty"}
disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
/>
) : (
<span className="text-xs text-info">
{t("q:settings.weaponPool.full")}
</span>
)}
</div>
<div className="stack horizontal md justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon.weaponSplId} className="stack xs">
<div>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
width={38}
height={38}
/>
</div>
<div className="stack sm horizontal items-center justify-center">
<SendouButton
icon={weapon.isFavorite ? <StarFilledIcon /> : <StarIcon />}
variant="minimal"
aria-label="Favorite weapon"
onPress={() =>
setWeapons(
weapons.map((w) =>
w.weaponSplId === weapon.weaponSplId
? {
...weapon,
isFavorite: weapon.isFavorite === 1 ? 0 : 1,
}
: w,
),
)
}
/>
<SendouButton
icon={<TrashIcon />}
variant="minimal-destructive"
aria-label="Delete weapon"
onPress={() =>
setWeapons(
weapons.filter(
(w) => w.weaponSplId !== weapon.weaponSplId,
),
)
}
data-testid={`delete-weapon-${weapon.weaponSplId}`}
size="small"
/>
</div>
</div>
);
})}
</div>
<div className="mt-6">
<SubmitButton
size="big"
className="mx-auto"
_action="UPDATE_SENDOUQ_WEAPON_POOL"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4">
<SendouForm
schema={updateWeaponPoolSchema}
defaultValues={{
weaponPool: defaultWeaponPool,
}}
>
{({ FormField }) => <FormField name="weaponPool" />}
</SendouForm>
</div>
</details>
);
}
@ -678,40 +503,25 @@ function TrustedUsers() {
function Misc() {
const data = useLoaderData<typeof loader>();
const [checked, setChecked] = React.useState(Boolean(data.settings.noScreen));
const { t } = useTranslation(["common", "q", "weapons"]);
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
return (
<details>
<summary className="q-settings__summary">
<div>{t("q:settings.misc.header")}</div>
</summary>
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
<div className="stack horizontal xs items-center">
<SendouSwitch
isSelected={checked}
onChange={setChecked}
id="noScreen"
name="noScreen"
/>
<label className="mb-0" htmlFor="noScreen">
{t("q:settings.avoid.label", {
special: t("weapons:SPECIAL_19"),
})}
</label>
</div>
<div className="mt-6">
<SubmitButton
size="big"
className="mx-auto"
_action="UPDATE_NO_SCREEN"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.settings.noScreen),
}}
action={SETTINGS_PAGE}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</details>
);
}

View File

@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseRequestPayload, successToast } from "~/utils/remix.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsEditSchema } from "../settings-schemas";
@ -44,5 +44,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
return successToast("Settings updated");
// TODO: removed temporarily, restore when we have better toasts
// (current problem is that when you update no screen from /q/settings, you get redirected to /settings)
// return successToast("Settings updated");
};

View File

@ -1,18 +1,14 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import {
useFetcher,
useLoaderData,
useNavigate,
useSearchParams,
} from "react-router";
import { SendouSwitch } from "~/components/elements/Switch";
import { useLoaderData, useNavigate, useSearchParams } from "react-router";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { SelectFormField } from "~/form/fields/SelectFormField";
import { SendouForm } from "~/form/SendouForm";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -21,6 +17,12 @@ import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
import { loader } from "../loaders/settings.server";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
updateNoScreenSchema,
} from "../settings-schemas";
export { loader, action };
export const handle: SendouRouteHandle = {
@ -42,41 +44,50 @@ export default function SettingsPage() {
<h2 className="text-lg">{t("common:pages.settings")}</h2>
<LanguageSelector />
<ThemeSelector />
{user ? <ClockFormatSelector /> : null}
{user ? (
<SendouForm
schema={clockFormatSchema}
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
{user ? (
<>
<PushNotificationsEnabler />
<div className="mt-6 stack md">
<PreferenceSelectorSwitch
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
defaultSelected={
user?.preferences.disableBuildAbilitySorting ?? false
}
label={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
)}
bottomText={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
<PreferenceSelectorSwitch
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
defaultSelected={
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
}
label={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
)}
bottomText={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
)}
/>
<PreferenceSelectorSwitch
_action="UPDATE_NO_SCREEN"
defaultSelected={Boolean(data.noScreen)}
label={t("common:settings.UPDATE_NO_SCREEN.label")}
bottomText={t("common:settings.UPDATE_NO_SCREEN.bottomText")}
/>
<SendouForm
schema={disableBuildAbilitySortingSchema}
defaultValues={{
newValue:
user.preferences.disableBuildAbilitySorting ?? false,
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={disallowScrimPickupsFromUntrustedSchema}
defaultValues={{
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.noScreen),
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</>
) : null}
@ -98,28 +109,23 @@ function LanguageSelector() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newLang = event.target.value;
const languageItems = languages.map((lang) => ({
value: lang.code,
label: lang.name,
}));
const handleLanguageChange = (newLang: string | null) => {
if (!newLang) return;
navigate(`?${addUniqueParam(searchParams, "lng", newLang).toString()}`);
};
return (
<div>
<Label htmlFor="lang">{t("common:header.language")}</Label>
<select
id="lang"
defaultValue={i18n.language}
onChange={handleLanguageChange}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
<SelectFormField
label={t("common:header.language")}
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}
/>
);
}
@ -138,55 +144,25 @@ function ThemeSelector() {
const { t } = useTranslation(["common"]);
const { userTheme, setUserTheme } = useTheme();
return (
<div>
<Label htmlFor="theme">{t("common:header.theme")}</Label>
<select
id="theme"
defaultValue={userTheme ?? "auto"}
onChange={(e) => setUserTheme(e.target.value as Theme)}
>
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
return (
<option key={theme} value={theme}>
{t(`common:theme.${theme}`)}
</option>
);
})}
</select>
</div>
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
(theme) => ({
value: theme,
label: t(`common:theme.${theme}`),
}),
);
}
function ClockFormatSelector() {
const { t } = useTranslation(["common"]);
const user = useUser();
const fetcher = useFetcher();
const handleClockFormatChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newFormat = event.target.value as "auto" | "24h" | "12h";
fetcher.submit(
{ _action: "UPDATE_CLOCK_FORMAT", newValue: newFormat },
{ method: "post", encType: "application/json" },
);
const handleThemeChange = (newTheme: string | null) => {
if (!newTheme) return;
setUserTheme(newTheme as Theme);
};
return (
<div>
<Label htmlFor="clock-format">{t("common:settings.clockFormat")}</Label>
<select
id="clock-format"
defaultValue={user?.preferences.clockFormat ?? "auto"}
onChange={handleClockFormatChange}
disabled={fetcher.state !== "idle"}
>
<option value="auto">{t("common:clockFormat.auto")}</option>
<option value="24h">{t("common:clockFormat.24h")}</option>
<option value="12h">{t("common:clockFormat.12h")}</option>
</select>
</div>
<SelectFormField
label={t("common:header.theme")}
items={themeItems}
value={userTheme ?? "auto"}
onChange={handleThemeChange}
/>
);
}
@ -280,38 +256,3 @@ function PushNotificationsEnabler() {
</div>
);
}
function PreferenceSelectorSwitch({
_action,
label,
bottomText,
defaultSelected,
}: {
_action: string;
label: string;
bottomText: string;
defaultSelected: boolean;
}) {
const fetcher = useFetcher();
const onChange = (isSelected: boolean) => {
fetcher.submit(
{ _action, newValue: isSelected },
{ method: "post", encType: "application/json" },
);
};
return (
<div>
<SendouSwitch
defaultSelected={defaultSelected}
onChange={onChange}
isDisabled={fetcher.state !== "idle"}
data-testid={`${_action}-switch`}
>
{label}
</SendouSwitch>
<FormMessage type="info">{bottomText}</FormMessage>
</div>
);
}

View File

@ -1,21 +1,45 @@
import { z } from "zod";
import { _action } from "~/utils/zod";
import { select, stringConstant, toggle } from "~/form/fields";
export const clockFormatSchema = z.object({
_action: stringConstant("UPDATE_CLOCK_FORMAT"),
newValue: select({
label: "labels.clockFormat",
items: [
{ value: "auto", label: "options.clockFormat.auto" },
{ value: "24h", label: "options.clockFormat.24h" },
{ value: "12h", label: "options.clockFormat.12h" },
],
}),
});
export const disableBuildAbilitySortingSchema = z.object({
_action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: toggle({
label: "labels.disableBuildAbilitySorting",
bottomText: "bottomTexts.disableBuildAbilitySorting",
}),
});
export const disallowScrimPickupsFromUntrustedSchema = z.object({
_action: stringConstant("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: toggle({
label: "labels.disallowScrimPickupsFromUntrusted",
bottomText: "bottomTexts.disallowScrimPickupsFromUntrusted",
}),
});
export const updateNoScreenSchema = z.object({
_action: stringConstant("UPDATE_NO_SCREEN"),
newValue: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",
}),
});
export const settingsEditSchema = z.union([
z.object({
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: z.boolean(),
}),
z.object({
_action: _action("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: z.boolean(),
}),
z.object({
_action: _action("UPDATE_NO_SCREEN"),
newValue: z.boolean(),
}),
z.object({
_action: _action("UPDATE_CLOCK_FORMAT"),
newValue: z.enum(["auto", "24h", "12h"]),
}),
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
updateNoScreenSchema,
clockFormatSchema,
]);

View File

@ -12,6 +12,7 @@ import {
concatUserSubmittedImagePrefix,
tournamentLogoOrNull,
} from "~/utils/kysely.server";
import { mySlugify } from "~/utils/urls";
export function findAllUndisbanded() {
return db
@ -243,20 +244,22 @@ export async function teamsByMemberUserId(
}
export async function create(
args: Pick<Insertable<Tables["Team"]>, "name" | "customUrl"> & {
args: Pick<Insertable<Tables["Team"]>, "name"> & {
ownerUserId: number;
isMainTeam: boolean;
},
) {
const customUrl = mySlugify(args.name);
return db.transaction().execute(async (trx) => {
const team = await trx
.insertInto("AllTeam")
.values({
name: args.name,
customUrl: args.customUrl,
customUrl,
inviteCode: shortNanoid(),
})
.returning("id")
.returning(["id", "customUrl"])
.executeTakeFirstOrThrow();
await trx
@ -268,22 +271,24 @@ export async function create(
isMainTeam: Number(args.isMainTeam),
})
.execute();
return team;
});
}
export async function update({
id,
name,
customUrl,
bio,
bsky,
tag,
css,
}: Pick<
Insertable<Tables["Team"]>,
"id" | "name" | "customUrl" | "bio" | "bsky" | "tag"
> & { css: string | null }) {
return db
}: Pick<Insertable<Tables["Team"]>, "id" | "name" | "bio" | "bsky" | "tag"> & {
css: string | null;
}) {
const customUrl = mySlugify(name);
const team = await db
.updateTable("AllTeam")
.set({
name,
@ -296,6 +301,8 @@ export async function update({
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
return team;
}
export function switchMainTeam({

View File

@ -6,15 +6,18 @@ import {
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamProfileAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {

View File

@ -50,27 +50,27 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
errorToastIfFalsy(
newCustomUrl.length > 0,
"Team name can't be only special characters",
);
// can't take someone else's custom url
if (existingTeam && existingTeam.id !== team.id) {
return {
errors: ["forms.errors.duplicateName"],
};
const teams = await TeamRepository.findAllUndisbanded();
const duplicateTeam = teams.find(
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
);
if (duplicateTeam) {
return { errors: ["forms:errors.duplicateName"] };
}
const editedTeam = await TeamRepository.update({
const updatedTeam = await TeamRepository.update({
id: team.id,
customUrl: newCustomUrl,
...data,
});
throw redirect(teamPage(editedTeam.customUrl));
throw redirect(teamPage(updatedTeam.customUrl));
}
default: {
assertUnreachable(data);

View File

@ -1,15 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas";
const action = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
describe("team creation", () => {
@ -24,12 +20,12 @@ describe("team creation", () => {
await action({ name: "Team 1" }, { user: "regular" });
const res = await action({ name: "Team 1" }, { user: "regular" });
expect(res.errors[0]).toBe("forms.errors.duplicateName");
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
});
it("prevents creating a team whose name is only special characters", async () => {
const response = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
const res = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
assertResponseErrored(response);
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
});
});

View File

@ -1,19 +1,26 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { mySlugify, teamPage } from "~/utils/urls";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { createTeamSchema } from "../team-schemas.server";
import { createTeamSchemaServer } from "../team-schemas.server";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: createTeamSchema,
schema: createTeamSchemaServer,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const teams = await TeamRepository.findAllUndisbanded();
const currentTeamCount = teams.filter((team) =>
@ -28,26 +35,11 @@ export const action: ActionFunction = async ({ request }) => {
"Already in max amount of teams",
);
// two teams can't have same customUrl
const customUrl = mySlugify(data.name);
errorToastIfFalsy(
customUrl.length > 0,
"Team name can't be only special characters",
);
if (teams.some((team) => team.customUrl === customUrl)) {
return {
errors: ["forms.errors.duplicateName"],
};
}
await TeamRepository.create({
const team = await TeamRepository.create({
ownerUserId: user.id,
name: data.name,
customUrl,
isMainTeam: currentTeamCount === 0,
});
throw redirect(teamPage(customUrl));
throw redirect(teamPage(team.customUrl));
};

View File

@ -7,14 +7,17 @@ import {
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {
@ -42,7 +45,7 @@ describe("team creation", () => {
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(res.errors[0]).toBe("forms.errors.duplicateName");
expect(res.errors[0]).toBe("forms:errors.duplicateName");
});
it("prevents editing team name to only special characters", async () => {

View File

@ -14,8 +14,8 @@ import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import * as TeamRepository from "../TeamRepository.server";
import type { createTeamSchema } from "../team-schemas";
import type {
createTeamSchema,
editTeamSchema,
teamProfilePageActionSchema,
} from "../team-schemas.server";
@ -28,12 +28,15 @@ const loadUserTeamLoader = wrappedLoader<
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
action: _teamPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
async function loadTeams() {

View File

@ -1,17 +1,16 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Form, Link, useLoaderData, useSearchParams } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { AddNewButton } from "~/components/AddNewButton";
import { Alert } from "~/components/Alert";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormErrors } from "~/components/FormErrors";
import { Input } from "~/components/Input";
import { SearchIcon } from "~/components/icons/Search";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form";
import { usePagination } from "~/hooks/usePagination";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@ -25,6 +24,7 @@ import {
import { action } from "../actions/t.server";
import { loader } from "../loaders/t.server";
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";
import { createTeamSchema } from "../team-schemas";
export { loader, action };
import "../team.css";
@ -40,7 +40,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
i18n: ["team", "forms"],
breadcrumb: () => ({
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
@ -196,23 +196,9 @@ function NewTeamDialog() {
isOpen={isOpen}
onCloseTo={TEAM_SEARCH_PAGE}
>
<Form method="post" className="stack md">
<div className="">
<label htmlFor="name">{t("common:forms.name")}</label>
<input
id="name"
name="name"
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
required
data-testid={isOpen ? "new-team-name-input" : undefined}
/>
</div>
<FormErrors namespace="team" />
<div className="mt-2">
<SubmitButton>{t("common:actions.create")}</SubmitButton>
</div>
</Form>
<SendouForm schema={createTeamSchema}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
</SendouDialog>
);
}

View File

@ -1,22 +1,25 @@
import { z } from "zod";
import {
_action,
customCssVarObject,
falsyToNull,
id,
safeStringSchema,
} from "~/utils/zod";
import { mySlugify } from "~/utils/urls";
import { _action, customCssVarObject, falsyToNull, id } from "~/utils/zod";
import * as TeamRepository from "./TeamRepository.server";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
import { createTeamSchema } from "./team-schemas";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
name: createTeamSchema.shape.name.refine(
async (name) => {
const teams = await TeamRepository.findAllUndisbanded();
const customUrl = mySlugify(name);
return !teams.some((team) => team.customUrl === customUrl);
},
{ message: "forms:errors.duplicateName" },
),
});
export const teamParamsSchema = z.object({ customUrl: z.string() });
export const createTeamSchema = z.object({
name: safeStringSchema({
min: TEAM.NAME_MIN_LENGTH,
max: TEAM.NAME_MAX_LENGTH,
}),
});
export const teamProfilePageActionSchema = z.union([
z.object({
_action: _action("LEAVE_TEAM"),

View File

@ -0,0 +1,17 @@
import { z } from "zod";
import { textFieldRequired } from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { TEAM } from "./team-constants";
export const createTeamSchema = z.object({
name: textFieldRequired({
label: "labels.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: {
func: (teamName) =>
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
message: "forms:errors.noOnlySpecialCharacters",
},
}),
});

View File

@ -2,21 +2,28 @@ import { type ActionFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { parseFormData } from "~/form/parse.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import { valueArrayToDBFormat } from "~/utils/form";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
import { actionError } from "~/utils/remix.server";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { organizationEditSchema } from "../tournament-organization-schemas";
import { organizationEditFormSchema } from "../tournament-organization-schemas";
import { organizationFromParams } from "../tournament-organization-utils.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: organizationEditSchema,
schema: organizationEditFormSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const t = await i18next.getFixedT(request, ["org"]);
const organization = await organizationFromParams(params);
@ -28,17 +35,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
(member) => member.userId === user.id && member.role === "ADMIN",
)
) {
return actionError<typeof organizationEditSchema>({
return actionError<typeof organizationEditFormSchema>({
msg: t("org:edit.form.errors.noUnadmin"),
field: "members.root",
});
}
const socials = data.socials.filter((s) => s.length > 0);
const newOrganization = await TournamentOrganizationRepository.update({
id: organization.id,
name: data.name,
description: data.description,
socials: valueArrayToDBFormat(data.socials),
socials: socials.length > 0 ? socials : null,
members: data.members,
series: data.series,
badges: data.badges,

View File

@ -8,7 +8,6 @@ import {
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
dayMonthYearToDate,
} from "~/utils/dates";
import { logger } from "~/utils/logger";
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
@ -52,7 +51,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
userId: data.userId,
privateNote: data.privateNote,
expiresAt: data.expiresAt
? dateToDatabaseTimestamp(dayMonthYearToDate(data.expiresAt))
? dateToDatabaseTimestamp(data.expiresAt)
: null,
});

View File

@ -1,18 +1,11 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { DateFormField } from "~/components/form/DateFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
import { SendouForm } from "~/form/SendouForm";
import { banUserActionSchema } from "../tournament-organization-schemas";
type FormFields = z.infer<typeof banUserActionSchema>;
export function BanUserModal() {
const { t } = useTranslation(["org", "common"]);
const { t } = useTranslation(["org"]);
return (
<SendouDialog
@ -24,32 +17,14 @@ export function BanUserModal() {
}
showCloseButton
>
<SendouForm
schema={banUserActionSchema}
defaultValues={{
_action: "BAN_USER",
userId: undefined,
privateNote: null,
expiresAt: null,
}}
>
<UserSearchFormField<FormFields>
label={t("org:banned.banModal.player")}
name="userId"
/>
<TextAreaFormField<FormFields>
label={t("org:banned.banModal.note")}
name="privateNote"
maxLength={TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH}
bottomText={t("org:banned.banModal.noteHelp")}
/>
<DateFormField<FormFields>
label={t("org:banned.banModal.expiresAt")}
name="expiresAt"
bottomText={t("org:banned.banModal.expiresAtHelp")}
/>
<SendouForm schema={banUserActionSchema}>
{({ FormField }) => (
<>
<FormField name="userId" />
<FormField name="privateNote" />
<FormField name="expiresAt" />
</>
)}
</SendouForm>
</SendouDialog>
);

View File

@ -1,265 +1,61 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import type { z } from "zod";
import { FormMessage } from "~/components/FormMessage";
import { AddFieldButton } from "~/components/form/AddFieldButton";
import { FormFieldset } from "~/components/form/FormFieldset";
import { InputFormField } from "~/components/form/InputFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { BadgesSelector } from "~/features/badges/components/BadgesSelector";
import { wrapToValueStringArrayWithDefault } from "~/utils/form";
import type { Unpacked } from "~/utils/types";
import { SendouForm } from "~/form/SendouForm";
import { uploadImagePage } from "~/utils/urls";
import { action } from "../actions/org.$slug.edit.server";
import { loader } from "../loaders/org.$slug.edit.server";
import { handle, meta } from "../routes/org.$slug";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
import { organizationEditSchema } from "../tournament-organization-schemas";
import { organizationEditFormSchema } from "../tournament-organization-schemas";
export { action, handle, loader, meta };
type FormFields = z.infer<typeof organizationEditSchema> & {
members: Array<
Omit<
Unpacked<z.infer<typeof organizationEditSchema>["members"]>,
"userId"
> & {
userId: number | null;
}
>;
};
export default function TournamentOrganizationEditPage() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["org", "common"]);
const { t } = useTranslation(["org"]);
return (
<Main>
<SendouForm
heading={t("org:edit.form.title")}
schema={organizationEditSchema}
title={t("org:edit.form.title")}
schema={organizationEditFormSchema}
defaultValues={{
name: data.organization.name,
description: data.organization.description,
socials: wrapToValueStringArrayWithDefault(data.organization.socials),
description: data.organization.description ?? "",
socials: data.organization.socials ?? [],
members: data.organization.members.map((member) => ({
userId: member.id,
role: member.role,
roleDisplayName: member.roleDisplayName,
roleDisplayName: member.roleDisplayName ?? "",
})),
series: data.organization.series.map((series) => ({
name: series.name,
description: series.description,
description: series.description ?? "",
showLeaderboard: Boolean(series.showLeaderboard),
})),
badges: data.organization.badges.map((badge) => badge.id),
}}
>
<Link
to={uploadImagePage({
type: "org-pfp",
slug: data.organization.slug,
})}
className="text-sm font-bold"
>
{t("org:edit.form.uploadLogo")}
</Link>
{({ FormField }) => (
<>
<Link
to={uploadImagePage({
type: "org-pfp",
slug: data.organization.slug,
})}
className="text-sm font-bold"
>
{t("org:edit.form.uploadLogo")}
</Link>
<InputFormField<FormFields>
label={t("common:forms.name")}
name="name"
/>
<TextAreaFormField<FormFields>
label={t("common:forms.description")}
name="description"
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
/>
<MembersFormField />
<TextArrayFormField<FormFields>
label={t("org:edit.form.socialLinks.title")}
name="socials"
format="object"
/>
<SeriesFormField />
<BadgesFormField />
<FormField name="name" />
<FormField name="description" />
<FormField name="members" />
<FormField name="socials" />
<FormField name="series" />
<FormField name="badges" options={data.badgeOptions} />
</>
)}
</SendouForm>
</Main>
);
}
function MembersFormField() {
const {
formState: { errors },
} = useFormContext<FormFields>();
const { fields, append, remove } = useFieldArray<FormFields>({
name: "members",
});
const { t } = useTranslation(["org"]);
const rootError = errors.members?.root;
return (
<div>
<Label>{t("org:edit.form.members.title")}</Label>
<div className="stack md">
{fields.map((field, i) => {
return <MemberFieldset key={field.id} idx={i} remove={remove} />;
})}
<AddFieldButton
onClick={() => {
append({ role: "MEMBER", roleDisplayName: null, userId: null });
}}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>
)}
<FormMessage type="info">{t("org:edit.form.members.info")}</FormMessage>
</div>
</div>
);
}
function MemberFieldset({
idx,
remove,
}: {
idx: number;
remove: (idx: number) => void;
}) {
const { t } = useTranslation(["org"]);
const { clearErrors } = useFormContext<FormFields>();
return (
<FormFieldset
title={`#${idx + 1}`}
onRemove={() => {
remove(idx);
clearErrors("members");
}}
>
<UserSearchFormField<FormFields>
label={t("org:edit.form.members.user.title")}
name={`members.${idx}.userId` as const}
/>
<SelectFormField<FormFields>
label={t("org:edit.form.members.role.title")}
name={`members.${idx}.role` as const}
values={TOURNAMENT_ORGANIZATION_ROLES.map((role) => ({
value: role,
label: t(`org:roles.${role}`),
}))}
/>
<InputFormField<FormFields>
label={t("org:edit.form.members.roleDisplayName.title")}
name={`members.${idx}.roleDisplayName` as const}
/>
</FormFieldset>
);
}
function SeriesFormField() {
const {
formState: { errors },
} = useFormContext<FormFields>();
const { fields, append, remove } = useFieldArray<FormFields>({
name: "series",
});
const { t } = useTranslation(["org"]);
const rootError = errors.series?.root;
return (
<div>
<Label>{t("org:edit.form.series.title")}</Label>
<div className="stack md">
{fields.map((field, i) => {
return <SeriesFieldset key={field.id} idx={i} remove={remove} />;
})}
<AddFieldButton
onClick={() => {
append({ description: "", name: "", showLeaderboard: false });
}}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>
)}
</div>
</div>
);
}
function SeriesFieldset({
idx,
remove,
}: {
idx: number;
remove: (idx: number) => void;
}) {
const { t } = useTranslation(["org", "common"]);
const { clearErrors } = useFormContext<FormFields>();
return (
<FormFieldset
title={`#${idx + 1}`}
onRemove={() => {
remove(idx);
clearErrors("series");
}}
>
<InputFormField<FormFields>
label={t("org:edit.form.series.seriesName.title")}
name={`series.${idx}.name` as const}
/>
<TextAreaFormField<FormFields>
label={t("common:forms.description")}
name={`series.${idx}.description` as const}
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
/>
<ToggleFormField<FormFields>
label={t("org:edit.form.series.showLeaderboard.title")}
name={`series.${idx}.showLeaderboard` as const}
/>
</FormFieldset>
);
}
function BadgesFormField() {
const { t } = useTranslation(["org"]);
const methods = useFormContext<FormFields>();
const data = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("org:edit.form.badges.title")}</Label>
<Controller
control={methods.control}
name="badges"
render={({ field: { onChange, onBlur, value } }) => (
<BadgesSelector
options={data.badgeOptions}
selectedBadges={value}
onBlur={onBlur}
onChange={onChange}
/>
)}
/>
</div>
);
}

View File

@ -1,10 +1,9 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useFetcher, useLoaderData, useSearchParams } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import {
SendouTab,
SendouTabList,
@ -21,6 +20,7 @@ import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { SendouForm } from "~/form/SendouForm";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
@ -40,6 +40,7 @@ import { EventCalendar } from "../components/EventCalendar";
import { SocialLinksList } from "../components/SocialLinksList";
import { loader } from "../loaders/org.$slug.server";
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "../tournament-organization-constants";
import { updateIsEstablishedSchema } from "../tournament-organization-schemas";
export { action, loader };
import "../tournament-organization.css";
@ -142,31 +143,23 @@ function LogoHeader() {
function AdminControls() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const isAdmin = useHasRole("ADMIN");
if (!isAdmin) return null;
const onChange = (isSelected: boolean) => {
fetcher.submit(
{ _action: "UPDATE_IS_ESTABLISHED", isEstablished: isSelected },
{ method: "post", encType: "application/json" },
);
};
return (
<div className="stack sm">
<div className="text-sm font-semi-bold">Admin Controls</div>
<div>
<SendouSwitch
defaultSelected={Boolean(data.organization.isEstablished)}
onChange={onChange}
isDisabled={fetcher.state !== "idle"}
data-testid="is-established-switch"
>
Is Established Organization
</SendouSwitch>
</div>
<SendouForm
className=""
schema={updateIsEstablishedSchema}
defaultValues={{
isEstablished: Boolean(data.organization.isEstablished),
}}
autoSubmit
>
{({ FormField }) => <FormField name="isEstablished" />}
</SendouForm>
</div>
);
}

View File

@ -1,19 +1,15 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Alert } from "~/components/Alert";
import { InputFormField } from "~/components/form/InputFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { Main } from "~/components/Main";
import { SendouForm } from "~/form/SendouForm";
import { useHasRole } from "~/modules/permissions/hooks";
import { action } from "../actions/org.new.server";
import { newOrganizationSchema } from "../tournament-organization-schemas";
export { action };
type FormFields = z.infer<typeof newOrganizationSchema>;
export default function NewOrganizationPage() {
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
const { t } = useTranslation(["common", "org"]);
const { t } = useTranslation(["org"]);
if (!isTournamentAdder) {
return (
@ -25,18 +21,8 @@ export default function NewOrganizationPage() {
return (
<Main halfWidth>
<SendouForm
heading={t("org:new.heading")}
schema={newOrganizationSchema}
defaultValues={{
name: "",
}}
>
<InputFormField<FormFields>
label={t("common:forms.name")}
name="name"
required
/>
<SendouForm title={t("org:new.heading")} schema={newOrganizationSchema}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
</Main>
);

View File

@ -1,116 +1,103 @@
import { isFuture } from "date-fns";
import { z } from "zod";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { TOURNAMENT_ORGANIZATION } from "~/features/tournament-organization/tournament-organization-constants";
import { dayMonthYearToDate } from "~/utils/dates";
import { mySlugify } from "~/utils/urls";
import {
_action,
dayMonthYear,
falsyToNull,
id,
safeNullableStringSchema,
} from "~/utils/zod";
array,
badges,
datetimeOptional,
fieldset,
select,
stringConstant,
textAreaOptional,
textFieldOptional,
textFieldRequired,
toggle,
userSearch,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { _action, id } from "~/utils/zod";
const nameSchema = z
.string()
.trim()
.min(2)
.max(64)
.refine((val) => mySlugify(val).length >= 2, {
message: "Not enough non-special characters",
});
export const newOrganizationSchema = z.object({
name: nameSchema,
const orgNameField = textFieldRequired({
label: "labels.name",
minLength: 2,
maxLength: 64,
validate: {
func: (val) => mySlugify(val).length > 0,
message: "forms:errors.noOnlySpecialCharacters",
},
});
export const organizationEditSchema = z.object({
name: nameSchema,
description: z.preprocess(
falsyToNull,
z
.string()
.trim()
.max(TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH)
.nullable(),
),
members: z
.array(
z.object({
userId: z.number().int().positive(),
role: z.enum(TOURNAMENT_ORGANIZATION_ROLES),
roleDisplayName: z.preprocess(
falsyToNull,
z.string().trim().max(32).nullable(),
),
export const newOrganizationSchema = z.object({
name: orgNameField,
});
export const organizationEditFormSchema = z.object({
name: orgNameField,
description: textAreaOptional({
label: "labels.description",
maxLength: TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH,
}),
members: array({
label: "labels.members",
bottomText: "bottomTexts.orgMembersInfo",
max: 32,
field: fieldset({
fields: z.object({
userId: userSearch({ label: "labels.orgMemberUser" }),
role: select({
label: "labels.orgMemberRole",
items: TOURNAMENT_ORGANIZATION_ROLES.map((role) => ({
value: role,
label: `options.orgRole.${role}` as const,
})),
}),
roleDisplayName: textFieldOptional({
label: "labels.orgMemberRoleDisplayName",
maxLength: 32,
}),
}),
)
.max(32)
.refine(
(arr) =>
arr.map((x) => x.userId).length ===
new Set(arr.map((x) => x.userId)).size,
{
message: "Same member listed twice",
},
),
socials: z
.array(
z.object({
value: z.string().trim().url().max(100).optional().or(z.literal("")),
}),
}),
socials: array({
label: "labels.orgSocialLinks",
max: 10,
field: textFieldRequired({ validate: "url", maxLength: 100 }),
}),
series: array({
label: "labels.orgSeries",
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({
label: "labels.orgSeriesName",
minLength: 1,
maxLength: 32,
}),
description: textAreaOptional({
label: "labels.description",
maxLength: TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH,
}),
showLeaderboard: toggle({ label: "labels.orgSeriesShowLeaderboard" }),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.value).length ===
new Set(arr.map((x) => x.value)).size,
{
message: "Duplicate social links",
},
),
series: z
.array(
z.object({
name: z.string().trim().min(1).max(32),
description: z.preprocess(
falsyToNull,
z
.string()
.trim()
.max(TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH)
.nullable(),
),
showLeaderboard: z.boolean(),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.name).length === new Set(arr.map((x) => x.name)).size,
{
message: "Duplicate series",
},
),
badges: z.array(id).max(50),
}),
}),
badges: badges({ label: "labels.orgBadges", maxCount: 50 }),
});
export const banUserActionSchema = z.object({
_action: _action("BAN_USER"),
userId: id,
privateNote: safeNullableStringSchema({
max: TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH,
_action: stringConstant("BAN_USER"),
userId: userSearch({ label: "labels.banUserPlayer" }),
privateNote: textAreaOptional({
label: "labels.banUserNote",
bottomText: "bottomTexts.banUserNoteHelp",
maxLength: TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH,
}),
expiresAt: datetimeOptional({
label: "labels.banUserExpiresAt",
bottomText: "bottomTexts.banUserExpiresAtHelp",
min: new Date(),
minMessage: "errors.dateInPast",
}),
expiresAt: dayMonthYear.nullish().refine(
(data) => {
if (!data) return true;
return isFuture(dayMonthYearToDate(data));
},
{
message: "Date must be in the future",
},
),
});
const unbanUserActionSchema = z.object({
@ -118,13 +105,15 @@ const unbanUserActionSchema = z.object({
userId: id,
});
const updateIsEstablishedActionSchema = z.object({
_action: _action("UPDATE_IS_ESTABLISHED"),
isEstablished: z.boolean(),
export const updateIsEstablishedSchema = z.object({
_action: stringConstant("UPDATE_IS_ESTABLISHED"),
isEstablished: toggle({
label: "labels.isEstablished",
}),
});
export const orgPageActionSchema = z.union([
banUserActionSchema,
unbanUserActionSchema,
updateIsEstablishedActionSchema,
updateIsEstablishedSchema,
]);

View File

@ -1,158 +1,43 @@
import { type ActionFunction, redirect } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { BUILD } from "~/features/builds/builds-constants";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { modesShort } from "~/modules/in-game-lists/modes";
import { parseFormData } from "~/form/parse.server";
import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types";
import { unJsonify } from "~/utils/kysely.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { userBuildsPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToBoolean,
checkboxValueToDbBoolean,
clothesMainSlotAbility,
dbBoolean,
falsyToNull,
filterOutNullishMembers,
headMainSlotAbility,
id,
processMany,
removeDuplicates as removeDuplicatesZod,
safeJSONParse,
shoesMainSlotAbility,
stackableAbility,
weaponSplId,
} from "~/utils/zod";
import { newBuildSchemaServer } from "../user-page-schemas.server";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: newBuildActionSchema,
schema: newBuildSchemaServer,
});
const usersBuilds = await BuildRepository.allByUserId(user.id, {
showPrivate: true,
});
if (usersBuilds.length >= BUILD.MAX_COUNT) {
throw new Response("Max amount of builds reached", { status: 400 });
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
errorToastIfFalsy(
!data.buildToEditId ||
usersBuilds.some((build) => build.id === data.buildToEditId),
"Build to edit not found",
);
const someGearIsMissing = !data.HEAD || !data.CLOTHES || !data.SHOES;
const commonArgs = {
title: data.title,
description: data.description,
abilities: data.abilities as BuildAbilitiesTuple,
headGearSplId: (someGearIsMissing ? -1 : data.HEAD)!,
clothesGearSplId: (someGearIsMissing ? -1 : data.CLOTHES)!,
shoesGearSplId: (someGearIsMissing ? -1 : data.SHOES)!,
modes: modesShort.filter((mode) => data[mode]),
weaponSplIds: data.weapons,
title: result.data.title,
description: result.data.description,
abilities: result.data.abilities as BuildAbilitiesTuple,
headGearSplId: result.data.head,
clothesGearSplId: result.data.clothes,
shoesGearSplId: result.data.shoes,
modes: result.data.modes,
weaponSplIds: result.data.weapons.map((w) => w.id),
ownerId: user.id,
private: data.private,
private: result.data.private ? 1 : 0,
};
if (data.buildToEditId) {
await BuildRepository.update({ id: data.buildToEditId, ...commonArgs });
if (result.data.buildToEditId) {
await BuildRepository.update({
id: result.data.buildToEditId,
...commonArgs,
});
} else {
await BuildRepository.create(commonArgs);
}
return redirect(userBuildsPage(user));
};
const newBuildActionSchema = z.object({
buildToEditId: z.preprocess(actualNumber, id.nullish()),
title: z
.string()
.min(BUILD.TITLE_MIN_LENGTH)
.max(BUILD.TITLE_MAX_LENGTH)
.transform(unJsonify),
description: z.preprocess(
falsyToNull,
z
.string()
.max(BUILD.DESCRIPTION_MAX_LENGTH)
.nullable()
.transform(unJsonify),
),
TW: z.preprocess(checkboxValueToBoolean, z.boolean()),
SZ: z.preprocess(checkboxValueToBoolean, z.boolean()),
TC: z.preprocess(checkboxValueToBoolean, z.boolean()),
RM: z.preprocess(checkboxValueToBoolean, z.boolean()),
CB: z.preprocess(checkboxValueToBoolean, z.boolean()),
private: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
weapons: z.preprocess(
processMany(safeJSONParse, filterOutNullishMembers, removeDuplicatesZod),
z.array(weaponSplId).min(1).max(BUILD.MAX_WEAPONS_COUNT),
),
HEAD: z.preprocess(
actualNumber,
z
.number()
.optional()
.refine(
(val) =>
val === undefined ||
headGearIds.includes(val as (typeof headGearIds)[number]),
),
),
CLOTHES: z.preprocess(
actualNumber,
z
.number()
.optional()
.refine(
(val) =>
val === undefined ||
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
),
),
SHOES: z.preprocess(
actualNumber,
z
.number()
.optional()
.refine(
(val) =>
val === undefined ||
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
),
),
abilities: z.preprocess(
safeJSONParse,
z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]),
),
});

View File

@ -1,10 +1,10 @@
import { type ActionFunction, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
} from "~/features/user-page/components/UserResultsTable";
import * as UserRepository from "~/features/user-page/UserRepository.server";
} from "~/features/user-page/user-page-constants";
import { normalizeFormFieldArray } from "~/utils/arrays";
import { parseRequestPayload } from "~/utils/remix.server";
import { userResultsPage } from "~/utils/urls";

View File

@ -0,0 +1,206 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
import { NewBuildForm } from "./NewBuildForm";
let mockFetcherData: { fieldErrors?: Record<string, string> } | undefined;
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useFetcher: () => ({
get data() {
return mockFetcherData;
},
state: "idle",
submit: vi.fn(),
}),
};
});
function renderForm(options?: {
defaultValues?: Record<string, unknown>;
gearIdToAbilities?: Record<string, BuildAbilitiesTupleWithUnknown[number]>;
isEditing?: boolean;
}) {
const router = createMemoryRouter(
[
{
path: "/",
element: (
<NewBuildForm
defaultValues={options?.defaultValues}
gearIdToAbilities={options?.gearIdToAbilities ?? {}}
isEditing={options?.isEditing}
/>
),
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
const GEAR_ERROR_MESSAGE = "Fill all gear slots or leave all empty";
describe("NewBuildForm", () => {
beforeEach(() => {
mockFetcherData = undefined;
});
describe("gear validation - all or none", () => {
test("shows error when only head gear is provided via default values", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
head: 1,
clothes: null,
shoes: null,
abilities: [
["LDE", "ISM", "ISM", "ISM"],
["NS", "SSU", "SSU", "SSU"],
["SJ", "SRU", "SRU", "SRU"],
],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
await expect.element(screen.getByText(GEAR_ERROR_MESSAGE)).toBeVisible();
});
test("shows error when head and clothes selected but not shoes", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
head: 1,
clothes: 1,
shoes: null,
abilities: [
["LDE", "ISM", "ISM", "ISM"],
["NS", "SSU", "SSU", "SSU"],
["SJ", "SRU", "SRU", "SRU"],
],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
await expect.element(screen.getByText(GEAR_ERROR_MESSAGE)).toBeVisible();
});
test("no gear error when all three gear pieces are selected", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
head: 1,
clothes: 1,
shoes: 1,
abilities: [
["LDE", "ISM", "ISM", "ISM"],
["NS", "SSU", "SSU", "SSU"],
["SJ", "SRU", "SRU", "SRU"],
],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const gearError = screen.container.querySelector("#head-error");
expect(gearError?.textContent?.includes(GEAR_ERROR_MESSAGE)).toBeFalsy();
});
test("no gear error when no gear is selected", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
head: null,
clothes: null,
shoes: null,
abilities: [
["LDE", "ISM", "ISM", "ISM"],
["NS", "SSU", "SSU", "SSU"],
["SJ", "SRU", "SRU", "SRU"],
],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const gearError = screen.container.querySelector("#head-error");
expect(gearError?.textContent?.includes(GEAR_ERROR_MESSAGE)).toBeFalsy();
});
});
describe("abilities validation", () => {
test("shows error when abilities contain UNKNOWN values", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const abilitiesError = screen.container.querySelector("#abilities-error");
expect(abilitiesError?.textContent).toBe("This field is required");
});
test("no abilities error when all abilities are set", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
abilities: [
["LDE", "ISM", "ISM", "ISM"],
["NS", "SSU", "SSU", "SSU"],
["SJ", "SRU", "SRU", "SRU"],
],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const abilitiesError = screen.container.querySelector("#abilities-error");
expect(abilitiesError?.textContent).toBeFalsy();
});
});
describe("ability prefill from gear", () => {
test("prefills abilities when selecting gear with known abilities", async () => {
const screen = await renderForm({
defaultValues: {
weapons: [{ id: 0, isFavorite: false }],
title: "Test Build",
},
gearIdToAbilities: {
HEAD_21000: ["LDE", "ISM", "ISM", "ISM"],
},
});
const getUnknownCount = () =>
screen.container.querySelectorAll('[data-testid="UNKNOWN-ability"]')
.length;
expect(getUnknownCount()).toBe(12);
const headGearSelect = screen.getByTestId("HEAD-gear-select");
await userEvent.click(headGearSelect.element());
const searchInput = screen.getByPlaceholder("Search gear...");
await userEvent.type(searchInput.element(), "Headlamp Helmet");
await userEvent.keyboard("{Enter}");
expect(getUnknownCount()).toBe(8);
});
});
});

View File

@ -0,0 +1,164 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { GearSelect } from "~/components/GearSelect";
import type { GearType } from "~/db/tables";
import type { CustomFieldRenderProps } from "~/form/FormField";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
import { newBuildSchema } from "../user-page-schemas";
interface NewBuildFormProps {
defaultValues?: Parameters<typeof SendouForm>[0]["defaultValues"];
gearIdToAbilities: Record<string, BuildAbilitiesTupleWithUnknown[number]>;
isEditing?: boolean;
}
export function NewBuildForm({
defaultValues,
gearIdToAbilities,
isEditing,
}: NewBuildFormProps) {
const { t } = useTranslation(["builds"]);
return (
<SendouForm
schema={newBuildSchema}
defaultValues={
defaultValues ?? {
modes: [...rankedModesShort],
}
}
title={t(
isEditing ? "builds:forms.title.edit" : "builds:forms.title.new",
)}
>
{({ FormField }) => (
<>
<FormField name="weapons" />
<FormField name="head">
{(props: CustomFieldRenderProps) => (
<GearFormField
type="HEAD"
gearIdToAbilities={gearIdToAbilities}
{...props}
/>
)}
</FormField>
<FormField name="clothes">
{(props: CustomFieldRenderProps) => (
<GearFormField
type="CLOTHES"
gearIdToAbilities={gearIdToAbilities}
{...props}
/>
)}
</FormField>
<FormField name="shoes">
{(props: CustomFieldRenderProps) => (
<GearFormField
type="SHOES"
gearIdToAbilities={gearIdToAbilities}
{...props}
/>
)}
</FormField>
<FormField name="abilities">
{(props: CustomFieldRenderProps) => (
<AbilitiesFormField {...props} />
)}
</FormField>
<FormField name="title" />
<FormField name="description" />
<FormField name="modes" />
<FormField name="private" />
</>
)}
</SendouForm>
);
}
function GearFormField({
type,
name,
value,
onChange,
error,
gearIdToAbilities,
}: {
type: GearType;
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
gearIdToAbilities: Record<string, BuildAbilitiesTupleWithUnknown[number]>;
}) {
const { t } = useTranslation("builds");
const { values, setValue } = useFormFieldContext();
const id = React.useId();
const handleChange = (gearId: number | null) => {
onChange(gearId);
if (!gearId) return;
const abilitiesFromExistingGear = gearIdToAbilities[`${type}_${gearId}`];
if (!abilitiesFromExistingGear) return;
const abilities = values.abilities as BuildAbilitiesTupleWithUnknown;
const gearIndex = type === "HEAD" ? 0 : type === "CLOTHES" ? 1 : 2;
const currentAbilities = abilities[gearIndex];
if (!currentAbilities.every((a) => a === "UNKNOWN")) return;
const newAbilities = structuredClone(abilities);
newAbilities[gearIndex] = abilitiesFromExistingGear;
setValue("abilities", newAbilities);
};
return (
<FormFieldWrapper
id={id}
name={name}
label={t(`forms.gear.${type}`)}
error={error}
>
<GearSelect
type={type}
value={value as number | null}
clearable
onChange={handleChange}
/>
</FormFieldWrapper>
);
}
function AbilitiesFormField({
name,
value,
onChange,
error,
}: {
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}) {
const { t } = useTranslation("builds");
return (
<FormFieldWrapper
id={`${name}-abilities`}
name={name}
label={t("forms.abilities")}
error={error}
>
<AbilitiesSelector
selectedAbilities={value as BuildAbilitiesTupleWithUnknown}
onChange={onChange}
/>
</FormFieldWrapper>
);
}

View File

@ -15,6 +15,10 @@ import {
userPage,
} from "~/utils/urls";
import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server";
import {
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
} from "../user-page-constants";
import { ParticipationPill } from "./ParticipationPill";
export type UserResultsTableProps = {
@ -23,9 +27,6 @@ export type UserResultsTableProps = {
hasHighlightCheckboxes?: boolean;
};
export const HIGHLIGHT_CHECKBOX_NAME = "highlightTeamIds";
export const HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME = "highlightTournamentTeamIds";
export function UserResultsTable({
results,
id,

View File

@ -1,9 +1,15 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import {
validatedBuildFromSearchParams,
validatedWeaponIdFromSearchParams,
} from "~/features/build-analyzer/core/utils";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { Ability } from "~/modules/in-game-lists/types";
import { actualNumber, id } from "~/utils/zod";
import type { newBuildBaseSchema } from "../user-page-schemas";
const newBuildLoaderParamsSchema = z.object({
buildId: z.preprocess(actualNumber, id),
@ -25,7 +31,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
);
return {
buildToEdit,
defaultValues: resolveDefaultValues(url.searchParams, buildToEdit),
gearIdToAbilities: resolveGearIdToAbilities(),
};
@ -42,3 +48,53 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
);
}
};
type NewBuildDefaultValues = Partial<z.infer<typeof newBuildBaseSchema>>;
function resolveDefaultValues(
searchParams: URLSearchParams,
buildToEdit:
| Awaited<ReturnType<typeof BuildRepository.allByUserId>>[number]
| undefined,
): NewBuildDefaultValues | null {
const weapons = resolveDefaultWeapons();
const abilities =
buildToEdit?.abilities ?? validatedBuildFromSearchParams(searchParams);
if (!buildToEdit && weapons.length === 0 && !abilities) {
return null;
}
return {
buildToEditId: buildToEdit?.id,
weapons,
head: buildToEdit?.headGearSplId === -1 ? null : buildToEdit?.headGearSplId,
clothes:
buildToEdit?.clothesGearSplId === -1
? null
: buildToEdit?.clothesGearSplId,
shoes:
buildToEdit?.shoesGearSplId === -1 ? null : buildToEdit?.shoesGearSplId,
abilities,
title: buildToEdit?.title,
description: buildToEdit?.description,
modes: buildToEdit?.modes ?? [],
private: Boolean(buildToEdit?.private),
};
function resolveDefaultWeapons(): WeaponPoolItem[] {
if (buildToEdit) {
return buildToEdit.weapons.map((wpn) => ({
id: wpn.weaponSplId,
isFavorite: false,
}));
}
const weaponIdFromParams = validatedWeaponIdFromSearchParams(searchParams);
if (weaponIdFromParams) {
return [{ id: weaponIdFromParams, isFavorite: false }];
}
return [];
}
}

View File

@ -1,16 +1,13 @@
import { useLoaderData } from "react-router";
import type { z } from "zod";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { PlusIcon } from "~/components/icons/Plus";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { USER } from "~/features/user-page/user-page-constants";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { SendouForm } from "~/form";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { databaseTimestampToDate } from "~/utils/dates";
import { action } from "../actions/u.$identifier.admin.server";
@ -156,8 +153,6 @@ function ModNotes() {
);
}
type FormFields = z.infer<typeof addModNoteSchema>;
function NewModNoteDialog() {
return (
<SendouDialog
@ -169,19 +164,8 @@ function NewModNoteDialog() {
</SendouButton>
}
>
<SendouForm
schema={addModNoteSchema}
defaultValues={{
value: "",
_action: "ADD_MOD_NOTE",
}}
>
<TextAreaFormField<FormFields>
name="value"
label="Text"
maxLength={USER.MOD_NOTE_MAX_LENGTH}
bottomText="This note will be only visible to staff members."
/>
<SendouForm schema={addModNoteSchema}>
{({ FormField }) => <FormField name="value" />}
</SendouForm>
</SendouDialog>
);

View File

@ -1,53 +1,27 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, useLoaderData, useMatches, useSearchParams } from "react-router";
import { AbilitiesSelector } from "~/components/AbilitiesSelector";
import { useLoaderData, useMatches } from "react-router";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { GearSelect } from "~/components/GearSelect";
import { Image } from "~/components/Image";
import { CrossIcon } from "~/components/icons/Cross";
import { PlusIcon } from "~/components/icons/Plus";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { GearType } from "~/db/tables";
import {
validatedBuildFromSearchParams,
validatedWeaponIdFromSearchParams,
} from "~/features/build-analyzer/core/utils";
import { BUILD } from "~/features/builds/builds-constants";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import type {
BuildAbilitiesTupleWithUnknown,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { modeImageUrl } from "~/utils/urls";
import { action } from "../actions/u.$identifier.builds.new.server";
import { NewBuildForm } from "../components/NewBuildForm";
import { loader } from "../loaders/u.$identifier.builds.new.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
export { loader, action };
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "gear"],
};
export default function NewBuildPage() {
const { buildToEdit } = useLoaderData<typeof loader>();
const { defaultValues, gearIdToAbilities } = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const { t } = useTranslation(["builds", "common"]);
const [searchParams] = useSearchParams();
const [abilities, setAbilities] =
React.useState<BuildAbilitiesTupleWithUnknown>(
buildToEdit?.abilities ?? validatedBuildFromSearchParams(searchParams),
);
const { t } = useTranslation(["builds"]);
if (layoutData.user.buildsCount >= BUILD.MAX_COUNT) {
return (
@ -59,280 +33,10 @@ export default function NewBuildPage() {
return (
<div className="half-width u__build-form">
<Form className="stack md items-start" method="post">
{buildToEdit && (
<input type="hidden" name="buildToEditId" value={buildToEdit.id} />
)}
<WeaponsSelector />
<FormMessage type="info">{t("builds:forms.noGear.info")}</FormMessage>
<GearSelector
type="HEAD"
abilities={abilities}
setAbilities={setAbilities}
/>
<GearSelector
type="CLOTHES"
abilities={abilities}
setAbilities={setAbilities}
/>
<GearSelector
type="SHOES"
abilities={abilities}
setAbilities={setAbilities}
/>
<div /> {/* spacer */}
<Abilities abilities={abilities} setAbilities={setAbilities} />
<TitleInput />
<DescriptionTextarea />
<ModeCheckboxes />
<PrivateCheckbox />
<SubmitButton className="mt-4">
{t("common:actions.submit")}
</SubmitButton>
</Form>
</div>
);
}
function TitleInput() {
const { t } = useTranslation("builds");
const { buildToEdit } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="title" required>
{t("forms.title")}
</Label>
<input
id="title"
name="title"
required
minLength={BUILD.TITLE_MIN_LENGTH}
maxLength={BUILD.TITLE_MAX_LENGTH}
defaultValue={buildToEdit?.title}
/>
</div>
);
}
function DescriptionTextarea() {
const { t } = useTranslation();
const { buildToEdit } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(buildToEdit?.description ?? "");
return (
<div>
<Label
htmlFor="description"
valueLimits={{
current: value.length,
max: BUILD.DESCRIPTION_MAX_LENGTH,
}}
>
{t("forms.description")}
</Label>
<textarea
id="description"
name="description"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={BUILD.DESCRIPTION_MAX_LENGTH}
/>
</div>
);
}
function ModeCheckboxes() {
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation("builds");
const modes = buildToEdit?.modes ?? rankedModesShort;
return (
<div>
<Label>{t("forms.modes")}</Label>
<div className="stack horizontal md">
{modesShort.map((mode) => (
<div key={mode} className="stack items-center">
<label htmlFor={mode}>
<Image alt="" path={modeImageUrl(mode)} width={24} height={24} />
</label>
<input
id={mode}
name={mode}
type="checkbox"
defaultChecked={modes.includes(mode)}
data-testid={`${mode}-checkbox`}
/>
</div>
))}
</div>
</div>
);
}
function PrivateCheckbox() {
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation(["builds", "common"]);
return (
<div>
<Label htmlFor="private">{t("common:build.private")}</Label>
<input
id="private"
name="private"
type="checkbox"
defaultChecked={Boolean(buildToEdit?.private)}
/>
<FormMessage type="info" className="mt-0">
{t("builds:forms.private.info")}
</FormMessage>
</div>
);
}
function WeaponsSelector() {
const [searchParams] = useSearchParams();
const { buildToEdit } = useLoaderData<typeof loader>();
const { t } = useTranslation(["common", "weapons", "builds"]);
const [weapons, setWeapons] = React.useState<Array<MainWeaponId | null>>(
buildToEdit?.weapons.map((wpn) => wpn.weaponSplId) ?? [
validatedWeaponIdFromSearchParams(searchParams),
],
);
return (
<div>
<Label required htmlFor="weapon">
{t("builds:forms.weapons")}
</Label>
<input type="hidden" name="weapons" value={JSON.stringify(weapons)} />
<div className="stack sm">
{weapons.map((weapon, i) => {
return (
<div key={i} className="stack horizontal sm items-center">
<WeaponSelect
isRequired
onChange={(weaponId) =>
setWeapons((weapons) => {
const newWeapons = [...weapons];
newWeapons[i] = weaponId;
return newWeapons;
})
}
value={weapon ?? null}
testId={`weapon-${i}`}
/>
{i === weapons.length - 1 && (
<>
<SendouButton
size="small"
isDisabled={weapons.length === BUILD.MAX_WEAPONS_COUNT}
onPress={() => setWeapons((weapons) => [...weapons, null])}
icon={<PlusIcon />}
data-testid="add-weapon-button"
/>
{weapons.length > 1 && (
<SendouButton
size="small"
onPress={() =>
setWeapons((weapons) => {
const newWeapons = [...weapons];
newWeapons.pop();
return newWeapons;
})
}
variant="destructive"
icon={<CrossIcon />}
/>
)}
</>
)}
</div>
);
})}
</div>
</div>
);
}
function GearSelector({
type,
abilities,
setAbilities,
}: {
type: GearType;
abilities: BuildAbilitiesTupleWithUnknown;
setAbilities: (abilities: BuildAbilitiesTupleWithUnknown) => void;
}) {
const { buildToEdit, gearIdToAbilities } = useLoaderData<typeof loader>();
const { t } = useTranslation("builds");
const [value, setValue] = React.useState(() => {
const gearId = !buildToEdit
? null
: type === "HEAD"
? buildToEdit.headGearSplId
: type === "CLOTHES"
? buildToEdit.clothesGearSplId
: buildToEdit.shoesGearSplId;
if (gearId === -1) return null;
return gearId;
});
return (
<>
<input type="hidden" name={type} value={value ?? ""} />
<GearSelect
label={t(`forms.gear.${type}`)}
type={type}
value={value}
clearable
onChange={(gearId) => {
setValue(gearId);
if (!gearId) return;
const abilitiesFromExistingGear =
gearIdToAbilities[`${type}_${gearId}`];
if (!abilitiesFromExistingGear) return;
const gearIndex = type === "HEAD" ? 0 : type === "CLOTHES" ? 1 : 2;
const currentAbilities = abilities[gearIndex];
// let's not overwrite current selections
if (!currentAbilities.every((a) => a === "UNKNOWN")) return;
const newAbilities = structuredClone(abilities);
newAbilities[gearIndex] = abilitiesFromExistingGear;
setAbilities(newAbilities);
}}
/>
</>
);
}
function Abilities({
abilities,
setAbilities,
}: {
abilities: BuildAbilitiesTupleWithUnknown;
setAbilities: (abilities: BuildAbilitiesTupleWithUnknown) => void;
}) {
return (
<div>
<RequiredHiddenInput
value={JSON.stringify(abilities)}
isValid={abilities.flat().every((a) => a !== "UNKNOWN")}
name="abilities"
/>
<AbilitiesSelector
selectedAbilities={abilities}
onChange={setAbilities}
<NewBuildForm
defaultValues={defaultValues}
gearIdToAbilities={gearIdToAbilities}
isEditing={defaultValues?.buildToEditId != null}
/>
</div>
);

View File

@ -1,3 +1,6 @@
export const HIGHLIGHT_CHECKBOX_NAME = "highlightTeamIds";
export const HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME = "highlightTournamentTeamIds";
export const USER = {
BIO_MAX_LENGTH: 2000,
CUSTOM_URL_MAX_LENGTH: 32,

View File

@ -0,0 +1,17 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { gearAllOrNoneRefine, newBuildBaseSchema } from "./user-page-schemas";
export const newBuildSchemaServer = newBuildBaseSchema
.refine(gearAllOrNoneRefine.fn, gearAllOrNoneRefine.opts)
.refine(
async (data) => {
if (!data.buildToEditId) return true;
const user = requireUser();
const ownerId = await BuildRepository.ownerIdById(data.buildToEditId);
return ownerId === user.id;
},
{ message: "Not a build you own", path: ["buildToEditId"] },
);

View File

@ -1,29 +1,50 @@
import { z } from "zod";
import { OBJECT_PRONOUNS, SUBJECT_PRONOUNS } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import {
checkboxGroup,
customField,
idConstantOptional,
stringConstant,
textAreaOptional,
textAreaRequired,
textFieldRequired,
toggle,
weaponPool,
} from "~/form/fields";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { isCustomUrl } from "~/utils/urls";
import {
_action,
actualNumber,
checkboxValueToDbBoolean,
clothesMainSlotAbility,
customCssVarObject,
dbBoolean,
emptyArrayToNull,
falsyToNull,
headMainSlotAbility,
id,
nullLiteraltoNull,
processMany,
safeJSONParse,
safeNullableStringSchema,
shoesMainSlotAbility,
stackableAbility,
undefinedToNull,
weaponSplId,
} from "~/utils/zod";
import * as Seasons from "../mmr/core/Seasons";
import {
COUNTRY_CODES,
HIGHLIGHT_CHECKBOX_NAME,
HIGHLIGHT_TOURNAMENT_CHECKBOX_NAME,
} from "./components/UserResultsTable";
import { COUNTRY_CODES, USER } from "./user-page-constants";
USER,
} from "./user-page-constants";
export const userParamsSchema = z.object({ identifier: z.string() });
@ -155,8 +176,12 @@ export const editHighlightsActionSchema = z.object({
});
export const addModNoteSchema = z.object({
_action: _action("ADD_MOD_NOTE"),
value: z.string().trim().min(1).max(USER.MOD_NOTE_MAX_LENGTH),
_action: stringConstant("ADD_MOD_NOTE"),
value: textAreaRequired({
label: "labels.text",
bottomText: "bottomTexts.modNote",
maxLength: USER.MOD_NOTE_MAX_LENGTH,
}),
});
const deleteModNoteSchema = z.object({
@ -173,3 +198,118 @@ export const userResultsPageSearchParamsSchema = z.object({
all: z.stringbool().catch(false),
page: z.coerce.number().min(1).max(1_000).catch(1),
});
const headGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null || headGearIds.includes(val as (typeof headGearIds)[number]),
);
const clothesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
clothesGearIds.includes(val as (typeof clothesGearIds)[number]),
);
const shoesGearIdSchema = z
.number()
.nullable()
.refine(
(val) =>
val === null ||
shoesGearIds.includes(val as (typeof shoesGearIds)[number]),
);
const abilitiesSchema = z.tuple([
z.tuple([
headMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
clothesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
z.tuple([
shoesMainSlotAbility,
stackableAbility,
stackableAbility,
stackableAbility,
]),
]);
const modeItems = [
{ label: "modes.TW" as const, value: "TW" as const },
{ label: "modes.SZ" as const, value: "SZ" as const },
{ label: "modes.TC" as const, value: "TC" as const },
{ label: "modes.RM" as const, value: "RM" as const },
{ label: "modes.CB" as const, value: "CB" as const },
];
export const newBuildBaseSchema = z.object({
buildToEditId: idConstantOptional(),
weapons: weaponPool({
label: "labels.buildWeapons",
minCount: 1,
maxCount: 5,
disableSorting: true,
disableFavorites: true,
}),
head: customField({ initialValue: null }, headGearIdSchema),
clothes: customField({ initialValue: null }, clothesGearIdSchema),
shoes: customField({ initialValue: null }, shoesGearIdSchema),
abilities: customField(
{
initialValue: [
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
["UNKNOWN", "UNKNOWN", "UNKNOWN", "UNKNOWN"],
],
},
abilitiesSchema,
),
title: textFieldRequired({
label: "labels.buildTitle",
maxLength: 50,
}),
description: textAreaOptional({
label: "labels.description",
maxLength: 280,
}),
modes: checkboxGroup({
label: "labels.buildModes",
items: modeItems,
}),
private: toggle({
label: "labels.buildPrivate",
bottomText: "bottomTexts.buildPrivate",
}),
});
function validateGearAllOrNone(data: {
head: number | null;
clothes: number | null;
shoes: number | null;
}) {
const gearFilled = [data.head, data.clothes, data.shoes].filter(
(g) => g !== null,
);
return gearFilled.length === 0 || gearFilled.length === 3;
}
export const gearAllOrNoneRefine = {
fn: validateGearAllOrNone,
opts: { message: "forms:errors.gearAllOrNone", path: ["head"] },
};
export const newBuildSchema = newBuildBaseSchema.refine(
gearAllOrNoneRefine.fn,
gearAllOrNoneRefine.opts,
);

View File

@ -86,7 +86,7 @@ export async function findVods({
query = query.where("VideoMatch.stageId", "=", stageId);
}
}
if (weapon) {
if (typeof weapon === "number") {
query = query.where(
"VideoMatchPlayer.weaponSplId",
"in",

View File

@ -1,51 +1,97 @@
import { type ActionFunction, redirect } from "react-router";
import type { z } from "zod";
import type { Tables } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import { parseFormData } from "~/form/parse.server";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { requireRole } from "~/modules/permissions/guards.server";
import { notFoundIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { vodVideoPage } from "~/utils/urls";
import * as VodRepository from "../VodRepository.server";
import { videoInputSchema } from "../vods-schemas";
import { canEditVideo } from "../vods-utils";
import { vodFormSchemaServer } from "../vods-schemas.server";
import type { VideoBeingAdded } from "../vods-types";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
requireRole(user, "VIDEO_ADDER");
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: videoInputSchema,
schema: vodFormSchemaServer,
});
let video: Tables["Video"];
if (data.vodToEditId) {
const vod = notFoundIfFalsy(
await VodRepository.findVodById(data.vodToEditId),
);
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
if (
!canEditVideo({
userId: user.id,
submitterUserId: vod.submitterUserId,
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
})
) {
throw new Response("no permissions to edit this vod", { status: 401 });
}
const formData = result.data;
const video = transformFormDataToVideo(formData);
video = await VodRepository.update({
...data.video,
let savedVideo: Tables["Video"];
if (formData.vodToEditId) {
savedVideo = await VodRepository.update({
...video,
submitterUserId: user.id,
isValidated: true,
id: data.vodToEditId,
id: formData.vodToEditId,
});
} else {
video = await VodRepository.insert({
...data.video,
savedVideo = await VodRepository.insert({
...video,
submitterUserId: user.id,
isValidated: true,
});
}
throw redirect(vodVideoPage(video.id));
throw redirect(vodVideoPage(savedVideo.id));
};
type VodFormData = z.output<typeof vodFormSchemaServer>;
function transformFormDataToVideo(data: VodFormData): VideoBeingAdded {
const teamSize = data.teamSize ? Number(data.teamSize) : 4;
return {
type: data.type,
youtubeUrl: data.youtubeUrl,
title: data.title,
date: data.date,
pov: transformPov(data.pov),
teamSize: data.type === "CAST" ? teamSize : undefined,
matches: data.matches.map((match) => ({
startsAt: match.startsAt,
mode: match.mode,
stageId: match.stageId as StageId,
weapons:
data.type === "CAST"
? [
...weaponPoolToIds(match.weaponsTeamOne ?? []),
...weaponPoolToIds(match.weaponsTeamTwo ?? []),
]
: typeof match.weapon === "number"
? [match.weapon as MainWeaponId]
: [],
})),
};
}
function weaponPoolToIds(pool: WeaponPoolItem[]): MainWeaponId[] {
return pool.map((item) => item.id as MainWeaponId);
}
function transformPov(
pov:
| { type: "USER"; userId?: number }
| { type: "NAME"; name: string }
| undefined,
):
| { type: "USER"; userId: number }
| { type: "NAME"; name: string }
| undefined {
if (!pov) return undefined;
if (pov.type === "NAME") return pov;
if (pov.type === "USER" && pov.userId) {
return { type: "USER", userId: pov.userId };
}
return undefined;
}

View File

@ -180,7 +180,7 @@ function Match({
width={120}
className="rounded"
/>
{weapon ? (
{typeof weapon === "number" ? (
<WeaponImage
weaponSplId={weapon}
variant="badge"

View File

@ -0,0 +1,219 @@
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { FormField } from "~/form/FormField";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import { SendouForm } from "~/form/SendouForm";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { vodFormBaseSchema } from "../vods-schemas";
let mockFetcherData: { fieldErrors?: Record<string, string> } | undefined;
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useFetcher: () => ({
get data() {
return mockFetcherData;
},
state: "idle",
submit: vi.fn(),
}),
};
});
interface MatchDefaults {
startsAt?: string;
mode?: ModeShort;
stageId?: StageId;
weapon?: MainWeaponId;
weaponsTeamOne?: WeaponPoolItem[];
weaponsTeamTwo?: WeaponPoolItem[];
}
function createDefaultMatch(overrides?: MatchDefaults) {
return {
startsAt: overrides?.startsAt ?? "0:00",
mode: overrides?.mode ?? ("SZ" as ModeShort),
stageId: overrides?.stageId ?? (1 as StageId),
weapon: overrides?.weapon,
weaponsTeamOne: overrides?.weaponsTeamOne ?? ([] as WeaponPoolItem[]),
weaponsTeamTwo: overrides?.weaponsTeamTwo ?? ([] as WeaponPoolItem[]),
};
}
function createDefaultValues(overrides?: {
matches?: MatchDefaults[];
[key: string]: unknown;
}) {
const matches = overrides?.matches
? overrides.matches.map((m) => createDefaultMatch(m))
: [createDefaultMatch()];
return {
youtubeUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
title: "Test VOD",
date: new Date(),
type: "TOURNAMENT" as const,
pov: { type: "USER" as const },
...overrides,
matches,
};
}
function renderForm(options?: {
defaultValues?: { matches?: MatchDefaults[]; [key: string]: unknown };
}) {
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm
title="Test VOD Form"
schema={vodFormBaseSchema}
defaultValues={createDefaultValues(options?.defaultValues)}
>
{({ names }) => (
<>
{Object.keys(names)
.filter((name) => name !== "pov")
.map((name) => (
<FormField key={name} name={name} />
))}
</>
)}
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
describe("VodForm", () => {
beforeEach(() => {
mockFetcherData = undefined;
});
describe("timestamp format validation", () => {
test("accepts MM:SS format", async () => {
const screen = await renderForm({
defaultValues: {
matches: [createDefaultMatch({ startsAt: "10:22" })],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const timestampError = screen.container.querySelector(
'[id*="startsAt-error"]',
);
expect(timestampError?.textContent).toBeFalsy();
});
test("accepts HH:MM:SS format", async () => {
const screen = await renderForm({
defaultValues: {
matches: [createDefaultMatch({ startsAt: "1:10:22" })],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const timestampError = screen.container.querySelector(
'[id*="startsAt-error"]',
);
expect(timestampError?.textContent).toBeFalsy();
});
test("rejects invalid timestamp format", async () => {
const screen = await renderForm({
defaultValues: {
matches: [createDefaultMatch({ startsAt: "invalid" })],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const timestampError = screen.container.querySelector(
'[id="matches[0].startsAt-error"]',
);
expect(timestampError?.textContent).toBe(
"Invalid time format. Use HH:MM:SS or MM:SS",
);
});
test("rejects timestamp with only seconds", async () => {
const screen = await renderForm({
defaultValues: {
matches: [createDefaultMatch({ startsAt: "22" })],
},
});
await screen.getByRole("button", { name: "Submit" }).click();
const timestampError = screen.container.querySelector(
'[id="matches[0].startsAt-error"]',
);
expect(timestampError?.textContent).toBe(
"Invalid time format. Use HH:MM:SS or MM:SS",
);
});
});
describe("array operations", () => {
test("can add multiple matches", async () => {
const screen = await renderForm();
const addButton = screen.getByRole("button", { name: "Add" });
await userEvent.click(addButton.element());
const fieldsets = screen.container.querySelectorAll("fieldset");
expect(fieldsets.length).toBe(2);
});
test("can remove matches when more than 1", async () => {
const screen = await renderForm({
defaultValues: {
matches: [
createDefaultMatch({ startsAt: "0:00" }),
createDefaultMatch({ startsAt: "5:00", mode: "TC" }),
],
},
});
let fieldsets = screen.container.querySelectorAll("fieldset");
expect(fieldsets.length).toBe(2);
const removeButtons = screen.container.querySelectorAll(
'button[aria-label="Remove item"]',
);
await userEvent.click(removeButtons[0]);
fieldsets = screen.container.querySelectorAll("fieldset");
expect(fieldsets.length).toBe(1);
});
test("cannot add more than 50 matches", async () => {
const fiftyMatches = Array.from({ length: 50 }, () =>
createDefaultMatch(),
);
const screen = await renderForm({
defaultValues: { matches: fiftyMatches },
});
const addButton = screen.getByRole("button", { name: "Add" });
await expect.element(addButton).toBeDisabled();
});
});
});

View File

@ -22,3 +22,16 @@
width: 400px;
}
}
/** we should use existing fieldset styles */
.matchFieldset {
border: 1px solid var(--bg-lighter);
border-radius: var(--rounded);
padding: var(--s-4);
}
.povField {
width: 100%;
--select-width: 100%;
--input-width: 100%;
}

View File

@ -1,49 +1,35 @@
import { useEffect, useState } from "react";
import {
Controller,
get,
useFieldArray,
useFormContext,
useWatch,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { AddFieldButton } from "~/components/form/AddFieldButton";
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
import { RemoveFieldButton } from "~/components/form/RemoveFieldButton";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { StageSelect } from "~/components/StageSelect";
import { WeaponSelect } from "~/components/WeaponSelect";
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
import type { Tables } from "~/db/tables";
import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { FormRenderProps } from "~/form/SendouForm";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Alert } from "../../../components/Alert";
import { DateFormField } from "../../../components/form/DateFormField";
import { InputFormField } from "../../../components/form/InputFormField";
import { SelectFormField } from "../../../components/form/SelectFormField";
import { SendouForm } from "../../../components/form/SendouForm";
import { action } from "../actions/vods.new.server";
import { loader } from "../loaders/vods.new.server";
import { videoMatchTypes } from "../vods-constants";
import { videoInputSchema } from "../vods-schemas";
import { vodFormBaseSchema } from "../vods-schemas";
import { extractYoutubeIdFromVideoUrl } from "../vods-utils";
import styles from "./vods.new.module.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["vods", "calendar"],
};
export type VodFormFields = z.infer<typeof videoInputSchema>;
export default function NewVodPage() {
const isVideoAdder = useHasRole("VIDEO_ADDER");
const data = useLoaderData<typeof loader>();
@ -58,53 +44,90 @@ export default function NewVodPage() {
);
}
const defaultValues = data.vodToEdit
? vodToEditToFormValues(data.vodToEdit)
: {
type: "TOURNAMENT" as const,
teamSize: "4" as const,
pov: { type: "USER" as const },
matches: [
{
mode: "SZ" as const,
stageId: 1 as StageId,
startsAt: "",
weapon: undefined as MainWeaponId | undefined,
weaponsTeamOne: [] as WeaponPoolItem[],
weaponsTeamTwo: [] as WeaponPoolItem[],
},
],
};
return (
<Main halfWidth className={styles.layout}>
<SendouForm
heading={
title={
data.vodToEdit
? t("vods:forms.title.edit")
: t("vods:forms.title.create")
}
schema={videoInputSchema}
defaultValues={
data.vodToEdit
? {
vodToEditId: data.vodToEdit.id,
video: data.vodToEdit,
}
: {
video: {
type: "TOURNAMENT",
teamSize: 4,
matches: [
{
mode: "SZ",
stageId: 1,
startsAt: "",
weapons: [],
},
],
pov: { type: "USER" } as VodFormFields["video"]["pov"],
},
}
}
schema={vodFormBaseSchema}
defaultValues={defaultValues}
>
<YouTubeEmbedWrapper onPlayerReady={setPlayer} />
<FormFields player={player} />
{({ FormField }) => (
<>
<YouTubeEmbedWrapper onPlayerReady={setPlayer} />
<VodFormFields player={player} FormField={FormField} />
</>
)}
</SendouForm>
</Main>
);
}
type VodToEdit = NonNullable<Awaited<ReturnType<typeof loader>>["vodToEdit"]>;
function vodToEditToFormValues(vodToEdit: VodToEdit) {
const teamSize = vodToEdit.teamSize ?? 4;
const isCast = vodToEdit.type === "CAST";
return {
vodToEditId: vodToEdit.id,
youtubeUrl: vodToEdit.youtubeUrl,
title: vodToEdit.title,
date: new Date(
vodToEdit.date.year,
vodToEdit.date.month,
vodToEdit.date.day,
),
type: vodToEdit.type,
teamSize: String(teamSize) as "1" | "2" | "3" | "4",
pov: vodToEdit.pov,
matches: vodToEdit.matches.map((match: VodToEdit["matches"][number]) => ({
startsAt: match.startsAt,
mode: match.mode,
stageId: match.stageId as StageId,
weapon: isCast ? undefined : (match.weapons[0] ?? undefined),
weaponsTeamOne: isCast
? match.weapons
.slice(0, teamSize)
.map((id: MainWeaponId) => ({ id, isFavorite: false }))
: [],
weaponsTeamTwo: isCast
? match.weapons
.slice(teamSize)
.map((id: MainWeaponId) => ({ id, isFavorite: false }))
: [],
})),
};
}
function YouTubeEmbedWrapper({
onPlayerReady,
}: {
onPlayerReady: (player: YT.Player) => void;
}) {
const youtubeUrl = useWatch<VodFormFields>({
name: "video.youtubeUrl",
}) as string | undefined;
const { values } = useFormFieldContext();
const youtubeUrl = values.youtubeUrl as string | undefined;
if (!youtubeUrl) return null;
@ -118,116 +141,115 @@ function YouTubeEmbedWrapper({
);
}
function FormFields({ player }: { player: YT.Player | null }) {
const { t } = useTranslation(["vods"]);
const videoType = useWatch({
name: "video.type",
}) as VodFormFields["video"]["type"];
type VodFormFieldComponent = FormRenderProps<
typeof vodFormBaseSchema.shape
>["FormField"];
function VodFormFields({
player,
FormField,
}: {
player: YT.Player | null;
FormField: VodFormFieldComponent;
}) {
const { values } = useFormFieldContext();
const videoType = values.type as string;
return (
<>
<InputFormField<VodFormFields>
label={t("vods:forms.title.youtubeUrl")}
name="video.youtubeUrl"
placeholder="https://www.youtube.com/watch?v=-dQ6JsVIKdY"
required
size="medium"
/>
<FormField name="youtubeUrl" />
<FormField name="title" />
<FormField name="date" />
<FormField name="type" />
<InputFormField<VodFormFields>
label={t("vods:forms.title.videoTitle")}
name="video.title"
placeholder="[SCL 47] (Grand Finals) Team Olive vs. Kraken Paradise"
required
size="medium"
/>
{videoType === "CAST" ? (
<TeamSizeField FormField={FormField} />
) : (
<PovFormField FormField={FormField} />
)}
<DateFormField<VodFormFields>
label={t("vods:forms.title.videoDate")}
name="video.date"
required
size="extra-small"
/>
<SelectFormField<VodFormFields>
label={t("vods:forms.title.type")}
name="video.type"
values={videoMatchTypes.map((role) => ({
value: role,
label: t(`vods:type.${role}`),
}))}
required
/>
{videoType === "CAST" ? <TeamSizeField /> : <PovFormField />}
<MatchesFormfield videoType={videoType} player={player} />
<FormField name="matches">
{(ctx: ArrayItemRenderContext) => (
<MatchFieldsetContent
index={ctx.index}
itemName={ctx.itemName}
values={ctx.values as unknown as MatchFieldValues}
formValues={ctx.formValues}
setItemField={
ctx.setItemField as <K extends keyof MatchFieldValues>(
field: K,
value: MatchFieldValues[K],
) => void
}
canRemove={ctx.canRemove}
remove={ctx.remove}
player={player}
videoType={videoType}
FormField={FormField}
/>
)}
</FormField>
</>
);
}
function TeamSizeField() {
const { t } = useTranslation(["vods"]);
const { setValue } = useFormContext<VodFormFields>();
const matches = useWatch<VodFormFields>({
name: "video.matches",
}) as VodFormFields["video"]["matches"];
function TeamSizeField({ FormField }: { FormField: VodFormFieldComponent }) {
const { values, setValue } = useFormFieldContext();
const matches = values.matches as Array<Record<string, unknown>>;
const handleTeamSizeChange = (newValue: string | null) => {
setValue("teamSize", newValue);
if (matches && Array.isArray(matches)) {
const clearedMatches = matches.map((match) => ({
...match,
weaponsTeamOne: [],
weaponsTeamTwo: [],
}));
setValue("matches", clearedMatches);
}
};
return (
<Controller
control={useFormContext<VodFormFields>().control}
name="video.teamSize"
render={({ field: { onChange, value } }) => {
return (
<div>
<Label required htmlFor="teamSize">
{t("vods:forms.title.teamSize")}
</Label>
<select
id="teamSize"
value={value ?? 4}
onChange={(e) => {
const newTeamSize = Number(e.target.value);
onChange(newTeamSize);
if (matches && Array.isArray(matches)) {
matches.forEach((_, idx) => {
setValue(`video.matches.${idx}.weapons`, []);
});
}
}}
required
>
<option value={1}>{t("vods:teamSize.1v1")}</option>
<option value={2}>{t("vods:teamSize.2v2")}</option>
<option value={3}>{t("vods:teamSize.3v3")}</option>
<option value={4}>{t("vods:teamSize.4v4")}</option>
</select>
</div>
);
}}
/>
<FormField name="teamSize">
{({ name, error, value }: CustomFieldRenderProps) => (
<FormFieldWrapper
id={name}
name={name}
label="forms:labels.vodTeamSize"
error={error}
>
<select
id={name}
name={name}
value={(value as string) ?? "4"}
onChange={(e) => handleTeamSizeChange(e.target.value)}
>
<option value="1">1v1</option>
<option value="2">2v2</option>
<option value="3">3v3</option>
<option value="4">4v4</option>
</select>
</FormFieldWrapper>
)}
</FormField>
);
}
function PovFormField() {
function PovFormField({ FormField }: { FormField: VodFormFieldComponent }) {
const { t } = useTranslation(["vods", "calendar"]);
const methods = useFormContext<VodFormFields>();
const povNameError = get(methods.formState.errors, "video.pov.name");
return (
<Controller
control={methods.control}
name="video.pov"
render={({
field: { onChange, onBlur, value },
fieldState: { error },
}) => {
// biome-ignore lint/complexity/noUselessFragments: Biome upgrade
if (!value) return <></>;
<FormField name="pov">
{({ name, error, value, onChange }: CustomFieldRenderProps) => {
const povValue = value as
| { type: "USER"; userId?: number }
| { type: "NAME"; name?: string }
| undefined;
const asPlainInput = value.type === "NAME";
if (!povValue) return null;
const asPlainInput = povValue.type === "NAME";
const toggleInputType = () => {
if (asPlainInput) {
@ -238,34 +260,32 @@ function PovFormField() {
};
return (
<div>
<div className={styles.povField}>
{asPlainInput ? (
<>
<Label required htmlFor="pov">
<Label required htmlFor={name}>
{t("vods:forms.title.pov")}
</Label>
<input
id="pov"
value={value.name ?? ""}
id={name}
value={povValue.name ?? ""}
onChange={(e) => {
onChange({ type: "NAME", name: e.target.value });
}}
onBlur={onBlur}
/>
</>
) : (
<UserSearch
label={t("vods:forms.title.pov")}
isRequired
name="team-player"
initialUserId={value.userId}
name="pov-user"
initialUserId={povValue.userId}
onChange={(newUser) =>
onChange({
type: "USER",
userId: newUser?.id,
})
}
onBlur={onBlur}
/>
)}
<SendouButton
@ -278,85 +298,42 @@ function PovFormField() {
? t("calendar:forms.team.player.addAsUser")
: t("calendar:forms.team.player.addAsText")}
</SendouButton>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{povNameError && (
<FormMessage type="error">
{povNameError.message as string}
</FormMessage>
)}
{error ? <FormMessage type="error">{error}</FormMessage> : null}
</div>
);
}}
/>
</FormField>
);
}
function MatchesFormfield({
videoType,
player,
}: {
videoType: Tables["Video"]["type"];
interface MatchFieldValues {
startsAt: string;
mode: string;
stageId: StageId;
weapon: MainWeaponId | null;
weaponsTeamOne: WeaponPoolItem[];
weaponsTeamTwo: WeaponPoolItem[];
}
type MatchFieldsetContentProps = ArrayItemRenderContext<MatchFieldValues> & {
player: YT.Player | null;
}) {
const {
formState: { errors },
} = useFormContext<VodFormFields>();
const { fields, append, remove } = useFieldArray<VodFormFields>({
name: "video.matches",
});
videoType: string;
FormField: VodFormFieldComponent;
};
const rootError = errors.video?.matches?.root;
return (
<div>
<div className="stack md">
{fields.map((field, i) => {
return (
<MatchesFieldset
key={field.id}
idx={i}
remove={remove}
canRemove={fields.length > 1}
videoType={videoType}
player={player}
/>
);
})}
<AddFieldButton
onClick={() => {
append({
mode: "SZ",
stageId: 1,
startsAt: "",
weapons: [],
});
}}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>
)}
</div>
</div>
);
}
function MatchesFieldset({
idx,
remove,
function MatchFieldsetContent({
index,
itemName,
values: matchValues,
formValues,
setItemField,
canRemove,
videoType,
remove,
player,
}: {
idx: number;
remove: (idx: number) => void;
canRemove: boolean;
videoType: Tables["Video"]["type"];
player: YT.Player | null;
}) {
const { t } = useTranslation(["vods", "game-misc"]);
const { setValue } = useFormContext<VodFormFields>();
videoType,
FormField,
}: MatchFieldsetContentProps) {
const { t } = useTranslation(["vods", "common"]);
const [currentTime, setCurrentTime] = useState<string>("");
useEffect(() => {
@ -376,175 +353,221 @@ function MatchesFieldset({
return () => clearInterval(interval);
}, [player]);
return (
<div className="stack md">
<div className="stack horizontal sm">
<h2 className="text-md">{t("vods:gameCount", { count: idx + 1 })}</h2>
{canRemove ? <RemoveFieldButton onClick={() => remove(idx)} /> : null}
</div>
const allMatches = formValues.matches as MatchFieldValues[];
const previousWeapons = index > 0 ? allMatches[index - 1] : null;
<div>
<InputFormField<VodFormFields>
required
label={t("vods:forms.title.startTimestamp")}
name={`video.matches.${idx}.startsAt`}
placeholder="10:22"
/>
{currentTime ? (
return (
<>
<div className="stack horizontal sm items-center justify-between">
<div className="text-md font-semi-bold">
{t("vods:gameCount", { count: index + 1 })}
</div>
{canRemove ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() =>
setValue(`video.matches.${idx}.startsAt`, currentTime)
}
className="mt-2"
size="small"
variant="minimal-destructive"
onPress={remove}
>
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
{t("common:actions.remove")}
</SendouButton>
) : null}
</div>
<InputGroupFormField<VodFormFields>
type="radio"
label={t("vods:forms.title.mode")}
name={`video.matches.${idx}.mode`}
values={modesShort.map((mode) => ({
value: mode,
label: t(`game-misc:MODE_SHORT_${mode}`),
}))}
direction="horizontal"
/>
<div className="stack md mt-4">
<FormField name={`${itemName}.startsAt`}>
{(props: CustomFieldRenderProps) => (
<FormFieldWrapper
id={`matches-${index}-startsAt`}
name={`${itemName}.startsAt`}
label="forms:labels.vodStartTimestamp"
error={props.error}
>
<input
id={`matches-${index}-startsAt`}
value={matchValues.startsAt}
onChange={(e) => setItemField("startsAt", e.target.value)}
placeholder="10:22"
/>
{currentTime ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => setItemField("startsAt", currentTime)}
className="mt-2"
>
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
</SendouButton>
) : null}
</FormFieldWrapper>
)}
</FormField>
<Controller
control={useFormContext<VodFormFields>().control}
name={`video.matches.${idx}.stageId`}
render={({ field: { onChange, value } }) => (
<StageSelect
isRequired
label={t("vods:forms.title.stage")}
value={value}
onChange={onChange}
/>
)}
/>
<FormField name={`${itemName}.mode`} />
<WeaponsField idx={idx} videoType={videoType} />
</div>
<FormField name={`${itemName}.stageId`} />
<WeaponsField
index={index}
matchValues={matchValues}
setItemField={setItemField}
videoType={videoType}
previousWeapons={previousWeapons}
formValues={formValues}
/>
</div>
</>
);
}
function WeaponsField({
idx,
index,
matchValues,
setItemField,
videoType,
previousWeapons,
formValues,
}: {
idx: number;
videoType: Tables["Video"]["type"];
index: number;
matchValues: MatchFieldValues;
setItemField: <K extends keyof MatchFieldValues>(
field: K,
value: MatchFieldValues[K],
) => void;
videoType: string;
previousWeapons: MatchFieldValues | null;
formValues: Record<string, unknown>;
}) {
const { t } = useTranslation(["vods"]);
const watchedTeamSize = useWatch<VodFormFields>({
name: "video.teamSize",
});
const teamSize = typeof watchedTeamSize === "number" ? watchedTeamSize : 4;
const { t } = useTranslation(["vods", "forms"]);
const teamSizeValue = formValues.teamSize as string | undefined;
const teamSize = teamSizeValue ? Number(teamSizeValue) : 4;
const { recentlyReportedWeapons, addRecentlyReportedWeapon } =
useRecentlyReportedWeapons();
const matches = useWatch<VodFormFields>({
name: "video.matches",
}) as VodFormFields["video"]["matches"];
const setWeapon = (value: MainWeaponId | null) => {
setItemField("weapon", value);
if (typeof value === "number") addRecentlyReportedWeapon(value);
};
const setTeamWeapon = (
team: "weaponsTeamOne" | "weaponsTeamTwo",
weaponIdx: number,
value: MainWeaponId | null,
) => {
const currentPool = [...(matchValues[team] || [])];
if (typeof value === "number") {
currentPool[weaponIdx] = { id: value, isFavorite: false };
} else {
currentPool.splice(weaponIdx, 1);
}
setItemField(team, currentPool);
if (typeof value === "number") addRecentlyReportedWeapon(value);
};
const copyFromPrevious = () => {
if (!previousWeapons) return;
if (videoType === "CAST") {
setItemField("weaponsTeamOne", [...previousWeapons.weaponsTeamOne]);
setItemField("weaponsTeamTwo", [...previousWeapons.weaponsTeamTwo]);
} else {
setItemField("weapon", previousWeapons.weapon);
}
};
const hasPreviousWeapons = previousWeapons
? videoType === "CAST"
? previousWeapons.weaponsTeamOne.length > 0 ||
previousWeapons.weaponsTeamTwo.length > 0
: previousWeapons.weapon !== null
: false;
return (
<Controller
control={useFormContext<VodFormFields>().control}
name={`video.matches.${idx}.weapons`}
render={({ field: { onChange, value } }) => {
const previousWeapons =
idx > 0 && matches?.[idx - 1]?.weapons
? matches[idx - 1].weapons
: null;
return (
<div>
{videoType === "CAST" ? (
<div>
<Label required>{t("vods:forms.title.weaponsTeamOne")}</Label>
<div className="stack sm">
{new Array(teamSize).fill(null).map((_, i) => {
return (
<WeaponSelect
key={i}
isRequired
testId={`player-${i}-weapon`}
value={value[i] ?? null}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
const weapons = [...value];
weapons[i] = weaponId;
<div>
{videoType === "CAST" ? (
<div>
<TeamWeaponSelects
label={t("forms:labels.vodWeaponsTeamOne")}
teamSize={teamSize}
matchIndex={index}
teamNumber={1}
weapons={matchValues.weaponsTeamOne}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponIdx, weaponId) =>
setTeamWeapon("weaponsTeamOne", weaponIdx, weaponId)
}
/>
<TeamWeaponSelects
label={t("forms:labels.vodWeaponsTeamTwo")}
teamSize={teamSize}
matchIndex={index}
teamNumber={2}
weapons={matchValues.weaponsTeamTwo}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponIdx, weaponId) =>
setTeamWeapon("weaponsTeamTwo", weaponIdx, weaponId)
}
className="mt-4"
/>
</div>
) : (
<WeaponSelect
label={t("forms:labels.vodWeapon")}
isRequired
testId={`match-${index}-weapon`}
value={matchValues.weapon}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={setWeapon}
/>
)}
{hasPreviousWeapons ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={copyFromPrevious}
className="mt-2"
>
{t("vods:forms.action.copyFromPrevious")}
</SendouButton>
) : null}
</div>
);
}
onChange(weapons);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
);
})}
</div>
<div className="mt-4">
<Label required>{t("vods:forms.title.weaponsTeamTwo")}</Label>
<div className="stack sm">
{new Array(teamSize).fill(null).map((_, i) => {
const adjustedI = i + teamSize;
return (
<WeaponSelect
key={adjustedI}
isRequired
testId={`player-${adjustedI}-weapon`}
value={value[adjustedI] ?? null}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
const weapons = [...value];
weapons[adjustedI] = weaponId;
onChange(weapons);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
);
})}
</div>
</div>
</div>
) : (
<WeaponSelect
label={t("vods:forms.title.weapon")}
isRequired
testId={`match-${idx}-weapon`}
value={value[0] ?? null}
quickSelectWeaponsIds={recentlyReportedWeapons}
onChange={(weaponId) => {
onChange([weaponId]);
if (weaponId) {
addRecentlyReportedWeapon(weaponId);
}
}}
/>
)}
{previousWeapons && previousWeapons.length > 0 ? (
<SendouButton
variant="minimal"
size="miniscule"
onPress={() => {
onChange([...previousWeapons]);
}}
className="mt-2"
>
{t("vods:forms.action.copyFromPrevious")}
</SendouButton>
) : null}
</div>
);
}}
/>
function TeamWeaponSelects({
label,
teamSize,
matchIndex,
teamNumber,
weapons,
quickSelectWeaponsIds,
onChange,
className,
}: {
label: string;
teamSize: number;
matchIndex: number;
teamNumber: 1 | 2;
weapons: WeaponPoolItem[];
quickSelectWeaponsIds: MainWeaponId[];
onChange: (weaponIdx: number, weaponId: MainWeaponId | null) => void;
className?: string;
}) {
return (
<div className={className}>
<Label required>{label}</Label>
<div className="stack sm">
{new Array(teamSize).fill(null).map((_, i) => (
<WeaponSelect
key={i}
isRequired
testId={`match-${matchIndex}-team${teamNumber}-weapon-${i}`}
value={(weapons[i]?.id as MainWeaponId) ?? null}
quickSelectWeaponsIds={quickSelectWeaponsIds}
onChange={(weaponId) => onChange(i, weaponId)}
/>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as VodRepository from "./VodRepository.server";
import { vodFormBaseSchema } from "./vods-schemas";
import { canEditVideo } from "./vods-utils";
export const vodFormSchemaServer = vodFormBaseSchema.refine(
async (data) => {
if (!data.vodToEditId) return true;
const user = requireUser();
const vod = await VodRepository.findVodById(data.vodToEditId);
if (!vod) return false;
return canEditVideo({
userId: user.id,
submitterUserId: vod.submitterUserId,
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
});
},
{ message: "No permissions to edit this VOD", path: ["vodToEditId"] },
);

View File

@ -1,11 +1,25 @@
import { add } from "date-fns";
import { z } from "zod";
import {
array,
customField,
dayMonthYearRequired,
fieldset,
idConstantOptional,
radioGroup,
select,
selectOptional,
stageSelect,
textFieldRequired,
weaponPool,
weaponSelectOptional,
} from "~/form/fields";
import { modesShort } from "~/modules/in-game-lists/modes";
import {
dayMonthYear,
id,
modeShort,
nonEmptyString,
safeJSONParse,
stageId,
weaponSplId,
} from "~/utils/zod";
@ -78,7 +92,95 @@ export const videoSchema = z.preprocess(
}),
);
export const videoInputSchema = z.object({
video: z.preprocess(safeJSONParse, videoSchema),
vodToEditId: id.optional(),
const povSchema = z.union([
z.object({
type: z.literal("USER"),
userId: id.optional(),
}),
z.object({
type: z.literal("NAME"),
name: nonEmptyString.max(100),
}),
]);
const matchFieldsetSchema = z.object({
startsAt: textFieldRequired({
label: "labels.vodStartTimestamp",
maxLength: 10,
regExp: {
pattern: HOURS_MINUTES_SECONDS_REGEX,
message: "Invalid time format. Use HH:MM:SS or MM:SS",
},
}),
mode: radioGroup({
label: "labels.vodMode",
items: modesShort.map((mode) => ({
label: `modes.${mode}` as const,
value: mode,
})),
}),
stageId: stageSelect({ label: "labels.vodStage" }),
weapon: weaponSelectOptional({ label: "labels.vodWeapon" }),
weaponsTeamOne: weaponPool({
label: "labels.vodWeaponsTeamOne",
maxCount: 4,
disableSorting: true,
disableFavorites: true,
allowDuplicates: true,
}),
weaponsTeamTwo: weaponPool({
label: "labels.vodWeaponsTeamTwo",
maxCount: 4,
disableSorting: true,
disableFavorites: true,
allowDuplicates: true,
}),
});
export const vodFormBaseSchema = z.object({
vodToEditId: idConstantOptional(),
youtubeUrl: textFieldRequired({
label: "labels.vodYoutubeUrl",
maxLength: 200,
validate: {
func: (val) => extractYoutubeIdFromVideoUrl(val) !== null,
message: "Invalid YouTube URL",
},
}),
title: textFieldRequired({
label: "labels.vodTitle",
maxLength: 100,
}),
date: dayMonthYearRequired({
label: "labels.vodDate",
max: add(new Date(), { days: 1 }),
maxMessage: "errors.dateMustNotBeFuture",
minMessage: "errors.dateTooOld",
}),
type: select({
label: "labels.vodType",
items: videoMatchTypes.map((type) => ({
label: `vodTypes.${type}` as const,
value: type,
})),
}),
teamSize: selectOptional({
label: "labels.vodTeamSize",
items: [
{ label: () => "1v1", value: "1" },
{ label: () => "2v2", value: "2" },
{ label: () => "3v3", value: "3" },
{ label: () => "4v4", value: "4" },
],
}),
pov: customField(
{ initialValue: { type: "USER" as const } },
povSchema.optional(),
),
matches: array({
label: "labels.vodMatches",
min: 1,
max: 50,
field: fieldset({ fields: matchFieldsetSchema }),
}),
});

418
app/form/FormField.tsx Normal file
View File

@ -0,0 +1,418 @@
import * as React from "react";
import type { z } from "zod";
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { formRegistry } from "./fields";
import { ArrayFormField } from "./fields/ArrayFormField";
import { BadgesFormField } from "./fields/BadgesFormField";
import { DatetimeFormField } from "./fields/DatetimeFormField";
import { DualSelectFormField } from "./fields/DualSelectFormField";
import { FieldsetFormField } from "./fields/FieldsetFormField";
import { InputFormField } from "./fields/InputFormField";
import {
CheckboxGroupFormField,
RadioGroupFormField,
} from "./fields/InputGroupFormField";
import { SelectFormField } from "./fields/SelectFormField";
import { StageSelectFormField } from "./fields/StageSelectFormField";
import { SwitchFormField } from "./fields/SwitchFormField";
import { TextareaFormField } from "./fields/TextareaFormField";
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
import { UserSearchFormField } from "./fields/UserSearchFormField";
import {
WeaponPoolFormField,
type WeaponPoolItem,
} from "./fields/WeaponPoolFormField";
import { WeaponSelectFormField } from "./fields/WeaponSelectFormField";
import { useOptionalFormFieldContext } from "./SendouForm";
import type {
ArrayItemRenderContext,
BadgeOption,
CustomFieldRenderProps,
FormField as FormFieldType,
SelectOption,
} from "./types";
import {
getNestedSchema,
getNestedValue,
setNestedValue,
validateField,
} from "./utils";
export type { CustomFieldRenderProps };
interface FormFieldProps {
name: string;
label?: string;
field?: z.ZodType;
children?:
| ((props: CustomFieldRenderProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
/** Field-specific options */
options?: unknown;
}
export function FormField({
name,
label,
field,
children,
options,
}: FormFieldProps) {
const context = useOptionalFormFieldContext();
const fieldSchema = React.useMemo(() => {
if (field) return field;
if (!context?.schema) {
throw new Error(
"FormField requires either a 'field' prop or to be used within a FormProvider",
);
}
const zodObject = context.schema;
const result = name.includes(".")
? getNestedSchema(zodObject, name)
: zodObject.shape[name];
if (!result) {
throw new Error(
`Field schema not found for name: ${name}. Does the schema have a corresponding key?`,
);
}
return result;
}, [field, context?.schema, name]);
const formField = React.useMemo(() => {
const result = formRegistry.get(fieldSchema) as FormFieldType | undefined;
if (!result) {
throw new Error(`Form field metadata not found for name: ${name}`);
}
const fieldWithLabel = label ? { ...result, label } : result;
return fieldWithLabel as FormFieldType;
}, [fieldSchema, name, label]);
const isNestedPath = name.includes(".") || name.includes("[");
const value =
(isNestedPath
? getNestedValue(context?.values ?? {}, name)
: context?.values[name]) ?? formField.initialValue;
const serverError =
context?.serverErrors[name as keyof typeof context.serverErrors];
const clientError = context?.clientErrors[name];
const hasSubmitted = context?.hasSubmitted ?? false;
const runValidation = (val: unknown) => {
if (!context?.schema) return;
const validationError = validateField(context.schema, name, val);
context.setClientError(name, validationError);
};
const handleBlur = (latestValue?: unknown) => {
if (hasSubmitted) return;
runValidation(latestValue ?? value);
};
const handleChange = (newValue: unknown) => {
context?.setValue(name, newValue);
if (hasSubmitted && context) {
const updatedValues = isNestedPath
? setNestedValue(context.values, name, newValue)
: { ...context.values, [name]: newValue };
context.revalidateAll(updatedValues);
}
context?.onFieldChange?.(name, newValue);
};
const displayedError = serverError ?? clientError;
const commonProps = { name, error: displayedError, onBlur: handleBlur };
if (formField.type === "text-field") {
return (
<InputFormField
{...commonProps}
{...formField}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "switch") {
return (
<SwitchFormField
{...commonProps}
{...formField}
checked={value as boolean}
onChange={handleChange as (v: boolean) => void}
/>
);
}
if (formField.type === "text-area") {
return (
<TextareaFormField
{...commonProps}
{...formField}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "select") {
return (
<SelectFormField
{...commonProps}
{...formField}
value={value as string | null}
onChange={handleChange as (v: string | null) => void}
/>
);
}
if (formField.type === "select-dynamic") {
if (!options) {
throw new Error("Dynamic select form field requires options prop");
}
const selectOptions = options as SelectOption[];
return (
<SelectFormField
{...commonProps}
{...formField}
items={selectOptions.map((opt) => ({
value: opt.value,
label: opt.label,
}))}
value={value as string | null}
onChange={handleChange as (v: string | null) => void}
/>
);
}
if (formField.type === "dual-select") {
return (
<DualSelectFormField
{...commonProps}
{...formField}
value={value as [string | null, string | null]}
onChange={handleChange as (v: [string | null, string | null]) => void}
/>
);
}
if (formField.type === "radio-group") {
return (
<RadioGroupFormField
{...commonProps}
{...formField}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "checkbox-group") {
return (
<CheckboxGroupFormField
{...commonProps}
{...formField}
value={value as string[]}
onChange={handleChange as (v: string[]) => void}
/>
);
}
if (formField.type === "datetime" || formField.type === "date") {
return (
<DatetimeFormField
{...commonProps}
{...formField}
granularity={formField.type === "date" ? "day" : "minute"}
value={value as Date | undefined}
onChange={handleChange as (v: Date | undefined) => void}
/>
);
}
if (formField.type === "time-range") {
return (
<TimeRangeFormField
{...commonProps}
{...formField}
value={value as { start: string; end: string } | null}
onChange={
handleChange as (v: { start: string; end: string } | null) => void
}
/>
);
}
if (formField.type === "weapon-pool") {
return (
<WeaponPoolFormField
{...commonProps}
{...formField}
value={value as WeaponPoolItem[]}
onChange={handleChange as (v: WeaponPoolItem[]) => void}
/>
);
}
if (formField.type === "custom") {
if (!children) {
throw new Error("Custom form field requires children render function");
}
return (
<>
{(children as (props: CustomFieldRenderProps) => React.ReactNode)({
name,
error: displayedError,
value,
onChange: handleChange,
})}
</>
);
}
if (
formField.type === "string-constant" ||
formField.type === "id-constant"
) {
return null;
}
if (formField.type === "array") {
// @ts-expect-error Type instantiation is excessively deep and possibly infinite
const innerFieldMeta = formRegistry.get(formField.field) as
| FormFieldType
| undefined;
const isObjectArray = innerFieldMeta?.type === "fieldset";
const hasCustomRender = typeof children === "function";
const itemInitialValue =
isObjectArray && innerFieldMeta
? computeFieldsetInitialValue(innerFieldMeta)
: innerFieldMeta?.initialValue;
return (
<ArrayFormField
{...commonProps}
{...formField}
value={value as unknown[]}
onChange={handleChange as (v: unknown[]) => void}
isObjectArray={isObjectArray}
itemInitialValue={itemInitialValue}
renderItem={(idx, itemName) => {
if (hasCustomRender && isObjectArray) {
const arrayValue = value as Record<string, unknown>[];
const itemValues = arrayValue[idx] ?? {};
const setItemField = (fieldName: string, fieldValue: unknown) => {
const newArray = [...arrayValue];
newArray[idx] = { ...newArray[idx], [fieldName]: fieldValue };
handleChange(newArray);
};
const remove = () => {
handleChange(arrayValue.filter((_, i) => i !== idx));
};
return (
children as (props: ArrayItemRenderContext) => React.ReactNode
)({
index: idx,
itemName,
values: itemValues,
formValues: context?.values ?? {},
setItemField,
canRemove: arrayValue.length > (formField.min ?? 0),
remove,
});
}
return (
<FormField key={idx} name={itemName} field={formField.field} />
);
}}
/>
);
}
if (formField.type === "fieldset") {
return <FieldsetFormField {...commonProps} {...formField} />;
}
if (formField.type === "user-search") {
return (
<UserSearchFormField
{...commonProps}
{...formField}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
/>
);
}
if (formField.type === "badges") {
if (!options) {
throw new Error("Badges form field requires options prop");
}
return (
<BadgesFormField
{...commonProps}
{...formField}
value={value as number[]}
onChange={handleChange as (v: number[]) => void}
options={options as BadgeOption[]}
/>
);
}
if (formField.type === "stage-select") {
return (
<StageSelectFormField
{...commonProps}
{...formField}
value={value as StageId | null}
onChange={handleChange as (v: StageId) => void}
/>
);
}
if (formField.type === "weapon-select") {
return (
<WeaponSelectFormField
{...commonProps}
{...formField}
value={value as MainWeaponId | null}
onChange={handleChange as (v: MainWeaponId | null) => void}
/>
);
}
return (
<div>Unsupported form field type: {(formField as FormFieldType).type}</div>
);
}
function computeFieldsetInitialValue(
fieldsetMeta: FormFieldType,
): Record<string, unknown> {
if (fieldsetMeta.type !== "fieldset") return {};
const shape = fieldsetMeta.fields.shape as Record<string, z.ZodType>;
const result: Record<string, unknown> = {};
for (const [key, fieldSchema] of Object.entries(shape)) {
const fieldMeta = formRegistry.get(fieldSchema) as
| FormFieldType
| undefined;
if (fieldMeta) {
result[key] = fieldMeta.initialValue;
}
}
return result;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
.form {
display: flex;
flex-direction: column;
gap: var(--s-4);
width: 100%;
max-width: 24rem;
margin: 0 auto;
}
.title {
font-size: var(--fonts-lg);
font-weight: bold;
}

399
app/form/SendouForm.tsx Normal file
View File

@ -0,0 +1,399 @@
import * as React from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { useFetcher, useLocation } from "react-router";
import type { z } from "zod";
import { FormMessage } from "~/components/FormMessage";
import { SubmitButton } from "~/components/SubmitButton";
import { FormField as FormFieldComponent } from "./FormField";
import { formRegistry } from "./fields";
import styles from "./SendouForm.module.css";
import type { FormField, TypedFormFieldComponent } from "./types";
import {
errorMessageId,
getNestedValue,
setNestedValue,
validateField,
} from "./utils";
type RequiredDefaultKeys<T extends z.ZodRawShape> = {
[K in keyof T & string]: T[K] extends { _requiresDefault: true } ? K : never;
}[keyof T & string];
type HasRequiredDefaults<T extends z.ZodRawShape> =
RequiredDefaultKeys<T> extends never ? false : true;
export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
schema: z.ZodObject<T>;
defaultValues?: Partial<z.input<z.ZodObject<T>>> | null;
serverErrors: Partial<Record<keyof z.infer<z.ZodObject<T>>, string>>;
clientErrors: Partial<Record<string, string>>;
hasSubmitted: boolean;
setClientError: (name: string, error: string | undefined) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
values: Record<string, unknown>;
setValue: (name: string, value: unknown) => void;
revalidateAll: (updatedValues: Record<string, unknown>) => void;
submitToServer: (values: Record<string, unknown>) => void;
fetcherState: "idle" | "loading" | "submitting";
}
const FormContext = React.createContext<FormContextValue | null>(null);
type FormNames<T extends z.ZodRawShape> = {
[K in keyof T]: K;
};
export interface FormRenderProps<T extends z.ZodRawShape> {
names: FormNames<T>;
FormField: TypedFormFieldComponent<T>;
}
type BaseFormProps<T extends z.ZodRawShape> = {
children: React.ReactNode | ((props: FormRenderProps<T>) => React.ReactNode);
schema: z.ZodObject<T>;
title?: React.ReactNode;
submitButtonText?: React.ReactNode;
action?: string;
method?: "post" | "get";
_action?: string;
submitButtonTestId?: string;
autoSubmit?: boolean;
className?: string;
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
secondarySubmit?: React.ReactNode;
};
type SendouFormProps<T extends z.ZodRawShape> = BaseFormProps<T> &
(HasRequiredDefaults<T> extends true
? {
defaultValues: Partial<z.input<z.ZodObject<T>>> &
Record<RequiredDefaultKeys<T>, unknown>;
}
: { defaultValues?: Partial<z.input<z.ZodObject<T>>> | null });
export function SendouForm<T extends z.ZodRawShape>({
children,
schema,
defaultValues,
title,
submitButtonText,
action,
method = "post",
_action,
submitButtonTestId,
autoSubmit,
className,
onApply,
secondarySubmit,
}: SendouFormProps<T>) {
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record<string, string> }>();
const [hasSubmitted, setHasSubmitted] = React.useState(false);
const [clientErrors, setClientErrors] = React.useState<
Partial<Record<string, string>>
>({});
const [visibleServerErrors, setVisibleServerErrors] = React.useState<
Partial<Record<string, string>>
>(fetcher.data?.fieldErrors ?? {});
const [fallbackError, setFallbackError] = React.useState<string | null>(null);
const initialValues = buildInitialValues(schema, defaultValues);
const [values, setValues] =
React.useState<Record<string, unknown>>(initialValues);
const location = useLocation();
const locationKey = `${location.pathname}${location.search}`;
const previousLocationKey = React.useRef(locationKey);
// Reset form when URL changes (handles edit → new transitions)
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset on URL change only, using current schema/defaultValues from closure
React.useEffect(() => {
if (previousLocationKey.current === locationKey) return;
previousLocationKey.current = locationKey;
const newInitialValues = buildInitialValues(schema, defaultValues);
setValues(newInitialValues);
setClientErrors({});
setHasSubmitted(false);
setFallbackError(null);
}, [locationKey]);
const latestActionData = React.useRef(fetcher.data);
if (fetcher.data !== latestActionData.current) {
latestActionData.current = fetcher.data;
setVisibleServerErrors(fetcher.data?.fieldErrors ?? {});
}
React.useLayoutEffect(() => {
const serverFieldErrors = fetcher.data?.fieldErrors ?? {};
for (const [fieldName, errorMessage] of Object.entries(serverFieldErrors)) {
const errorElement = document.getElementById(errorMessageId(fieldName));
if (!errorElement) {
setFallbackError(`${t(errorMessage as never)} (${fieldName})`);
return;
}
}
setFallbackError(null);
}, [fetcher.data, t]);
const serverErrors = visibleServerErrors as Partial<
Record<keyof z.infer<z.ZodObject<T>>, string>
>;
const setClientError = (name: string, error: string | undefined) => {
setClientErrors((prev) => {
if (error === undefined) {
const next = { ...prev };
delete next[name];
return next;
}
return { ...prev, [name]: error };
});
};
const setValue = (name: string, newValue: unknown) => {
if (name.includes(".") || name.includes("[")) {
setValues((prev) => setNestedValue(prev, name, newValue));
} else {
setValues((prev) => ({ ...prev, [name]: newValue }));
}
};
const validateAndPrepare = (): boolean => {
setHasSubmitted(true);
setVisibleServerErrors({});
const newErrors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, values[key]);
if (error) {
newErrors[key] = error;
}
}
const fullValidation = schema.safeParse(values);
if (!fullValidation.success) {
for (const issue of fullValidation.error.issues) {
const fieldName = buildFieldPath(issue.path);
if (fieldName && !newErrors[fieldName]) {
const value = getNestedValue(values, fieldName);
const properError = validateField(schema, fieldName, value);
newErrors[fieldName] = properError ?? issue.message;
}
}
}
if (Object.keys(newErrors).length > 0) {
flushSync(() => {
setClientErrors(newErrors);
});
scrollToFirstError(newErrors);
return false;
}
return true;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateAndPrepare()) return;
if (onApply) {
onApply(values as z.infer<z.ZodObject<T>>);
} else {
fetcher.submit(values as Record<string, string>, {
method,
action,
encType: "application/json",
});
}
};
const revalidateAll = (updatedValues: Record<string, unknown>) => {
const newErrors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, updatedValues[key]);
if (error) {
newErrors[key] = error;
}
}
const fullValidation = schema.safeParse(updatedValues);
if (!fullValidation.success) {
for (const issue of fullValidation.error.issues) {
const fieldName = buildFieldPath(issue.path);
if (fieldName && !newErrors[fieldName]) {
const value = getNestedValue(updatedValues, fieldName);
const properError = validateField(schema, fieldName, value);
newErrors[fieldName] = properError ?? issue.message;
}
}
}
setClientErrors(newErrors);
};
const onFieldChange = autoSubmit
? (changedName: string, changedValue: unknown) => {
const updatedValues = { ...values, [changedName]: changedValue };
const newErrors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, updatedValues[key]);
if (error) {
newErrors[key] = error;
}
}
if (Object.keys(newErrors).length > 0) {
setClientErrors(newErrors);
return;
}
fetcher.submit(updatedValues as Record<string, string>, {
method,
action,
encType: "application/json",
});
}
: undefined;
const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
if (!validateAndPrepare()) return;
if (onApply) {
onApply(values as z.infer<z.ZodObject<T>>);
}
fetcher.submit(valuesToSubmit as Record<string, string>, {
method,
action,
encType: "application/json",
});
};
const contextValue: FormContextValue<T> = {
schema,
defaultValues,
serverErrors,
clientErrors,
hasSubmitted,
setClientError,
onFieldChange,
revalidateAll,
values,
setValue,
submitToServer,
fetcherState: fetcher.state,
};
function scrollToFirstError(errors: Record<string, string>) {
const firstErrorField = Object.keys(errors)[0];
if (!firstErrorField) return;
const errorElement = document.getElementById(
errorMessageId(firstErrorField),
);
if (errorElement) {
errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
setFallbackError(null);
} else {
const firstError = errors[firstErrorField];
setFallbackError(
firstError ? `${t(firstError as never)} (${firstErrorField})` : null,
);
}
}
const names = Object.fromEntries(
Object.keys(schema.shape).map((key) => [key, key]),
) as FormNames<T>;
const resolvedChildren =
typeof children === "function"
? children({
names,
FormField: FormFieldComponent as TypedFormFieldComponent<T>,
})
: children;
return (
<FormContext.Provider value={contextValue as FormContextValue}>
<form
method={method}
action={action}
className={className ?? styles.form}
onSubmit={handleSubmit}
>
{title ? <h2 className={styles.title}>{title}</h2> : null}
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
{autoSubmit ? null : (
<div className="mt-4 stack horizontal md mx-auto justify-center">
<SubmitButton
_action={_action}
testId={submitButtonTestId}
state={fetcher.state}
>
{submitButtonText ?? t("submit")}
</SubmitButton>
{secondarySubmit}
</div>
)}
{fallbackError ? (
<div className="mt-4 mx-auto" data-testid="fallback-form-error">
<FormMessage type="error">{fallbackError}</FormMessage>
</div>
) : null}
</form>
</FormContext.Provider>
);
}
function buildFieldPath(path: PropertyKey[]): string | null {
if (path.length === 0) return null;
return path
.map((segment, index) => {
if (typeof segment === "number") return `[${segment}]`;
if (typeof segment === "symbol") return null;
return index === 0 ? segment : `.${segment}`;
})
.filter((part) => part !== null)
.join("");
}
function buildInitialValues<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
defaultValues?: Partial<z.input<z.ZodObject<T>>> | null,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, fieldSchema] of Object.entries(schema.shape)) {
const formField = formRegistry.get(fieldSchema as z.ZodType) as
| FormField
| undefined;
if (defaultValues && key in defaultValues) {
result[key] = defaultValues[key as keyof typeof defaultValues];
} else if (formField) {
result[key] = formField.initialValue;
}
}
return result;
}
export function useFormFieldContext() {
const context = React.useContext(FormContext);
if (!context) {
throw new Error("useFormFieldContext must be used within a FormProvider");
}
return context;
}
export function useOptionalFormFieldContext() {
return React.useContext(FormContext);
}

710
app/form/fields.ts Normal file
View File

@ -0,0 +1,710 @@
import * as R from "remeda";
import { z } from "zod";
import {
date,
falsyToNull,
id,
safeNullableStringSchema,
safeStringSchema,
stageId,
timeString,
weaponSplId,
} from "~/utils/zod";
import type {
BadgeOption,
FieldWithOptions,
FormField,
FormFieldArray,
FormFieldDatetime,
FormFieldDualSelect,
FormFieldFieldset,
FormFieldInputGroup,
FormFieldItems,
FormFieldSelect,
FormsTranslationKey,
SelectOption,
} from "./types";
export const formRegistry = z.registry<FormField>();
export type RequiresDefault<T extends z.ZodType> = T & {
_requiresDefault: true;
};
type WithTypedTranslationKeys<T> = Omit<T, "label" | "bottomText"> & {
label?: FormsTranslationKey;
bottomText?: FormsTranslationKey;
};
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>;
};
type WithTypedDualSelectFields<T, V extends string> = Omit<
T,
"fields" | "validate"
> & {
fields: [
{
label?: FormsTranslationKey;
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>;
},
{
label?: FormsTranslationKey;
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>;
},
];
validate?: {
func: (value: [V | null, V | null]) => boolean;
message: FormsTranslationKey;
};
};
function prefixKey(key: FormsTranslationKey | undefined): string | undefined {
return key ? `forms:${key}` : undefined;
}
function prefixItems<V extends string>(
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>,
) {
return items.map((item) => ({
...item,
label: typeof item.label === "string" ? `forms:${item.label}` : item.label,
}));
}
export function customField<T extends z.ZodType>(
args: Omit<Extract<FormField, { type: "custom" }>, "type">,
schema: T,
) {
// @ts-expect-error Complex generic type with registry
return schema.register(formRegistry, {
...args,
type: "custom",
});
}
export function textFieldOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>,
) {
const schema =
args.validate === "url"
? z.url()
: safeNullableStringSchema({ min: args.minLength, max: args.maxLength });
return textFieldRefined(schema, args).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: false,
type: "text-field",
initialValue: "",
});
}
export function textFieldRequired(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>,
) {
const schema =
args.validate === "url"
? z.string().url()
: safeStringSchema({ min: args.minLength, max: args.maxLength });
return textFieldRefined(schema, args).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: true,
type: "text-field",
initialValue: "",
});
}
function textFieldRefined<T extends z.ZodType<string | null>>(
schema: T,
args: Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>,
): T {
let result = schema as z.ZodType<string | null>;
if (args.regExp) {
result = result.refine(
(val) => {
if (val === null) return true;
return args.regExp!.pattern.test(val);
},
{ message: args.regExp!.message },
);
}
if (args.validate && typeof args.validate !== "string") {
result = result.refine(
(val) => {
if (val === null) return true;
return (args.validate as { func: (value: string) => boolean }).func(
val,
);
},
{ message: args.validate!.message },
);
}
if (args.toLowerCase) {
result = result.transform(
(val) => val?.toLowerCase() ?? null,
) as unknown as typeof result;
}
return result as T;
}
export function numberFieldOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "type"
| "initialValue"
| "required"
| "validate"
| "inputType"
| "maxLength"
>
> & { maxLength?: number },
) {
return z.coerce
.number()
.int()
.nonnegative()
.optional()
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required: false,
type: "text-field",
inputType: "number",
initialValue: "",
maxLength: args.maxLength ?? 10,
});
}
export function textAreaOptional(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "text-area" }>, "type" | "initialValue">
>,
) {
return safeNullableStringSchema({ max: args.maxLength }).register(
formRegistry,
{
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "text-area",
initialValue: "",
},
);
}
export function textAreaRequired(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "text-area" }>, "type" | "initialValue">
>,
) {
return safeStringSchema({ max: args.maxLength }).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "text-area",
initialValue: "",
});
}
export function toggle(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "switch" }>, "type" | "initialValue">
>,
) {
return z
.boolean()
.optional()
.default(false)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "switch",
initialValue: false,
});
}
function itemsSchema<V extends string>(items: FormFieldItems<V>) {
return z.enum(items.map((item) => item.value) as [V, ...V[]]);
}
function clearableItemsSchema<V extends string>(items: FormFieldItems<V>) {
return z.preprocess(
falsyToNull,
z.enum(items.map((item) => item.value) as [V, ...V[]]).nullable(),
);
}
export function selectOptional<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
Omit<FormFieldSelect<"select", V>, "type" | "initialValue" | "clearable">,
V
>
>,
) {
return clearableItemsSchema(args.items).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "select",
initialValue: null,
clearable: true,
});
}
export function select<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
Omit<FormFieldSelect<"select", V>, "type" | "initialValue" | "clearable">,
V
>
>,
) {
return itemsSchema(args.items).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "select",
initialValue: args.items[0].value,
clearable: false,
});
}
export function selectDynamicOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "select-dynamic" }>,
"type" | "initialValue" | "clearable"
>
>,
) {
return z
.preprocess(falsyToNull, z.string().nullable())
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "select-dynamic",
initialValue: null,
clearable: true,
}) as unknown as z.ZodType<string | null> &
FieldWithOptions<SelectOption[]>;
}
export function dualSelectOptional<V extends string>(
args: WithTypedTranslationKeys<
WithTypedDualSelectFields<
Omit<
FormFieldDualSelect<"dual-select", V>,
"type" | "initialValue" | "clearable"
>,
V
>
>,
) {
let schema = z
.tuple([
clearableItemsSchema(args.fields[0].items),
clearableItemsSchema(args.fields[1].items),
])
.optional();
if (args.validate) {
schema = schema.refine(
(val) => {
if (!val) return true;
const [first, second] = val;
return args.validate!.func([first, second]);
},
{ message: `forms:${args.validate!.message}` },
);
}
// @ts-expect-error Complex generic type
return schema.register(formRegistry, {
...args,
bottomText: prefixKey(args.bottomText),
fields: args.fields.map((field) => ({
...field,
label: prefixKey(field.label),
items: prefixItems(field.items),
})),
type: "dual-select",
initialValue: [null, null],
clearable: true,
});
}
export function radioGroup<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
Omit<FormFieldInputGroup<"radio-group", V>, "type" | "initialValue">,
V
>
>,
) {
return itemsSchema(args.items).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "radio-group",
initialValue: args.items[0].value,
});
}
type DateTimeArgs = WithTypedTranslationKeys<
Omit<FormFieldDatetime<"datetime">, "type" | "initialValue" | "required">
> & {
minMessage?: FormsTranslationKey;
maxMessage?: FormsTranslationKey;
};
export function datetimeRequired(args: DateTimeArgs) {
const minDate = args.min ?? new Date(Date.UTC(2015, 4, 28));
const maxDate = args.max ?? new Date(Date.UTC(2030, 4, 28));
return z
.preprocess(
date,
z
.date({ message: "forms:errors.required" })
.min(
minDate,
args.minMessage ? { message: `forms:${args.minMessage}` } : undefined,
)
.max(
maxDate,
args.maxMessage ? { message: `forms:${args.maxMessage}` } : undefined,
),
)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "datetime",
initialValue: null,
required: true,
});
}
export function datetimeOptional(args: DateTimeArgs) {
const minDate = args.min ?? new Date(Date.UTC(2015, 4, 28));
const maxDate = args.max ?? new Date(Date.UTC(2030, 4, 28));
return z
.preprocess(
date,
z
.date()
.min(
minDate,
args.minMessage ? { message: `forms:${args.minMessage}` } : undefined,
)
.max(
maxDate,
args.maxMessage ? { message: `forms:${args.maxMessage}` } : undefined,
)
.optional(),
)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "datetime",
initialValue: null,
required: false,
});
}
export function dayMonthYearRequired(args: DateTimeArgs) {
const minDate = args.min ?? new Date(Date.UTC(2015, 4, 28));
const maxDate = args.max ?? new Date(Date.UTC(2030, 4, 28));
return z
.preprocess(
date,
z
.date({ message: "forms:errors.required" })
.min(
minDate,
args.minMessage ? { message: `forms:${args.minMessage}` } : undefined,
)
.max(
maxDate,
args.maxMessage ? { message: `forms:${args.maxMessage}` } : undefined,
),
)
.transform((d) => ({
day: d.getDate(),
month: d.getMonth(),
year: d.getFullYear(),
}))
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "date",
initialValue: null,
required: true,
});
}
export function checkboxGroup<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabels<
Omit<FormFieldInputGroup<"checkbox-group", V>, "type" | "initialValue">,
V
>
>,
) {
return z
.array(itemsSchema(args.items))
.min(args.minLength ?? 0)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "checkbox-group",
initialValue: [],
});
}
export function weaponPool(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "weapon-pool" }>, "type" | "initialValue">
>,
) {
let schema = z
.array(
z.object({
id: weaponSplId,
isFavorite: z.boolean(),
}),
)
.min(args.minCount ?? 0)
.max(args.maxCount);
if (!args.allowDuplicates) {
schema = schema.refine(
(val) => val.length === R.uniqueBy(val, (item) => item.id).length,
);
}
return schema.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-pool",
initialValue: [],
});
}
export function stringConstant<T extends string>(value: T) {
// @ts-expect-error Complex generic type with registry
return z.literal(value).register(formRegistry, {
type: "string-constant",
initialValue: value,
value,
});
}
export function idConstant<T extends number>(value: T): z.ZodLiteral<T>;
export function idConstant(): RequiresDefault<z.ZodNumber>;
export function idConstant<T extends number>(value?: T) {
const schema = value !== undefined ? z.literal(value) : id;
return schema.register(formRegistry, {
type: "id-constant",
initialValue: value,
value: value ?? null,
}) as never;
}
export function idConstantOptional<T extends number>(value?: T) {
const schema = value ? z.literal(value).optional() : id.optional();
return schema.register(formRegistry, {
type: "id-constant",
initialValue: value,
value: value ?? null,
});
}
export function array<S extends z.ZodType>(
args: WithTypedTranslationKeys<
Omit<FormFieldArray<"array", S>, "type" | "initialValue">
>,
) {
const schema = z
.array(args.field)
.min(args.min ?? 0)
.max(args.max);
// @ts-expect-error Complex generic type with registry
return schema.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "array",
initialValue: [],
});
}
type TimeRangeArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "time-range" }>,
"type" | "initialValue" | "startLabel" | "endLabel"
>
> & {
startLabel?: FormsTranslationKey;
endLabel?: FormsTranslationKey;
};
export function timeRangeOptional(args: TimeRangeArgs) {
return z
.object({
start: timeString,
end: timeString,
})
.nullable()
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
startLabel: prefixKey(args.startLabel),
endLabel: prefixKey(args.endLabel),
type: "time-range",
initialValue: null,
});
}
export function fieldset<S extends z.ZodRawShape>(
args: WithTypedTranslationKeys<
Omit<FormFieldFieldset<"fieldset", S>, "type" | "initialValue">
>,
) {
// @ts-expect-error Complex generic type with registry
return args.fields.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "fieldset",
initialValue: {},
});
}
export function userSearch(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return id.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "user-search",
initialValue: null,
required: true,
});
}
export function userSearchOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return id.optional().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "user-search",
initialValue: null,
required: false,
});
}
export function badges(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "badges" }>, "type" | "initialValue">
>,
) {
return z
.array(id)
.max(args.maxCount ?? 50)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "badges",
initialValue: [],
}) as z.ZodArray<typeof id> & FieldWithOptions<BadgeOption[]>;
}
export function stageSelect(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "stage-select" }>,
"type" | "initialValue" | "required"
>
>,
) {
return stageId.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "stage-select",
initialValue: 1,
required: true,
});
}
export function weaponSelectOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"type" | "initialValue" | "required"
>
>,
) {
return weaponSplId.optional().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-select",
initialValue: null,
required: false,
});
}

View File

@ -0,0 +1,40 @@
.card {
border: var(--border-style);
border-radius: var(--rounded-xs);
margin: 0;
padding: 0;
}
.header {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
background-color: var(--bg-lighter);
border-bottom: var(--border-style);
border-radius: var(--rounded-xs) var(--rounded-xs) 0 0;
}
.dragHandle {
width: var(--s-5);
height: var(--s-5);
cursor: grab;
color: var(--text-lighter);
}
.headerLabel {
flex: 1;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
}
.content {
padding: var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.itemInput {
flex: 1;
}

View File

@ -0,0 +1,138 @@
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { PlusIcon } from "~/components/icons/Plus";
import { TrashIcon } from "~/components/icons/Trash";
import type { FormFieldProps } from "../types";
import styles from "./ArrayFormField.module.css";
import { useTranslatedTexts } from "./FormFieldWrapper";
type ArrayFormFieldProps = Omit<FormFieldProps<"array">, "field"> & {
name: string;
value: unknown[];
onChange: (value: unknown[]) => void;
renderItem: (index: number, name: string) => React.ReactNode;
isObjectArray?: boolean;
sortable?: boolean;
itemInitialValue?: unknown;
};
export function ArrayFormField({
label,
name,
bottomText,
error,
min = 0,
max,
value,
onChange,
renderItem,
isObjectArray,
sortable,
itemInitialValue,
}: ArrayFormFieldProps) {
const { t } = useTranslation(["common"]);
const { translatedLabel, translatedBottomText, translatedError } =
useTranslatedTexts({ label, bottomText, error });
const count = value.length;
const handleAdd = () => {
const newItemValue =
itemInitialValue !== undefined
? itemInitialValue
: isObjectArray
? {}
: undefined;
onChange([...value, newItemValue]);
};
const handleRemoveAt = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
return (
<div className="stack md w-full">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{Array.from({ length: count }).map((_, idx) =>
isObjectArray ? (
<ArrayItemFieldset
key={idx}
index={idx}
canRemove={count > min}
onRemove={() => handleRemoveAt(idx)}
sortable={sortable}
>
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
) : (
<div key={idx} className="stack horizontal sm items-center w-full">
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>
{count > min ? (
<SendouButton
icon={<TrashIcon />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={() => handleRemoveAt(idx)}
/>
) : null}
</div>
),
)}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
{translatedBottomText && !translatedError ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
<SendouButton
size="small"
icon={<PlusIcon />}
onPress={handleAdd}
isDisabled={count >= max}
className="m-0-auto"
>
{t("common:actions.add")}
</SendouButton>
</div>
);
}
function ArrayItemFieldset({
index,
children,
canRemove,
onRemove,
sortable,
}: {
index: number;
children: React.ReactNode;
canRemove: boolean;
onRemove: () => void;
sortable?: boolean;
}) {
return (
<fieldset className={styles.card}>
<div className={styles.header}>
{sortable ? <span className={styles.dragHandle}></span> : null}
<legend className={styles.headerLabel}>#{index + 1}</legend>
{canRemove ? (
<SendouButton
icon={<TrashIcon />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={onRemove}
/>
) : null}
</div>
<div className={styles.content}>{children}</div>
</fieldset>
);
}

Some files were not shown because too many files have changed in this diff Show More