391 lines
13 KiB
JavaScript
391 lines
13 KiB
JavaScript
/**
|
|
* Copyright since 2007 PrestaShop SA and Contributors
|
|
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
|
|
*
|
|
* NOTICE OF LICENSE
|
|
*
|
|
* This source file is subject to the Open Software License (OSL 3.0)
|
|
* that is bundled with this package in the file LICENSE.md.
|
|
* It is also available through the world-wide-web at this URL:
|
|
* https://opensource.org/licenses/OSL-3.0
|
|
* If you did not receive a copy of the license and are unable to
|
|
* obtain it through the world-wide-web, please send an email
|
|
* to license@prestashop.com so we can send you a copy immediately.
|
|
*
|
|
* DISCLAIMER
|
|
*
|
|
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
|
|
* versions in the future. If you wish to customize PrestaShop for your
|
|
* needs please refer to https://devdocs.prestashop.com/ for more information.
|
|
*
|
|
* @author PrestaShop SA and Contributors <contact@prestashop.com>
|
|
* @copyright Since 2007 PrestaShop SA and Contributors
|
|
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
|
|
*/
|
|
|
|
import _ from 'lodash';
|
|
|
|
const {$} = window;
|
|
|
|
/**
|
|
* This is able to watch an HTML form and parse it as a Javascript object based on a configurable
|
|
* mapping. Each field from the model is mapped to a form input, or several, each input is watched
|
|
* to keep the model consistent.
|
|
*
|
|
* The model mapping used for this component is an object which uses the modelKey as a key (it represents
|
|
* the property path in the object, separated by a dot) and the input names as value (they follow Symfony
|
|
* convention naming using brackets). Here is an example of mapping:
|
|
*
|
|
* const modelMapping = {
|
|
* 'product.stock.quantity': 'product[stock][quantity]',
|
|
* 'product.price.priceTaxExcluded': [
|
|
* 'product[price][price_tax_excluded]',
|
|
* 'product[shortcuts][price][price_tax_excluded]',
|
|
* ],
|
|
* };
|
|
*
|
|
* As you can see for priceTaxExcluded it is possible to assign multiple inputs to the same modelKey, thus
|
|
* any update in one of the inputs will update the model, and all these inputs are kept in sync.
|
|
*
|
|
* With the previous configuration this component would return an object that looks like this:
|
|
*
|
|
* {
|
|
* product: {
|
|
* stock: {
|
|
* // Mapped to product[stock][quantity] input
|
|
* quantity: 200,
|
|
* },
|
|
* price: {
|
|
* // Mapped to two inputs product[price][price_tax_excluded] and product[shortcuts][price][price_tax_excluded]
|
|
* priceTaxExcluded: 20.45,
|
|
* }
|
|
* }
|
|
* }
|
|
*/
|
|
export default class FormObjectMapper {
|
|
/**
|
|
* @param {jQuery} $form - Form element to attach the mapper to
|
|
* @param {Object} modelMapping - Structure mapping a model to form names
|
|
* @param {EventEmitter} eventEmitter
|
|
* @param {Object} [config] - Event names
|
|
* @param {Object} [config.updateModel] - Name of the event to listen to trigger a refresh of the model update
|
|
* @param {Object} [config.modelUpdated] - Name of the event emitted each time the model is updated
|
|
* @param {Object} [config.modelFieldUpdated] - Name of the event emitted each time a field is updated
|
|
* @return {Object}
|
|
*/
|
|
constructor($form, modelMapping, eventEmitter, config) {
|
|
this.$form = $form;
|
|
this.fullModelMapping = modelMapping;
|
|
this.eventEmitter = eventEmitter;
|
|
|
|
const inputConfig = config || {};
|
|
|
|
// This event is registered so when it is triggered it forces the form mapping and object update,
|
|
// it can be useful when some new inputs have been added in the DOM (or removed) so that the model
|
|
// acknowledges the update
|
|
this.updateModelEventName = inputConfig.updateModel || 'updateModel';
|
|
|
|
// This event is emitted each time the object is updated (from both input change and external event)
|
|
this.modelUpdatedEventName = inputConfig.modelUpdated || 'modelUpdated';
|
|
// This event is emitted each time an object field is updated (from both input change and external event)
|
|
this.modelFieldUpdatedEventName = inputConfig.modelFieldUpdated || 'modelFieldUpdated';
|
|
|
|
// Contains callbacks identified by model keys
|
|
this.watchedProperties = {};
|
|
|
|
this.initFormMapping();
|
|
this.updateFullObject();
|
|
this.watchUpdates();
|
|
|
|
return {
|
|
/**
|
|
* Returns the model mapped to the form (current live state)
|
|
*
|
|
* @returns {*|{}}
|
|
*/
|
|
getModel: () => this.model,
|
|
|
|
/**
|
|
* Returns all inputs associated to a model field.
|
|
*
|
|
* @param {string} modelKey
|
|
*
|
|
* @returns {undefined|jQuery}
|
|
*/
|
|
getInputsFor: (modelKey) => {
|
|
if (!Object.prototype.hasOwnProperty.call(this.fullModelMapping, modelKey)) {
|
|
return undefined;
|
|
}
|
|
|
|
const inputNames = this.fullModelMapping[modelKey];
|
|
|
|
// We must loop manually to keep the order in configuration, if we use jQuery multiple selectors the collection
|
|
// will be filled respecting the order in the DOM
|
|
const inputs = [];
|
|
const domForm = this.$form.get(0);
|
|
inputNames.forEach((inputName) => {
|
|
const inputsByName = domForm.querySelectorAll(`[name="${inputName}"]`);
|
|
|
|
if (inputsByName.length) {
|
|
inputsByName.forEach((input) => {
|
|
inputs.push(input);
|
|
});
|
|
}
|
|
});
|
|
|
|
return inputs.length ? $(inputs) : undefined;
|
|
},
|
|
|
|
/**
|
|
* Set a value to a field of the object based on the model key, the object itself is updated
|
|
* of course but the mapped inputs are also synced (all of them if multiple). Events are also
|
|
* triggered to indicate the object has been updated (the general and the individual field ones).
|
|
*
|
|
* @param {string} modelKey
|
|
* @param {*|{}} value
|
|
*/
|
|
set: (modelKey, value) => {
|
|
if (!Object.prototype.hasOwnProperty.call(this.modelMapping, modelKey) || value === this.getValue(modelKey)) {
|
|
return;
|
|
}
|
|
|
|
// First update the inputs then the model, so that the event is sent at last
|
|
this.updateInputValue(modelKey, value);
|
|
this.updateObjectByKey(modelKey, value);
|
|
this.eventEmitter.emit(this.modelUpdatedEventName, this.model);
|
|
},
|
|
|
|
/**
|
|
* Alternative to the event listening, you can watch a specific field of the model and assign a callback.
|
|
* When the specified model field is updated the event is still thrown but additionally any callback assigned
|
|
* to this specific value is also called, the parameter is the same event.
|
|
*
|
|
* @param {string} modelKey
|
|
* @param {function} callback
|
|
*/
|
|
watch: (modelKey, callback) => {
|
|
if (!Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey)) {
|
|
this.watchedProperties[modelKey] = [];
|
|
}
|
|
this.watchedProperties[modelKey].push(callback);
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a field from the object based on the model key, you can even get a sub part of the whole model,
|
|
* this internal method is used by both get and set public methods.
|
|
*
|
|
* @param {string} modelKey
|
|
*
|
|
* @returns {*|{}|undefined} Returns any element from the model, undefined if not found
|
|
* @private
|
|
*/
|
|
getValue(modelKey) {
|
|
const modelKeys = modelKey.split('.');
|
|
|
|
return $.serializeJSON.deepGet(this.model, modelKeys);
|
|
}
|
|
|
|
/**
|
|
* Watches if changes happens from the form or via an event.
|
|
*
|
|
* @private
|
|
*/
|
|
watchUpdates() {
|
|
this.$form.on('keyup change dp.change', ':input', _.debounce(
|
|
(event) => this.inputUpdated(event),
|
|
350,
|
|
{maxWait: 1500},
|
|
));
|
|
this.eventEmitter.on(this.updateModelEventName, () => this.updateFullObject());
|
|
}
|
|
|
|
/**
|
|
* Triggered when a form input has been changed.
|
|
*
|
|
* @param {jQuery.Event} event
|
|
*
|
|
* @private
|
|
*/
|
|
inputUpdated(event) {
|
|
const target = event.currentTarget;
|
|
|
|
// All inputs changes are watched, but not all of them are part of the mapping so we ignore them
|
|
if (!Object.prototype.hasOwnProperty.call(this.formMapping, target.name)) {
|
|
return;
|
|
}
|
|
|
|
const updatedValue = $(target).val();
|
|
const updatedModelKey = this.formMapping[target.name];
|
|
|
|
// Update the mapped input fields
|
|
this.updateInputValue(updatedModelKey, updatedValue, target.name);
|
|
|
|
// Then update model and emit event
|
|
this.updateObjectByKey(updatedModelKey, updatedValue);
|
|
this.eventEmitter.emit(this.modelUpdatedEventName, this.model);
|
|
}
|
|
|
|
/**
|
|
* Update all the inputs mapped to a model key
|
|
*
|
|
* @param {string} modelKey
|
|
* @param {*|{}} value
|
|
* @param {string|undefined} sourceInputName Source of the change (no need to update it)
|
|
*
|
|
* @private
|
|
*/
|
|
updateInputValue(modelKey, value, sourceInputName = undefined) {
|
|
const modelInputs = this.fullModelMapping[modelKey];
|
|
|
|
// Update linked inputs (when there is more than one input associated to the model field)
|
|
if (Array.isArray(modelInputs)) {
|
|
modelInputs.forEach((inputName) => {
|
|
if (sourceInputName === inputName) {
|
|
return;
|
|
}
|
|
|
|
this.updateInputByName(inputName, value);
|
|
});
|
|
} else if (sourceInputName !== modelInputs) {
|
|
this.updateInputByName(modelInputs, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update individual input based on its name
|
|
*
|
|
* @param {string} inputName
|
|
* @param {*|{}} value
|
|
*
|
|
* @private
|
|
*/
|
|
updateInputByName(inputName, value) {
|
|
const $input = $(`[name="${inputName}"]`, this.$form);
|
|
|
|
if (!$input.length) {
|
|
console.error(`Input with name ${inputName} is not present in form.`);
|
|
|
|
return;
|
|
}
|
|
|
|
// This check is important to avoid infinite loops, we don't use strict equality on purpose because it would result
|
|
// into a potential infinite loop if type don't match, which can easily happen with a number value and a text input.
|
|
// eslint-disable-next-line eqeqeq
|
|
if ($input.val() != value) {
|
|
$input.val(value);
|
|
|
|
if ($input.data('toggle') === 'select2') {
|
|
// This is required for select2, because only changing the val doesn't update the wrapping component
|
|
$input.trigger('change');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serializes and updates the object based on form content and the mapping configuration, finally
|
|
* emit an event for external components that may need the update.
|
|
*
|
|
* This method is called when this component initializes or when triggered by an external event.
|
|
*
|
|
* @private
|
|
*/
|
|
updateFullObject() {
|
|
const serializedForm = this.$form.serializeJSON();
|
|
this.model = {};
|
|
Object.keys(this.modelMapping).forEach((modelKey) => {
|
|
const formMapping = this.modelMapping[modelKey];
|
|
const formKeys = $.serializeJSON.splitInputNameIntoKeysArray(formMapping);
|
|
const formValue = $.serializeJSON.deepGet(serializedForm, formKeys);
|
|
|
|
this.updateObjectByKey(modelKey, formValue);
|
|
});
|
|
|
|
this.eventEmitter.emit(this.modelUpdatedEventName, this.model);
|
|
}
|
|
|
|
/**
|
|
* Update a specific field of the object.
|
|
*
|
|
* @param {string} modelKey
|
|
* @param {*|{}} value
|
|
*
|
|
* @private
|
|
*/
|
|
updateObjectByKey(modelKey, value) {
|
|
const modelKeys = modelKey.split('.');
|
|
const previousValue = $.serializeJSON.deepGet(this.model, modelKeys);
|
|
|
|
// This check has two interests, there is no point in modifying a value or emit an event for a value that did not
|
|
// change, and it avoids infinite loops when the object field are co-dependent and need to be updated dynamically
|
|
// (ex: update price tax included when price tax excluded is updated and vice versa, without this check an infinite
|
|
// loop would happen)
|
|
if (previousValue === value) {
|
|
return;
|
|
}
|
|
|
|
$.serializeJSON.deepSet(this.model, modelKeys, value);
|
|
|
|
const updateEvent = {
|
|
object: this.model,
|
|
modelKey,
|
|
value,
|
|
previousValue,
|
|
};
|
|
this.eventEmitter.emit(this.modelFieldUpdatedEventName, updateEvent);
|
|
|
|
if (Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey)) {
|
|
const propertyWatchers = this.watchedProperties[modelKey];
|
|
propertyWatchers.forEach((callback) => {
|
|
callback(updateEvent);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reverse the initial mapping Model->Form to the opposite Form->Model
|
|
* This simplifies the sync in when data updates.
|
|
*
|
|
* @private
|
|
*/
|
|
initFormMapping() {
|
|
// modelMapping is a light version of the fullModelMapping, it only contains one input name which is considered
|
|
// as the default one (when full object is updated, only the default input is used)
|
|
this.modelMapping = {};
|
|
|
|
// formMapping is the inverse of modelMapping for each input name it associated the model key, it is generated for
|
|
// performance and convenience, this allows to get mapping data faster in other functions
|
|
this.formMapping = {};
|
|
|
|
Object.keys(this.fullModelMapping).forEach((modelKey) => {
|
|
const formMapping = this.fullModelMapping[modelKey];
|
|
|
|
if (Array.isArray(formMapping)) {
|
|
formMapping.forEach((aliasFormMapping) => {
|
|
this.addFormMapping(aliasFormMapping, modelKey);
|
|
});
|
|
} else {
|
|
this.addFormMapping(formMapping, modelKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} formName
|
|
* @param {string} modelMapping
|
|
*
|
|
* @private
|
|
*/
|
|
addFormMapping(formName, modelMapping) {
|
|
if (Object.prototype.hasOwnProperty.call(this.formMapping, formName)) {
|
|
console.error(`The form element ${formName} is already mapped to ${this.formMapping[formName]}`);
|
|
|
|
return;
|
|
}
|
|
|
|
this.formMapping[formName] = modelMapping;
|
|
this.modelMapping[modelMapping] = formName;
|
|
}
|
|
}
|