/**
 * @defgroup js_controllers_form
 */
/**
 * @file js/controllers/form/FormHandler.js
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class FormHandler
 * @ingroup js_controllers_form
 *
 * @brief Abstract form handler.
 */
(function($) {

	/** @type {Object} */
	$.pkp.controllers.form = $.pkp.controllers.form || {};



	/**
	 * @constructor
	 *
	 * @extends $.pkp.classes.Handler
	 *
	 * @param {jQueryObject} $form the wrapped HTML form element.
	 * @param {{
	 *  transformButtons: boolean,
	 *  submitHandler: Function,
	 *  cancelRedirectUrl: string,
	 *  disableControlsOnSubmit: boolean,
	 *  trackFormChanges: boolean,
	 *  enableDisablePairs: Object
	 *  }} options options to configure the form handler.
	 */
	$.pkp.controllers.form.FormHandler = function($form, options) {
		var key, validator;

		this.parent($form, options);

		// Check whether we really got a form.
		if (!$form.is('form')) {
			throw new Error(['A form handler controller can only be bound',
				' to an HTML form element!'].join(''));
		}

		// Activate and configure the validation plug-in.
		if (options.submitHandler) {
			this.callerSubmitHandler_ = options.submitHandler;
		}

		// Handle datepicker
		// If we find a .datepicker in tpl the code adds (form/textInput.tpl) a new
		// hidden field with the id = <original-element-id>-altField attr:
		// data-date-format = date format from config.inc.php translated into JQuery
		// Datepicker date format. The <original-element-name> if changed to
		// <original-element-name>-removed in order for the hidden field value to
		// send to post request altField and altFormat are used in order for the user
		// to have its config.inc.php dateFormatShort parameter displayed but
		// 'yy-mm-dd' to be send to post request.
		// [http://api.jqueryui.com/datepicker/#option-altField]
		$form.find('.datepicker').each(function() {
			var $this = $(this);
			$this.datepicker({
				altField: '#' + $this.prop('id') + '-altField',
				altFormat: 'yy-mm-dd',
				dateFormat: $('#' + $this.prop('id') + '-altField')
						.attr('data-date-format')
			});

			$this.prop('name', $this.prop('name') + '-removed');
		});


		// Set the redirect-to URL for the cancel button (if there is one).
		if (options.cancelRedirectUrl) {
			this.cancelRedirectUrl_ = options.cancelRedirectUrl;
		}

		// specific forms may override the form's default behavior
		// to warn about unsaved changes.
		if (typeof options.trackFormChanges !== 'undefined') {
			this.trackFormChanges = options.trackFormChanges;
		}

		// disable submission controls on certain forms.
		if (options.disableControlsOnSubmit) {
			this.disableControlsOnSubmit = options.disableControlsOnSubmit;
		}

		// Items that should enable or disable each other.
		if (options.enableDisablePairs) {
			this.enableDisablePairs_ = options.enableDisablePairs;
			this.setupEnableDisablePairs();
		}
		// Update enable disable pairs state.
		for (key in this.enableDisablePairs_) {
			$form.find("[id^='" + key + "']").trigger('updatePair');
		}

		validator = $form.validate({
			onfocusout: this.callbackWrapper(this.onFocusOutValidation_),
			errorClass: 'error',
			highlight: function(element, errorClass) {
				$(element).parent().parent().addClass(errorClass);
			},
			unhighlight: function(element, errorClass) {
				$(element).parent().parent().removeClass(errorClass);
			},
			submitHandler: this.callbackWrapper(this.submitHandler_),
			showErrors: this.callbackWrapper(this.showErrors),
			errorPlacement: function(error, element) {
				if (element.is(':checkbox')) {
					// place error after checkbox text
					element.parent().closest(':not(label)').append(error);
				} else {
					// default jquery validate placement
					error.insertAfter(element);
				}
			}
		});

		// Activate the cancel button (if present).
		$('[id^=\'cancelFormButton-\']', $form)
				.click(this.callbackWrapper(this.cancelForm));

		$form.find('.showMore, .showLess').bind('click', this.switchViz);

		// Initial form validation.
		if (validator.checkForm()) {
			this.trigger('formValid');
		} else {
			this.trigger('formInvalid');
		}

		this.initializeTinyMCE();

		// bind a handler to make sure tinyMCE fields are populated.
		$('[id^=\'submitFormButton\']', $form).click(this.callbackWrapper(
				this.pushTinyMCEChanges_));

		// bind a handler to handle change events on input fields.
		// 1. For normal inputs...
		$(':input', $form).change(this.callbackWrapper(this.formChange));
		// 2. For other kinds of controls like listbuilders
		this.bind('formChange', this.callbackWrapper(this.formChange));

		// ensure that date picker modals are hidden when clicked away from.
		$form.click(this.callbackWrapper(this.hideDatepicker_));

		this.publishEvent('tinyMCEInitialized');
		this.bind('tinyMCEInitialized', this.tinyMCEInitHandler_);
		this.bind('containerClose', this.containerCloseHandler);
	};
	$.pkp.classes.Helper.inherits(
			$.pkp.controllers.form.FormHandler,
			$.pkp.classes.Handler);


	//
	// Protected properties
	//
	/**
	 * If true, the FormHandler will disable the submit button if the form
	 * successfully validates and is submitted.
	 * @protected
	 * @type {boolean}
	 */
	$.pkp.controllers.form.FormHandler.prototype.disableControlsOnSubmit = false;


	/**
	 * By default, all FormHandler instances and subclasses track changes to
	 * form data.
	 * @protected
	 * @type {boolean}
	 */
	$.pkp.controllers.form.FormHandler.prototype.trackFormChanges = true;


	//
	// Private properties
	//
	/**
	 * If provided, the caller's submit handler, which will be
	 * triggered to save the form.
	 * @private
	 * @type {Function?}
	 */
	$.pkp.controllers.form.FormHandler.prototype.callerSubmitHandler_ = null;


	/**
	 * If provided, the URL to redirect to when the cancel button is clicked
	 * @private
	 * @type {string?}
	 */
	$.pkp.controllers.form.FormHandler.prototype.cancelRedirectUrl_ = null;


	/**
	 * Only submit a track event for this form once.
	 * @type {boolean}
	 */
	$.pkp.controllers.form.FormHandler.prototype.formChangesTracked = false;


	/**
	 * An object containing items that should enable or disable each other.
	 * @private
	 * @type {Object?}
	 */
	$.pkp.controllers.form.FormHandler.prototype.enableDisablePairs_ = null;


	//
	// Public methods
	//
	/**
	 * Internal callback called whenever the validator has to show form errors.
	 *
	 * @param {Object} validator The validator plug-in.
	 * @param {Object} errorMap An associative list that attributes
	 *  element names to error messages.
	 * @param {Array} errorList An array with objects that contains
	 *  error messages and the corresponding HTMLElements.
	 */
	$.pkp.controllers.form.FormHandler.prototype.showErrors =
			function(validator, errorMap, errorList) {
		// ensure that rich content elements have their
		// values stored before validation.
		if (typeof tinyMCE !== 'undefined') {
			tinyMCE.EditorManager.triggerSave();
		}

		// Clone the form validator before checking the entire form.
		// We need to clone otherwise the internal validator error
		// list will be changed when the show errors timeout is
		// executed, showing/hiding the wrong errors.
		var validatorClone = $.extend(true, {}, validator);

		// Show errors generated by the form change.
		// Use a timer so we make sure that concurrent triggered events
		// are handled before the error messages appear in the UI.
		//
		// The main issue  is a click event in cancel buttons while a non
		// valid field is focused. Without the timer, the UI is changed
		// before the click action is complete (the mouse up will not occur in
		// the cancel link, because it will be moved by the error messages).
		setTimeout(this.callbackWrapper(function() {
			validatorClone.defaultShowErrors();
			validatorClone = null;
		}), 250);

		// Emit validation events.
		if (validator.checkForm()) {
			// Trigger a "form valid" event.
			this.trigger('formValid');
		} else {
			// Trigger a "form invalid" event.
			this.trigger('formInvalid');
			this.enableFormControls();
		}
	};


	/**
	 * Internal callback called when a form element changes.
	 *
	 * @param {HTMLElement} formElement The form element that generated the event.
	 * @param {Event} event The formChange event.
	 */
	$.pkp.controllers.form.FormHandler.prototype.formChange =
			function(formElement, event) {
		if (this.trackFormChanges && !this.formChangesTracked) {
			this.formChangesTracked = true;
			this.trigger('formChanged');
		}
	};


	//
	// Protected methods
	//
	/**
	 * Protected method to disable a form's submit control if it is
	 * desired.
	 *
	 * @return {boolean} true.
	 * @protected
	 */
	$.pkp.controllers.form.FormHandler.prototype.disableFormControls =
			function() {

		// We have made it to submission, disable the form control if
		// necessary, submit the form.
		if (this.disableControlsOnSubmit) {
			this.getHtmlElement().find(':submit').attr('disabled', 'disabled').
					addClass('ui-state-disabled');
		}
		return true;
	};


	/**
	 * Protected method to reenable a form's submit control if it is
	 * desired.
	 *
	 * @return {boolean} true.
	 * @protected
	 */
	$.pkp.controllers.form.FormHandler.prototype.enableFormControls =
			function() {

		this.getHtmlElement().find(':submit').removeAttr('disabled').
				removeClass('ui-state-disabled');
		return true;
	};


	/**
	 * Internal callback called to cancel the form.
	 *
	 * @param {HTMLElement} cancelButton The cancel button.
	 * @param {Event} event The event that triggered the
	 *  cancel button.
	 * @return {boolean} false.
	 */
	$.pkp.controllers.form.FormHandler.prototype.cancelForm =
			function(cancelButton, event) {

		this.unregisterForm();
		this.trigger('formCanceled');
		return false;
	};


	/**
	 * Unregister form for tracking changed data.
	 */
	$.pkp.controllers.form.FormHandler.prototype.unregisterForm =
			function() {
		// Trigger the "form canceled" event and unregister the form.
		this.formChangesTracked = false;
		this.trigger('unregisterChangedForm');
	};


	/**
	 * Configures the enable/disable pair bindings between a checkbox
	 * and some other form element.
	 *
	 * @return {boolean} true.
	 */
	$.pkp.controllers.form.FormHandler.prototype.setupEnableDisablePairs =
			function() {

		var formElement = this.getHtmlElement(), key;
		for (key in this.enableDisablePairs_) {
			$(formElement).find("[id^='" + key + "']").bind(
					'click updatePair', this.callbackWrapper(this.toggleDependentElement_));
		}
		return true;
	};


	/**
	 * Internal callback called to submit the form
	 * without further validation.
	 *
	 * @param {Object} validator The validator plug-in.
	 */
	$.pkp.controllers.form.FormHandler.prototype.submitFormWithoutValidation =
			function(validator) {

		// NB: When setting a submitHandler in jQuery's validator
		// plugin then the submit event will always be canceled and our
		// return value will be ignored (see the handle() method in the
		// validator plugin). The only way around this seems to be unsetting
		// the submit handler before calling the submit method again.
		validator.settings.submitHandler = null;
		this.disableFormControls();
		this.getHtmlElement().submit();
		this.formChangesTracked = false;
	};


	/**
	 * Add a class to the spinner to indicate it should be hidden
	 *
	 * @protected
	 */
	$.pkp.controllers.form.FormHandler.prototype.hideSpinner =
			function() {
		this.getHtmlElement()
				.find('.formButtons .pkp_spinner').removeClass('is_visible');
	};


	//
	// Private Methods
	//
	/**
	 * Internal callback called after form validation to handle form
	 * submission.
	 *
	 * NB: Returning from this method without explicitly submitting
	 * the form will cancel form submission.
	 *
	 * @private
	 *
	 * @param {Object} validator The validator plug-in.
	 * @param {HTMLElement} formElement The wrapped HTML form.
	 */
	$.pkp.controllers.form.FormHandler.prototype.submitHandler_ =
			function(validator, formElement) {

		// Notify any nested formWidgets of the submit action.
		var defaultPrevented = false;
		$(formElement).find('.formWidget').each(function() {
			var formSubmitEvent = new $.Event('formSubmitRequested');
			if (!defaultPrevented) {
				$(this).trigger(formSubmitEvent);
				defaultPrevented = formSubmitEvent.isDefaultPrevented();
			}
		});

		// If the default behavior was prevented for any reason, stop.
		if (defaultPrevented) {
			return;
		}

		// For datepicker controls, ensure that empty values are respected.
		$(formElement).find('.datepicker').each(function() {
			if ($(this).prop('value') === '') {
				$('#' + $(this).prop('id') + '-altField').prop('value', '');
			}
		});

		this.showSpinner_();

		this.trigger('unregisterChangedForm');

		if (this.callerSubmitHandler_ !== null) {
			this.formChangesTracked = false;
			// A form submission handler (e.g. Ajax) was provided. Use it.
			this.callbackWrapper(this.callerSubmitHandler_).
					call(validator, formElement);
		} else {
			// No form submission handler was provided. Use the usual method.
			this.submitFormWithoutValidation(validator);
		}
	};


	/**
	 * Internal callback called to push TinyMCE changes back to fields
	 * so they can be validated.
	 *
	 * @param {HTMLElement} submitButton The submit button.
	 * @param {Event} event The event that triggered the
	 *  submit button.
	 * @return {boolean} true.
	 * @private
	 */
	$.pkp.controllers.form.FormHandler.prototype.pushTinyMCEChanges_ =
			function(submitButton, event) {

		// ensure that rich content elements have their
		// values stored before validation.
		if (typeof tinyMCE !== 'undefined') {
			tinyMCE.EditorManager.triggerSave();
		}
		return true;
	};


	/**
	 * Enables or disables the item which depends on the state of source of the
	 * Event.
	 * @param {HTMLElement} sourceElement The element which generated the event.
	 * @param {Event} event The event.
	 * @return {boolean} true.
	 * @private
	 */
	$.pkp.controllers.form.FormHandler.prototype.toggleDependentElement_ =
			function(sourceElement, event) {
		var formElement, elementId, targetElement;

		formElement = this.getHtmlElement();
		elementId = $(sourceElement).attr('id');
		targetElement = $(formElement).find(
				"[id^='" + this.enableDisablePairs_[elementId] + "']");

		if ($(sourceElement).is(':checked')) {
			$(targetElement).prop('disabled', false);
		} else {
			$(targetElement).prop('disabled', true);
		}

		return true;
	};


	/**
	 * Bind a blur handler on tinyMCE instances inside this form
	 * to call form validation on form elements that stores the correspondent
	 * tinyMCE editors.
	 * @private
	 * @param {HTMLElement} input The input element that triggered the
	 * event.
	 * @param {Event} event The tinyMCE initialized event.
	 * @param {Object} tinyMCEObject An array containing the tinyMCE object inside
	 * this multilingual element handler that was initialized.
	 */
	$.pkp.controllers.form.FormHandler.prototype.tinyMCEInitHandler_ =
			function(input, event, tinyMCEObject) {

		var editorId = tinyMCEObject.id;

		tinyMCEObject.on('blur', this.callbackWrapper(function(tinyMCEObject) {
			// Save the current tinyMCE value to the form element.
			tinyMCEObject.save();

			// Get the form element that stores the tinyMCE data.
			var $form = this.getHtmlElement(),
					formElement = $('#' +
					$.pkp.classes.Helper.escapeJQuerySelector(editorId), $form),
					// Validate only this element.
					validator = $form.validate();

			validator.element(formElement);
		}));
	};


	/**
	 * Bind a handler for container (e.g. modal) close events to permit forms
	 * to clean up.blur handler on tinyMCE instances inside this form
	 * @param {HTMLElement} input The input element that triggered the
	 * event.
	 * @param {Event} event The initialized event.
	 * @param {{closePermitted: boolean}} informationObject
	 * @return {boolean} Event handling success.
	 */
	$.pkp.controllers.form.FormHandler.prototype.containerCloseHandler =
			function(input, event, informationObject) {

		var $form = $(this.getHtmlElement());
		// prevent orphaned date pickers that may be still open.
		$form.find('.hasDatepicker').datepicker('hide');
		if (this.formChangesTracked) {
			if (!confirm(pkp.localeKeys['form.dataHasChanged'])) {
				if (informationObject) {
					informationObject.closePermitted = false;
				}
				return false;
			} else {
				this.trigger('unregisterAllForms');
			}
		}

		if (typeof informationObject !== 'undefined') {
			informationObject.closePermitted = true;
		}
		return true;
	};


	/**
	 * Blur event handler, attached to all input fields on this form
	 * by the form validator. It's passed as an option when initializing
	 * the validator.
	 *
	 * It will make sure that fields are always validated on blur. Without this
	 * users can delete data from a required field and move to another one
	 * without receiving any validation alert.
	 * @private
	 * @param {Object} validator Validator.
	 * @param {Object} element Element.
	 * @return {boolean} True so the blur event can still be handled.
	 */
	$.pkp.controllers.form.FormHandler.prototype.onFocusOutValidation_ =
			function(validator, element) {

		var $form = this.getHtmlElement();
		// Make sure the element is still present in form.
		if ($(element).parents('#' + $form.attr('id')).length) {
			validator.element(element);
		}

		return true;
	};


	/**
	 * Hide a date picker if a user clicks outside of the element.
	 * @private
	 * @param {Object} formElement Element.
	 * @param {Event} event The event.
	 */
	$.pkp.controllers.form.FormHandler.prototype.hideDatepicker_ =
			function(formElement, event) {

		var originalEvent, ele, form;

		originalEvent = event.originalEvent;
		if (typeof originalEvent == 'undefined') {
			return;
		}

		ele = originalEvent.target;

		form = this.getHtmlElement();
		if (!$(ele).hasClass('hasDatepicker') &&
				!$(ele).hasClass('ui-datepicker') &&
				!$(ele).hasClass('ui-icon') &&
				!$(ele).hasClass('ui-datepicker-next') &&
				!$(ele).hasClass('ui-datepicker-prev') &&
				!$(ele).parent().parents('.ui-datepicker').length) {
			$(form).find('.hasDatepicker').datepicker('hide');
		}
	};


	/**
	 * Add a class to the spinner to indicate it should be visible
	 *
	 * @private
	 */
	$.pkp.controllers.form.FormHandler.prototype.showSpinner_ =
			function() {
		this.getHtmlElement()
				.find('.formButtons .pkp_spinner').addClass('is_visible');
	};


}(jQuery));
