mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
a004cf33b7
commit
c20701d98c
6
.beans.yml
Normal file
6
.beans.yml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
beans:
|
||||
path: .beans
|
||||
prefix: sendou.ink-2-
|
||||
id_length: 4
|
||||
default_status: todo
|
||||
default_type: task
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.selectWidthWider {
|
||||
--select-width: 250px;
|
||||
--select-width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newCalendarEventActionSchema,
|
||||
parseAsync: true,
|
||||
});
|
||||
|
||||
const isEditing = Boolean(data.eventToEditId);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
4
app/features/scrims/routes/scrims.new.module.css
Normal file
4
app/features/scrims/routes/scrims.new.module.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.datePickerFullWidth {
|
||||
--input-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
38
app/features/sendouq-settings/q-settings-schemas.ts
Normal file
38
app/features/sendouq-settings/q-settings-schemas.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
17
app/features/team/team-schemas.ts
Normal file
17
app/features/team/team-schemas.ts
Normal 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",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]),
|
||||
]),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
206
app/features/user-page/components/NewBuildForm.browser.test.tsx
Normal file
206
app/features/user-page/components/NewBuildForm.browser.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
164
app/features/user-page/components/NewBuildForm.tsx
Normal file
164
app/features/user-page/components/NewBuildForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
17
app/features/user-page/user-page-schemas.server.ts
Normal file
17
app/features/user-page/user-page-schemas.server.ts
Normal 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"] },
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ function Match({
|
|||
width={120}
|
||||
className="rounded"
|
||||
/>
|
||||
{weapon ? (
|
||||
{typeof weapon === "number" ? (
|
||||
<WeaponImage
|
||||
weaponSplId={weapon}
|
||||
variant="badge"
|
||||
|
|
|
|||
219
app/features/vods/routes/vods.new.browser.test.tsx
Normal file
219
app/features/vods/routes/vods.new.browser.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
21
app/features/vods/vods-schemas.server.ts
Normal file
21
app/features/vods/vods-schemas.server.ts
Normal 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"] },
|
||||
);
|
||||
|
|
@ -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
418
app/form/FormField.tsx
Normal 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;
|
||||
}
|
||||
1121
app/form/SendouForm.browser.test.tsx
Normal file
1121
app/form/SendouForm.browser.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
13
app/form/SendouForm.module.css
Normal file
13
app/form/SendouForm.module.css
Normal 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
399
app/form/SendouForm.tsx
Normal 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
710
app/form/fields.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
40
app/form/fields/ArrayFormField.module.css
Normal file
40
app/form/fields/ArrayFormField.module.css
Normal 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;
|
||||
}
|
||||
138
app/form/fields/ArrayFormField.tsx
Normal file
138
app/form/fields/ArrayFormField.tsx
Normal 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
Loading…
Reference in New Issue
Block a user