mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
683
apps/web-react/app/form/FormField.tsx
Normal file
683
apps/web-react/app/form/FormField.tsx
Normal 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;
|
||||
}
|
||||
1644
apps/web-react/app/form/SendouForm.browser.test.tsx
Normal file
1644
apps/web-react/app/form/SendouForm.browser.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
18
apps/web-react/app/form/SendouForm.module.css
Normal file
18
apps/web-react/app/form/SendouForm.module.css
Normal 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);
|
||||
}
|
||||
785
apps/web-react/app/form/SendouForm.tsx
Normal file
785
apps/web-react/app/form/SendouForm.tsx
Normal 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" });
|
||||
}
|
||||
84
apps/web-react/app/form/UnsavedChangesGuard.tsx
Normal file
84
apps/web-react/app/form/UnsavedChangesGuard.tsx
Normal 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;
|
||||
}
|
||||
931
apps/web-react/app/form/fields.ts
Normal file
931
apps/web-react/app/form/fields.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
apps/web-react/app/form/fields/ArrayFormField.module.css
Normal file
39
apps/web-react/app/form/fields/ArrayFormField.module.css
Normal 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;
|
||||
}
|
||||
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal file
259
apps/web-react/app/form/fields/ArrayFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web-react/app/form/fields/BadgesFormField.tsx
Normal file
46
apps/web-react/app/form/fields/BadgesFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web-react/app/form/fields/DatetimeFormField.tsx
Normal file
76
apps/web-react/app/form/fields/DatetimeFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web-react/app/form/fields/DualSelectFormField.tsx
Normal file
53
apps/web-react/app/form/fields/DualSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web-react/app/form/fields/FieldsetFormField.tsx
Normal file
49
apps/web-react/app/form/fields/FieldsetFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
apps/web-react/app/form/fields/FormFieldWrapper.module.css
Normal file
12
apps/web-react/app/form/fields/FormFieldWrapper.module.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
119
apps/web-react/app/form/fields/FormFieldWrapper.tsx
Normal file
119
apps/web-react/app/form/fields/FormFieldWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/web-react/app/form/fields/ImageFormField.module.css
Normal file
13
apps/web-react/app/form/fields/ImageFormField.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
111
apps/web-react/app/form/fields/ImageFormField.tsx
Normal file
111
apps/web-react/app/form/fields/ImageFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/web-react/app/form/fields/InGameNameFormField.tsx
Normal file
49
apps/web-react/app/form/fields/InGameNameFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/web-react/app/form/fields/InputFormField.tsx
Normal file
78
apps/web-react/app/form/fields/InputFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/web-react/app/form/fields/InputGroupFormField.tsx
Normal file
170
apps/web-react/app/form/fields/InputGroupFormField.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.searchable {
|
||||
--select-width: 100%;
|
||||
}
|
||||
172
apps/web-react/app/form/fields/SelectFormField.tsx
Normal file
172
apps/web-react/app/form/fields/SelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
41
apps/web-react/app/form/fields/StageSelectFormField.tsx
Normal file
41
apps/web-react/app/form/fields/StageSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web-react/app/form/fields/SwitchFormField.tsx
Normal file
39
apps/web-react/app/form/fields/SwitchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web-react/app/form/fields/TeamSearchFormField.tsx
Normal file
46
apps/web-react/app/form/fields/TeamSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
apps/web-react/app/form/fields/TextareaFormField.tsx
Normal file
67
apps/web-react/app/form/fields/TextareaFormField.tsx
Normal 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);
|
||||
}
|
||||
91
apps/web-react/app/form/fields/TimeRangeFormField.tsx
Normal file
91
apps/web-react/app/form/fields/TimeRangeFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web-react/app/form/fields/TournamentSearchFormField.tsx
Normal file
53
apps/web-react/app/form/fields/TournamentSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web-react/app/form/fields/TrophiesFormField.tsx
Normal file
43
apps/web-react/app/form/fields/TrophiesFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
47
apps/web-react/app/form/fields/UserSearchFormField.tsx
Normal file
47
apps/web-react/app/form/fields/UserSearchFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
324
apps/web-react/app/form/fields/WeaponPoolFormField.tsx
Normal file
324
apps/web-react/app/form/fields/WeaponPoolFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.root {
|
||||
& button[class] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
42
apps/web-react/app/form/fields/WeaponSelectFormField.tsx
Normal file
42
apps/web-react/app/form/fields/WeaponSelectFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
apps/web-react/app/form/image-field.ts
Normal file
75
apps/web-react/app/form/image-field.ts
Normal 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;
|
||||
}
|
||||
4
apps/web-react/app/form/index.ts
Normal file
4
apps/web-react/app/form/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Form system exports
|
||||
|
||||
export { SendouForm } from "./SendouForm";
|
||||
export type { ArrayItemRenderContext, CustomFieldRenderProps } from "./types";
|
||||
174
apps/web-react/app/form/parse.server.ts
Normal file
174
apps/web-react/app/form/parse.server.ts
Normal 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 });
|
||||
}
|
||||
362
apps/web-react/app/form/types.ts
Normal file
362
apps/web-react/app/form/types.ts
Normal 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;
|
||||
};
|
||||
126
apps/web-react/app/form/utils.test.ts
Normal file
126
apps/web-react/app/form/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
282
apps/web-react/app/form/utils.ts
Normal file
282
apps/web-react/app/form/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user