mobx-react-form
Version:
Reactive MobX Form State Management
271 lines (230 loc) • 7.38 kB
text/typescript
import {
action,
computed,
observable,
makeObservable,
autorun,
runInAction,
} from "mobx";
import { debounce, each, merge } from "lodash";
import Base from "./Base";
import Validator from "./Validator";
import State from "./State";
import Field from "./Field";
import ValidatorInterface from "./models/ValidatorInterface";
import { FieldInterface, FieldConstructor } from "./models/FieldInterface";
import {
FormInterface,
FieldsDefinitions,
FormConfig,
PathsOf,
} from "./models/FormInterface";
import { FieldPropsEnum } from "./models/FieldProps";
import { OptionsEnum } from "./models/OptionsModel";
export default class Form<F extends Record<string, any> = Record<string, any>> extends Base<F> implements FormInterface<F> {
name: string;
override path: any = null;
extra: Record<string, any> = {};
validator: ValidatorInterface;
debouncedValidation: any = null;
constructor(
setup: FieldsDefinitions = {},
{
name = "",
options = {},
plugins = {},
bindings = {},
hooks = {},
handlers = {},
extra = {},
}: FormConfig = {}
) {
super();
makeObservable(this, {
extra: observable,
fields: observable,
flatMapValues: computed,
validatedValues: computed,
error: computed,
hasError: computed,
isValid: computed,
isPristine: computed,
isDirty: computed,
isDefault: computed,
isEmpty: computed,
focused: computed,
touched: computed,
disabled: computed,
// init: action,
invalidate: action,
clear: action,
reset: action,
resetValidation: action,
});
this.name = name;
this.extra = extra;
runInAction(() => (this.$hooks = hooks));
runInAction(() => (this.$handlers = handlers));
// load data from initializers methods
const initial = each(
{
setup,
options,
plugins,
bindings,
},
(val, key) =>
typeof (this as any)[key] === "function"
? merge(val, (this as any)[key].apply(this, [this]))
: val
);
// setup hooks & handlers from initialization methods
runInAction(() =>
Object.assign(this.$hooks, (this as any).hooks?.apply(this, [this]))
);
runInAction(() =>
Object.assign(this.$handlers, (this as any).handlers?.apply(this, [this]))
);
this.state = new State({
form: this as any,
initial: initial.setup,
options: initial.options,
bindings: initial.bindings,
});
this.validator = new Validator({
form: this as any,
plugins: initial.plugins,
});
this.initFields(initial.setup);
this.debouncedValidation = debounce(
this.validate,
this.state.options.get(OptionsEnum.validationDebounceWait),
this.state.options.get(OptionsEnum.validationDebounceOptions)
);
// execute validation on form initialization
this.state.options.get(OptionsEnum.validateOnInit) &&
this.validator.validate({
showErrors: this.state.options.get(OptionsEnum.showErrorsOnInit),
});
this.execHook(FieldPropsEnum.onInit);
// handle Form onChange Hook
autorun(() => this.$changed && this.execHook(FieldPropsEnum.onChange));
}
/* ------------------------------------------------------------------ */
/* COMPUTED */
get validatedValues(): Record<string, any> {
console.warn("validatedValues is deprecated, use flatMapValues instead.");
return this.flatMapValues;
}
get flatMapValues(): Record<string, any> {
const data: any = {};
this.each(($field: any) => (data[$field.path] = $field.validatedValue));
return data;
}
get error(): string | null {
return this.validator.error || this.firstError() || null;
}
get hasError(): boolean {
return !!this.validator.error || this.check(FieldPropsEnum.hasError, true);
}
get isValid(): boolean {
return !this.validator.error && this.check(FieldPropsEnum.isValid, true);
}
get isPristine(): boolean {
return this.check(FieldPropsEnum.isPristine, true);
}
get isDirty(): boolean {
return this.check(FieldPropsEnum.isDirty, true);
}
get isDefault(): boolean {
return this.check(FieldPropsEnum.isDefault, true);
}
get isEmpty(): boolean {
return this.check(FieldPropsEnum.isEmpty, true);
}
get focused(): boolean {
return this.check(FieldPropsEnum.focused, true);
}
get touched(): boolean {
return this.check(FieldPropsEnum.touched, true);
}
get disabled(): boolean {
return this.check(FieldPropsEnum.disabled, true);
}
makeField(data: FieldConstructor, FieldClass: typeof Field = Field) {
return new FieldClass(data);
}
/**
* Select a field by key with type inference.
* Provides transparent autocomplete for both top-level keys (`keyof F`)
* AND nested paths (`PathsOf<F>`) without any type annotations needed.
*
* @example
* // Top-level keys — autocomplete from keyof F:
* form.$('username'); // returns Field<string>
*
* @example
* // Nested paths — autocomplete from PathsOf<F>:
* form.$('club'); // "club"
* form.$('club.name'); // "club.name" ← autocomplete after dot!
* form.$('members[].firstname'); // "members[].firstname"
*/
override $(key: keyof F | PathsOf<F>): Field<F[keyof F]> {
return super.$(key as string) as Field<F[keyof F]>;
}
/**
* Select a field by path.
* For typed autocomplete, use `$()` instead, which is typed with `keyof F`.
*/
override select(path: string | number, fields: any = null, isStrict: boolean = true): Field<any> {
return super.select(path, fields, isStrict) as Field<any>;
}
override values(): { [K in keyof F]?: F[K] } {
return super.values() as { [K in keyof F]?: F[K] };
}
/** DEPRECATED
Init Form Fields and Nested Fields
init($fields: any = null): void {
_.set(this, "fields", observable.map({}));
this.state.initial.props.values = $fields; // eslint-disable-line
this.state.current.props.values = $fields; // eslint-disable-line
this.initFields({
fields: $fields || this.state.struct(),
});
}
*/
invalidate(message: string | null = null, deep: boolean = true): void {
this.debouncedValidation.cancel();
this.validator.error =
message ||
this.state.options.get(OptionsEnum.defaultGenericError) ||
true;
deep &&
this.each((field: FieldInterface) => field.debouncedValidation.cancel());
}
showErrors(show: boolean = true): void {
this.each((field: FieldInterface) => field.showErrors(show));
}
resetValidation(deep: boolean = true): void {
this.validator.error = null;
deep && this.each((field: FieldInterface) => field.resetValidation(deep));
}
/**
Clear Form Fields
*/
clear(deep: boolean = true, execHook: boolean = true): void {
execHook && this.execHook(FieldPropsEnum.onClear);
this.$touched = false;
this.$changed = 0;
deep && this.each((field: FieldInterface) => field.clear(deep));
}
/**
Reset Form Fields
*/
reset(deep: boolean = true, execHook: boolean = true): void {
execHook && this.execHook(FieldPropsEnum.onReset);
this.$touched = false;
this.$changed = 0;
deep && this.each((field: FieldInterface) => field.reset(deep));
}
}