/** * 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 * @copyright Since 2007 PrestaShop SA and Contributors * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ /* eslint max-classes-per-file: ["error", 2] */ import BigNumber from 'bignumber.js'; import {transform as numberCommaTransform} from '@js/app/utils/number-comma-transformer'; import {isUndefined} from '@PSTypes/typeguard'; import NameValuePair = JQuery.NameValuePair; const {$} = window; export interface FormUpdateEvent { object: Record, modelKey: string, value: any, previousValue: any, /** * You can stop the propagation of an event propagation to avoid the other watchers to be triggered, * this allows preventing infinite loop in case you change the value from an event (by stopping it * and re-setting new value). */ stopPropagation(): void, /** * When true the watchers callback stop being called (for this specific event instance only). */ isPropagationStopped(): boolean, } /** * Internal class for the event object that implements the FormUpdateEvent interface. */ class UpdateEvent implements FormUpdateEvent { object: Record; modelKey: string; value: any; previousValue: any; private propagationStopped: boolean; constructor( object: Record, modelKey: string, value: any, previousValue: any, ) { this.object = object; this.modelKey = modelKey; this.value = value; this.previousValue = previousValue; this.propagationStopped = false; } stopPropagation(): void { this.propagationStopped = true; } isPropagationStopped(): boolean { return this.propagationStopped; } } /** * 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 { private $form: JQuery; private fullModelMapping: Record; private model: Record; private modelMapping: Record; private formMapping: Record; private watchedProperties: Record void>>; /** * @param {JQuery} $form - Form element to attach the mapper to * @param {Object} modelMapping - Structure mapping a model to form names * @return {Object} */ constructor( $form: JQuery, modelMapping: Record, ) { if (!$form.length) { console.error('Invalid empty form as input'); } this.$form = $form; this.fullModelMapping = modelMapping; this.model = {}; this.modelMapping = {}; // 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 = {}; // Contains callbacks identified by model keys this.watchedProperties = {}; this.initFormMapping(); this.updateFullObject(); this.watchUpdates(); } /** * Returns the model mapped to the form (current live state) * * @returns {*|{}} */ getModel(): Record { return this.model; } /** * Returns all inputs associated to a model field. * * @param {string} modelKey * * @returns {undefined|JQuery} */ getInputsFor(modelKey: string): JQuery | undefined { if ( !Object.prototype.hasOwnProperty.call(this.fullModelMapping, modelKey) ) { return undefined; } let inputNames = this.fullModelMapping[modelKey]; // Turn single identifier into array to limit duplicated code in the following code if (!Array.isArray(inputNames)) { inputNames = [inputNames]; } // 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: Array = []; const domForm = this.$form.get(0); if (!domForm) return undefined; inputNames.forEach((inputName: string) => { 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: string, value: string | number | string[] | undefined): void { 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); } /** * 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 | string[]} modelKeys * @param {function} callback */ watch(modelKeys: string | string[], callback: (event: FormUpdateEvent) => void): void { const watchedKeys: string[] = Array.isArray(modelKeys) ? modelKeys : [modelKeys]; watchedKeys.forEach((modelKey: string) => { if ( !Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey) ) { this.watchedProperties[modelKey] = []; } this.watchedProperties[modelKey].push(callback); }); } /** * Returns a model field by modelKey converted as a BigNumber instance, it also cleans * any invalid comma to avoid conversion error (since some languages sue comma as a decimal * separator). * * @param modelKey */ getBigNumber(modelKey: string): BigNumber | undefined { const numberValue = this.getValue(modelKey); return isUndefined(numberValue) ? undefined : new BigNumber(numberCommaTransform(numberValue)); } /** * Get a field from the object based on the model key, * you can even get a sub part of the whole model, * 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 */ getValue(modelKey: string): string | number | string[] | undefined { const modelKeys = modelKey.split('.'); return $.serializeJSON.deepGet(this.model, modelKeys); } /** * Serializes and updates the object based on form content and the mapping configuration, an event will be triggered * for each field of the object. */ updateFullObject():void { // Temporarily enable all inputs or they will not be serialized const $disabledInputs: JQuery = this.$form.find(':input:disabled').removeAttr('disabled'); const serializedFormArray = this.$form.serializeArray(); // Restore initial disabled state $disabledInputs.prop('disabled', true); const serializedFormMap: Record = {}; serializedFormArray.forEach((value: NameValuePair) => { serializedFormMap[value.name] = value.value; }); this.model = {}; Object.keys(this.modelMapping).forEach((modelKey) => { const formMapping = this.modelMapping[modelKey]; const formValue = serializedFormMap[formMapping]; this.updateObjectByKey(modelKey, formValue); }); } /** * Watches if changes happens from the form or via an event. */ private watchUpdates(): void { // Only watch change event, not keyup event, this reduces the number of computing while typing and it prevents a // bug when using the NumberFormatter component which only applies on change event So both component must trigger // on change event only if we want them to apply their modifications appropriately The second advantage is that // debounce is not needed anymore which prevents any bug when form is submitted before un-focusing the input this.$form.on( 'change dp.change', ':input', (event: JQuery.TriggeredEvent) => this.inputUpdated(event), ); } /** * Triggered when a form input has been changed. * * @param {JQuery.TriggeredEvent} event */ private inputUpdated(event: JQuery.TriggeredEvent): void { 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 = this.getInputValue($(target)); 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); } /** * @param {jQuery} $input * * @returns {*} */ private getInputValue($input: JQuery): string | number | string[] | boolean | undefined { if ($input.is(':checkbox')) { return $input.is(':checked'); } return $input.val(); } /** * 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: string, value: string | number | string[] | boolean | undefined, sourceInputName?: string, ): void { 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: string, value: string | number | string[] | boolean | undefined, ): void { const $input: JQuery = $(`[name="${inputName}"]`, this.$form); if (!$input.length) { console.error(`Input with name ${inputName} is not present in form.`); return; } if (!this.hasSameValue(this.getInputValue($input), value)) { if ($input.is(':checkbox')) { $input.val(value ? 1 : 0); $input.prop('checked', !!value); } else { $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'); } this.triggerChangeEvent(inputName); } } /** * Simulate change event programmatically, this is required because when changing the value of an input via js no * change event is triggered, so if you added a listener for this event it won't trigger and your app will not * behave as expected. * * @param inputName */ private triggerChangeEvent(inputName: string): void { const input: HTMLInputElement = document.querySelector(`[name="${inputName}"]`); if (!input) { return; } const event = document.createEvent('HTMLEvents'); event.initEvent('change', false, true); input.dispatchEvent(event); } /** * Check if both values are equal regardless of their type. * * @param inputValue * @param referenceValue * @private */ private hasSameValue( inputValue: string | number | boolean | string[] | undefined, referenceValue: string | number | boolean | string[] | undefined, ): boolean { /* * We need a custom checking method for equality, we don't use strict equality on purpose because it would result * into a potential infinite loop if type doesn't match, which can easily happen when checking values with different * type but same values in essence. */ if (typeof inputValue === 'boolean' || typeof referenceValue === 'boolean') { return inputValue === referenceValue; } /* * And we also try to see if both values have the same BigNumber value, this avoids forcing a number input value when * it's not written exactly the same way (like pending zeros). When checking a number we use the numberCommaTransform * as numbers can be written with comma separator depending on the language. */ const referenceBigNumber: BigNumber = new BigNumber(numberCommaTransform(referenceValue)); const inputBigNumber: BigNumber = new BigNumber(numberCommaTransform(inputValue)); if (inputBigNumber.isEqualTo(referenceBigNumber)) { return true; } // eslint-disable-next-line eqeqeq return referenceValue == inputValue; } /** * Update a specific field of the object. * * @param {string} modelKey * @param {*|{}} value */ private updateObjectByKey( modelKey: string, value: string | number | string[] | boolean | undefined, ): void { 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: FormUpdateEvent = new UpdateEvent( this.model, modelKey, value, previousValue, ); if ( Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey) ) { const propertyWatchers = this.watchedProperties[modelKey]; propertyWatchers.forEach( (callback: (param: FormUpdateEvent) => void) => { if (!updateEvent.isPropagationStopped()) { callback(updateEvent); } }, ); } } /** * Reverse the initial mapping Model->Form to the opposite Form->Model * This simplifies the sync in when data updates. */ private initFormMapping(): void { 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: string, modelMapping: string): void { 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; } }