comindware.core.ui
Version:
Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.
377 lines (332 loc) • 12.3 kB
JavaScript
import FieldView from '../fields/FieldView';
import ErrorPlaceholderView from '../fields/ErrorPlaceholderView';
import transliterator from '../../utils/transliterator';
const componentTypes = {
editor: 'editor',
field: 'field'
};
// every of options.transliteratedFields becomes required-like, and overwrite next property in schema { changeMode: 'blur', autocommit: true, forceCommit: true}
// allowEmptyValue: true; // in schema turn off required-like behavior for name
const Form = Marionette.MnObject.extend({
/**
* Constructor
*
* @param {Object} [options.schema]
* @param {Backbone.Model} [options.model]
*/
initialize(options = {}) {
this.options = options;
this.schema = _.result(options, 'schema');
this.model = options.model;
if (typeof this.options.transliteratedFields === 'object') {
transliterator.initializeTransliteration({
model: this.model,
schema: this.schema,
transliteratedFields: options.transliteratedFields
});
}
this.fields = {};
this.regions = [];
Object.entries(this.schema).forEach(entry => {
const fieldScema = entry[1];
const FieldType = fieldScema.field || options.field || FieldView; //TODO fix api
let field;
try {
field = new FieldType({
key: entry[0],
schema: fieldScema,
model: this.model
});
this.listenTo(field, 'all', this.__handleEditorEvent);
} catch (e) {
field = new ErrorPlaceholderView();
Core.InterfaceError.logError(e, field.getId());
} finally {
this.fields[entry[0]] = field;
}
});
this.__renderComponents(componentTypes.editor);
this.__renderComponents(componentTypes.field);
},
handleAttach() {
this.regions.forEach(region => {
const currentView = region.currentView;
if (currentView) {
currentView._isAttached = true;
currentView.trigger('attach');
}
});
},
onDestroy() {
this.regions.forEach(region => region.destroy());
},
/**
* Update the model with all latest values.
*
* @param {Object} [options] Options to pass to Model#set (e.g. { silent: true })
*
* @return {Object} Validation errors
*/
commit(options = {}) {
// Validate
const errors = this.validate({
skipModelValidate: !options.validate
});
if (errors) {
return errors;
}
// Commit
let modelError = null;
const setOptions = {
error(model, e) {
modelError = e;
},
...options
};
this.model.set(this.getValue(), setOptions);
return modelError;
},
/**
* Returns the editor for a given field key
*
* @param {String} key
*
* @return {Editor}
*/
getEditor(key) {
const field = this.fields[key];
if (!field) {
throw new Error(`Field not found: ${key}`);
}
return field;
},
/**
* Get all the field values as an object.
* @param {String} [key] Specific field value to get
*/
getValue(key) {
// Return only given key if specified
if (key) {
return this.fields[key].getValue();
}
// Otherwise return entire form
const values = {};
_.each(this.fields, field => {
values[field.key] = field.getValue();
});
return values;
},
/**
* Update field values, referenced by key
*
* @param {Object|String} prop New values to set, or property to set
* @param val Value to set
*/
setValue(prop, val) {
let data = {};
if (typeof prop === 'string') {
data[prop] = val;
} else {
data = prop;
}
Object.keys(this.schema).forEach(key => {
if (data[key] !== undefined) {
this.fields[key].setValue(data[key]);
}
});
},
__handleEditorEvent(event, editor) {
switch (event) {
case 'change':
this.trigger('change', this, editor);
this.trigger(`${editor.key}:change`, this, editor);
break;
case 'focus':
if (!this.hasFocus) {
this.hasFocus = true;
this.trigger('focus', this);
}
break;
case 'blur':
if (this.hasFocus) {
const focusedField = Object.values(this.fields).find(f => f.editor?.hasFocus);
if (!focusedField) {
this.hasFocus = false;
this.trigger('blur', this);
}
}
break;
default:
break;
}
},
setErrors(errors) {
Object.entries(errors).forEach(entry => {
const field = this.fields[entry[0]];
if (field) {
field.setError(entry[1]);
}
});
},
/**
* Validate the data
* @return {Object} Validation errors
*/
validate(options = {}) {
const fields = this.fields;
const model = this.model;
const errors = {};
//Collect errors from schema validation
Object.values(fields).forEach(field => {
const error = field.validate(options);
if (error) {
errors[field.key] = error;
}
});
//Get errors from default Backbone model validator
if (!options.skipModelValidate && model && model.validate) {
const modelErrors = model.validate(this.getValue());
if (modelErrors) {
const isDictionary = _.isObject(modelErrors) && !Array.isArray(modelErrors);
//If errors are not in object form then just store on the error object
if (!isDictionary) {
errors._others = errors._others || [];
errors._others.push(modelErrors);
}
//Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' })
if (isDictionary) {
Object.entries(modelErrors).forEach(entrie => {
//Set error on field if there isn't one already
if (fields[entrie[0]] && !errors[entrie[0]]) {
fields[entrie[0]].setError(entrie[1]);
errors[entrie[0]] = entrie[1];
} else {
//Otherwise add to '_others' entrie[0]
errors._others = errors._others || [];
errors._others.push({
[entrie[0]]: entrie[1]
});
}
});
}
}
}
const result = Object.keys(errors).length === 0 ? null : errors;
this.trigger('form:validated', !result, result);
return result;
},
/**
* Gives the first editor in the form focus
*/
focus() {
if (this.hasFocus) {
return;
}
const field = this.fields[0];
if (!field) {
return;
}
field.focus();
},
/**
* Removes focus from the currently focused editor
*/
blur() {
if (!this.hasFocus) {
return;
}
const focusedField = Object.values(this.fields).forEach(field => field.hasFocus);
if (focusedField) {
focusedField.blur();
}
},
__renderComponents(componentType) {
const target = this.options.target;
const view = this.options.view;
target.querySelectorAll(`[data-${componentType}s]`).forEach(el => {
if ((!this.model.uniqueFormId && !el.hasAttribute(`${componentType}-for`)) || this.model.uniqueFormId.has(el.getAttribute(`${componentType}-for`))) {
const key = el.getAttribute(`data-${componentType}s`);
const regionName = _.uniqueId('field-region');
const fieldRegion = view.addRegion(regionName, { el });
this.regions.push(fieldRegion); //todo chech this out
if (this.fields[key]) {
view.showChildView(regionName, this.fields[key]);
}
}
});
}
});
/**
* Marionette.Behavior constructor shall never be called manually.
* The options described here should be passed as behavior options (look into Marionette documentation for details).
* @name BackboneFormBehavior
* @memberof module:core.form.behaviors
* @class This behavior turns any Marionette.View into Backbone.Form. To do this Backbone.Form scans this.$el at the moment
* DOM-elements with corresponding Backbone.Form data-attributes.
* It's important to note that Backbone.Form will scan the whole this.$el including nested regions that might lead to unexpected behavior.
* Possible events:<ul>
* <li><code>'form:render' (form)</code> - the form has rendered and available via <code>form</code> property of the view.</li>
* </ul>
* @constructor
* @extends Marionette.Behavior
* @param {Object} options Options object.
* @param {Object|Function} options.schema Backbone.Form schema as it's listed in [docs](https://github.com/powmedia/backbone-forms).
* @param {Object|Function} [options.model] Backbone.Model that the form binds it's editors to. <code>this.model</code> is used by default.
* <li><code>'render'</code> - On view's 'render' event.</li>
* <li><code>'show'</code> - On view's 'show' event.</li>
* <li><code>'manual'</code> - Form render method (<code>renderForm()</code>) must be called manually.</li>
* </ul>
* @param {Backbone.Form.Field} [options.field] Backbone.Form.Field that will be used to render fields of the form.
* The field <code>core.form.fields.Field</code> is used by default.
* @param {Marionette.View} view A view the behavior is applied to.
* */
export default Marionette.Behavior.extend({
initialize(options, view) {
view.renderForm = this.__renderForm.bind(this);
this.model = options.model;
this.schema = options.schema;
},
onRender() {
this.__renderForm();
},
onDestroy() {
if (this.form) {
this.form.destroy();
}
},
onAttach() {
this.form.handleAttach();
},
__renderForm() {
let model = this.options.model;
if (typeof model === 'function') {
model = model.call(this.view);
}
let schema = this.options.schema;
if (typeof schema === 'function') {
schema = schema.call(this.view);
}
let options = this.options.options;
if (typeof options === 'function') {
options = options.call(this.view);
}
const form = new Form(
_.defaults(
{
model,
schema,
target: this.el,
view: this.view
},
this.options,
options
)
);
this.view.form = this.form = form;
if (this.view.initForm) {
this.view.initForm();
}
this.view.trigger('form:render', form);
this.view.onFormRender?.call(this.view, form);
}
});