import FormInputComponent from './FormInputComponent'; type LoadingListener = (isLoading: boolean) => void; export type ValidationError = | string | { type: string; payload?: { [key: string]: any }; }; export default class FormModel { fields = {}; errors: { [fieldId: string]: ValidationError; } = {}; handlers: LoadingListener[] = []; renderErrors: boolean; _isLoading: boolean; /** * @param {object} options * @param {bool} [options.renderErrors=true] - whether the bound filed should * render their errors */ constructor(options: { renderErrors?: boolean } = {}) { this.renderErrors = options.renderErrors !== false; } hasField(fieldId: string) { return !!this.fields[fieldId]; } /** * Connects form with React's component * * Usage: * * * @param {string} name - the name of field * * @returns {object} - ref and name props for component */ bindField( name: string, ): { name: string; ref: (el: any) => void; error?: ValidationError; } { this.fields[name] = {}; const props: { name: string; ref: (el: any) => void; error?: ValidationError; } = { name, ref: (el: FormInputComponent | null) => { if (el) { if (!(el instanceof FormInputComponent)) { throw new Error('Expected FormInputComponent component'); } this.fields[name] = el; } else { delete this.fields[name]; } }, }; const error = this.getError(name); if (this.renderErrors && error) { props.error = error; } return props; } /** * Focuses field * * @param {string} fieldId - an id of field to focus */ focus(fieldId: string) { if (!this.fields[fieldId]) { throw new Error( `Can not focus. The field with an id ${fieldId} does not exists`, ); } this.fields[fieldId].focus(); } /** * Get a value of field * * @param {string} fieldId - an id of field to get value of * * @returns {string} */ value(fieldId: string) { const field = this.fields[fieldId]; if (!field) { throw new Error( `Can not get value. The field with an id ${fieldId} does not exists`, ); } if (!field.getValue) { return ''; // the field was not initialized through ref yet } return field.getValue(); } /** * Add errors to form fields * * errorType may be string or object {type: string, payload: object}, where * payload is additional data for errorType * * @param {object} errors - object maping {fieldId: errorType} */ setErrors(errors: { [key: string]: ValidationError }) { if (typeof errors !== 'object' || errors === null) { throw new Error('Errors must be an object'); } const oldErrors = this.errors; this.errors = errors; Object.keys(this.fields).forEach(fieldId => { if (this.renderErrors) { if (oldErrors[fieldId] || errors[fieldId]) { this.fields[fieldId].setError(errors[fieldId] || null); } } if (this.hasErrors()) { this.fields[fieldId].onFormInvalid(); } }); } getFirstError(): ValidationError | null { const [error] = Object.values(this.errors); return error || null; } /** * Get error by id * * @param {string} fieldId - an id of field to get error for * * @returns {string|object|null} */ getError(fieldId: string) { return this.errors[fieldId] || null; } /** * @returns {bool} */ hasErrors() { return Object.keys(this.errors).length > 0; } /** * Convert form into key-value object representation * * @returns {object} */ serialize(): { [key: string]: any } { return Object.keys(this.fields).reduce((acc, fieldId) => { const field = this.fields[fieldId]; if (field) { acc[fieldId] = field.getValue(); } else { console.warn('Can not serialize %s field. Because it is null', fieldId); } return acc; }, {}); } /** * Bind handler to listen for form loading state change * * @param {Function} fn */ addLoadingListener(fn: LoadingListener) { this.removeLoadingListener(fn); this.handlers.push(fn); } /** * Remove form loading state handler * * @param {Function} fn */ removeLoadingListener(fn: LoadingListener) { this.handlers = this.handlers.filter(handler => handler !== fn); } /** * Switch form in loading state */ beginLoading() { this._isLoading = true; this.notifyHandlers(); } /** * Disable loading state */ endLoading() { this._isLoading = false; this.notifyHandlers(); } private notifyHandlers() { this.handlers.forEach(fn => fn(this._isLoading)); } }