/** * @defgroup js_controllers_listbuilder */ /** * @file js/controllers/listbuilder/ListbuilderHandler.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 ListbuilderHandler * @ingroup js_controllers_listbuilder * * @brief Listbuilder row handler. */ (function($) { /** @type {Object} */ $.pkp.controllers.listbuilder = $.pkp.controllers.listbuilder || {}; /** * @constructor * * @extends $.pkp.controllers.grid.GridHandler * * @param {jQueryObject} $listbuilder The listbuilder this handler is * attached to. * @param {Object} options Listbuilder handler configuration. */ $.pkp.controllers.listbuilder.ListbuilderHandler = function($listbuilder, options) { this.parent($listbuilder, options); }; $.pkp.classes.Helper.inherits($.pkp.controllers.listbuilder.ListbuilderHandler, $.pkp.controllers.grid.GridHandler); // // Private properties // /** * The source type (LISTBUILDER_SOURCE_TYPE_...) of the listbuilder. * @private * @type {?number} */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. sourceType_ = null; /** * The "save" URL of the listbuilder (for * LISTBUILDER_SAVE_TYPE_INTERNAL). * @private * @type {?string} */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. saveUrl_ = null; /** * The "save" field name of the listbuilder (for * LISTBUILDER_SAVE_TYPE_EXTERNAL). * @private * @type {?string} */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. saveFieldName_ = null; /** * The "fetch options" URL of the listbuilder (for "select" source type). * @private * @type {?string} */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. fetchOptionsUrl_ = null; /** * Stores the calling context of the edit item click event. * @private * @type {HTMLElement} */ $.pkp.controllers.listbuilder.ListbuilderHandler. prototype.editItemCallingContext_ = null; /** * Flag whether there's still available options to be selected or not. * @private * @type {boolean} */ $.pkp.controllers.listbuilder.ListbuilderHandler. prototype.availableOptions_ = false; // // Protected methods // /** * @inheritDoc */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.initialize = function(options) { this.parent('initialize', options); // Save listbuilder options this.sourceType_ = options.sourceType; this.saveUrl_ = options.saveUrl; this.saveFieldName_ = options.saveFieldName; this.fetchOptionsUrl_ = options.fetchOptionsUrl; this.availableOptions_ = options.availableOptions; // Attach the button handlers var $listbuilder = this.getHtmlElement(); // Use mousedown to avoid two events being triggered at the same time // (click event was being triggered together with blur event from inputs. // That and a syncronous ajax call triggered by those events // handlers, was leading to an error in IE8 and it was freezing // Firefox 13.0). $listbuilder.find('.actions .pkp_linkaction_addItem').mousedown( this.callbackWrapper(this.addItemHandler_)); // Attach the content manipulation handlers this.attachContentHandlers_($listbuilder); // Sign up for notification of form submission. this.bind('formSubmitRequested', this.formSubmitHandler_); // Sign up for notification of form submitted. this.bind('formSubmitted', this.formSubmittedHandler_); }; /** * Get the "save" URL for LISTBUILDER_SAVE_TYPE_INTERNAL. * @private * @return {?string} URL to the "save listbuilder" handler operation. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.getSaveUrl_ = function() { return this.saveUrl_; }; /** * Get the "save" field name for LISTBUILDER_SAVE_TYPE_EXTERNAL. * @private * @return {string} Name of the field to transmit LB contents in. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.getSaveFieldName_ = function() { return /** @type {string} */ (this.saveFieldName_); }; /** * "Save" and close any editing rows in the listbuilder. * @protected */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.closeEdits = function() { var $editedRow = this.getHtmlElement().find('.gridRowEdit:visible'); if ($editedRow.length !== 0) { this.saveRow($editedRow); $editedRow.removeClass('gridRowEdit'); } }; /** * Save the listbuilder. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.save = function() { // Get deletions var deletions = this.getHtmlElement().find('input.deletions').val(), // Get insertions and modifications changes = [], numberOfRows, stringifiedData, saveUrl, saveFieldName, $e, handler = this; this.getHtmlElement().find('.gridRow input.isModified[value="1"]') .each(function(index, v) { var $row = $(v).parents('.gridRow'), params = handler.buildParamsFromInputs_($row.find(':input')); changes.push(params); }); // The listbuilder form validator needs to know if this listbuilder contains // rows or not, so we pass the items number. numberOfRows = this.getRows().length; // Assemble and send to the server stringifiedData = JSON.stringify( {deletions: deletions, changes: changes, numberOfRows: numberOfRows}); saveUrl = this.getSaveUrl_(); if (saveUrl) { // Post the changes to the server using the internal // save handler. $.post(saveUrl, {data: stringifiedData}, this.callbackWrapper(this.saveResponseHandler_, null), 'json'); } else { // Supply the data to an external save handler (e.g. // a form handler) using a hidden field. saveFieldName = this.getSaveFieldName_(); // Try to find and reuse an existing element (if // e.g. a previous attempt was aborted) $e = this.getHtmlElement() .find(':input[type=hidden]') .filter( function() {return $(this).attr('name') == saveFieldName;}) .first(); // If we couldn't find one, create one. if ($e.length === 0) { $e = $(''); $e.attr('name', saveFieldName); this.getHtmlElement().append($e); } // Set the value of the hidden element. $e.attr('value', stringifiedData); } }; /** * Function that will be called to save an edited row. * @param {Object} $row The DOM element representing the row to save. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. saveRow = function($row) { // Retrieve a single new row from the server. // (Avoid IE closure leak using this flag rather than passing // around a DOM element in a closure.) $row.addClass('saveRowResponsePlaceholder'); var params = this.buildParamsFromInputs_($row.find(':input')); params.modify = true; // Flag the row for modification // Use a blocking request to avoid race conditions sometimes // duplicating items, i.e. when editing an existing item after // adding a new one. this.disableControls(); $.ajax({ url: this.getFetchRowUrl(), data: params, success: this.callbackWrapper(this.saveRowResponseHandler_, null), dataType: 'json', async: false }); }; // // Extended methods from GridHandler. // /** * @inheritDoc */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.getEmptyElement = function($element) { // Listbuilders have only one empty element placeholder. return this.getHtmlElement().find('.empty'); }; // // Private Methods // /** * Callback that will be activated when the "add item" icon is clicked * * @private * * @param {Object} callingContext The calling element or object. * @param {Event=} opt_event The triggering event (e.g. a click on * a button. * @return {boolean} Should return false to stop event processing. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.addItemHandler_ = function(callingContext, opt_event) { if (this.availableOptions_) { // Make sure this event will be handled after any other next triggered one, // like blur event that comes from inputs. setTimeout(this.callbackWrapper(function() { // Close any existing edits if necessary this.closeEdits(); this.disableControls(); $.get(this.getFetchRowUrl(), {modify: true}, this.callbackWrapper(this.appendRowResponseHandler_, null), 'json'); }), 0); } return false; }; /** * Callback that will be activated when a delete icon is clicked * * @private * * @param {Object} callingContext The calling element or object. * @param {Event=} opt_event The triggering event (e.g. a click on * a button. * @return {boolean} Should return false to stop event processing. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype.deleteItemHandler_ = function(callingContext, opt_event) { // Close any existing edits if necessary this.closeEdits(); var $callingContext = $(callingContext), $targetRow = $callingContext.closest('.gridRow'), $deletions = $callingContext.closest('.pkp_controllers_listbuilder') .find('.deletions'), rowId = $targetRow.find('input[name="rowId"]').val(); // Append the row ID to the deletions list. if (rowId !== undefined) { $deletions.val($deletions.val() + ' ' + rowId); // Notify containing form (if any) about a change this.getHtmlElement().trigger('formChange'); } this.deleteElement(/** @type {jQueryObject} */ ($targetRow)); this.availableOptions_ = true; return false; }; /** * Callback that will be activated when a request for row appending * returns. * * @private * * @param {Object} ajaxContext The AJAX request context. * @param {Object} jsonData A parsed JSON response object. * @return {boolean} Should return false to stop event processing. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. appendRowResponseHandler_ = function(ajaxContext, jsonData) { var processedJsonData = this.handleJson(jsonData), $newRow; if (processedJsonData !== false) { // Show the new input row; hide the "empty" row $newRow = $(processedJsonData.content); this.getHtmlElement().find('.empty').hide().before($newRow); // Attach content handlers and focus this.attachContentHandlers_($newRow); $newRow.addClass('gridRowEdit'); $newRow.find(':input').not('[type="hidden"]').first().focus(); // If this is a select menu listbuilder, load the options if (this.sourceType_ == $.pkp.cons.LISTBUILDER_SOURCE_TYPE_SELECT) { this.disableControls(); $.get(this.fetchOptionsUrl_, {}, this.callbackWrapper(this.fetchOptionsResponseHandler_, null), 'json'); } else { this.enableControls(); } this.callFeaturesHook('addElement', $newRow); } return false; }; /** * Callback that will be activated when a set of options is returned * from the server for a new select control. * * @private * * @param {Object} ajaxContext The AJAX request context. * @param {Object} jsonData A parsed JSON response object. * @return {boolean} Should return false to stop event processing. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. fetchOptionsResponseHandler_ = function(ajaxContext, jsonData) { // Find the currently editable select menu and fill var pjd = this.handleJson(jsonData), $listbuilder = this.getHtmlElement(), selectedValues = [], $selectInput, i, limit, $pulldown, $container, optionsCount, j, $option, label, $optgroup, k, optionsInsideGroup, $lastElement; if (pjd !== false) { // Get the list of already-selected options, to ensure // that we don't offer duplicates. $listbuilder.find('.gridCellDisplay :input').each(function(i, selected) { selectedValues[i] = $(selected).val(); }); // Get the currently available input row's elements $selectInput = $listbuilder.find( '.gridRowEdit:visible .selectMenu:input' ); // For each pulldown (generally 1), add options. for (i = 0, limit = $selectInput.length; i < limit; i++) { // Fetch some useful properties $pulldown = $($selectInput[i]); $container = $pulldown.parents('.gridCellContainer'); // Add the options, noting the currently selected index optionsCount = 0; $pulldown.children().empty(); j = null; for (j in pjd.content[i]) { // Ignore optgroup labels. if (j == $.pkp.cons.LISTBUILDER_OPTGROUP_LABEL) { continue; } if (typeof(pjd.content[i][j]) == 'object') { // Options must go inside an optgroup. // Check if we have optgroup label data. if ( pjd. content[i][$.pkp.cons.LISTBUILDER_OPTGROUP_LABEL] === undefined) { continue; } if (typeof( pjd.content[i][$.pkp.cons.LISTBUILDER_OPTGROUP_LABEL] ) != 'object') { continue; } label = pjd.content[i][$.pkp.cons.LISTBUILDER_OPTGROUP_LABEL][j]; if (!label) { continue; } $optgroup = $(''); $optgroup.attr('label', label); $pulldown.append($optgroup); k = null; optionsInsideGroup = 0; for (k in pjd.content[i][j]) { // Populate the optgroup. $option = this.populatePulldown_($optgroup, selectedValues, pjd.content[i][j][k], k); if ($option) { optionsCount++; optionsInsideGroup++; } } // Avoid inserting optgroups that have no option. if (optionsInsideGroup === 0) { $optgroup.remove(); } } else { // Just insert the current option. $option = this.populatePulldown_($pulldown, selectedValues, pjd.content[i][j], j); if ($option) { optionsCount++; } } } $lastElement = $option; // If only one element is available, select it. if (optionsCount === 1 && $lastElement) { $lastElement.attr('selected', 'selected'); this.availableOptions_ = false; } // If no options are available for this select menu, // hide the input to prevent empty dropdown. if (optionsCount === 0) { $container.find('.gridCellDisplay').show(); $container.find('.gridCellEdit').hide(); } } } this.enableControls(); return false; }; /** * Populate the pulldown with options. * @private * @param {jQueryObject} $element The element to be populated. * Can be a pulldown or an optgroup inside the pulldonw. * @param {Object} selectedValues Current listbuilder * selected values. * @param {string} optionText The text to populate the pulldown with. * @param {string} optionValue The key to populate the pulldown with. * @return {Object|boolean} Return the inserted option or false. */ $.pkp.controllers.listbuilder.ListbuilderHandler.prototype. populatePulldown_ = function( $element, selectedValues, optionText, optionValue) { var $container = $element.parents('.gridCellContainer'), currentIndex = $container.find('.gridCellDisplay :input').val(), isDuplicate = false, k, $option; // Check to see if this option is already in the LB. if (optionValue != currentIndex) { // If it's the current row, don't consider it a duplicate for (k = 0; k < selectedValues.length; k++) { if (selectedValues[k] == optionValue) { isDuplicate = true; } } } if (!isDuplicate) { // Create and populate the option node $option = $('