import { action, observable, computed } from "mobx"; import { BoxedValue } from "boxm"; import { Rule } from "./rules"; export interface Field extends BoxedValue, Rule { model: Model; error: string[]; } export class ValidationError { message: string; errors: string[]; constructor(errors: string[] | string) { this.errors = typeof errors === "string" ? [errors] : errors; this.message = this.errors.join("\n"); } } function getErrors(e: any) { if (e instanceof ValidationError) { return e.errors; } if (e && e.message) { return [e.message as string]; } try { return [JSON.stringify(e)]; } catch (x) { return [x.message as string || "Unknown error"]; } } class Adaptation implements Field { @observable modelStore: Model; @observable viewStoreCanonical: View; @observable viewStoreAny: View; @observable errorStore: string[]; constructor(init: Model, private modelBox: BoxedValue | undefined, public label: string | undefined, private render: (view: Model) => View, private parse: (str: View) => Model ) { this.modelStore = init; this.viewStoreCanonical = this.viewStoreAny = render(init); // Set up the initial validation error, if any try { this.parse(this.viewStoreAny); this.errorStore = []; } catch (error) { if (error instanceof ValidationError) { this.errorStore = getErrors(error); } else { throw error; } } } @computed get viewFromModel() { return this.render(this.modelBox ? this.modelBox.get() : this.modelStore); } @computed get view() { // If viewFromModel doesn't match viewStoreCanonical, use viewFromModel: return this.viewFromModel !== this.viewStoreCanonical ? this.viewFromModel : this.viewStoreAny // otherwise return our view state } set view(value: View) { // Make modelBox and modelStore consistent (one way or the other) // and set errorStore appropriately: try { this.modelStore = this.parse(value); this.errorStore = []; if (this.modelBox) { this.modelBox.set(this.modelStore); } } catch (error) { if (error instanceof ValidationError) { this.errorStore = getErrors(error); if (this.modelBox) { this.modelStore = this.modelBox.get(); } } else { throw error; } } this.viewStoreAny = value; this.viewStoreCanonical = this.viewFromModel; } @computed get model() { return this.modelBox ? this.modelBox.get() : this.modelStore; } set model(value: Model) { if (this.modelBox) { this.modelBox.set(value); } this.modelStore = value; this.viewStoreCanonical = this.viewStoreAny = this.render(value); this.errorStore = []; } @computed get error(): string[] { return this.viewFromModel !== this.viewStoreCanonical ? [] : this.errorStore; } get() { return this.view; } @action set(v: View) { this.view = v; } toJSON() { return this.modelStore; } } export interface Adaptor { render(model: Model): View; parse(view: View): Model; } export interface FieldBuilder { also(outer: Adaptor): FieldBuilder; check(check: Check): FieldBuilder; create(value: Model, label?: string): Field; use(box: BoxedValue, label?: string): Field; } export function field(inner: Adaptor): FieldBuilder { function also(outer: Adaptor) { function render(m: Model) { return outer.render(inner.render(m)); }; function parse(v: View2) { return inner.parse(outer.parse(v)); }; return field({ render, parse }); }; function check(check: Check) { return also(checker(check)); } function create(value: Model, label?: string): Field { return new Adaptation(value, undefined, label, inner.render, inner.parse); } function use(box: BoxedValue, label?: string): Field { return new Adaptation(box.get(), box, label, inner.render, inner.parse); } return { also, check, create, use }; } export function numberAsString(decimalPlaces?: number) { const pattern = /^\s*[\-\+]?\d*[\.\,]?\d*\s*$/; function render(value: number) { return (decimalPlaces === undefined ? value : value.toFixed(decimalPlaces)) + ""; } function parse(str: string) { const value = parseFloat(str); if (isNaN(value) || !pattern.test(str)) { throw new ValidationError("Must be a number"); } return value; } return { render, parse }; } export type Check = (val: T) => string | string[] | undefined; export function identity() { return { render(value: T) { return value; }, parse(value: T) { return value; } }; } export function checker(check: Check) { return { render(value: T) { return value; }, parse(value: T) { const error = check(value); if (error) { throw new ValidationError(error); } return value; } }; } export function numberLimits(min: number, max: number) { return checker((val: number) => (val < min) ? `Minimum value ${min}` : (val > max) ? `Maximum value ${max}` : undefined); } export function stringLimits(minLength: number, maxLength: number) { return checker((str: string) => (str.length < minLength) ? `Minimum length ${minLength} characters` : (str.length > maxLength) ? `Maximum length ${maxLength} characters` : undefined); }