Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,683 @@
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 { ImageFormField } from "./fields/ImageFormField";
import { InGameNameFormField } from "./fields/InGameNameFormField";
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 { TeamSearchFormField } from "./fields/TeamSearchFormField";
import { TextareaFormField } from "./fields/TextareaFormField";
import { TimeRangeFormField } from "./fields/TimeRangeFormField";
import { TournamentSearchFormField } from "./fields/TournamentSearchFormField";
import { UserSearchFormField } from "./fields/UserSearchFormField";
import {
WeaponPoolFormField,
type WeaponPoolItem,
} from "./fields/WeaponPoolFormField";
import { WeaponSelectFormField } from "./fields/WeaponSelectFormField";
import type { ImageFieldValue } from "./image-field";
import { EMPTY_FORM_STORE, useOptionalFormFieldContext } from "./SendouForm";
import type {
ArrayItemRenderContext,
BadgeOption,
CustomFieldRenderProps,
FormFieldItemsWithImage,
FormField as FormFieldType,
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
TrophyOption,
UserSearchFieldOptions,
} from "./types";
import {
fieldsetDefaults,
getNestedSchema,
getNestedValue,
validateField,
} from "./utils";
// lazy loaded so the trophies field's WebGL renderer stays out of the eager
// bundle of every page rendering a form
const TrophiesFormField = React.lazy(() =>
import("./fields/TrophiesFormField").then((module) => ({
default: module.TrophiesFormField,
})),
);
export type { CustomFieldRenderProps };
const EMPTY_ITEM_VALUES: Record<string, unknown> = {};
interface FormFieldProps {
name: string;
label?: string;
/** Extra element rendered next to the label, e.g. an `<InfoPopover />` explaining the field's syntax. Only `text-field` supports it. */
labelPopover?: React.ReactNode;
disabled?: boolean;
/** Focuses the field on mount. Only `text-field` and `text-area` support it. */
autoFocus?: boolean;
maxCount?: number;
field?: z.ZodType;
children?:
| ((props: CustomFieldRenderProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
/** Field-specific options */
options?: unknown;
/** For `array` fields: hide the remove button for items where this returns false. */
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
/**
* Runs after the new value has been stored. For side effects on other fields;
* to change what gets stored use the field schema's own options instead.
*/
onValueChange?: (newValue: unknown) => void;
}
/** Field types that render `children`. Any other type would silently discard it. */
const FIELD_TYPES_WITH_RENDER_PROP = ["custom", "array"];
export function FormField({
name,
label,
labelPopover,
disabled,
autoFocus,
maxCount,
field,
children,
options,
canRemoveItem,
onValueChange,
}: FormFieldProps) {
const context = useOptionalFormFieldContext();
const isDisabled = disabled ?? context?.readOnly ?? false;
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 store = context?.store ?? EMPTY_FORM_STORE;
const getValue = () =>
isNestedPath ? getNestedValue(store.values, name) : store.values[name];
const storedValue = React.useSyncExternalStore(
store.subscribe,
getValue,
getValue,
);
const value = storedValue ?? formField.initialValue;
const getClientError = () => store.clientErrors[name];
const clientError = React.useSyncExternalStore(
store.subscribe,
getClientError,
getClientError,
);
const serverError =
context?.serverErrors[name as keyof typeof context.serverErrors];
const hasSubmitted = context?.hasSubmitted ?? false;
const runValidation = (val: unknown) => {
if (!context?.schema) return;
const validationError = validateField(context.schema, name, val);
context.setClientError(name, validationError);
};
// After the first submit, changes revalidate the whole form — except array
// appends, which stay silent so a freshly added empty item doesn't error
// immediately. Blur is the moment the user leaves such an item, so
// revalidating here surfaces its error without waiting for the next submit.
const handleBlur = (latestValue?: unknown) => {
if (!context) return;
if (hasSubmitted) {
context.revalidateAll(context.store.values);
return;
}
runValidation(latestValue ?? value);
};
// Read through a ref so an inline `onValueChange` does not destabilize
// `handleChange`, which fields rely on to skip re-rendering.
const latestOnValueChange = React.useRef(onValueChange);
latestOnValueChange.current = onValueChange;
const handleChange = React.useCallback(
(newValue: unknown) => {
if (!context) return;
const previousValues = context.store.values;
context.setValue(name, newValue);
context.clearServerError(name);
if (
context.hasSubmitted &&
!isArrayAppend(previousValues, name, newValue)
) {
context.revalidateAll(context.store.values);
}
context.onFieldChange?.(name, newValue);
latestOnValueChange.current?.(newValue);
},
[context, name],
);
const displayedError = serverError ?? clientError;
if (
typeof children === "function" &&
!FIELD_TYPES_WITH_RENDER_PROP.includes(formField.type)
) {
throw new Error(
`Field "${name}" is of type "${formField.type}" which renders itself, so its render function child would never run. Remove the child or change the field to customField().`,
);
}
const commonProps = { name, error: displayedError, onBlur: handleBlur };
if (formField.type === "text-field") {
return (
<InputFormField
{...commonProps}
{...formField}
disabled={isDisabled}
autoFocus={autoFocus}
labelPopover={labelPopover}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "in-game-name") {
return (
<InGameNameFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "switch") {
return (
<SwitchFormField
{...commonProps}
{...formField}
isDisabled={isDisabled}
checked={value as boolean}
onChange={handleChange as (v: boolean) => void}
/>
);
}
if (formField.type === "text-area") {
return (
<TextareaFormField
{...commonProps}
{...formField}
disabled={isDisabled}
autoFocus={autoFocus}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "select") {
return (
<SelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
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}
disabled={isDisabled}
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}
disabled={isDisabled}
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}
disabled={isDisabled}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "radio-group-dynamic") {
if (!options) {
throw new Error("Dynamic radio group form field requires options prop");
}
const radioItems = options as FormFieldItemsWithImage<string>;
return (
<RadioGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
items={radioItems}
value={value as string}
onChange={handleChange as (v: string) => void}
/>
);
}
if (formField.type === "checkbox-group") {
return (
<CheckboxGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as string[]}
onChange={handleChange as (v: string[]) => void}
/>
);
}
if (formField.type === "checkbox-group-dynamic") {
if (!options) {
throw new Error(
"Dynamic checkbox group form field requires options prop",
);
}
const checkboxItems = options as FormFieldItemsWithImage<string>;
return (
<CheckboxGroupFormField
{...commonProps}
{...formField}
disabled={isDisabled}
items={checkboxItems}
value={value as string[]}
onChange={handleChange as (v: string[]) => void}
/>
);
}
if (formField.type === "datetime" || formField.type === "date") {
return (
<DatetimeFormField
{...commonProps}
{...formField}
disabled={isDisabled}
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}
disabled={isDisabled}
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}
disabled={isDisabled}
value={value as WeaponPoolItem[]}
onChange={handleChange as (v: WeaponPoolItem[]) => void}
/>
);
}
if (formField.type === "image") {
return (
<ImageFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as ImageFieldValue}
onChange={handleChange as (v: ImageFieldValue) => 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,
disabled: isDisabled,
})}
</>
);
}
if (formField.type === "hidden") {
return null;
}
if (formField.type === "array") {
// @ts-expect-error Type instantiation is excessively deep with complex schemas
const innerFieldMeta = formRegistry.get(formField.field) as
| FormFieldType
| undefined;
const isObjectArray = innerFieldMeta?.type === "fieldset";
const hasCustomRender = typeof children === "function";
const itemInitialValue =
isObjectArray && innerFieldMeta
? fieldsetDefaults(innerFieldMeta)
: innerFieldMeta?.initialValue;
return (
<ArrayFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as unknown[]}
onChange={handleChange as (v: unknown[]) => void}
isObjectArray={isObjectArray}
itemInitialValue={itemInitialValue}
canRemoveItem={canRemoveItem}
renderItem={(idx, itemName) => {
if (hasCustomRender && isObjectArray) {
const arrayValue = value as Record<string, unknown>[];
return (
<ArrayItemCustomRender
arrayName={name}
index={idx}
itemName={itemName}
itemValues={arrayValue[idx] ?? EMPTY_ITEM_VALUES}
canRemove={arrayValue.length > (formField.min ?? 0)}
onArrayChange={handleChange as (v: unknown[]) => void}
renderChildren={
children as (props: ArrayItemRenderContext) => React.ReactNode
}
/>
);
}
return (
<FormField
key={idx}
name={itemName}
field={formField.field}
disabled={disabled}
/>
);
}}
/>
);
}
if (formField.type === "fieldset") {
return (
<FieldsetFormField {...commonProps} {...formField} disabled={disabled} />
);
}
if (formField.type === "user-search") {
const userOptions = options as UserSearchFieldOptions | undefined;
return (
<UserSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
onUserSelected={userOptions?.onUserSelected}
/>
);
}
if (formField.type === "tournament-search") {
const tournamentOptions = options as
| TournamentSearchFieldOptions
| undefined;
return (
<TournamentSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number | null}
onChange={handleChange as (v: number | null) => void}
pastOnly={tournamentOptions?.pastOnly}
onTournamentSelected={tournamentOptions?.onTournamentSelected}
/>
);
}
if (formField.type === "team-search") {
const teamOptions = options as TeamSearchFieldOptions | undefined;
return (
<TeamSearchFormField
{...commonProps}
{...formField}
disabled={isDisabled}
onChange={handleChange as (v: number | null) => void}
onTeamSelected={teamOptions?.onTeamSelected}
initialTeam={teamOptions?.initialTeam}
/>
);
}
if (formField.type === "badges") {
if (!options) {
throw new Error("Badges form field requires options prop");
}
return (
<BadgesFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as number[]}
onChange={handleChange as (v: number[]) => void}
options={options as BadgeOption[]}
{...(maxCount !== undefined ? { maxCount } : {})}
/>
);
}
if (formField.type === "trophies") {
if (!options) {
throw new Error("Trophies form field requires options prop");
}
return (
<React.Suspense>
<TrophiesFormField
{...commonProps}
{...formField}
value={value as number[]}
onChange={handleChange as (v: number[]) => void}
options={options as TrophyOption[]}
{...(maxCount !== undefined ? { maxCount } : {})}
/>
</React.Suspense>
);
}
if (formField.type === "stage-select") {
return (
<StageSelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as StageId | null}
onChange={handleChange as (v: StageId) => void}
/>
);
}
if (formField.type === "weapon-select") {
return (
<WeaponSelectFormField
{...commonProps}
{...formField}
disabled={isDisabled}
value={value as MainWeaponId | null}
onChange={handleChange as (v: MainWeaponId | null) => void}
/>
);
}
return (
<div>Unsupported form field type: {(formField as FormFieldType).type}</div>
);
}
interface ArrayItemCustomRenderProps {
arrayName: string;
index: number;
itemName: string;
itemValues: Record<string, unknown>;
canRemove: boolean;
onArrayChange: (value: unknown[]) => void;
renderChildren: (props: ArrayItemRenderContext) => React.ReactNode;
}
/**
* One custom-rendered array item, memoized so that a form edit re-renders only
* the item whose slice of the array changed — the array field itself re-renders
* on every write to the array. For this to hold, anything the item render reads
* from the form outside its own item must be subscribed to via `useFormValue`.
* The callbacks read the current array through the store instead of closing
* over it so a skipped re-render can never make them act on stale values.
*/
const ArrayItemCustomRender = React.memo(function ArrayItemCustomRender({
arrayName,
index,
itemName,
itemValues,
canRemove,
onArrayChange,
renderChildren,
}: ArrayItemCustomRenderProps) {
const context = useOptionalFormFieldContext();
const setItemField = (fieldName: string, fieldValue: unknown) => {
context?.setValueFromPrev(arrayName, (prev) => {
const currentArray = (prev ?? []) as Record<string, unknown>[];
const newArray = [...currentArray];
newArray[index] = {
...currentArray[index],
[fieldName]: fieldValue,
};
return newArray;
});
};
const remove = () => {
if (!context) return;
const currentArray = (getNestedValue(context.store.values, arrayName) ??
[]) as unknown[];
onArrayChange(currentArray.filter((_, i) => i !== index));
};
return (
<>
{renderChildren({
index,
itemName,
values: itemValues,
setItemField,
canRemove,
remove,
})}
</>
);
});
function isArrayAppend(
values: Record<string, unknown>,
name: string,
newValue: unknown,
): boolean {
if (!Array.isArray(newValue)) return false;
const isNestedPath = name.includes(".") || name.includes("[");
const prevValue = isNestedPath ? getNestedValue(values, name) : values[name];
return Array.isArray(prevValue) && newValue.length > prevValue.length;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
.form {
display: flex;
flex-direction: column;
gap: var(--s-6);
width: 100%;
max-width: 24rem;
margin: 0 auto;
&.fullWidth {
margin: 0;
max-width: none;
}
}
.title {
font-size: var(--font-lg);
font-weight: var(--weight-extra);
}

View File

@@ -0,0 +1,785 @@
import clsx from "clsx";
import * as React from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import type { FetcherWithComponents } from "react-router";
import { useFetcher, useLocation } from "react-router";
import { isPlainObject } from "remeda";
import type { z } from "zod";
import type { SendouButtonProps } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { SubmitButton } from "~/components/SubmitButton";
import { FormField as FormFieldComponent } from "./FormField";
import { getFormFieldMetadata } from "./fields";
import styles from "./SendouForm.module.css";
import type { TypedFormFieldComponent } from "./types";
import { useUnsavedChangesChecker } from "./UnsavedChangesGuard";
import {
buildFieldPath,
errorMessageId,
getNestedValue,
seedArrayItemDefaults,
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;
clearServerError: (name: string) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
readOnly: boolean;
values: Record<string, unknown>;
setValue: (name: string, value: unknown) => void;
setValueFromPrev: (name: string, updater: (prev: unknown) => unknown) => void;
revalidateAll: (updatedValues: Record<string, unknown>) => void;
submitToServer: (values: Record<string, unknown>) => void;
fetcherState: "idle" | "loading" | "submitting";
}
/**
* Holds the frequently-changing form state (values and client errors) outside
* React state so that a change to one field does not re-render the whole form.
* Fields subscribe to their own slice via `useSyncExternalStore` and only
* re-render when that slice changes.
*/
interface FormStore {
values: Record<string, unknown>;
clientErrors: Partial<Record<string, string>>;
/** Has the user edited any field since mount / the last successful submit? */
dirty: boolean;
subscribe: (listener: () => void) => () => void;
setValues: (values: Record<string, unknown>) => void;
setClientErrors: (errors: Partial<Record<string, string>>) => void;
setDirty: (dirty: boolean) => void;
}
type FormFieldContextValue = Omit<
FormContextValue,
"values" | "clientErrors"
> & {
store: FormStore;
};
const FormContext = React.createContext<FormFieldContextValue | null>(null);
export const EMPTY_FORM_STORE = createFormStore({}, {});
export interface FormRenderProps<T extends z.ZodRawShape> {
FormField: TypedFormFieldComponent<T>;
}
export type FormMode = "submit" | "autoSubmit" | "client";
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;
submitButtonTestId?: string;
/** Styling of the submit button, for forms embedded somewhere the default button is too heavy. */
submitButtonVariant?: SendouButtonProps["variant"];
submitButtonSize?: SendouButtonProps["size"];
revalidateRoot?: boolean;
/**
* Replaces the default form layout classes entirely (it does not merge with
* them), so `fullWidth` has no effect when this is set.
*/
className?: string;
/**
* When true, opts out of the default centered, max-width layout so the form
* expands to fill its parent container. Use when embedding a form inside a
* layout that already controls width/alignment.
*/
fullWidth?: boolean;
/**
* When true, renders the form for viewing only: every field is disabled and
* the submit button is hidden.
*/
readOnly?: boolean;
secondarySubmit?: React.ReactNode;
/**
* Called once after a server submission completes successfully (the action
* returned without field errors). Useful for collapsing an inline edit form
* back to a read-only view.
*/
onSuccess?: () => void;
};
/**
* How submitting works:
* - `"submit"` (default): the user submits via the submit button. Values go to
* the server, or to `onApply` when provided.
* - `"autoSubmit"`: no submit button; every change that passes validation is
* sent to the server.
* - `"client"`: no submit button and no `<form>` element; every change is
* passed to `onApply` and field errors are computed already on mount.
*/
type FormModeProps<T extends z.ZodRawShape> =
| {
mode?: "submit";
/** When set, a valid submit is handed to this callback instead of being sent to the server. */
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
}
| { mode: "autoSubmit"; onApply?: never }
| { mode: "client"; onApply: (values: z.infer<z.ZodObject<T>>) => void };
export type FormDefaultValues<T extends z.ZodRawShape> = Partial<
z.input<z.ZodObject<T>>
>;
type SendouFormProps<T extends z.ZodRawShape> = BaseFormProps<T> &
FormModeProps<T> &
(HasRequiredDefaults<T> extends true
? {
defaultValues: FormDefaultValues<T> &
Record<RequiredDefaultKeys<T>, unknown>;
}
: { defaultValues?: FormDefaultValues<T> | null });
interface LatestFormProps {
schema: z.ZodObject<z.ZodRawShape>;
onApply: ((values: Record<string, unknown>) => void) | undefined;
action: string | undefined;
revalidateRoot: boolean | undefined;
mode: FormMode;
fetcher: FetcherWithComponents<{ fieldErrors?: Record<string, string> }>;
t: (key: string) => string;
}
export function SendouForm<T extends z.ZodRawShape>(props: SendouFormProps<T>) {
// Remounting on URL change resets all form state (handles edit → new transitions)
const location = useLocation();
return (
<SendouFormInner
key={`${location.pathname}${location.search}`}
{...props}
/>
);
}
function SendouFormInner<T extends z.ZodRawShape>({
children,
schema,
defaultValues,
title,
submitButtonText,
action,
submitButtonTestId,
submitButtonVariant,
submitButtonSize,
revalidateRoot,
className,
fullWidth,
readOnly = false,
mode = "submit",
onApply,
secondarySubmit,
onSuccess,
}: SendouFormProps<T>) {
const { t } = useTranslation(["forms"]);
const fetcher = useFetcher<{ fieldErrors?: Record<string, string> }>();
const [hasSubmitted, setHasSubmitted] = React.useState(false);
const [visibleServerErrors, setVisibleServerErrors] = React.useState<
Partial<Record<string, string>>
>(fetcher.data?.fieldErrors ?? {});
const [fallbackError, setFallbackError] = React.useState<string | null>(null);
const storeRef = React.useRef<FormStore | null>(null);
if (storeRef.current === null) {
const initialValues = buildInitialValues(schema, defaultValues);
storeRef.current = createFormStore(
initialValues,
mode === "client"
? computeTopLevelFieldErrors(schema, initialValues)
: {},
);
}
const store = storeRef.current;
const latestProps: LatestFormProps = {
schema: schema as z.ZodObject<z.ZodRawShape>,
onApply: onApply as unknown as LatestFormProps["onApply"],
action,
revalidateRoot,
mode,
fetcher,
t: t as unknown as LatestFormProps["t"],
};
const latest = React.useRef(latestProps);
latest.current = latestProps;
const [actions] = React.useState(() =>
createFormActions({
store,
latest,
setHasSubmitted,
setVisibleServerErrors,
setFallbackError,
}),
);
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 ?? {};
const errorEntries = Object.entries(serverFieldErrors);
if (errorEntries.length === 0) {
setFallbackError(null);
return;
}
for (const [fieldName, errorMessage] of errorEntries) {
const errorElement = document.getElementById(errorMessageId(fieldName));
if (!errorElement) {
setFallbackError(`${t(errorMessage as never)} (${fieldName})`);
return;
}
}
setFallbackError(null);
const firstError = findFirstErrorElementInDomOrder(
errorEntries.map(([fieldName]) => fieldName),
);
if (firstError) focusAndScrollToError(firstError);
}, [fetcher.data, t]);
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
hasUnsavedChangesRef.current = () =>
mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
useUnsavedChangesChecker(hasUnsavedChangesRef);
const previousFetcherStateRef = React.useRef(fetcher.state);
React.useEffect(() => {
if (
previousFetcherStateRef.current !== "idle" &&
fetcher.state === "idle" &&
!fetcher.data?.fieldErrors
) {
store.setDirty(false);
onSuccess?.();
}
previousFetcherStateRef.current = fetcher.state;
}, [fetcher.state, fetcher.data, onSuccess, store]);
const contextValue = React.useMemo<FormFieldContextValue>(
() => ({
schema: schema as z.ZodObject<z.ZodRawShape>,
defaultValues: defaultValues as FormFieldContextValue["defaultValues"],
serverErrors: visibleServerErrors,
hasSubmitted,
setClientError: actions.setClientError,
clearServerError: actions.clearServerError,
onFieldChange: mode !== "submit" ? actions.onFieldChange : undefined,
readOnly,
setValue: actions.setValue,
setValueFromPrev: actions.setValueFromPrev,
revalidateAll: actions.revalidateAll,
submitToServer: actions.submitToServer,
fetcherState: fetcher.state,
store,
}),
[
schema,
defaultValues,
visibleServerErrors,
hasSubmitted,
mode,
readOnly,
fetcher.state,
store,
actions,
],
);
const resolvedChildren =
typeof children === "function"
? children({
FormField: FormFieldComponent as TypedFormFieldComponent<T>,
})
: children;
const formContent = (
<>
{title ? <h2 className={styles.title}>{title}</h2> : null}
{resolvedChildren}
{mode !== "submit" || readOnly ? null : (
<div className="mt-4 stack horizontal md mx-auto justify-center items-center">
<SubmitButton
testId={submitButtonTestId}
state={fetcher.state}
variant={submitButtonVariant}
size={submitButtonSize}
>
{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}
</>
);
const resolvedClassName =
className ?? clsx(styles.form, { [styles.fullWidth]: fullWidth });
return (
<FormContext.Provider value={contextValue}>
{mode === "client" ? (
<div className={resolvedClassName}>{formContent}</div>
) : (
<form
method="post"
action={action}
className={resolvedClassName}
noValidate
onSubmit={actions.handleSubmit}
>
{formContent}
</form>
)}
</FormContext.Provider>
);
}
function createFormStore(
initialValues: Record<string, unknown>,
initialClientErrors: Partial<Record<string, string>>,
): FormStore {
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of listeners) {
listener();
}
};
const store: FormStore = {
values: initialValues,
clientErrors: initialClientErrors,
dirty: false,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
setValues(values) {
store.values = values;
notify();
},
setClientErrors(errors) {
store.clientErrors = errors;
notify();
},
// Read only at navigation time (unsaved-changes guard), so no notify.
setDirty(dirty) {
store.dirty = dirty;
},
};
return store;
}
interface FormActionDeps {
store: FormStore;
latest: React.RefObject<LatestFormProps>;
setHasSubmitted: React.Dispatch<React.SetStateAction<boolean>>;
setVisibleServerErrors: React.Dispatch<
React.SetStateAction<Partial<Record<string, string>>>
>;
setFallbackError: React.Dispatch<React.SetStateAction<string | null>>;
}
/**
* Creates the form's action functions once per form instance. They read the
* current values/props through the store and the `latest` ref, so their
* identities stay stable across renders — this keeps the form context
* referentially stable, which is what lets fields skip re-rendering when an
* unrelated field changes.
*/
function createFormActions({
store,
latest,
setHasSubmitted,
setVisibleServerErrors,
setFallbackError,
}: FormActionDeps) {
const scrollToFirstError = (errors: Record<string, string>) => {
const errorFieldNames = Object.keys(errors);
if (errorFieldNames.length === 0) return;
const firstError = findFirstErrorElementInDomOrder(errorFieldNames);
if (firstError) {
focusAndScrollToError(firstError);
setFallbackError(null);
} else {
const firstErrorField = errorFieldNames[0];
const firstErrorMessage = errors[firstErrorField];
setFallbackError(
firstErrorMessage
? `${latest.current.t(firstErrorMessage)} (${firstErrorField})`
: null,
);
}
};
const validateAndPrepare = (): boolean => {
setHasSubmitted(true);
setVisibleServerErrors({});
const newErrors = computeFieldErrors(latest.current.schema, store.values);
if (Object.keys(newErrors).length > 0) {
flushSync(() => {
store.setClientErrors(newErrors);
});
scrollToFirstError(newErrors);
return false;
}
return true;
};
const submitValues = (values: Record<string, unknown>) => {
const { fetcher, action, revalidateRoot } = latest.current;
const submitted = revalidateRoot
? { ...values, revalidateRoot: true }
: values;
fetcher.submit(submitted as Record<string, string>, {
method: "post",
action,
encType: "application/json",
});
};
const setClientError = (name: string, error: string | undefined) => {
if (error === undefined) {
if (!(name in store.clientErrors)) return;
const next = { ...store.clientErrors };
delete next[name];
store.setClientErrors(next);
return;
}
store.setClientErrors({ ...store.clientErrors, [name]: error });
};
// Server errors are keyed by positional path (e.g. `members[2].userId`). When
// the user edits a field, the server's verdict for that field — and for any
// nested descendants when an array/object changes — is stale, so drop it.
// Without this, removing an array item and re-adding one at the same index
// would resurrect the previous item's server error.
const clearServerError = (name: string) => {
setVisibleServerErrors((prev) => {
const isStale = (key: string) =>
key === name ||
key.startsWith(`${name}.`) ||
key.startsWith(`${name}[`);
if (!Object.keys(prev).some(isStale)) return prev;
const next: Partial<Record<string, string>> = {};
for (const [key, value] of Object.entries(prev)) {
if (!isStale(key)) next[key] = value;
}
return next;
});
};
const setValue = (name: string, newValue: unknown) => {
store.setDirty(true);
if (name.includes(".") || name.includes("[")) {
store.setValues(
setNestedValue(
seedArrayItemDefaults(latest.current.schema, store.values, name),
name,
newValue,
),
);
} else {
store.setValues({ ...store.values, [name]: newValue });
}
};
const setValueFromPrev = (
name: string,
updater: (prev: unknown) => unknown,
) => {
store.setDirty(true);
store.setValues({ ...store.values, [name]: updater(store.values[name]) });
};
const revalidateAll = (updatedValues: Record<string, unknown>) => {
store.setClientErrors(
computeFieldErrors(latest.current.schema, updatedValues),
);
};
const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
if (!validateAndPrepare()) return;
// Cleared before `onApply` because it may navigate synchronously (e.g.
// calendar filters set search params) — the blocker would otherwise still
// see the form as dirty and block that navigation.
store.setDirty(false);
latest.current.onApply?.(store.values);
submitValues(valuesToSubmit);
};
const onFieldChange = (changedName: string, changedValue: unknown) => {
const { schema, mode, onApply } = latest.current;
const isNestedPath = changedName.includes(".") || changedName.includes("[");
const updatedValues = isNestedPath
? setNestedValue(store.values, changedName, changedValue)
: { ...store.values, [changedName]: changedValue };
const newErrors = computeTopLevelFieldErrors(schema, updatedValues);
store.setClientErrors(newErrors);
const hasFieldErrors = Object.keys(newErrors).length > 0;
if (mode === "client") {
onApply?.(updatedValues);
} else if (mode === "autoSubmit" && !hasFieldErrors) {
submitValues(updatedValues);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!validateAndPrepare()) return;
const { onApply } = latest.current;
if (onApply) {
// Cleared before `onApply` because it may navigate synchronously (e.g.
// calendar filters set search params) — the blocker would otherwise
// still see the form as dirty and block that navigation.
store.setDirty(false);
onApply(store.values);
} else {
submitValues(store.values);
}
};
return {
setClientError,
clearServerError,
setValue,
setValueFromPrev,
revalidateAll,
submitToServer,
onFieldChange,
handleSubmit,
};
}
/**
* Derives all client errors from a single full-schema parse. Each issue is
* attributed both to its top-level field (single-control composites like
* dual-select or weapon-pool read their error keyed by their own name even
* when the issue points inside the value) and to its full nested path
* (array/fieldset children render their own error slots).
*/
function computeFieldErrors(
schema: z.ZodObject<z.ZodRawShape>,
values: Record<string, unknown>,
): Record<string, string> {
const newErrors: Record<string, string> = {};
const fullValidation = schema.safeParse(values);
if (fullValidation.success) return newErrors;
for (const issue of fullValidation.error.issues) {
const topLevelKey =
typeof issue.path[0] === "string" ? issue.path[0] : undefined;
if (topLevelKey && newErrors[topLevelKey] === undefined) {
const topLevelError = validateField(
schema,
topLevelKey,
values[topLevelKey],
);
if (topLevelError) newErrors[topLevelKey] = topLevelError;
}
const fieldName = buildFieldPath(issue.path);
if (fieldName && newErrors[fieldName] === undefined) {
const value = getNestedValue(values, fieldName);
newErrors[fieldName] =
validateField(schema, fieldName, value) ?? issue.message;
}
}
return newErrors;
}
function computeTopLevelFieldErrors(
schema: z.ZodObject<z.ZodRawShape>,
values: Record<string, unknown>,
): Record<string, string> {
const errors: Record<string, string> = {};
for (const key of Object.keys(schema.shape)) {
const error = validateField(schema, key, values[key]);
if (error) errors[key] = error;
}
return errors;
}
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 = getFormFieldMetadata(fieldSchema as z.ZodType);
const defaultValue = defaultValues?.[key as keyof typeof defaultValues];
if (defaultValue !== undefined) {
if (formField?.type === "array" && Array.isArray(defaultValue)) {
// only object-array (fieldset) items get a stable `_key`; spreading would
// collapse non-plain objects like `Date` into `{}`, so leave those intact
result[key] = (defaultValue as unknown[]).map((item) =>
isPlainObject(item)
? {
...item,
_key: crypto.randomUUID(),
}
: item,
);
} else {
result[key] = defaultValue;
}
} else if (formField) {
result[key] = formField.initialValue;
}
}
return result;
}
export function useFormFieldContext(): FormContextValue {
const context = React.useContext(FormContext);
const store = context?.store ?? EMPTY_FORM_STORE;
const getValues = () => store.values;
const values = React.useSyncExternalStore(
store.subscribe,
getValues,
getValues,
);
const getClientErrors = () => store.clientErrors;
const clientErrors = React.useSyncExternalStore(
store.subscribe,
getClientErrors,
getClientErrors,
);
if (!context) {
throw new Error("useFormFieldContext must be used within a FormProvider");
}
return {
schema: context.schema,
defaultValues: context.defaultValues,
serverErrors: context.serverErrors,
clientErrors,
hasSubmitted: context.hasSubmitted,
setClientError: context.setClientError,
clearServerError: context.clearServerError,
onFieldChange: context.onFieldChange,
readOnly: context.readOnly,
values,
setValue: context.setValue,
setValueFromPrev: context.setValueFromPrev,
revalidateAll: context.revalidateAll,
submitToServer: context.submitToServer,
fetcherState: context.fetcherState,
};
}
export function useOptionalFormFieldContext() {
return React.useContext(FormContext);
}
/**
* Subscribes to a single form value by (possibly nested) path, e.g. `"type"`
* or `"matches[2].mode"`. Unlike `useFormFieldContext` this re-renders only
* when that value changes, so prefer it for reading form state in components
* that should not re-render on every form edit.
*/
export function useFormValue(name: string): unknown {
const context = React.useContext(FormContext);
const store = context?.store ?? EMPTY_FORM_STORE;
const getValue = () => getNestedValue(store.values, name);
return React.useSyncExternalStore(store.subscribe, getValue, getValue);
}
/**
* "First" error means first in DOM order, not first in error-map insertion
* order — validation collects errors in schema order which does not have to
* match the rendered field order.
*/
function findFirstErrorElementInDomOrder(errorFieldNames: string[]) {
const errorElements = errorFieldNames.flatMap((name) => {
const element = document.getElementById(errorMessageId(name));
return element ? [{ name, element }] : [];
});
errorElements.sort((a, b) =>
a.element.compareDocumentPosition(b.element) &
Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1,
);
return errorElements.at(0);
}
/**
* Moves focus to the failing field so keyboard and screen reader users are
* taken to the problem, not just scrolled past it. Prefers the control that
* references the error via `aria-errormessage`, then any focusable control in
* the same wrapper, and as a last resort the error message element itself.
*/
function focusAndScrollToError({
name,
element,
}: {
name: string;
element: HTMLElement;
}) {
const control = document.querySelector<HTMLElement>(
`[aria-errormessage="${errorMessageId(name)}"]`,
);
const focusTarget =
control ??
element.parentElement?.querySelector<HTMLElement>(
"input, select, textarea, button",
) ??
element;
if (focusTarget === element) {
element.setAttribute("tabindex", "-1");
}
focusTarget.focus({ preventScroll: true });
element.scrollIntoView({ behavior: "smooth", block: "center" });
}

View File

@@ -0,0 +1,84 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useBlocker } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
const dirtyCheckers = new Set<() => boolean>();
/**
* Confirms navigating away when any mounted form has unsaved changes.
* Rendered once in the root layout because react-router supports only a
* single active blocker at a time — individual forms register a checker via
* `useUnsavedChangesChecker` instead of calling `useBlocker` themselves.
*/
export function UnsavedChangesGuard() {
const { t } = useTranslation(["common", "forms"]);
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
(currentLocation.pathname !== nextLocation.pathname ||
currentLocation.search !== nextLocation.search) &&
hasUnsavedChanges(),
);
React.useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!hasUnsavedChanges()) return;
event.preventDefault();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
if (blocker.state !== "blocked") return null;
return (
<SendouDialog
heading={t("forms:unsavedChanges.title")}
onClose={() => blocker.reset()}
isDismissable
>
<div className="stack md stack md text-sm text-lighter">
{t("forms:unsavedChanges.body")}
<div className="stack horizontal md justify-center">
<SendouButton variant="outlined" onPress={() => blocker.reset()}>
{t("common:actions.cancel")}
</SendouButton>
<SendouButton
variant="destructive"
onPress={() => blocker.proceed()}
data-testid="discard-changes-button"
>
{t("forms:unsavedChanges.discard")}
</SendouButton>
</div>
</div>
</SendouDialog>
);
}
/**
* Registers a callback reporting whether the calling form currently has
* unsaved changes. The ref indirection lets the callback read the latest
* form state without re-registering on every render.
*/
export function useUnsavedChangesChecker(
checkerRef: React.RefObject<() => boolean>,
) {
React.useEffect(() => {
const checker = () => checkerRef.current();
dirtyCheckers.add(checker);
return () => {
dirtyCheckers.delete(checker);
};
}, [checkerRef]);
}
function hasUnsavedChanges() {
for (const checker of dirtyCheckers) {
if (checker()) return true;
}
return false;
}

View File

@@ -0,0 +1,931 @@
import * as R from "remeda";
import { z } from "zod";
import {
IN_GAME_NAME_MAX_LENGTH,
inGameNameIsValid,
} from "~/features/user-page/in-game-name";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
import {
date,
falsyToNull,
id,
safeNullableStringSchema,
safeStringSchema,
stageId,
timeString,
weaponSplId,
} from "~/utils/zod";
import { imageValue } from "./image-field";
import type {
BadgeOption,
FieldWithOptions,
FormField,
FormFieldArray,
FormFieldDatetime,
FormFieldDualSelect,
FormFieldFieldset,
FormFieldInputGroup,
FormFieldItems,
FormFieldItemsWithImage,
FormFieldSelect,
FormsTranslationKey,
SelectOption,
TeamSearchFieldOptions,
TournamentSearchFieldOptions,
TrophyOption,
} from "./types";
export const formRegistry = z.registry<FormField>();
/**
* Looks up a schemas form field metadata. Needed to bypass the
* registrys deep generic `get` signature which causes
* "Type instantiation is excessively deep" errors.
*/
export function getFormFieldMetadata(schema: z.ZodType): FormField | undefined {
const registry = formRegistry as {
get(schema: z.ZodType): FormField | undefined;
};
return registry.get(schema);
}
export type RequiresDefault<T extends z.ZodType> = T & {
_requiresDefault: true;
};
type WithTypedTranslationKeys<T> = Omit<
T,
"label" | "bottomText" | "placeholder"
> & {
label?: FormsTranslationKey;
bottomText?: FormsTranslationKey;
placeholder?: FormsTranslationKey;
};
type TypedItemLabel<V extends string> = {
label: FormsTranslationKey | (() => string);
value: V;
};
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
items: Array<TypedItemLabel<V>>;
};
type WithTypedItemLabelsWithImage<T, V extends string> = Omit<T, "items"> & {
items: Array<TypedItemLabel<V> & { imgSrc?: string }>;
};
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, T extends TypedItemLabel<V>>(
items: Array<T>,
) {
return items.map((item) => ({
...item,
label: typeof item.label === "string" ? `forms:${item.label}` : item.label,
}));
}
export function image(args: {
label: FormsTranslationKey;
bottomText?: FormsTranslationKey;
dimensions?: "logo" | "thick-banner" | { width: number; height: number };
autoValidate?: boolean;
}) {
// clone so each field gets its own registry entry (the shared `imageValue`
// instance would otherwise have its metadata overwritten by later fields)
return imageValue.clone().register(formRegistry, {
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
dimensions: args.dimensions ?? "logo",
autoValidate: args.autoValidate ?? false,
type: "image",
initialValue: null,
});
}
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",
});
}
type TextFieldArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
"type" | "initialValue" | "required"
>
>;
export function textFieldOptional(args: TextFieldArgs) {
const schema =
args.validate === "url"
? z.url()
: safeNullableStringSchema({ min: args.minLength, max: args.maxLength });
return registerTextField(schema, args, false);
}
export function textField(args: TextFieldArgs) {
const schema =
args.validate === "url"
? z.string().url()
: safeStringSchema({ min: args.minLength, max: args.maxLength });
return registerTextField(schema, args, true);
}
function registerTextField<T extends z.ZodType<string | null>>(
schema: T,
args: TextFieldArgs,
required: boolean,
): T {
const refined = textFieldRefined(schema, args) as z.ZodType<string | null>;
return refined.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
placeholder: prefixKey(args.placeholder),
required,
type: "text-field",
initialValue: "",
}) as T;
}
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 inGameName(
args: WithTypedTranslationKeys<{
label?: FormsTranslationKey;
bottomText?: FormsTranslationKey;
}>,
) {
const schema = safeNullableStringSchema({
max: IN_GAME_NAME_MAX_LENGTH,
}).refine((val) => val === null || inGameNameIsValid(val), {
message: "forms:errors.profileInGameName",
});
return schema.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
maxLength: IN_GAME_NAME_MAX_LENGTH,
required: false,
type: "in-game-name",
initialValue: "",
});
}
type NumberFieldArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-field" }>,
| "type"
| "initialValue"
| "required"
| "validate"
| "inputType"
| "maxLength"
>
> & { maxLength?: number };
export function numberField(
args: NumberFieldArgs & { min?: number; max?: number },
) {
let schema = numberSchema();
// an empty field coerces to 0, so `min` is also what makes a required number
// field reject being left blank
if (typeof args.min === "number") {
schema = schema.min(args.min, { message: "forms:errors.numberOutOfRange" });
}
if (typeof args.max === "number") {
schema = schema.max(args.max, { message: "forms:errors.numberOutOfRange" });
}
return schema.register(formRegistry, numberFieldMetadata(args, true));
}
export function numberFieldOptional(args: NumberFieldArgs) {
return numberSchema()
.optional()
.register(formRegistry, numberFieldMetadata(args, false));
}
function numberSchema() {
return z.coerce
.number()
.int({ message: "forms:errors.mustBeWholeNumber" })
.nonnegative();
}
function numberFieldMetadata(args: NumberFieldArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required,
type: "text-field" as const,
inputType: "number" as const,
initialValue: "",
maxLength: args.maxLength ?? 10,
};
}
type TextAreaArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "text-area" }>,
"type" | "initialValue" | "required"
>
>;
export function textAreaOptional(args: TextAreaArgs) {
return registerTextArea(
safeNullableStringSchema({ max: args.maxLength }),
args,
false,
);
}
export function textArea(args: TextAreaArgs) {
return registerTextArea(
safeStringSchema({ max: args.maxLength }),
args,
true,
);
}
function registerTextArea<T extends z.ZodType<string | null>>(
schema: T,
args: TextAreaArgs,
required: boolean,
): T {
return (schema as z.ZodType<string | null>).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
required,
type: "text-area",
initialValue: "",
}) as T;
}
export function toggle(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "switch" }>, "type" | "initialValue">
> & {
/** Value used when the form has no default value for the field. Defaults to `false`. */
initialValue?: boolean;
},
) {
return z
.boolean()
.optional()
.default(false)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "switch",
initialValue: args.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
>
> & {
/** Value selected when the form has no default value for the field. Defaults to the first item. */
initialValue?: V;
},
) {
return itemsSchema(args.items).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
items: prefixItems(args.items),
type: "select",
initialValue: args.initialValue ?? args.items[0].value,
clearable: false,
});
}
export function selectDynamic(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "select-dynamic" }>,
"type" | "initialValue" | "clearable"
>
> & {
/** Value used when the form has no default value for the field. Defaults to no selection. */
initialValue?: string;
},
) {
return z.string().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "select-dynamic",
initialValue: args.initialValue ?? null,
clearable: false,
}) as unknown as z.ZodType<string> & FieldWithOptions<SelectOption[]>;
}
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<
WithTypedItemLabelsWithImage<
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,
});
}
export function radioGroupDynamic(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "radio-group-dynamic" }>,
"type" | "initialValue"
>
>,
) {
return z.string().register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "radio-group-dynamic",
initialValue: null,
}) as unknown as z.ZodType<string> &
FieldWithOptions<FormFieldItemsWithImage<string>>;
}
export function checkboxGroupDynamic(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "checkbox-group-dynamic" }>,
"type" | "initialValue"
>
>,
) {
return z
.array(z.string())
.min(args.minLength ?? 0, "forms:errors.required")
.refine((val) => val.length === R.unique(val).length)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "checkbox-group-dynamic",
initialValue: [],
}) as unknown as z.ZodType<string[]> &
FieldWithOptions<FormFieldItemsWithImage<string>>;
}
type DateTimeArgs = WithTypedTranslationKeys<
Omit<FormFieldDatetime<"datetime">, "type" | "initialValue" | "required">
> & {
minMessage?: FormsTranslationKey;
maxMessage?: FormsTranslationKey;
};
function boundedDate(args: DateTimeArgs, schema: z.ZodDate) {
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
return schema
.refine((d) => d >= resolveMin(), {
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
})
.refine((d) => d <= resolveMax(), {
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
});
}
function datetimeMetadata(
args: DateTimeArgs,
overrides: { type: "datetime" | "date"; required: boolean },
) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
initialValue: null,
...overrides,
};
}
export function datetime(args: DateTimeArgs) {
return z
.preprocess(
date,
boundedDate(args, z.date({ message: "forms:errors.required" })),
)
.register(
formRegistry,
datetimeMetadata(args, { type: "datetime", required: true }),
);
}
export function datetimeOptional(args: DateTimeArgs) {
return z
.preprocess(date, boundedDate(args, z.date()).nullish())
.register(
formRegistry,
datetimeMetadata(args, { type: "datetime", required: false }),
);
}
export function dayMonthYear(args: DateTimeArgs) {
return z
.preprocess(
date,
boundedDate(args, z.date({ message: "forms:errors.required" })),
)
.transform((d) => ({
day: d.getDate(),
month: d.getMonth(),
year: d.getFullYear(),
}))
.register(
formRegistry,
datetimeMetadata(args, { type: "date", required: true }),
);
}
export function checkboxGroup<V extends string>(
args: WithTypedTranslationKeys<
WithTypedItemLabelsWithImage<
Omit<FormFieldInputGroup<"checkbox-group", V>, "type" | "initialValue">,
V
>
>,
) {
return z
.array(itemsSchema(args.items))
.min(args.minLength ?? 0)
.refine((val) => val.length === R.unique(val).length)
.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,
);
}
if (args.disableAltSkinDuplicates) {
schema = schema.refine(
(val) =>
val.length ===
R.uniqueBy(val, (item) => canonicalWeaponSplId(item.id)).length,
);
}
return schema.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-pool",
initialValue: [],
});
}
/**
* Field that renders no control at all. Use it for values the form needs to
* submit but the user never edits, e.g. a discriminator seeded from the loader.
*
* Pass `initialValue` to hardcode the starting value. Omitting it makes the
* field require a matching entry in the form's `defaultValues`.
*/
export function hidden<T extends z.ZodType>(
schema: T,
initialValue: z.input<T>,
): T;
export function hidden<T extends z.ZodType>(schema: T): RequiresDefault<T>;
export function hidden<T extends z.ZodType>(
schema: T,
initialValue?: z.input<T>,
) {
// @ts-expect-error Complex generic type with registry
return schema.register(formRegistry, {
type: "hidden",
initialValue,
}) as never;
}
export function stringConstant<T extends string>(value: T) {
return hidden(z.literal(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) {
return (
value !== undefined ? hidden(z.literal(value), value) : hidden(id.clone())
) as never;
}
export function idConstantOptional<T extends number>(value?: T) {
return value
? hidden(z.literal(value).optional(), value)
: hidden(id.optional(), undefined);
}
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" | "fields">
> & { fields: z.ZodObject<S> },
): z.ZodObject<S> {
// @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: {},
}) as z.ZodObject<S>;
}
type UserSearchArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "user-search" }>,
"type" | "initialValue" | "required"
>
>;
export function userSearch(args: UserSearchArgs) {
return id.clone().register(formRegistry, userSearchMetadata(args, true));
}
export function userSearchOptional(args: UserSearchArgs) {
return id.optional().register(formRegistry, userSearchMetadata(args, false));
}
function userSearchMetadata(args: UserSearchArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "user-search" as const,
initialValue: null,
required,
};
}
export function tournamentSearchOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "tournament-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "tournament-search",
initialValue: null,
required: false,
}) as unknown as z.ZodType<number | null> &
FieldWithOptions<TournamentSearchFieldOptions>;
}
export function teamSearchOptional(
args: WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "team-search" }>,
"type" | "initialValue" | "required"
>
>,
) {
return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "team-search",
initialValue: null,
required: false,
}) as unknown as z.ZodType<number | null> &
FieldWithOptions<TeamSearchFieldOptions>;
}
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 trophies(
args: WithTypedTranslationKeys<
Omit<Extract<FormField, { type: "trophies" }>, "type" | "initialValue">
>,
) {
return z
.array(id)
.max(args.maxCount ?? 100)
.register(formRegistry, {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "trophies",
initialValue: [],
}) as z.ZodArray<typeof id> & FieldWithOptions<TrophyOption[]>;
}
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,
});
}
type WeaponSelectArgs = WithTypedTranslationKeys<
Omit<
Extract<FormField, { type: "weapon-select" }>,
"type" | "initialValue" | "required"
>
>;
export function weaponSelect(args: WeaponSelectArgs) {
return weaponSplId.register(formRegistry, weaponSelectMetadata(args, true));
}
export function weaponSelectOptional(args: WeaponSelectArgs) {
return weaponSplId
.optional()
.register(formRegistry, weaponSelectMetadata(args, false));
}
function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) {
return {
...args,
label: prefixKey(args.label),
bottomText: prefixKey(args.bottomText),
type: "weapon-select" as const,
initialValue: null,
required,
};
}

View File

@@ -0,0 +1,39 @@
.card {
border: var(--border-style);
border-radius: var(--radius-box);
margin: 0;
padding: 0;
}
.header {
--header-border-radius: calc(var(--radius-field) - 2px);
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
background-color: var(--color-bg-higher);
border-bottom: var(--border-style);
border-radius: var(--header-border-radius) var(--header-border-radius) 0 0;
}
.headerLabel {
flex: 1;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.content {
padding: var(--s-4);
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.itemInput {
flex: 1;
}
.removeButton {
height: var(--field-size);
align-self: flex-end;
}

View File

@@ -0,0 +1,259 @@
import { ChevronDown, ChevronUp, Plus, Trash } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { isDeepEqual, omit } from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
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;
addable?: boolean;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
disabled?: boolean;
};
export function ArrayFormField({
label,
name,
bottomText,
error,
min = 0,
max,
value,
onChange,
renderItem,
isObjectArray,
sortable,
itemInitialValue,
addable = true,
canRemoveItem,
disabled,
}: ArrayFormFieldProps) {
const { t } = useTranslation(["common"]);
const { translatedLabel, translatedBottomText, translatedError } =
useTranslatedTexts({ label, bottomText, error });
const count = value.length;
// Always render at least one item so an empty array still shows an input
// the user can fill, rather than only an "Add" button. The underlying value
// stays empty until edited, so submitting an untouched field sends nothing.
const minVisible = Math.max(min, 1);
const visibleCount = Math.max(count, minVisible);
const makeNewItem = () => {
const baseValue =
itemInitialValue !== undefined
? itemInitialValue
: isObjectArray
? {}
: undefined;
return typeof baseValue === "object" && baseValue !== null
? {
...(baseValue as Record<string, unknown>),
_key: crypto.randomUUID(),
}
: baseValue;
};
const handleAdd = () => {
// While the array is empty we still render one placeholder row that isn't
// part of `value` yet. Pad `value` up to the number of visible rows first so
// the added item appears below them instead of only backing the placeholder.
const padded = [...value];
while (padded.length < visibleCount) {
padded.push(makeNewItem());
}
onChange([...padded, makeNewItem()]);
};
// An item the user hasn't touched still equals the freshly added template, so
// it's indistinguishable from the placeholder shown for an empty array.
const isPristineItem = (item: unknown) => {
const template = itemInitialValue;
if (typeof template === "object" && template !== null) {
if (typeof item !== "object" || item === null) return true;
return isDeepEqual(
omit(item as Record<string, unknown>, ["_key"]),
template,
);
}
return template === undefined
? item === null || item === undefined || item === ""
: isDeepEqual(item, template);
};
// A single pristine row is indistinguishable from the empty-array placeholder,
// so it shouldn't offer a remove button (you can't go below one visible row
// anyway). A lone edited row stays removable so the only item can be cleared.
const canRemoveAt = (index: number) =>
!disabled &&
(canRemoveItem ? canRemoveItem(value[index], index) : true) &&
count > min &&
(count > minVisible || !isPristineItem(value[index]));
const handleRemoveAt = (index: number) => {
const next = value.filter((_, i) => i !== index);
// Removing down to a single pristine row would leave a stray entry that
// looks untouched but still fails validation on submit; collapse it back to
// an empty array so it matches the pristine state.
onChange(next.length === 1 && isPristineItem(next[0]) ? [] : next);
};
const itemKey = (idx: number) => {
if (!isObjectArray) return idx;
return ((value[idx] as Record<string, unknown>)?._key as string) ?? idx;
};
// Sorting is only offered for object arrays; primitive arrays are rendered
// inline without the fieldset header that carries the reorder controls.
const isSortable = Boolean(sortable) && isObjectArray && !disabled;
const handleMoveAt = (index: number, direction: 1 | -1) => {
const target = index + direction;
if (target < 0 || target >= count) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onChange(next);
};
return (
<div className="stack md w-full">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{isObjectArray
? Array.from({ length: visibleCount }).map((_, idx) => (
<ArrayItemFieldset
key={itemKey(idx)}
index={idx}
canRemove={canRemoveAt(idx)}
removeButtonTestId={`${name}-remove-item-button`}
onRemove={() => handleRemoveAt(idx)}
sortable={isSortable}
canMoveUp={idx > 0}
canMoveDown={idx < count - 1}
onMoveUp={() => handleMoveAt(idx, -1)}
onMoveDown={() => handleMoveAt(idx, 1)}
>
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
))
: Array.from({ length: visibleCount }).map((_, idx) => (
<div
key={itemKey(idx)}
className="stack horizontal sm items-start w-full"
>
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>
{canRemoveAt(idx) ? (
<SendouButton
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={() => handleRemoveAt(idx)}
className={styles.removeButton}
data-testid={`${name}-remove-item-button`}
/>
) : null}
</div>
))}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
{translatedBottomText && !translatedError ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
{addable ? (
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
onPress={handleAdd}
isDisabled={count >= max || disabled}
className="m-0-auto"
data-testid={`${name}-add-item-button`}
>
{t("common:actions.add")}
</SendouButton>
) : null}
</div>
);
}
function ArrayItemFieldset({
index,
children,
canRemove,
removeButtonTestId,
onRemove,
sortable,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
}: {
index: number;
children: React.ReactNode;
canRemove: boolean;
removeButtonTestId?: string;
onRemove: () => void;
sortable?: boolean;
canMoveUp?: boolean;
canMoveDown?: boolean;
onMoveUp?: () => void;
onMoveDown?: () => void;
}) {
return (
<fieldset className={styles.card}>
<div className={styles.header}>
<legend className={styles.headerLabel}>#{index + 1}</legend>
{sortable ? (
<>
<SendouButton
shape="circle"
icon={<ChevronDown />}
aria-label="Move down"
size="small"
variant="minimal"
onPress={onMoveDown}
isDisabled={!canMoveDown}
/>
<SendouButton
shape="circle"
icon={<ChevronUp />}
aria-label="Move up"
size="small"
variant="minimal"
onPress={onMoveUp}
isDisabled={!canMoveUp}
/>
</>
) : null}
<SendouButton
className={canRemove ? undefined : "invisible"}
shape="circle"
icon={<Trash />}
aria-label="Remove item"
size="small"
variant="minimal-destructive"
onPress={onRemove}
isDisabled={!canRemove}
data-testid={removeButtonTestId}
/>
</div>
<div className={styles.content}>{children}</div>
</fieldset>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { BadgesSelector } from "~/features/badges/components/BadgesSelector";
import type { BadgeOption, FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type BadgesFormFieldProps = Omit<FormFieldProps<"badges">, "onBlur"> & {
value: number[];
onChange: (value: number[]) => void;
onBlur?: () => void;
options: BadgeOption[];
disabled?: boolean;
};
export function BadgesFormField({
name,
label,
bottomText,
error,
maxCount,
value,
onChange,
onBlur,
options,
disabled,
}: BadgesFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<BadgesSelector
options={options}
selectedBadges={value}
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
disabled={disabled}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,76 @@
import type { CalendarDate, CalendarDateTime } from "@internationalized/date";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { dateToCalendarDate, dateToDateValue } from "~/utils/dates";
import type { FormFieldProps } from "../types";
import { errorMessageId } from "../utils";
import { FormFieldWrapper, useTranslatedTexts } from "./FormFieldWrapper";
type DatetimeFormFieldProps = Omit<
FormFieldProps<"datetime">,
"min" | "max"
> & {
value: Date | undefined;
onChange: (value: Date | undefined) => void;
granularity?: "day" | "minute";
disabled?: boolean;
};
export function DatetimeFormField({
name,
label,
bottomText,
error,
required,
onBlur,
value,
onChange,
granularity = "minute",
disabled,
}: DatetimeFormFieldProps) {
const { translatedLabel, translatedError, translatedBottomText } =
useTranslatedTexts({ label, error, bottomText });
const handleChange = (val: CalendarDateTime | CalendarDate | null) => {
if (val) {
if (granularity === "day") {
onChange(new Date(val.year, val.month - 1, val.day));
} else {
const dateTimeVal = val as CalendarDateTime;
onChange(
new Date(
dateTimeVal.year,
dateTimeVal.month - 1,
dateTimeVal.day,
dateTimeVal.hour,
dateTimeVal.minute,
),
);
}
} else {
onChange(undefined);
}
};
return (
<FormFieldWrapper id={name} name={name}>
<SendouDatePicker
label={translatedLabel ?? ""}
granularity={granularity}
errorText={translatedError}
errorId={errorMessageId(name)}
bottomText={translatedBottomText}
isRequired={required}
isDisabled={disabled}
value={
value
? granularity === "day"
? dateToCalendarDate(value)
: dateToDateValue(value)
: null
}
onChange={handleChange}
onBlur={() => onBlur?.()}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,53 @@
import type { FormFieldItems, FormFieldProps } from "../types";
import { FormFieldMessages } from "./FormFieldWrapper";
import { SelectFormField } from "./SelectFormField";
type DualSelectFormFieldProps<V extends string> = Omit<
FormFieldProps<"dual-select">,
"clearable"
> & {
fields: [
{ label?: string; items: FormFieldItems<V> },
{ label?: string; items: FormFieldItems<V> },
];
value: [V | null, V | null];
onChange: (value: [V | null, V | null]) => void;
disabled?: boolean;
};
export function DualSelectFormField<V extends string>({
name,
bottomText,
error,
onBlur,
value,
onChange,
fields,
disabled,
}: DualSelectFormFieldProps<V>) {
return (
<div className="stack xs">
<div className="stack horizontal md">
<SelectFormField
label={fields[0].label}
items={fields[0].items}
value={value[0]}
onChange={(newValue) => onChange([newValue, value[1]])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
<SelectFormField
label={fields[1].label}
items={fields[1].items}
value={value[1]}
onChange={(newValue) => onChange([value[0], newValue])}
onBlur={onBlur}
clearable
disabled={disabled}
/>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { z } from "zod";
import { FormMessage } from "~/components/FormMessage";
import { FormField } from "../FormField";
import type { FormFieldProps } from "../types";
import { useTranslatedTexts } from "./FormFieldWrapper";
type FieldsetFormFieldProps<S extends z.ZodRawShape> = Omit<
FormFieldProps<"fieldset">,
"fields"
> & {
name: string;
fields: z.ZodObject<S>;
disabled?: boolean;
};
export function FieldsetFormField<S extends z.ZodRawShape>({
label,
name,
bottomText,
error,
fields,
disabled,
}: FieldsetFormFieldProps<S>) {
const fieldNames = Object.keys(fields.shape);
const { translatedLabel, translatedBottomText, translatedError } =
useTranslatedTexts({ label, bottomText, error });
return (
<div className="stack md">
{translatedLabel ? (
<div className="text-xs font-semi-bold">{translatedLabel}</div>
) : null}
{fieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={`${name}.${fieldName}`}
field={fields.shape[fieldName] as z.ZodType}
disabled={disabled}
/>
))}
{translatedError ? (
<FormMessage type="error">{translatedError}</FormMessage>
) : null}
{translatedBottomText && !translatedError ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.root {
width: 100%;
& input:not([type="radio"], [type="checkbox"]),
& textarea,
& select,
& .react-aria-Group,
& .react-aria-Select button,
& .react-aria-DatePicker {
width: 100%;
}
}

View File

@@ -0,0 +1,119 @@
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { errorMessageId, infoMessageId } from "../utils";
import styles from "./FormFieldWrapper.module.css";
export function useTranslatedTexts({
label,
error,
bottomText,
}: {
label?: string;
error?: string;
bottomText?: string;
}) {
const { t } = useTranslation(["forms"]);
return {
translatedLabel: label?.includes(":") ? t(label as never) : label,
translatedError: error?.includes(":") ? t(error as never) : error,
translatedBottomText: bottomText?.includes(":")
? t(bottomText as never)
: bottomText,
};
}
export function FormFieldMessages({
name,
error,
bottomText,
}: {
name?: string;
error?: string;
bottomText?: string;
}) {
const { translatedError, translatedBottomText } = useTranslatedTexts({
error,
bottomText,
});
return (
<>
{translatedError ? (
<FormMessage
type="error"
spaced={false}
id={name ? errorMessageId(name) : undefined}
>
{translatedError}
</FormMessage>
) : null}
{translatedBottomText ? (
<FormMessage
type="info"
spaced={false}
id={name ? infoMessageId(name) : undefined}
>
{translatedBottomText}
</FormMessage>
) : null}
</>
);
}
interface FormFieldWrapperProps {
id: string;
name?: string;
label?: string;
/** Extra element rendered next to the label, e.g. an `<InfoPopover />` explaining the field's syntax. */
labelPopover?: React.ReactNode;
required?: boolean;
error?: string;
bottomText?: string;
valueLimits?: { current: number; max: number };
children: React.ReactNode;
}
export function FormFieldWrapper({
id,
name,
label,
labelPopover,
required,
error,
bottomText,
valueLimits,
children,
}: FormFieldWrapperProps) {
const { translatedLabel } = useTranslatedTexts({ label });
const labelElement = translatedLabel ? (
<Label
htmlFor={id}
required={required}
valueLimits={valueLimits}
spaced={false}
>
{translatedLabel}
</Label>
) : null;
return (
<div className={styles.root}>
<div className="stack xs">
{labelElement && labelPopover ? (
<div className="stack horizontal xs items-center">
{labelElement}
{labelPopover}
</div>
) : (
labelElement
)}
{children}
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
.preview {
width: 144px;
height: 144px;
border-radius: 50%;
object-fit: cover;
&.banner {
width: 100%;
height: auto;
max-width: 320px;
border-radius: var(--radius-box);
}
}

View File

@@ -0,0 +1,111 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { logger } from "~/utils/logger";
import {
type ImageFieldValue,
resolveImageFieldDimensions,
} from "../image-field";
import type { FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
import styles from "./ImageFormField.module.css";
type ImageFormFieldProps = Omit<FormFieldProps<"image">, "onBlur"> & {
value: ImageFieldValue;
onChange: (value: ImageFieldValue) => void;
disabled?: boolean;
};
export function ImageFormField({
name,
label,
bottomText,
dimensions,
autoValidate,
error,
value,
onChange,
disabled,
}: ImageFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["common"]);
const resolvedDimensions = resolveImageFieldDimensions(dimensions);
const previewUrl =
value?.type === "EXISTING"
? value.url
: value?.type === "NEW"
? value.dataUrl
: null;
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const uploadedFile = event.target.files?.[0];
if (!uploadedFile) return;
new Compressor(uploadedFile, {
width: resolvedDimensions.width,
height: resolvedDimensions.height,
maxWidth: resolvedDimensions.width,
maxHeight: resolvedDimensions.height,
resize: "cover",
mimeType: "image/webp",
success(result) {
const reader = new FileReader();
reader.onload = () =>
onChange({ type: "NEW", dataUrl: reader.result as string });
reader.onerror = () => logger.error("Failed to read compressed image");
reader.readAsDataURL(result);
},
error(err) {
logger.error(err.message);
},
});
};
const isBanner =
dimensions === "thick-banner" ||
(typeof dimensions === "object" && dimensions.width > dimensions.height);
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={
bottomText ??
(autoValidate ? undefined : "forms:bottomTexts.imageModeration")
}
>
<div className="stack sm items-start">
{previewUrl ? (
<img
src={previewUrl}
alt=""
className={clsx(styles.preview, { [styles.banner]: isBanner })}
/>
) : null}
{value ? (
<SendouButton
variant="minimal-destructive"
size="small"
onPress={() => onChange(null)}
isDisabled={disabled}
>
{t("common:actions.remove")}
</SendouButton>
) : (
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/webp"
onChange={handleFileChange}
disabled={disabled}
/>
)}
</div>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { IngameNameInput } from "~/components/IngameNameInput";
import { inGameNameLength } from "~/features/user-page/in-game-name";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type InGameNameFormFieldProps = FormFieldProps<"in-game-name"> & {
disabled?: boolean;
value: string;
onChange: (value: string) => void;
};
export function InGameNameFormField({
name,
label,
bottomText,
maxLength,
error,
onBlur,
required,
disabled,
value,
onChange,
}: InGameNameFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
valueLimits={{ current: inGameNameLength(value), max: maxLength }}
>
<IngameNameInput
id={id}
name={name}
value={value}
onChange={onChange}
onBlur={() => onBlur?.()}
disabled={disabled}
{...ariaAttributes({ name, bottomText, error, required })}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type InputFormFieldProps = FormFieldProps<"text-field"> & {
disabled?: boolean;
autoFocus?: boolean;
labelPopover?: React.ReactNode;
value: string;
onChange: (value: string) => void;
};
export function InputFormField({
name,
label,
labelPopover,
bottomText,
leftAddon,
transformValue,
placeholder,
maxLength,
error,
onBlur,
required,
inputType = "text",
disabled,
autoFocus,
value,
onChange,
}: InputFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["forms"]);
const translatedPlaceholder = placeholder?.includes(":")
? t(placeholder as never)
: placeholder;
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
labelPopover={labelPopover}
required={required}
error={error}
bottomText={bottomText}
>
<div className={leftAddon ? "input-container" : undefined}>
{leftAddon ? <span className="input-addon">{leftAddon}</span> : null}
<input
id={id}
className={leftAddon ? "in-container" : undefined}
type={inputType}
value={value}
onChange={(e) =>
onChange(
transformValue ? transformValue(e.target.value) : e.target.value,
)
}
onBlur={() => onBlur?.()}
maxLength={maxLength}
disabled={disabled}
// biome-ignore lint/a11y/noAutofocus: opt-in per call site, used for inline edit forms
autoFocus={autoFocus}
placeholder={translatedPlaceholder}
{...ariaAttributes({
name,
bottomText,
error,
required,
})}
/>
</div>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,170 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { FormFieldItemsWithImage, FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type RadioGroupFormFieldProps<V extends string> = Omit<
FormFieldProps<"radio-group">,
"items"
> & {
items: FormFieldItemsWithImage<V>;
value: V;
onChange: (value: V) => void;
disabled?: boolean;
};
export function RadioGroupFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
minLength,
disabled,
}: RadioGroupFormFieldProps<V>) {
const id = React.useId();
const itemsWithLabels = useItemsWithResolvedLabels(items);
const required = typeof minLength !== "number" || minLength > 0;
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
>
<div
role="radiogroup"
aria-orientation="vertical"
aria-labelledby={id}
className="stack sm items-start"
>
{itemsWithLabels.map((item) => (
<div key={item.value} className="stack horizontal sm items-center">
<input
type="radio"
id={`${id}-${item.value}`}
name={id}
value={item.value}
checked={value === item.value}
onChange={() => onChange(item.value)}
onBlur={() => onBlur?.()}
disabled={disabled}
/>
<label
htmlFor={`${id}-${item.value}`}
className="stack horizontal sm items-center mb-0 whitespace-nowrap"
>
{item.imgSrc ? (
<img src={item.imgSrc} width={24} height={24} alt="" />
) : null}
{item.resolvedLabel}
</label>
</div>
))}
</div>
</FormFieldWrapper>
);
}
type CheckboxGroupFormFieldProps<V extends string> = Omit<
FormFieldProps<"checkbox-group">,
"items"
> & {
items: FormFieldItemsWithImage<V>;
value: V[];
onChange: (value: V[]) => void;
disabled?: boolean;
};
export function CheckboxGroupFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
minLength,
disabled,
}: CheckboxGroupFormFieldProps<V>) {
const id = React.useId();
const itemsWithLabels = useItemsWithResolvedLabels(items);
const required = typeof minLength !== "number" || minLength > 0;
const handleChange = (itemValue: V, checked: boolean) => {
const newValue = checked
? [...value, itemValue]
: value.filter((v) => v !== itemValue);
onChange(newValue);
// validate with the value the click produced — the click event would
// otherwise validate the pre-click value and show a stale error
onBlur?.(newValue);
};
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
>
<div className="stack sm items-start">
{itemsWithLabels.map((item) => (
<div key={item.value} className="stack horizontal sm items-center">
<input
type="checkbox"
id={`${id}-${item.value}`}
value={item.value}
checked={value.includes(item.value)}
onChange={(e) => handleChange(item.value, e.target.checked)}
disabled={disabled}
/>
<label
htmlFor={`${id}-${item.value}`}
className="stack horizontal sm items-center mb-0 whitespace-nowrap"
>
{item.imgSrc ? (
<img src={item.imgSrc} width={24} height={24} alt="" />
) : null}
{item.resolvedLabel}
</label>
</div>
))}
</div>
</FormFieldWrapper>
);
}
function useItemsWithResolvedLabels<V extends string>(
items: FormFieldItemsWithImage<V>,
) {
const { t, i18n } = useTranslation();
return items.map((item) => {
const itemLabel = item.label;
const resolvedLabel =
typeof itemLabel === "function"
? itemLabel(i18n.language)
: typeof itemLabel === "string" && itemLabel.includes(":")
? t(itemLabel as never)
: String(itemLabel);
return {
...item,
resolvedLabel,
};
});
}

View File

@@ -0,0 +1,3 @@
.searchable {
--select-width: 100%;
}

View File

@@ -0,0 +1,172 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import type { FormFieldItems, FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import {
FormFieldMessages,
FormFieldWrapper,
useTranslatedTexts,
} from "./FormFieldWrapper";
import styles from "./SelectFormField.module.css";
type SelectFormFieldProps<V extends string> = Omit<
FormFieldProps<"select">,
"items" | "clearable" | "onBlur" | "name" | "searchable"
> & {
name?: string;
items: FormFieldItems<V>;
value: V | null;
onChange: (value: V | null) => void;
onSelect?: (value: V) => void;
onBlur?: () => void;
clearable?: boolean;
searchable?: boolean;
disabled?: boolean;
};
export function SelectFormField<V extends string>({
name,
label,
bottomText,
items,
error,
onBlur,
value,
onChange,
onSelect,
clearable,
searchable,
disabled,
}: SelectFormFieldProps<V>) {
const { t, i18n } = useTranslation(["common"]);
const id = React.useId();
const itemsWithResolvedLabels = items.map((item) => {
const itemLabel = item.label;
const resolvedLabel =
typeof itemLabel === "function"
? itemLabel(i18n.language)
: typeof itemLabel === "string" &&
itemLabel.includes(":") &&
i18n.exists(itemLabel)
? t(itemLabel as never)
: String(itemLabel);
return {
value: item.value,
resolvedLabel,
};
});
if (searchable) {
return (
<SearchableSelect
name={name}
label={label}
bottomText={bottomText}
error={error}
items={itemsWithResolvedLabels}
value={value}
onChange={onChange}
onBlur={onBlur}
clearable={clearable}
disabled={disabled}
searchPlaceholder={t("common:actions.search")}
/>
);
}
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as V);
onChange(newValue);
if (newValue && onSelect) {
onSelect(newValue);
}
};
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<select
id={id}
name={name}
value={value ?? ""}
onChange={handleChange}
onBlur={() => onBlur?.()}
disabled={disabled}
{...ariaAttributes({ name, error, bottomText })}
>
{clearable ? <option value="">—</option> : null}
{itemsWithResolvedLabels.map((item) => (
<option key={item.value} value={item.value}>
{item.resolvedLabel}
</option>
))}
</select>
</FormFieldWrapper>
);
}
function SearchableSelect<V extends string>({
name,
label,
bottomText,
error,
items,
value,
onChange,
onBlur,
clearable,
disabled,
searchPlaceholder,
}: {
name?: string;
label?: string;
bottomText?: string;
error?: string;
items: Array<{ value: V; resolvedLabel: string }>;
value: V | null;
onChange: (value: V | null) => void;
onBlur?: () => void;
clearable?: boolean;
disabled?: boolean;
searchPlaceholder: string;
}) {
const { translatedLabel } = useTranslatedTexts({ label });
const selectItems = items.map((item) => ({
id: item.value,
textValue: item.resolvedLabel,
}));
return (
<div className={styles.searchable}>
<SendouSelect
label={translatedLabel}
selectedKey={value}
onSelectionChange={(key) => {
const newValue = key === "" ? null : (key as V);
onChange(newValue);
onBlur?.();
}}
items={selectItems}
search={{ placeholder: searchPlaceholder }}
clearable={clearable}
isDisabled={disabled}
>
{(item) => (
<SendouSelectItem id={item.id} textValue={item.textValue}>
{item.textValue}
</SendouSelectItem>
)}
</SendouSelect>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,5 @@
.root {
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,41 @@
import { StageSelect } from "~/components/StageSelect";
import type { StageId } from "~/modules/in-game-lists/types";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./StageSelectFormField.module.css";
type StageSelectFormFieldProps = FormFieldProps<"stage-select"> & {
value: StageId | null;
onChange: (value: StageId) => void;
disabled?: boolean;
};
export function StageSelectFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onBlur,
disabled,
}: StageSelectFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className={styles.root}>
<StageSelect
label={translatedLabel}
value={value}
onChange={(id) => {
onChange(id);
onBlur?.(id);
}}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import { SendouSwitch } from "~/components/elements/Switch";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
type SwitchFormFieldProps = Omit<FormFieldProps<"switch">, "onBlur"> & {
checked: boolean;
onChange: (checked: boolean) => void;
isDisabled?: boolean;
};
export function SwitchFormField({
name,
label,
bottomText,
error,
checked,
onChange,
isDisabled,
}: SwitchFormFieldProps) {
const id = React.useId();
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className="stack xs">
<div className="stack horizontal sm items-center">
<SendouSwitch
id={id}
isSelected={checked}
onChange={onChange}
isDisabled={isDisabled}
>
{translatedLabel}
</SendouSwitch>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { TeamSearch } from "~/components/elements/TeamSearch";
import type { FormFieldProps, TeamSearchFieldOptions } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type TeamSearchFormFieldProps = FormFieldProps<"team-search"> &
TeamSearchFieldOptions & {
onChange: (value: number | null) => void;
disabled?: boolean;
};
export function TeamSearchFormField({
name,
label,
bottomText,
error,
required,
onChange,
onBlur,
onTeamSelected,
initialTeam,
disabled,
}: TeamSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<TeamSearch
initialTeam={initialTeam}
onChange={(team) => {
onChange(team?.id ?? null);
onTeamSelected?.(team);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import * as React from "react";
import type { FormFieldProps } from "../types";
import { ariaAttributes } from "../utils";
import { FormFieldWrapper } from "./FormFieldWrapper";
type TextareaFormFieldProps = FormFieldProps<"text-area"> & {
disabled?: boolean;
autoFocus?: boolean;
value: string;
onChange: (value: string) => void;
};
export function TextareaFormField({
name,
label,
bottomText,
maxLength,
error,
onBlur,
required,
disabled,
autoFocus,
value,
onChange,
}: TextareaFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
required={required}
error={error}
bottomText={bottomText}
valueLimits={
maxLength ? { current: value?.length ?? 0, max: maxLength } : undefined
}
>
<textarea
id={id}
ref={autoFocus ? moveCaretToEnd : undefined}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onBlur?.()}
disabled={disabled}
// biome-ignore lint/a11y/noAutofocus: opt-in per call site, used for inline edit forms
autoFocus={autoFocus}
{...ariaAttributes({
name,
bottomText,
error,
required,
})}
/>
</FormFieldWrapper>
);
}
/**
* Autofocusing a textarea leaves the caret before any existing text, which is the wrong place when
* editing something already written. Defined at module level so the ref identity stays stable and
* React only runs it on mount.
*/
function moveCaretToEnd(element: HTMLTextAreaElement | null) {
element?.setSelectionRange(element.value.length, element.value.length);
}

View File

@@ -0,0 +1,91 @@
import * as React from "react";
import type { FormFieldProps } from "../types";
import {
FormFieldMessages,
FormFieldWrapper,
useTranslatedTexts,
} from "./FormFieldWrapper";
type TimeRange = { start: string; end: string } | null;
type TimeRangeFormFieldProps = Omit<FormFieldProps<"time-range">, "onBlur"> & {
value: TimeRange;
onChange: (value: TimeRange) => void;
onBlur?: () => void;
disabled?: boolean;
};
export function TimeRangeFormField({
name,
label,
bottomText,
startLabel,
endLabel,
error,
onBlur,
value,
onChange,
disabled,
}: TimeRangeFormFieldProps) {
const startId = React.useId();
const endId = React.useId();
const { translatedLabel } = useTranslatedTexts({ label });
const { translatedLabel: translatedStartLabel } = useTranslatedTexts({
label: startLabel,
});
const { translatedLabel: translatedEndLabel } = useTranslatedTexts({
label: endLabel,
});
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = e.target.value;
if (!newStart && !value?.end) {
onChange(null);
} else {
onChange({ start: newStart, end: value?.end ?? "" });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = e.target.value;
if (!newEnd && !value?.start) {
onChange(null);
} else {
onChange({ start: value?.start ?? "", end: newEnd });
}
};
return (
<div className="stack xs">
{translatedLabel ? (
<span className="text-sm font-semi-bold">{translatedLabel}</span>
) : null}
<div className="stack horizontal sm">
<FormFieldWrapper id={startId} label={translatedStartLabel}>
<input
id={startId}
type="time"
value={value?.start ?? ""}
onChange={handleStartChange}
onBlur={() => onBlur?.()}
disabled={disabled}
className="size-extra-small"
/>
</FormFieldWrapper>
<FormFieldWrapper id={endId} label={translatedEndLabel}>
<input
id={endId}
type="time"
value={value?.end ?? ""}
onChange={handleEndChange}
onBlur={() => onBlur?.()}
disabled={disabled}
className="size-extra-small"
/>
</FormFieldWrapper>
</div>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,53 @@
import {
TournamentSearch,
type TournamentSearchItem,
} from "~/components/elements/TournamentSearch";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type TournamentSearchFormFieldProps = FormFieldProps<"tournament-search"> & {
value: number | null;
onChange: (value: number | null) => void;
onTournamentSelected?: (tournament: TournamentSearchItem | null) => void;
pastOnly?: boolean;
disabled?: boolean;
};
export function TournamentSearchFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onTournamentSelected,
onBlur,
pastOnly,
disabled,
}: TournamentSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<TournamentSearch
initialTournamentId={value ?? undefined}
pastOnly={pastOnly}
onChange={(tournament) => {
onChange(tournament?.id ?? null);
onTournamentSelected?.(tournament);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { TrophiesSelector } from "~/features/trophies/components/TrophiesSelector";
import type { FormFieldProps, TrophyOption } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
type TrophiesFormFieldProps = Omit<FormFieldProps<"trophies">, "onBlur"> & {
value: number[];
onChange: (value: number[]) => void;
onBlur?: () => void;
options: TrophyOption[];
};
export function TrophiesFormField({
name,
label,
bottomText,
error,
maxCount,
value,
onChange,
onBlur,
options,
}: TrophiesFormFieldProps) {
const id = React.useId();
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<TrophiesSelector
options={options}
selectedTrophies={value}
onChange={onChange}
onBlur={onBlur}
maxCount={maxCount}
/>
</FormFieldWrapper>
);
}

View File

@@ -0,0 +1,7 @@
.root {
width: 100%;
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,47 @@
import { UserSearch } from "~/components/elements/UserSearch";
import type { FormFieldProps, UserSearchFieldOptions } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./UserSearchFormField.module.css";
type UserSearchFormFieldProps = FormFieldProps<"user-search"> &
UserSearchFieldOptions & {
value: number | null;
onChange: (value: number | null) => void;
disabled?: boolean;
};
export function UserSearchFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onUserSelected,
onBlur,
disabled,
}: UserSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
});
return (
<div className={styles.root}>
<div className="stack xs">
<UserSearch
initialUserId={value ?? undefined}
onChange={(user) => {
onChange(user?.id ?? null);
onUserSelected?.(user);
}}
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
.list {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding: 0;
margin: 0;
margin-block-start: var(--s-2);
list-style: none;
}
.item {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) var(--s-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
transition: opacity 0.2s;
&.isDragging {
opacity: 0.5;
}
}
.dragHandle {
cursor: grab;
user-select: none;
color: var(--color-text-high);
font-size: var(--font-md);
touch-action: none;
background: none;
border: none;
padding: 0;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.weaponName {
text-transform: uppercase;
font-weight: var(--weight-semi);
color: var(--color-text);
font-size: var(--font-2xs);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weaponImage {
flex-shrink: 0;
}
.actions {
display: flex;
gap: var(--s-2);
flex-shrink: 0;
}
.starIconOutlined {
color: var(--color-text-accent);
}
.starIconFilled {
fill: var(--color-text-accent);
}

View File

@@ -0,0 +1,324 @@
import type { DragEndEvent } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import clsx from "clsx";
import { Star, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { WeaponImage } from "~/components/Image";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import type { FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
import styles from "./WeaponPoolFormField.module.css";
export type WeaponPoolItem = {
id: MainWeaponId;
isFavorite: boolean;
};
type WeaponPoolFormFieldProps = FormFieldProps<"weapon-pool"> & {
value: WeaponPoolItem[];
onChange: (value: WeaponPoolItem[]) => void;
disabled?: boolean;
};
export function WeaponPoolFormField({
name,
label,
bottomText,
error,
maxCount,
disableSorting,
disableFavorites,
disableAltSkinDuplicates,
value,
onChange,
onBlur,
disabled,
}: WeaponPoolFormFieldProps) {
const { t } = useTranslation(["forms"]);
const id = React.useId();
const isFull = value.length >= maxCount;
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const disabledWeaponIds = disableAltSkinDuplicates
? value.flatMap((weapon) => weaponIdToArrayWithAlts(weapon.id))
: value.map((weapon) => weapon.id);
const handleSelect = (weaponId: MainWeaponId) => {
const newWeapon = {
id: weaponId,
isFavorite: false,
};
const newValue = [...value, newWeapon];
if (disableSorting) {
newValue.sort((a, b) => a.id - b.id);
}
onChange(newValue);
onBlur(newValue);
};
const handleToggleFavorite = (weaponId: MainWeaponId) => {
onChange(
value.map((weapon) =>
weapon.id === weaponId
? { ...weapon, isFavorite: !weapon.isFavorite }
: weapon,
),
);
};
const handleRemove = (weaponId: MainWeaponId) => {
const newValue = value.filter((weapon) => weapon.id !== weaponId);
onChange(newValue);
onBlur(newValue);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = value.findIndex((weapon) => weapon.id === active.id);
const newIndex = value.findIndex((weapon) => weapon.id === over.id);
const newValue = [...value];
const [removed] = newValue.splice(oldIndex, 1);
newValue.splice(newIndex, 0, removed);
onChange(newValue);
}
};
const weaponList = (
<ul className={styles.list}>
{value.map((weapon) =>
disableSorting || disabled ? (
<StaticWeaponItem
key={weapon.id}
weapon={weapon}
showFavoriteToggle={!disableFavorites}
onToggleFavorite={handleToggleFavorite}
onRemove={handleRemove}
disabled={disabled}
/>
) : (
<SortableWeaponItem
key={weapon.id}
weapon={weapon}
showFavoriteToggle={!disableFavorites}
onToggleFavorite={handleToggleFavorite}
onRemove={handleRemove}
/>
),
)}
</ul>
);
return (
<FormFieldWrapper
id={id}
name={name}
label={label}
error={error}
bottomText={bottomText}
>
<WeaponSelect
key={value.length}
onChange={(weaponId) => {
if (weaponId !== null) {
handleSelect(weaponId);
}
}}
disabledWeaponIds={disabledWeaponIds}
clearable
isDisabled={isFull || disabled}
placeholder={
isFull ? t("forms:placeholders.weaponPoolFull") : undefined
}
/>
{value.length > 0 ? (
disableSorting ? (
weaponList
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={value.map((weapon) => weapon.id)}
strategy={verticalListSortingStrategy}
>
{weaponList}
</SortableContext>
</DndContext>
)
) : null}
</FormFieldWrapper>
);
}
function StaticWeaponItem({
weapon,
showFavoriteToggle,
onToggleFavorite,
onRemove,
disabled,
}: {
weapon: WeaponPoolItem;
showFavoriteToggle: boolean;
onToggleFavorite: (id: MainWeaponId) => void;
onRemove: (id: MainWeaponId) => void;
disabled?: boolean;
}) {
const { t } = useTranslation(["weapons"]);
return (
<li className={styles.item}>
<WeaponImage
weaponSplId={weapon.id}
variant={
showFavoriteToggle && weapon.isFavorite ? "badge-5-star" : "badge"
}
size={32}
className={styles.weaponImage}
/>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weapon.id}`)}
</span>
<div className={styles.actions}>
{showFavoriteToggle ? (
<SendouButton
variant="minimal"
size="small"
icon={
weapon.isFavorite ? (
<Star className={styles.starIconFilled} fill="currentColor" />
) : (
<Star className={styles.starIconOutlined} />
)
}
aria-label="Toggle favorite"
onPress={() => onToggleFavorite(weapon.id)}
isDisabled={disabled}
/>
) : null}
<SendouButton
variant="minimal-destructive"
size="small"
icon={<Trash />}
aria-label="Delete"
onPress={() => onRemove(weapon.id)}
isDisabled={disabled}
/>
</div>
</li>
);
}
function SortableWeaponItem({
weapon,
showFavoriteToggle,
onToggleFavorite,
onRemove,
}: {
weapon: WeaponPoolItem;
showFavoriteToggle: boolean;
onToggleFavorite: (id: MainWeaponId) => void;
onRemove: (id: MainWeaponId) => void;
}) {
const { t } = useTranslation(["weapons"]);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: weapon.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
ref={setNodeRef}
style={style}
className={clsx(styles.item, { [styles.isDragging]: isDragging })}
{...attributes}
>
<button
type="button"
className={styles.dragHandle}
aria-label="Drag to reorder"
{...listeners}
>
☰
</button>
<WeaponImage
weaponSplId={weapon.id}
variant={
showFavoriteToggle && weapon.isFavorite ? "badge-5-star" : "badge"
}
size={32}
className={styles.weaponImage}
/>
<span className={styles.weaponName}>
{t(`weapons:MAIN_${weapon.id}`)}
</span>
<div className={styles.actions}>
{showFavoriteToggle ? (
<SendouButton
variant="minimal"
size="small"
icon={
weapon.isFavorite ? (
<Star className={styles.starIconFilled} fill="currentColor" />
) : (
<Star className={styles.starIconOutlined} />
)
}
aria-label="Toggle favorite"
onPress={() => onToggleFavorite(weapon.id)}
/>
) : null}
<SendouButton
variant="minimal-destructive"
size="small"
icon={<Trash />}
aria-label="Delete"
onPress={() => onRemove(weapon.id)}
/>
</div>
</li>
);
}

View File

@@ -0,0 +1,5 @@
.root {
& button[class] {
width: 100%;
}
}

View File

@@ -0,0 +1,42 @@
import { WeaponSelect } from "~/components/WeaponSelect";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { FormFieldProps } from "../types";
import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper";
import styles from "./WeaponSelectFormField.module.css";
type WeaponSelectFormFieldProps = FormFieldProps<"weapon-select"> & {
value: MainWeaponId | null;
onChange: (value: MainWeaponId | null) => void;
disabled?: boolean;
};
export function WeaponSelectFormField({
name,
label,
bottomText,
error,
required,
value,
onChange,
onBlur,
disabled,
}: WeaponSelectFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({ label });
return (
<div className={styles.root}>
<WeaponSelect
label={translatedLabel}
value={value}
onChange={(id) => {
onChange(id);
onBlur?.(id);
}}
isRequired={required}
clearable={!required}
isDisabled={disabled}
/>
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
import { id } from "~/utils/zod";
/**
* Allowed prefixes for a {@link imageValue} `NEW` data URL. The client compresses to webp,
* but browsers without canvas webp encoding (Safari, Brave with fingerprint protection)
* silently fall back to png per the HTML spec.
*/
const IMAGE_FIELD_DATA_URL_PREFIX_REGEX = /^data:image\/(webp|png);base64,/;
/**
* Hard ceiling for a `NEW` data URL's length. Caps the JSON body size so a malicious or
* oversized payload can't bloat the request. A `thick-banner` webp base64-encodes to ~200KB,
* so this leaves comfortable headroom.
*/
const IMAGE_FIELD_MAX_DATA_URL_LENGTH = 3_000_000;
/**
* JSON-serializable value of a SendouForm `image` field. Covers every state an edit form needs:
* `null` (none / removed), an unchanged `EXISTING` image (only the id reference + a preview url
* ride in JSON, never bytes), or a newly picked `NEW` image as a base64 webp/png data URL.
*/
export const imageValue = z
.union([
z.object({
type: z.literal("EXISTING"),
imgId: id,
url: z.string(),
}),
z.object({
type: z.literal("NEW"),
dataUrl: z
.string()
.max(IMAGE_FIELD_MAX_DATA_URL_LENGTH)
.regex(IMAGE_FIELD_DATA_URL_PREFIX_REGEX),
}),
])
.nullable();
export type ImageFieldValue = z.infer<typeof imageValue>;
/**
* Builds an `EXISTING` {@link ImageFieldValue} for an edit form's default values, or `null`
* when either the id or preview url is missing.
*/
export function existingImage(
imgId: number | null | undefined,
url: string | null | undefined,
): ImageFieldValue {
return imgId && url ? { type: "EXISTING", imgId, url } : null;
}
export type ImageFieldDimensions =
| "logo"
| "thick-banner"
| { width: number; height: number };
const IMAGE_FIELD_DIMENSION_PRESETS = {
logo: { width: 400, height: 400 },
"thick-banner": { width: 1000, height: 500 },
} as const;
/** Resolves an `image` field's `dimensions` (preset name or explicit numbers) to a `{ width, height }`. */
export function resolveImageFieldDimensions(
dimensions?: ImageFieldDimensions,
): {
width: number;
height: number;
} {
if (!dimensions || typeof dimensions === "string") {
return IMAGE_FIELD_DIMENSION_PRESETS[dimensions ?? "logo"];
}
return dimensions;
}

View File

@@ -0,0 +1,4 @@
// Form system exports
export { SendouForm } from "./SendouForm";
export type { ArrayItemRenderContext, CustomFieldRenderProps } from "./types";

View File

@@ -0,0 +1,174 @@
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { imageFieldValueToImgId } from "~/features/img-upload/image-field.server";
import { formDataToObject } from "~/utils/remix.server";
import { formRegistry } from "./fields";
import type { ImageFieldValue } from "./image-field";
import { buildFieldPath } from "./utils";
export type ParseResult<T> =
| { success: true; data: T }
| { success: false; fieldErrors: Record<string, string> };
/**
* Ceiling for a request body, in bytes. Sized to fit a form with a couple of `image()` fields (each
* capped at ~3M base64 characters) plus its other fields. Forms that legitimately submit more (e.g.
* art) pass their own `maxBodyBytes`.
*/
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
/**
* Maps a {@link z.ZodError} to field-level errors keyed by form field name
* (e.g. `members[0].userId`), keeping the first error per field.
*/
function fieldErrorsFromZodError(error: z.ZodError): Record<string, string> {
const fieldErrors: Record<string, string> = {};
for (const issue of error.issues) {
const path = buildFieldPath(issue.path);
if (path && !fieldErrors[path]) {
fieldErrors[path] = issue.message;
}
}
return fieldErrors;
}
/**
* Parses request body against a Zod schema.
* Handles both JSON (SendouForm) and form data (FormWithConfirm) based on Content-Type.
* Returns parsed data on success, or field-level errors on validation failure.
*/
export async function parseFormData<T extends z.ZodTypeAny>({
request,
schema,
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
}: {
request: Request;
schema: T;
/** Overrides {@link DEFAULT_MAX_BODY_BYTES} for forms that legitimately submit a bigger body. */
maxBodyBytes?: number;
}): Promise<ParseResult<z.infer<T>>> {
const data = await requestBodyToObject(request, maxBodyBytes);
const result = await schema.safeParseAsync(data);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, fieldErrors: fieldErrorsFromZodError(result.error) };
}
/** Image field values collapse to their stored id; everything else passes through. */
type ResolvedImages<T> = T extends unknown
? { [K in keyof T]: T[K] extends ImageFieldValue ? number | null : T[K] }
: never;
/**
* Like {@link parseFormData}, but additionally resolves every `image()` field in the schema to the
* image id to store on the consuming FK column (`number | null`) via {@link imageFieldValueToImgId}
* — uploading newly picked images, keeping unchanged ones, and clearing removed ones. The schema
* may be a single object or a union of objects (e.g. an `_action` discriminated form). The
* consuming action receives a plain id per image field and only writes it to its own entity.
*/
export async function parseFormDataWithImages<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<ParseResult<ResolvedImages<z.infer<T>>>> {
const result = await parseFormData({ request, schema });
if (!result.success) return result;
const user = requireUser();
const data = { ...(result.data as Record<string, unknown>) };
for (const { key, autoValidate } of imageFields(schema)) {
if (key in data) {
data[key] = await imageFieldValueToImgId({
value: data[key] as ImageFieldValue,
user,
autoValidate,
});
}
}
return { success: true, data: data as ResolvedImages<z.infer<T>> };
}
/**
* Collects every `image()` field across a schema object or union of objects, along with each
* field's `autoValidate` flag (whether its uploads bypass the moderator queue).
*/
function imageFields(
schema: z.ZodTypeAny,
): Array<{ key: string; autoValidate: boolean }> {
const objects =
schema instanceof z.ZodUnion
? (schema.options as z.ZodObject<z.ZodRawShape>[])
: schema instanceof z.ZodObject
? [schema]
: [];
const fields = new Map<string, boolean>();
for (const object of objects) {
for (const [key, fieldSchema] of Object.entries(object.shape)) {
const meta = formRegistry.get(fieldSchema);
if (meta?.type === "image") {
fields.set(key, meta.autoValidate ?? false);
}
}
}
return [...fields].map(([key, autoValidate]) => ({ key, autoValidate }));
}
/**
* Reads the request body into the plain object the schema parses, refusing anything over
* `maxBytes`. `Content-Length` is checked up front so an oversized body is rejected before a byte
* of it is read.
*/
async function requestBodyToObject(request: Request, maxBytes: number) {
if (Number(request.headers.get("Content-Length")) > maxBytes) {
throw payloadTooLarge();
}
if (request.headers.get("Content-Type") === "application/json") {
return JSON.parse(await readBodyText(request, maxBytes));
}
return formDataToObject(await request.formData());
}
/**
* Reads the body as text, aborting the stream as soon as `maxBytes` is exceeded. The running total
* is enforced (rather than trusting `Content-Length`) so a chunked body that omits or understates
* the header can't be buffered in full either.
*/
async function readBodyText(request: Request, maxBytes: number) {
const reader = request.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder();
let bytesRead = 0;
let text = "";
let chunk = await reader.read();
while (!chunk.done) {
bytesRead += chunk.value.byteLength;
if (bytesRead > maxBytes) {
await reader.cancel();
throw payloadTooLarge();
}
text += decoder.decode(chunk.value, { stream: true });
chunk = await reader.read();
}
return text + decoder.decode();
}
function payloadTooLarge() {
return new Response(null, { status: 413 });
}

View File

@@ -0,0 +1,362 @@
import type { z } from "zod";
import type { TeamSearchResult } from "~/components/elements/TeamSearch";
import type { TournamentSearchItem } from "~/components/elements/TournamentSearch";
import type { UserSearchResult } from "~/components/elements/UserSearch";
import type forms from "../../locales/en/forms.json";
import type { ImageFieldDimensions } from "./image-field";
export type FormsTranslationKey = keyof typeof forms;
interface FormFieldBase<T extends string> {
type: T;
label?: string;
bottomText?: string;
initialValue: unknown;
}
/** A field that never renders a control. Its value is seeded from the schema's `initialValue` or the form's `defaultValues`. */
type FormFieldHidden<T extends string> = Omit<
FormFieldBase<T>,
"label" | "bottomText"
>;
interface FormFieldText<T extends string> extends FormFieldBase<T> {
minLength?: number;
maxLength: number;
toLowerCase?: boolean;
/**
* Normalizes what the user types before it is stored, e.g. reducing a pasted
* full URL down to the part the field actually holds.
*/
transformValue?: (value: string) => string;
leftAddon?: string;
placeholder?: string;
required: boolean;
inputType?: "text" | "number";
regExp?: {
pattern: RegExp;
message: string;
};
validate?:
| "url"
| {
func: (value: string) => boolean;
message: string;
};
}
interface FormFieldTextarea<T extends string> extends FormFieldBase<T> {
maxLength: number;
required: boolean;
}
interface FormFieldInGameName<T extends string> extends FormFieldBase<T> {
maxLength: number;
required: boolean;
}
interface FormFieldItem<V extends string> {
label: string | number | ((lang: string) => string);
value: V;
}
interface FormFieldItemWithImage<V extends string> extends FormFieldItem<V> {
/** Full image url (including the file extension) shown next to the item's label. */
imgSrc?: string;
}
export type FormFieldItems<V extends string> = Array<FormFieldItem<V>>;
export type FormFieldItemsWithImage<V extends string> = Array<
FormFieldItemWithImage<V>
>;
export interface FormFieldSelect<T extends string, V extends string>
extends FormFieldBase<T> {
items: FormFieldItems<V>;
clearable: boolean;
searchable?: boolean;
}
type FormFieldDualSelectField<T extends string, V extends string> = Omit<
FormFieldSelect<T, V>,
"bottomText" | "type" | "clearable"
>;
export interface FormFieldDualSelect<T extends string, V extends string>
extends Omit<FormFieldBase<T>, "label"> {
fields: [
Omit<FormFieldDualSelectField<T, V>, "initialValue">,
Omit<FormFieldDualSelectField<T, V>, "initialValue">,
];
validate?: {
func: (value: [V | null, V | null]) => boolean;
message: string;
};
clearable: boolean;
}
export interface FormFieldInputGroup<T extends string, V extends string>
extends FormFieldBase<T> {
items: FormFieldItemsWithImage<V>;
minLength?: number;
}
export interface FormFieldDatetime<T extends string> extends FormFieldBase<T> {
min?: () => Date;
max?: () => Date;
required: boolean;
}
interface FormFieldWeaponPool<T extends string> extends FormFieldBase<T> {
minCount?: number;
maxCount: number;
/** Does the weapon pool have an ordering? If true, the order is fixed and cannot be changed by the user (order ASC by weapon id) */
disableSorting?: boolean;
/** Can user favorite weapons in the pool? If disabled, all weapons have isFavorite: false */
disableFavorites?: boolean;
/** Allow duplicate weapon IDs in the pool */
allowDuplicates?: boolean;
/** Treat alt-skin variants of an already-picked weapon as duplicates (e.g. picking Splattershot also disables Hero Shot Replica) */
disableAltSkinDuplicates?: boolean;
}
interface FormFieldImage<T extends string> extends FormFieldBase<T> {
dimensions?: ImageFieldDimensions;
/** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */
autoValidate?: boolean;
}
export interface FormFieldArray<T extends string, S extends z.ZodType>
extends FormFieldBase<T> {
min?: number;
max: number;
field: S;
/** When false, the "Add" button is hidden (the array can only be edited/shrunk, not grown). Defaults to true. */
addable?: boolean;
/** When true, items (object arrays only) can be reordered via drag-and-drop and the new order is reflected in the value. */
sortable?: boolean;
}
interface FormFieldTimeRange<T extends string> extends FormFieldBase<T> {
startLabel?: string;
endLabel?: string;
}
export interface FormFieldFieldset<T extends string, S extends z.ZodRawShape>
extends FormFieldBase<T> {
fields: z.ZodObject<S>;
}
interface FormFieldUserSearch<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldTournamentSearch<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldTeamSearch<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldBadges<T extends string> extends FormFieldBase<T> {
maxCount?: number;
}
interface FormFieldTrophies<T extends string> extends FormFieldBase<T> {
maxCount?: number;
}
interface FormFieldSelectDynamic<T extends string> extends FormFieldBase<T> {
clearable: boolean;
searchable?: boolean;
}
interface FormFieldInputGroupDynamic<T extends string>
extends FormFieldBase<T> {
minLength?: number;
}
interface FormFieldStageSelect<T extends string> extends FormFieldBase<T> {
required: boolean;
}
interface FormFieldWeaponSelect<T extends string> extends FormFieldBase<T> {
required: boolean;
}
export type FormField<V extends string = string> =
| FormFieldBase<"custom">
| FormFieldText<"text-field">
| FormFieldInGameName<"in-game-name">
| FormFieldTextarea<"text-area">
| FormFieldBase<"switch">
| FormFieldSelect<"select", V>
| FormFieldSelectDynamic<"select-dynamic">
| FormFieldDualSelect<"dual-select", V>
| FormFieldInputGroup<"radio-group", V>
| FormFieldInputGroupDynamic<"radio-group-dynamic">
| FormFieldInputGroup<"checkbox-group", V>
| FormFieldInputGroupDynamic<"checkbox-group-dynamic">
| FormFieldDatetime<"datetime">
| FormFieldDatetime<"date">
| FormFieldWeaponPool<"weapon-pool">
| FormFieldImage<"image">
| FormFieldHidden<"hidden">
| FormFieldArray<"array", z.ZodType>
| FormFieldTimeRange<"time-range">
| FormFieldFieldset<"fieldset", z.ZodRawShape>
| FormFieldUserSearch<"user-search">
| FormFieldTournamentSearch<"tournament-search">
| FormFieldTeamSearch<"team-search">
| FormFieldBadges<"badges">
| FormFieldTrophies<"trophies">
| FormFieldStageSelect<"stage-select">
| FormFieldWeaponSelect<"weapon-select">;
export type FormFieldProps<T extends FormField["type"]> = Omit<
Extract<FormField, { type: T }>,
"type" | "initialValue"
> & {
name: string;
error?: string;
onBlur: (latestValue?: unknown) => void;
};
export interface ArrayItemRenderContext<TItem = Record<string, unknown>> {
index: number;
itemName: string;
values: TItem;
setItemField: <K extends keyof TItem>(field: K, value: TItem[K]) => void;
canRemove: boolean;
remove: () => void;
}
export type BadgeOption = {
id: number;
displayName: string;
code: string;
hue: number | null;
};
export type TrophyOption = {
id: number;
name: string;
model: string;
tier: number | null;
};
export type SelectOption = {
value: string;
label: string;
};
/** Brand type to encode required options directly in Zod schema types */
export type FieldWithOptions<TOptions> = { _requiredOptions: TOptions };
/**
* Custom render props for FormField children.
* Generic type parameter allows for type-safe value handling when the type is known.
*/
export type CustomFieldRenderProps<TValue = unknown> = {
name: string;
error: string | undefined;
value: TValue;
onChange: (value: TValue) => void;
/** True when the field's `disabled` prop is set or the whole form is `readOnly`. */
disabled?: boolean;
};
/** Non-generic version for internal use to avoid excessive type instantiation */
type FormFieldChildrenProps = {
name: string;
error: string | undefined;
value: unknown;
onChange: (value: unknown) => void;
disabled?: boolean;
};
/** Props for a typed FormField based on field name and schema */
export type TypedFormFieldProps<
TSchema extends z.ZodRawShape,
TName extends keyof TSchema & string,
> = {
name: TName;
label?: string;
disabled?: boolean;
/** Focuses the field on mount. Only `text-field` and `text-area` support it. */
autoFocus?: boolean;
maxCount?: number;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
onValueChange?: (newValue: unknown) => void;
children?:
| ((props: FormFieldChildrenProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
} & (TSchema[TName] extends FieldWithOptions<infer TOptions>
? { options: TOptions }
: { options?: never });
/** Nested path pattern for array/object traversal */
type NestedPath = `${string}.${string}` | `${string}[${string}`;
/** Props for FormField with flexible (string) name - used for nested paths like `${itemName}.field` */
export type FlexibleFormFieldProps = {
name: NestedPath;
label?: string;
disabled?: boolean;
/** Focuses the field on mount. Only `text-field` and `text-area` support it. */
autoFocus?: boolean;
maxCount?: number;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
onValueChange?: (newValue: unknown) => void;
children?:
| ((props: FormFieldChildrenProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
options?: unknown;
};
/** Typed FormField component type for a specific schema */
export type TypedFormFieldComponent<TSchema extends z.ZodRawShape> = {
<TName extends keyof TSchema & string>(
props: TypedFormFieldProps<TSchema, TName>,
): React.ReactNode;
(props: FlexibleFormFieldProps): React.ReactNode;
};
/**
* Runtime config consumed only by the `team-search` field. Passed via the
* `options` prop (the same channel `badges`/`select-dynamic` use), so it stays
* scoped to this field type instead of polluting every `FormField`.
*
* `initialTeam` carries the selected team's display data (name, avatar) for the
* edit/prefill case — that metadata is not part of the stored form value (a
* plain team id), so it cannot come from `defaultValues`.
*/
export type TeamSearchFieldOptions = {
onTeamSelected?: (team: TeamSearchResult | null) => void;
initialTeam?: { id: number; name: string; avatarUrl?: string | null };
};
/**
* Runtime config consumed only by the `user-search` field, passed via the
* `options` prop (the same channel `team-search` uses).
*
* `onUserSelected` exposes the resolved user (with its username) on selection —
* the stored form value is only the user id, so callers that need to display the
* picked user's name elsewhere capture it here.
*/
export type UserSearchFieldOptions = {
onUserSelected?: (user: UserSearchResult | null) => void;
};
/**
* Runtime config consumed only by the `tournament-search` field, passed via the
* `options` prop (the same channel `team-search` uses).
*/
export type TournamentSearchFieldOptions = {
/** Restrict results to tournaments that have already started (finished/past). */
pastOnly?: boolean;
/** Exposes the resolved tournament on selection — the stored form value is only the tournament id. */
onTournamentSelected?: (tournament: TournamentSearchItem | null) => void;
};

View File

@@ -0,0 +1,126 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { getNestedSchema, getNestedValue, setNestedValue } from "./utils";
describe("getNestedValue", () => {
test("returns value at simple path", () => {
expect(getNestedValue({ name: "test" }, "name")).toBe("test");
});
test("returns value at nested path", () => {
expect(getNestedValue({ config: { name: "test" } }, "config.name")).toBe(
"test",
);
});
test("returns undefined for missing path", () => {
expect(getNestedValue({ config: {} }, "config.name")).toBe(undefined);
});
test("returns undefined when parent is null", () => {
expect(getNestedValue({ config: null }, "config.name")).toBe(undefined);
});
test("handles deeply nested paths", () => {
const obj = { a: { b: { c: { d: "deep" } } } };
expect(getNestedValue(obj, "a.b.c.d")).toBe("deep");
});
});
describe("setNestedValue", () => {
test("sets value at simple path", () => {
expect(setNestedValue({}, "name", "test")).toEqual({ name: "test" });
});
test("sets value at nested path", () => {
expect(setNestedValue({}, "config.name", "test")).toEqual({
config: { name: "test" },
});
});
test("preserves existing sibling values", () => {
const obj = { config: { existing: "keep" } };
expect(setNestedValue(obj, "config.name", "test")).toEqual({
config: { existing: "keep", name: "test" },
});
});
test("is immutable - does not modify original", () => {
const obj = { config: { name: "old" } };
setNestedValue(obj, "config.name", "new");
expect(obj.config.name).toBe("old");
});
test("handles deeply nested paths", () => {
expect(setNestedValue({}, "a.b.c.d", "deep")).toEqual({
a: { b: { c: { d: "deep" } } },
});
});
});
describe("getNestedSchema", () => {
test("returns schema for simple path", () => {
const schema = z.object({ name: z.string() });
const result = getNestedSchema(schema, "name");
expect(result).toBeInstanceOf(z.ZodString);
});
test("returns schema for nested path", () => {
const schema = z.object({ config: z.object({ name: z.string() }) });
const result = getNestedSchema(schema, "config.name");
expect(result).toBeInstanceOf(z.ZodString);
});
test("unwraps nullable wrapper", () => {
const schema = z.object({
config: z.object({ name: z.string() }).nullable(),
});
const result = getNestedSchema(schema, "config.name");
const def = result?._def ?? (result as unknown as { def?: unknown })?.def;
const typeName =
(def as { typeName?: string })?.typeName ??
(def as { type?: string })?.type;
expect(typeName).toBe("string");
});
test("unwraps optional wrapper", () => {
const schema = z.object({
config: z.object({ name: z.string() }).optional(),
});
const result = getNestedSchema(schema, "config.name");
const def = result?._def ?? (result as unknown as { def?: unknown })?.def;
const typeName =
(def as { typeName?: string })?.typeName ??
(def as { type?: string })?.type;
expect(typeName).toBe("string");
});
test("returns undefined for invalid path", () => {
const schema = z.object({ name: z.string() });
expect(getNestedSchema(schema, "missing.path")).toBe(undefined);
});
test("returns undefined when path goes through non-object", () => {
const schema = z.object({ name: z.string() });
expect(getNestedSchema(schema, "name.invalid")).toBe(undefined);
});
test("returns schema for array element path", () => {
const schema = z.object({
items: z.array(z.object({ name: z.string() })),
});
const result = getNestedSchema(schema, "items[0].name");
expect(result).toBeInstanceOf(z.ZodString);
});
test("returns schema for array element path with min/max", () => {
const schema = z.object({
items: z
.array(z.object({ name: z.string() }))
.min(1)
.max(10),
});
const result = getNestedSchema(schema, "items[0].name");
expect(result).toBeInstanceOf(z.ZodString);
});
});

View File

@@ -0,0 +1,282 @@
import type { z } from "zod";
import { getFormFieldMetadata } from "./fields";
import type { FormField } from "./types";
export function infoMessageId(fieldId: string) {
return `${fieldId}-info`;
}
/**
* Builds a form field name (e.g. `members[0].userId`) from a Zod issue path so
* that server- and client-side validation errors key fields identically.
*/
export 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("");
}
export function getNestedValue(
obj: Record<string, unknown>,
path: string,
): unknown {
const parts = parsePath(path);
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof part === "number") {
current = (current as unknown[])[part];
} else {
current = (current as Record<string, unknown>)[part];
}
}
return current;
}
function parsePath(path: string): (string | number)[] {
const parts: (string | number)[] = [];
const regex = /([^.[[\]]+)|\[(\d+)\]/g;
const matches = path.matchAll(regex);
for (const match of matches) {
if (match[1] !== undefined) {
parts.push(match[1]);
} else if (match[2] !== undefined) {
parts.push(Number(match[2]));
}
}
return parts;
}
export function setNestedValue(
obj: Record<string, unknown>,
path: string,
value: unknown,
): Record<string, unknown> {
const parts = parsePath(path);
if (parts.length === 0) return obj;
if (parts.length === 1) {
const key = parts[0]!;
if (typeof key === "number") {
const arr = Array.isArray(obj) ? [...obj] : [];
arr[key] = value;
return arr as unknown as Record<string, unknown>;
}
return { ...obj, [key]: value };
}
const [head, ...tail] = parts;
const tailPath = tail
.map((p) => (typeof p === "number" ? `[${p}]` : p))
.join(".")
.replace(/\.\[/g, "[");
if (typeof head === "number") {
const arr = Array.isArray(obj) ? [...obj] : [];
arr[head] = setNestedValue(
(arr[head] as Record<string, unknown>) ?? {},
tailPath,
value,
);
return arr as unknown as Record<string, unknown>;
}
const nested = (obj[head] as Record<string, unknown>) ?? {};
return {
...obj,
[head]: setNestedValue(nested, tailPath, value),
};
}
/**
* The default value object for a `fieldset` field, built from each sub-field's
* own `initialValue` (e.g. a `select`'s first option). Returns `{}` for
* non-fieldset fields.
*/
export function fieldsetDefaults(
fieldsetMeta: FormField,
): 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 = getFormFieldMetadata(fieldSchema);
if (fieldMeta) result[key] = fieldMeta.initialValue;
}
return result;
}
/**
* When a leaf field inside an array-of-fieldset item is edited (e.g.
* `staff[0].userId`), the enclosing item is created on demand. Without this the
* item would only contain the touched field, dropping defaults that were merely
* shown as a fallback (e.g. a required `select`'s first option) and failing
* validation on submit. This seeds the item with its fieldset defaults, keeping
* any values it already has. Untouched items are never created, so this doesn't
* affect submitting a pristine form.
*/
export function seedArrayItemDefaults(
schema: z.ZodObject<z.ZodRawShape>,
values: Record<string, unknown>,
name: string,
): Record<string, unknown> {
const lastBracket = name.lastIndexOf("]");
// No enclosing array item (`-1`) or the leaf is the array element itself
// (path ends in `]`, i.e. a primitive array) — nothing to seed.
if (lastBracket === -1 || lastBracket === name.length - 1) return values;
const itemPath = name.slice(0, lastBracket + 1);
const itemSchema = getNestedSchema(schema, itemPath);
if (!itemSchema) return values;
const itemMeta = getFormFieldMetadata(itemSchema);
if (itemMeta?.type !== "fieldset") return values;
const existing = getNestedValue(values, itemPath) as
| Record<string, unknown>
| undefined;
const merged = { ...fieldsetDefaults(itemMeta), ...(existing ?? {}) };
return setNestedValue(values, itemPath, merged);
}
export function getNestedSchema(
schema: z.ZodObject<z.ZodRawShape>,
path: string,
): z.ZodType | undefined {
const parts = parsePath(path);
let current: z.ZodType = schema;
for (const part of parts) {
const unwrapped = unwrapSchema(current);
if (typeof part === "number") {
const def = unwrapped._def as {
type?: string;
element?: z.ZodType;
};
if (def.type === "array" && def.element) {
current = def.element;
} else {
return undefined;
}
} else if ("shape" in unwrapped && unwrapped.shape) {
const nextSchema = (unwrapped.shape as Record<string, z.ZodType>)[part];
if (!nextSchema) return undefined;
current = nextSchema;
} else {
return undefined;
}
}
return current;
}
function unwrapSchema(schema: z.ZodType): z.ZodType {
const def = schema._def ?? (schema as unknown as { def: unknown }).def;
const typeName =
(def as { typeName?: string }).typeName ?? (def as { type?: string }).type;
if (
typeName === "ZodNullable" ||
typeName === "ZodOptional" ||
typeName === "ZodDefault" ||
typeName === "nullable" ||
typeName === "optional" ||
typeName === "default"
) {
const inner = (def as unknown as { innerType: z.ZodType }).innerType;
return unwrapSchema(inner);
}
if (typeName === "ZodEffects" || typeName === "effects") {
return unwrapSchema((def as unknown as { schema: z.ZodType }).schema);
}
return schema;
}
export function errorMessageId(fieldId: string) {
return `${fieldId}-error`;
}
export function validateField(
schema: z.ZodObject<z.ZodRawShape>,
name: string,
value: unknown,
): string | undefined {
const fieldSchema = name.includes(".")
? getNestedSchema(schema, name)
: (schema.shape[name] as z.ZodType | undefined);
if (!fieldSchema) return undefined;
const result = fieldSchema.safeParse(value);
if (result.success) return undefined;
// `array`/`fieldset` fields render each child as its own FormField with its
// own error slot, so a nested issue (e.g. an empty member inside a `members`
// array) belongs to that child — attributing it to the parent would surface
// the wrong message at the wrong field. Other composite fields (e.g. a custom
// tuple) render as a single control, so their nested issues belong to them.
const fieldMeta = getFormFieldMetadata(fieldSchema);
const childrenRenderOwnErrors =
fieldMeta?.type === "array" || fieldMeta?.type === "fieldset";
const issue = childrenRenderOwnErrors
? result.error.issues.find((i) => i.path.length === 0)
: result.error.issues[0];
if (!issue) return undefined;
const valueIsEmpty = value === null || value === undefined || value === "";
if (
valueIsEmpty &&
(issue.code === "invalid_type" || issue.code === "too_small")
) {
return "forms:errors.required";
}
if (issue.code === "too_small" && issue.minimum === 1) {
return "forms:errors.required";
}
return issue.message;
}
/**
* Accessibility attributes for a form control. Ids are derived from the field
* `name` because the error/info messages render with name-based ids
* (`errorMessageId`/`infoMessageId`) — a `useId()` value would point at
* elements that don't exist. The error id is also included in
* `aria-describedby` since `aria-errormessage` support in screen readers is
* still inconsistent.
*/
export function ariaAttributes({
name,
error,
bottomText,
required,
}: {
name?: string;
error?: string;
bottomText?: string;
required?: boolean;
}) {
const describedBy = name
? [
error ? errorMessageId(name) : undefined,
bottomText ? infoMessageId(name) : undefined,
]
.filter((id) => id !== undefined)
.join(" ")
: "";
return {
"aria-invalid": error ? ("true" as const) : undefined,
"aria-describedby": describedBy !== "" ? describedBy : undefined,
"aria-errormessage": error && name ? errorMessageId(name) : undefined,
"aria-required": required ? ("true" as const) : undefined,
};
}