mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-19 01:34:27 -05:00
1018 lines
25 KiB
TypeScript
1018 lines
25 KiB
TypeScript
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<object, FormField>();
|
|
|
|
/** Clones the schema first so shared instances (e.g. `id`, `stageId`) each get their own registry entry. */
|
|
function register<T extends AnySyncSchema>(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 extends AnySyncSchema> = T & {
|
|
_requiresDefault: true;
|
|
};
|
|
|
|
// a builder's signature declares what `defaultValues` must supply and returns `as never`; parsing starts from `unknown`
|
|
|
|
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;
|
|
}) {
|
|
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<T extends AnySyncSchema>(
|
|
args: Omit<Extract<FormField, { type: "custom" }>, "type">,
|
|
schema: T,
|
|
) {
|
|
return register(schema, {
|
|
...args,
|
|
type: "custom",
|
|
});
|
|
}
|
|
|
|
type TextFieldArgs = WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "text-field" }>,
|
|
"type" | "initialValue" | "required"
|
|
>
|
|
>;
|
|
|
|
export function textFieldOptional(
|
|
args: TextFieldArgs,
|
|
): v.GenericSchema<string | null, string | null> {
|
|
return registerTextField(
|
|
safeNullableStringSchema({ min: args.minLength, max: args.maxLength }),
|
|
args,
|
|
false,
|
|
true,
|
|
) as never;
|
|
}
|
|
|
|
export function textField(args: TextFieldArgs): v.GenericSchema<string> {
|
|
return registerTextField(
|
|
safeStringSchema({ min: args.minLength, max: args.maxLength }),
|
|
args,
|
|
true,
|
|
false,
|
|
) as never;
|
|
}
|
|
|
|
function registerTextField<T extends v.GenericSchema<any, string | null>>(
|
|
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<T extends v.GenericSchema<any, string | null>>(
|
|
schema: T,
|
|
args: Omit<
|
|
Extract<FormField, { type: "text-field" }>,
|
|
"type" | "initialValue" | "required"
|
|
>,
|
|
): v.GenericSchema<any, string | null> {
|
|
let result: v.GenericSchema<any, string | null> = 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<string | null, string | null>,
|
|
null
|
|
>;
|
|
}
|
|
|
|
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 },
|
|
): v.GenericSchema<number> {
|
|
let schema: v.GenericSchema<number> = 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<v.GenericSchema<number>, undefined> {
|
|
return register(v.optional(numberSchema()), numberFieldMetadata(args, false));
|
|
}
|
|
|
|
function numberSchema(): v.GenericSchema<number> {
|
|
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<FormField, { type: "text-area" }>,
|
|
"type" | "initialValue" | "required"
|
|
>
|
|
>;
|
|
|
|
export function textAreaOptional(
|
|
args: TextAreaArgs,
|
|
): v.GenericSchema<string | null, string | null> {
|
|
return registerTextArea(
|
|
safeNullableStringSchema({ max: args.maxLength }),
|
|
args,
|
|
false,
|
|
) as never;
|
|
}
|
|
|
|
export function textArea(args: TextAreaArgs): v.GenericSchema<string> {
|
|
return registerTextArea(
|
|
safeStringSchema({ max: args.maxLength }),
|
|
args,
|
|
true,
|
|
) as never;
|
|
}
|
|
|
|
function registerTextArea<T extends v.GenericSchema<any, string | null>>(
|
|
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<Extract<FormField, { type: "switch" }>, "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<TSchema extends v.GenericSchema<any, any>>(
|
|
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<V extends string> = NoInfer<V>;
|
|
|
|
function itemsSchema<V extends string>(items: FormFieldItems<V>) {
|
|
return v.picklist(items.map((item) => item.value) as [V, ...V[]]);
|
|
}
|
|
|
|
function clearableItemsSchema<V extends string>(items: FormFieldItems<V>) {
|
|
return preprocess(
|
|
falsyToNull,
|
|
v.nullable(v.picklist(items.map((item) => item.value) as [V, ...V[]])),
|
|
);
|
|
}
|
|
|
|
export function selectOptional<V extends string>(
|
|
args: WithTypedTranslationKeys<
|
|
WithTypedItemLabels<
|
|
Omit<FormFieldSelect<"select", V>, "type" | "initialValue" | "clearable">,
|
|
V
|
|
>
|
|
>,
|
|
): v.GenericSchema<ItemValue<V> | null, ItemValue<V> | 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<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;
|
|
},
|
|
): v.GenericSchema<ItemValue<V>, ItemValue<V>> {
|
|
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<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 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<string> & FieldWithOptions<SelectOption[]>;
|
|
}
|
|
|
|
export function selectDynamicOptional(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "select-dynamic" }>,
|
|
"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<string | null, string | null> &
|
|
FieldWithOptions<SelectOption[]>;
|
|
}
|
|
|
|
/** Value schema of a dual select, before the `optional` wrapper is applied. */
|
|
type DualSelectSchema<V extends string> = v.GenericSchema<
|
|
[unknown, unknown],
|
|
[V | null, V | null]
|
|
>;
|
|
|
|
export function dualSelectOptional<V extends string>(
|
|
args: WithTypedTranslationKeys<
|
|
WithTypedDualSelectFields<
|
|
Omit<
|
|
FormFieldDualSelect<"dual-select", V>,
|
|
"type" | "initialValue" | "clearable"
|
|
>,
|
|
V
|
|
>
|
|
>,
|
|
): v.OptionalSchema<DualSelectSchema<ItemValue<V>>, 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<V> = 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<V extends string>(
|
|
args: WithTypedTranslationKeys<
|
|
WithTypedItemLabelsWithImage<
|
|
Omit<FormFieldInputGroup<"radio-group", V>, "type" | "initialValue">,
|
|
V
|
|
>
|
|
>,
|
|
): v.GenericSchema<ItemValue<V>, ItemValue<V>> {
|
|
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<FormField, { type: "radio-group-dynamic" }>,
|
|
"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<string> &
|
|
FieldWithOptions<FormFieldItemsWithImage<string>>;
|
|
}
|
|
|
|
export function checkboxGroupDynamic(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "checkbox-group-dynamic" }>,
|
|
"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<string[]> &
|
|
FieldWithOptions<FormFieldItemsWithImage<string>>;
|
|
}
|
|
|
|
type DateTimeArgs = WithTypedTranslationKeys<
|
|
Omit<FormFieldDatetime<"datetime">, "type" | "initialValue" | "required">
|
|
> & {
|
|
minMessage?: FormsTranslationKey;
|
|
maxMessage?: FormsTranslationKey;
|
|
};
|
|
|
|
function boundedDate(args: DateTimeArgs, schema: v.GenericSchema<Date, Date>) {
|
|
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<Date> {
|
|
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<v.GenericSchema<Date>, 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<Date, DayMonthYear> {
|
|
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<V extends string>(
|
|
args: WithTypedTranslationKeys<
|
|
WithTypedItemLabelsWithImage<
|
|
Omit<FormFieldInputGroup<"checkbox-group", V>, "type" | "initialValue">,
|
|
V
|
|
>
|
|
>,
|
|
): v.GenericSchema<ItemValue<V>[], ItemValue<V>[]> {
|
|
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<Extract<FormField, { type: "weapon-pool" }>, "type" | "initialValue">
|
|
>,
|
|
) {
|
|
type WeaponPoolInput = Array<{
|
|
id: v.InferInput<typeof weaponSplId>;
|
|
isFavorite: boolean;
|
|
}>;
|
|
type WeaponPoolValue = Array<{
|
|
id: v.InferOutput<typeof weaponSplId>;
|
|
isFavorite: boolean;
|
|
}>;
|
|
let schema: v.GenericSchema<WeaponPoolInput, WeaponPoolValue> = 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<T extends AnySyncSchema>(
|
|
schema: T,
|
|
initialValue: v.InferInput<T>,
|
|
): T;
|
|
export function hidden<T extends AnySyncSchema>(schema: T): RequiresDefault<T>;
|
|
export function hidden<T extends AnySyncSchema>(
|
|
schema: T,
|
|
initialValue?: v.InferInput<T>,
|
|
) {
|
|
return register(schema, {
|
|
type: "hidden",
|
|
initialValue,
|
|
}) as never;
|
|
}
|
|
|
|
export function stringConstant<T extends string>(value: T) {
|
|
return hidden(v.literal(value), value);
|
|
}
|
|
|
|
export function idConstant<T extends number>(
|
|
value: T,
|
|
): v.LiteralSchema<T, undefined>;
|
|
export function idConstant(): RequiresDefault<v.GenericSchema<number, number>>;
|
|
export function idConstant<T extends number>(value?: T) {
|
|
return (
|
|
value !== undefined ? hidden(v.literal(value), value) : hidden(id)
|
|
) as never;
|
|
}
|
|
|
|
export function idConstantOptional<T extends number>(value?: T) {
|
|
return value
|
|
? hidden(v.optional(v.literal(value)), value)
|
|
: hidden(v.optional(id), undefined);
|
|
}
|
|
|
|
export function array<S extends AnySyncSchema>(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<FormFieldArray<"array", S>, "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<FormField, { type: "time-range" }>,
|
|
"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<S extends v.ObjectEntries>(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<FormFieldFieldset<"fieldset", S>, "type" | "initialValue" | "fields">
|
|
> & { fields: v.ObjectSchema<S, undefined> },
|
|
): v.ObjectSchema<S, undefined> {
|
|
return register(args.fields, {
|
|
...args,
|
|
label: prefixKey(args.label),
|
|
bottomText: prefixKey(args.bottomText),
|
|
type: "fieldset",
|
|
initialValue: {},
|
|
} as FormField);
|
|
}
|
|
|
|
type UserSearchArgs = WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "user-search" }>,
|
|
"type" | "initialValue" | "required"
|
|
>
|
|
>;
|
|
|
|
export function userSearch(args: UserSearchArgs): v.GenericSchema<number> {
|
|
return register(id, userSearchMetadata(args, true)) as never;
|
|
}
|
|
|
|
export function userSearchOptional(
|
|
args: UserSearchArgs,
|
|
): v.OptionalSchema<v.GenericSchema<number>, 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<FormField, { type: "tournament-search" }>,
|
|
"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<number | null, number | null> &
|
|
FieldWithOptions<TournamentSearchFieldOptions>;
|
|
}
|
|
|
|
export function teamSearchOptional(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "team-search" }>,
|
|
"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<number | null, number | null> &
|
|
FieldWithOptions<TeamSearchFieldOptions>;
|
|
}
|
|
|
|
export function badges(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<Extract<FormField, { type: "badges" }>, "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<number[], number[]> &
|
|
FieldWithOptions<BadgeOption[]>;
|
|
}
|
|
|
|
export function trophies(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<Extract<FormField, { type: "trophies" }>, "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<number[], number[]> &
|
|
FieldWithOptions<TrophyOption[]>;
|
|
}
|
|
|
|
export function stageSelect(
|
|
args: WithTypedTranslationKeys<
|
|
Omit<
|
|
Extract<FormField, { type: "stage-select" }>,
|
|
"type" | "initialValue" | "required"
|
|
>
|
|
>,
|
|
): v.GenericSchema<StageId> {
|
|
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<FormField, { type: "weapon-select" }>,
|
|
"type" | "initialValue" | "required"
|
|
>
|
|
>;
|
|
|
|
export function weaponSelect(
|
|
args: WeaponSelectArgs,
|
|
): v.GenericSchema<MainWeaponId> {
|
|
return register(weaponSplId, weaponSelectMetadata(args, true)) as never;
|
|
}
|
|
|
|
export function weaponSelectOptional(
|
|
args: WeaponSelectArgs,
|
|
): v.OptionalSchema<v.GenericSchema<MainWeaponId>, 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,
|
|
};
|
|
}
|