import * as R from "remeda"; import * as v from "valibot"; import { IN_GAME_NAME_MAX_LENGTH, inGameNameIsValid, normalizeInGameName, } from "~/features/user-page/in-game-name"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids"; import type { AnySyncSchema, DayMonthYear } from "~/utils/schema"; import { coerceNumber, date, falsyToNull, id, preprocess, safeNullableStringSchema, safeStringSchema, stageId, timeString, weaponSplId, } from "~/utils/schema"; 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 = new WeakMap(); /** Clones the schema first so shared instances (e.g. `id`, `stageId`) each get their own registry entry. */ function register(schema: T, metadata: FormField): T { const clone = { ...schema }; formRegistry.set(clone, metadata); return clone; } /** Looks up a schema's form field metadata. */ export function getFormFieldMetadata( schema: AnySyncSchema, ): FormField | undefined { return formRegistry.get(schema); } export type RequiresDefault = T & { _requiresDefault: true; }; // a builder's signature declares what `defaultValues` must supply and returns `as never`; parsing starts from `unknown` type WithTypedTranslationKeys = Omit< T, "label" | "bottomText" | "placeholder" > & { label?: FormsTranslationKey; bottomText?: FormsTranslationKey; placeholder?: FormsTranslationKey; }; type TypedItemLabel = { label: FormsTranslationKey | (() => string); value: V; }; type WithTypedItemLabels = Omit & { items: Array>; }; type WithTypedItemLabelsWithImage = Omit & { items: Array & { imgSrc?: string }>; }; type WithTypedDualSelectFields = 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>( items: Array, ) { 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; }) { return register(imageValue, { label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), dimensions: args.dimensions ?? "logo", autoValidate: args.autoValidate ?? false, type: "image", initialValue: null, }); } export function customField( args: Omit, "type">, schema: T, ) { return register(schema, { ...args, type: "custom", }); } type TextFieldArgs = WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "required" > >; export function textFieldOptional( args: TextFieldArgs, ): v.GenericSchema { return registerTextField( safeNullableStringSchema({ min: args.minLength, max: args.maxLength }), args, false, true, ) as never; } export function textField(args: TextFieldArgs): v.GenericSchema { return registerTextField( safeStringSchema({ min: args.minLength, max: args.maxLength }), args, true, false, ) as never; } function registerTextField>( schema: T, args: TextFieldArgs, required: boolean, nullable: boolean, ): T { const refined = textFieldRefined(schema, args); return register(nullable ? optionalKey(refined) : refined, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), placeholder: prefixKey(args.placeholder), required, type: "text-field", initialValue: "", }) as unknown as T; } function textFieldRefined>( schema: T, args: Omit< Extract, "type" | "initialValue" | "required" >, ): v.GenericSchema { let result: v.GenericSchema = schema; if (args.validate === "url") { result = v.pipe( result, v.check( (val) => val === null || isHttpUrl(val), "forms:errors.invalidUrl", ), ); } if (args.regExp) { result = v.pipe( result, v.check((val) => { if (val === null) return true; return args.regExp!.pattern.test(val); }, args.regExp!.message), ); } if (args.validate && typeof args.validate !== "string") { result = v.pipe( result, v.check((val) => { if (val === null) return true; return (args.validate as { func: (value: string) => boolean }).func( val, ); }, args.validate!.message), ); } if (args.toLowerCase) { result = v.pipe( result, v.transform((val) => val?.toLowerCase() ?? null), ); } return result; } /** Only http(s) is allowed so that e.g. a `javascript:` URL can never end up in a rendered link. */ function isHttpUrl(value: string) { try { return ["http:", "https:"].includes(new URL(value).protocol); } catch { return false; } } export function inGameName( args: WithTypedTranslationKeys<{ label?: FormsTranslationKey; bottomText?: FormsTranslationKey; }>, ) { const schema = v.pipe( safeNullableStringSchema({ max: IN_GAME_NAME_MAX_LENGTH, }), v.transform((val) => (val === null ? null : normalizeInGameName(val))), v.check( (val) => val === null || inGameNameIsValid(val), "forms:errors.profileInGameName", ), ); return register(optionalKey(schema), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), maxLength: IN_GAME_NAME_MAX_LENGTH, required: false, type: "in-game-name", initialValue: "", }) as unknown as v.OptionalSchema< v.GenericSchema, null >; } type NumberFieldArgs = WithTypedTranslationKeys< Omit< Extract, | "type" | "initialValue" | "required" | "validate" | "inputType" | "maxLength" > > & { maxLength?: number }; export function numberField( args: NumberFieldArgs & { min?: number; max?: number }, ): v.GenericSchema { let schema: v.GenericSchema = numberSchema(); // an empty field coerces to 0, so `min` is also what rejects a blank required field if (typeof args.min === "number") { schema = v.pipe( schema, v.minValue(args.min, "forms:errors.numberOutOfRange"), ); } if (typeof args.max === "number") { schema = v.pipe( schema, v.maxValue(args.max, "forms:errors.numberOutOfRange"), ); } return register(schema, numberFieldMetadata(args, true)); } export function numberFieldOptional( args: NumberFieldArgs, ): v.OptionalSchema, undefined> { return register(v.optional(numberSchema()), numberFieldMetadata(args, false)); } function numberSchema(): v.GenericSchema { return v.pipe( coerceNumber(), v.integer("forms:errors.mustBeWholeNumber"), v.minValue(0), ) as never; } 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, "type" | "initialValue" | "required" > >; export function textAreaOptional( args: TextAreaArgs, ): v.GenericSchema { return registerTextArea( safeNullableStringSchema({ max: args.maxLength }), args, false, ) as never; } export function textArea(args: TextAreaArgs): v.GenericSchema { return registerTextArea( safeStringSchema({ max: args.maxLength }), args, true, ) as never; } function registerTextArea>( schema: T, args: TextAreaArgs, required: boolean, ): T { return register((required ? schema : optionalKey(schema)) as T, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), required, type: "text-area", initialValue: "", }); } export function toggle( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> > & { /** Value used when the form has no default value for the field. Defaults to `false`. */ initialValue?: boolean; }, ) { return register(v.optional(v.boolean(), false), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "switch", initialValue: args.initialValue ?? false, }); } /** * `v.object` requires every key whose schema isn't optional, and a `preprocess` pipe hides the nullable * wrapper; the `null` default goes through the schema so an absent field parses like an explicit `null`. */ function optionalKey>( schema: TSchema, ) { return v.optional(schema, null); } /** Without `NoInfer`, a builder inline in `v.object({...})` has `V` driven from the return position, widening literals to `string`. */ type ItemValue = NoInfer; function itemsSchema(items: FormFieldItems) { return v.picklist(items.map((item) => item.value) as [V, ...V[]]); } function clearableItemsSchema(items: FormFieldItems) { return preprocess( falsyToNull, v.nullable(v.picklist(items.map((item) => item.value) as [V, ...V[]])), ); } export function selectOptional( args: WithTypedTranslationKeys< WithTypedItemLabels< Omit, "type" | "initialValue" | "clearable">, V > >, ): v.GenericSchema | null, ItemValue | null> { return register(optionalKey(clearableItemsSchema(args.items)), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), items: prefixItems(args.items), type: "select", initialValue: null, clearable: true, }) as never; } export function select( args: WithTypedTranslationKeys< WithTypedItemLabels< Omit, "type" | "initialValue" | "clearable">, V > > & { /** Value selected when the form has no default value for the field. Defaults to the first item. */ initialValue?: V; }, ): v.GenericSchema, ItemValue> { return register(itemsSchema(args.items), { ...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, "type" | "initialValue" | "clearable" > > & { /** Value used when the form has no default value for the field. Defaults to no selection. */ initialValue?: string; }, ) { return register(v.string(), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "select-dynamic", initialValue: args.initialValue ?? null, clearable: false, }) as unknown as v.GenericSchema & FieldWithOptions; } export function selectDynamicOptional( args: WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "clearable" > >, ) { return register( optionalKey(preprocess(falsyToNull, v.nullable(v.string()))), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "select-dynamic", initialValue: null, clearable: true, }, ) as unknown as v.GenericSchema & FieldWithOptions; } /** Value schema of a dual select, before the `optional` wrapper is applied. */ type DualSelectSchema = v.GenericSchema< [unknown, unknown], [V | null, V | null] >; export function dualSelectOptional( args: WithTypedTranslationKeys< WithTypedDualSelectFields< Omit< FormFieldDualSelect<"dual-select", V>, "type" | "initialValue" | "clearable" >, V > >, ): v.OptionalSchema>, undefined> { // `optional` stays outermost so `v.object` reads the key as optional (a pipe would hide the wrapper) const tuple = v.tuple([ clearableItemsSchema(args.fields[0].items), clearableItemsSchema(args.fields[1].items), ]); const schema: DualSelectSchema = args.validate ? v.pipe( tuple, v.check( ([first, second]) => args.validate!.func([first, second]), `forms:${args.validate!.message}`, ), ) : tuple; return register(v.optional(schema), { ...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, } as unknown as FormField); } export function radioGroup( args: WithTypedTranslationKeys< WithTypedItemLabelsWithImage< Omit, "type" | "initialValue">, V > >, ): v.GenericSchema, ItemValue> { return register(itemsSchema(args.items), { ...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, "type" | "initialValue" > >, ) { return register(v.string(), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "radio-group-dynamic", initialValue: null, }) as unknown as v.GenericSchema & FieldWithOptions>; } export function checkboxGroupDynamic( args: WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" > >, ) { return register( v.pipe( v.array(v.string()), v.minLength(args.minLength ?? 0, "forms:errors.required"), v.check((val) => val.length === R.unique(val).length), ), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "checkbox-group-dynamic", initialValue: [], }, ) as unknown as v.GenericSchema & FieldWithOptions>; } type DateTimeArgs = WithTypedTranslationKeys< Omit, "type" | "initialValue" | "required"> > & { minMessage?: FormsTranslationKey; maxMessage?: FormsTranslationKey; }; function boundedDate(args: DateTimeArgs, schema: v.GenericSchema) { const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28))); const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28))); return v.pipe( schema, v.check( (d) => d >= resolveMin(), `forms:${args.minMessage ?? "errors.dateTooEarly"}`, ), v.check( (d) => d <= resolveMax(), `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): v.GenericSchema { return register( preprocess(date, boundedDate(args, v.date("forms:errors.required"))), datetimeMetadata(args, { type: "datetime", required: true }), ) as never; } export function datetimeOptional( args: DateTimeArgs, ): v.NullishSchema, undefined> { // `nullish` stays outermost so `v.object` reads the key as optional (a pipe would hide the wrapper) return register( v.nullish(preprocess(date, boundedDate(args, v.date()))), datetimeMetadata(args, { type: "datetime", required: false }), ) as never; } export function dayMonthYear( args: DateTimeArgs, ): v.GenericSchema { return register( v.pipe( preprocess(date, boundedDate(args, v.date("forms:errors.required"))), v.transform((d) => ({ day: d.getDate(), month: d.getMonth(), year: d.getFullYear(), })), ), datetimeMetadata(args, { type: "date", required: true }), ) as never; } export function checkboxGroup( args: WithTypedTranslationKeys< WithTypedItemLabelsWithImage< Omit, "type" | "initialValue">, V > >, ): v.GenericSchema[], ItemValue[]> { return register( v.pipe( v.array(itemsSchema(args.items)), v.minLength(args.minLength ?? 0), v.check((val) => val.length === R.unique(val).length), ), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), items: prefixItems(args.items), type: "checkbox-group", initialValue: [], }, ); } export function weaponPool( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> >, ) { type WeaponPoolInput = Array<{ id: v.InferInput; isFavorite: boolean; }>; type WeaponPoolValue = Array<{ id: v.InferOutput; isFavorite: boolean; }>; let schema: v.GenericSchema = v.pipe( v.array( v.object({ id: weaponSplId, isFavorite: v.boolean(), }), ), v.minLength(args.minCount ?? 0), v.maxLength(args.maxCount), ); if (!args.allowDuplicates) { schema = v.pipe( schema, v.check( (val) => val.length === R.uniqueBy(val, (item) => item.id).length, ), ); } if (args.disableAltSkinDuplicates) { schema = v.pipe( schema, v.check( (val) => val.length === R.uniqueBy(val, (item) => canonicalWeaponSplId(item.id)).length, ), ); } return register(schema, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "weapon-pool", initialValue: [], }); } /** * Renders no control, for values the user never edits (e.g. a discriminator from the loader). Without * `initialValue` the field requires an entry in the form's `defaultValues`. */ export function hidden( schema: T, initialValue: v.InferInput, ): T; export function hidden(schema: T): RequiresDefault; export function hidden( schema: T, initialValue?: v.InferInput, ) { return register(schema, { type: "hidden", initialValue, }) as never; } export function stringConstant(value: T) { return hidden(v.literal(value), value); } export function idConstant( value: T, ): v.LiteralSchema; export function idConstant(): RequiresDefault>; export function idConstant(value?: T) { return ( value !== undefined ? hidden(v.literal(value), value) : hidden(id) ) as never; } export function idConstantOptional(value?: T) { return value ? hidden(v.optional(v.literal(value)), value) : hidden(v.optional(id), undefined); } export function array( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> >, ) { const schema = v.pipe( v.array(args.field), v.minLength(args.min ?? 0), v.maxLength(args.max), ); return register(schema, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "array", initialValue: [], } as FormField); } type TimeRangeArgs = WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "startLabel" | "endLabel" > > & { startLabel?: FormsTranslationKey; endLabel?: FormsTranslationKey; }; const timeRangeEndpoint = v.pipe( v.string(), v.nonEmpty("forms:errors.timeRangeIncomplete"), v.check((value) => v.is(timeString, value), "forms:errors.invalidTime"), ); export function timeRangeOptional(args: TimeRangeArgs) { return register( v.nullable( v.object({ start: timeRangeEndpoint, end: timeRangeEndpoint, }), ), { ...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( args: WithTypedTranslationKeys< Omit, "type" | "initialValue" | "fields"> > & { fields: v.ObjectSchema }, ): v.ObjectSchema { return register(args.fields, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "fieldset", initialValue: {}, } as FormField); } type UserSearchArgs = WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "required" > >; export function userSearch(args: UserSearchArgs): v.GenericSchema { return register(id, userSearchMetadata(args, true)) as never; } export function userSearchOptional( args: UserSearchArgs, ): v.OptionalSchema, undefined> { return register(v.optional(id), userSearchMetadata(args, false)) as never; } 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, "type" | "initialValue" | "required" > >, ) { return register(optionalKey(preprocess(falsyToNull, v.nullable(id))), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "tournament-search", initialValue: null, required: false, }) as unknown as v.GenericSchema & FieldWithOptions; } export function teamSearchOptional( args: WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "required" > >, ) { return register(optionalKey(preprocess(falsyToNull, v.nullable(id))), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "team-search", initialValue: null, required: false, }) as unknown as v.GenericSchema & FieldWithOptions; } export function badges( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> >, ) { return register(v.pipe(v.array(id), v.maxLength(args.maxCount ?? 50)), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "badges", initialValue: [], }) as unknown as v.GenericSchema & FieldWithOptions; } export function trophies( args: WithTypedTranslationKeys< Omit, "type" | "initialValue"> >, ) { return register(v.pipe(v.array(id), v.maxLength(args.maxCount ?? 100)), { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "trophies", initialValue: [], }) as unknown as v.GenericSchema & FieldWithOptions; } export function stageSelect( args: WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "required" > >, ): v.GenericSchema { return register(stageId, { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "stage-select", initialValue: 1, required: true, }) as never; } type WeaponSelectArgs = WithTypedTranslationKeys< Omit< Extract, "type" | "initialValue" | "required" > >; export function weaponSelect( args: WeaponSelectArgs, ): v.GenericSchema { return register(weaponSplId, weaponSelectMetadata(args, true)) as never; } export function weaponSelectOptional( args: WeaponSelectArgs, ): v.OptionalSchema, undefined> { return register( v.optional(weaponSplId), weaponSelectMetadata(args, false), ) as never; } function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) { return { ...args, label: prefixKey(args.label), bottomText: prefixKey(args.bottomText), type: "weapon-select" as const, initialValue: null, required, }; }