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 = React.useCallback( (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); }, [context, hasSubmitted, isNestedPath, name], ); const displayedError = serverError ?? clientError; const commonProps = { name, error: displayedError, onBlur: handleBlur }; if (formField.type === "text-field") { return ( void} /> ); } if (formField.type === "switch") { return ( void} /> ); } if (formField.type === "text-area") { return ( void} /> ); } if (formField.type === "select") { return ( void} /> ); } if (formField.type === "select-dynamic") { if (!options) { throw new Error("Dynamic select form field requires options prop"); } const selectOptions = options as SelectOption[]; return ( ({ value: opt.value, label: opt.label, }))} value={value as string | null} onChange={handleChange as (v: string | null) => void} /> ); } if (formField.type === "dual-select") { return ( void} /> ); } if (formField.type === "radio-group") { return ( void} /> ); } if (formField.type === "checkbox-group") { return ( void} /> ); } if (formField.type === "datetime" || formField.type === "date") { return ( void} /> ); } if (formField.type === "time-range") { return ( void } /> ); } if (formField.type === "weapon-pool") { return ( 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 ( void} isObjectArray={isObjectArray} itemInitialValue={itemInitialValue} renderItem={(idx, itemName) => { if (hasCustomRender && isObjectArray) { const arrayValue = value as Record[]; const itemValues = arrayValue[idx] ?? {}; const setItemField = (fieldName: string, fieldValue: unknown) => { context?.setValueFromPrev(name, (prev) => { const currentArray = (prev ?? []) as Record[]; 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 ( ); }} /> ); } if (formField.type === "fieldset") { return ; } if (formField.type === "user-search") { return ( void} /> ); } if (formField.type === "badges") { if (!options) { throw new Error("Badges form field requires options prop"); } return ( void} options={options as BadgeOption[]} {...(maxCount !== undefined ? { maxCount } : {})} /> ); } if (formField.type === "stage-select") { return ( void} /> ); } if (formField.type === "weapon-select") { return ( void} /> ); } return (
Unsupported form field type: {(formField as FormFieldType).type}
); } function computeFieldsetInitialValue( fieldsetMeta: FormFieldType, ): Record { if (fieldsetMeta.type !== "fieldset") return {}; const shape = fieldsetMeta.fields.shape as Record; const result: Record = {}; for (const [key, fieldSchema] of Object.entries(shape)) { const fieldMeta = formRegistry.get(fieldSchema) as | FormFieldType | undefined; if (fieldMeta) { result[key] = fieldMeta.initialValue; } } return result; }