mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
433 lines
11 KiB
TypeScript
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;
|
|
}
|