UNPKG

mobx-easy-form

Version:

Simple and performant form library built with MobX

254 lines (221 loc) 7.23 kB
import { action, observable } from "mobx"; import type { StandardSchemaV1 } from "@standard-schema/spec"; export type ValidationFn<ValueType, ParsedType = ValueType> = ( value: ValueType ) => | { error?: undefined; parsed: ParsedType } | { error: Error | string; parsed?: undefined }; type YupLikeSchema<ParsedType> = { validateSync( value: unknown, options?: { abortEarly?: boolean } ): ParsedType; }; export type ValidationSchema<ParsedType = unknown> = | StandardSchemaV1<unknown, ParsedType> | YupLikeSchema<ParsedType>; export type InferParsed<Schema, Fallback> = Schema extends StandardSchemaV1<unknown, infer Out> ? Out : Schema extends YupLikeSchema<infer P> ? P : Fallback; export type CreateFieldArgs< ValueType, ParsedType = ValueType, Schema extends ValidationSchema<ParsedType> | undefined = | ValidationSchema<ParsedType> | undefined, > = { id: string; initialValue: ValueType; initialError?: string | undefined; form: { actions: { add(field: Field<any, any>): void; submit(): unknown; }; }; } & ( | { validate?: undefined; validationSchema?: undefined } | { validate: ValidationFn<ValueType, ParsedType>; validationSchema?: undefined; } | { validate?: undefined; validationSchema: Schema } ); function isStandardSchema(schema: unknown): schema is StandardSchemaV1 { return ( typeof schema === "object" && schema !== null && "~standard" in schema ); } function isYupLikeSchema<T>(schema: unknown): schema is YupLikeSchema<T> { return ( typeof schema === "object" && schema !== null && "validateSync" in schema && typeof (schema as { validateSync: unknown }).validateSync === "function" ); } export function createField< ValueType, Schema extends ValidationSchema<unknown> | undefined = undefined, ParsedType = InferParsed<Schema, ValueType>, >( args: CreateFieldArgs< ValueType, ParsedType, Schema extends ValidationSchema<ParsedType> ? Schema : undefined > ): Field<ValueType, ParsedType> { const { id, initialValue, initialError, form } = args; const validate = args.validate; const validationSchema = args.validationSchema as | ValidationSchema<ParsedType> | undefined; const runValidation = getValidateFunction<ValueType, ParsedType>( validate, validationSchema ); const state = observable({ id, errorOverride: initialError, value: initialValue, isFocused: false, wasEverFocused: false, wasEverBlurred: false, }); const computed = observable({ get parsed(): ParsedType | undefined { const result = runValidation(state.value); if (result.error) return undefined; return result.parsed; }, get isDirty() { return JSON.stringify(state.value) !== JSON.stringify(initialValue); }, get error(): string | undefined { const { error } = runValidation(state.value); if (state.errorOverride) { return state.errorOverride; } if (error instanceof Error && error.name === "ValidationError") { const msg: unknown = (error as { message: unknown }).message; if ( msg !== null && typeof msg === "object" && "value" in msg && (msg as { value: unknown }).value !== undefined ) { return String((msg as { value: unknown }).value); } if (typeof msg === "string") return msg; return String(error); } if (error instanceof Error) { return error.message; } return error; }, get ifWasEverFocusedThenError(): string | undefined { if (!state.wasEverFocused) return undefined; if (!computed.error) return undefined; return String(computed.error); }, get ifWasEverBlurredThenError(): string | undefined { if (!state.wasEverBlurred) return undefined; if (!computed.error) return undefined; return String(computed.error); }, }); const actions = { onFocus: action(function onFocus() { state.isFocused = true; state.wasEverFocused = true; }), onBlur: action(function onBlur() { state.isFocused = false; state.wasEverBlurred = true; }), onChange: action(function onChange(value: ValueType) { if (state.errorOverride) state.errorOverride = undefined; state.value = value; }), setError: action(function setError(value: string | undefined) { state.errorOverride = value; }), }; const field: Field<ValueType, ParsedType> = { state, computed, actions }; form.actions.add(field); return field; } function getValidateFunction<ValueType, ParsedType>( validate: ValidationFn<ValueType, ParsedType> | undefined, validationSchema: ValidationSchema<ParsedType> | undefined ): ValidationFn<ValueType, ParsedType> { if (validate) return validate; if (validationSchema) { // Prefer Yup's `validateSync` when available — it's guaranteed sync, // while Standard Schema's `validate` may return a Promise (Yup itself // implements Standard Schema in async mode). if (isYupLikeSchema<ParsedType>(validationSchema)) { const schema = validationSchema; return function validateWithYup(value: ValueType) { try { const parsed = schema.validateSync(value, { abortEarly: true }); return { parsed, error: undefined }; } catch (error) { if (error instanceof Error && error.name === "ValidationError") { return { parsed: undefined, error }; } throw error; } }; } if (isStandardSchema(validationSchema)) { const schema = validationSchema; return function validateWithStandardSchema(value: ValueType) { const result = schema["~standard"].validate(value); if (result instanceof Promise) { throw new TypeError( "mobx-easy-form: Standard Schema async validation is not supported. Use a synchronous schema." ); } if (result.issues) { const message = result.issues[0]?.message ?? "Invalid"; return { parsed: undefined, error: message }; } return { parsed: result.value as ParsedType, error: undefined }; }; } throw new TypeError( "mobx-easy-form: validationSchema must implement Standard Schema or expose a `validateSync` method (Yup-like)." ); } return function passthrough(value: ValueType) { return { parsed: value as unknown as ParsedType, error: undefined }; }; } export interface Field<ValueType, ParsedType = ValueType> { state: { id: string; errorOverride: undefined | string; value: ValueType; isFocused: boolean; wasEverFocused: boolean; wasEverBlurred: boolean; }; computed: { readonly parsed: ParsedType | undefined; readonly isDirty: boolean; readonly error: undefined | string; readonly ifWasEverFocusedThenError: undefined | string; readonly ifWasEverBlurredThenError: undefined | string; }; actions: { onFocus(): void; onChange(value: ValueType): void; onBlur(): void; setError(value: string | undefined): void; }; }