sendou.ink/app/form/FormField.tsx
Kalle fef1ffc955
Design refresh + a bunch of stuff (#2864)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-03-19 17:51:42 +02:00

433 lines
11 KiB
TypeScript

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