/**
* @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 = $('<input type="hidden" />');
$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></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 = $('<option/>');
$option.attr('value', optionValue);
$option.text(optionText);
if (optionValue == currentIndex) {
$option.attr('selected', 'selected');
}
$element.append($option);
return $option;
} else {
return false;
}
};
/**
* Callback that will be activated when a row is clicked for editing
*
* @private
*
* @param {HTMLElement} 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.editItemHandler_ =
function(callingContext, opt_event) {
// Close any existing edits if necessary
this.closeEdits();
this.editItemCallingContext_ = callingContext;
// Show inputs; hide display. IE8 is slow, and it will execute
// this before the timeout setted in inputBlurHandler_. Insert this
// code inside a timeout too to avoid closing inputs that are not
// meant to.
setTimeout(this.callbackWrapper(function() {
var $targetRow = $(this.editItemCallingContext_).closest('.gridRow');
$targetRow.addClass('gridRowEdit');
$targetRow.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');
}
}), 0);
return false;
};
/**
* Helper function to turn a row into an array of parameters used
* to generate the DOM representation of that row when bounced
* off the server.
*
* @private
*
* @param {Object} $inputs The grid inputs to mine for parameters.
* @return {Object} A name: value association of relevant parameters.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
buildParamsFromInputs_ = function($inputs) {
var params = {};
$.each($inputs.serializeArray(), function(k, v) {
var name = v.name,
value = v.value;
params[name] = params[name] === undefined ? value :
$.isArray(params[name]) ? params[name].concat(value) :
[params[name], value];
});
return params;
};
/**
* Callback that will be activated upon keystroke in a new input field
* to check for a <cr> (acts as tab-to-next, or if no next, submit).
*
* @private
*
* @param {HTMLElement} callingContext The calling element or object.
* @param {Event=} opt_event The triggering event.
* @return {boolean} Should return false to stop event processing.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
inputKeystrokeHandler_ = function(callingContext, opt_event) {
var CR_KEY = 13, TAB_KEY = 9,
$target, $row, $inputs, i;
if (opt_event.which == CR_KEY) {
$target = $(callingContext);
$row = $target.parents('.gridRow');
$inputs = $row.find(':input:visible');
i = $inputs.index($target);
if ($inputs.length == i + 1) {
this.saveRow($row);
return false; // Prevent default
} else {
// Not the last field. Tab to the next.
$inputs[i + 1].focus();
return false; // Prevent default
}
}
return true;
};
/**
* Callback that will be activated when a new/modifying row's input
* field is blurred to check whether or not to save the row.
*
* @private
*
* @param {HTMLElement} callingContext The calling element or object.
* @param {Event=} opt_event The triggering event.
* @return {boolean} Should return false to stop event processing.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
inputBlurHandler_ = function(callingContext, opt_event) {
// Flag currently selected input using a CSS class. (Don't
// want to pass it into the closure because of the IE memory
// leak bug.)
$(callingContext).closest('.gridRow').addClass('editingRowPlaceholder');
// Check to see whether the row has lost focus after this event has
// been processed.
setTimeout(this.callbackWrapper(function() {
var $editingRow = $('.editingRowPlaceholder'),
found = false;
$editingRow.find(':input').each(function(index, elem) {
if (elem === document.activeElement) {
found = true;
}
});
// Clean up extra placeholder class.
$editingRow.removeClass('editingRowPlaceholder');
// If the focused element isn't within the current row, save.
if (!found) {
this.closeEdits();
}
}), 0);
return true;
};
/**
* Callback to replace a grid row's content.
*
* @private
*
* @param {Object} ajaxContext The AJAX request context.
* @param {Object} jsonData A parsed JSON response object.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
saveRowResponseHandler_ = function(ajaxContext, jsonData) {
var processedJsonData = this.handleJson(jsonData),
$newContent, rowId;
if (processedJsonData !== false) {
// Unfortunately we can't use a closure to get this from
// the calling context. Use a class flag "saveRowResponsePlaceholder".
// (Risks IE closure/DOM element memory leak.)
$newContent = $(processedJsonData.content);
// Store current row id.
rowId = /** @type {string} */ (this.getHtmlElement()
.find('.saveRowResponsePlaceholder').attr('id'));
// Add to the DOM
this.getHtmlElement().find('.saveRowResponsePlaceholder').
replaceWith($newContent);
// Make sure row id won't change.
$newContent.attr('id', rowId);
// Attach handlers for content manipulation
this.attachContentHandlers_($newContent);
this.callFeaturesHook('replaceElement', $newContent);
}
// Ensure that containing forms are notified of the changed data
this.getHtmlElement().trigger('formChange');
this.enableControls();
};
/**
* Callback after a save response returns from the server.
*
* @private
*
* @param {Object} ajaxContext The AJAX request context.
* @param {Object} jsonData A parsed JSON response object.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
saveResponseHandler_ = function(ajaxContext, jsonData) {
// Noop
};
/**
* Attach content handlers to all "click-to-edit" content within
* the provided context.
*
* @private
*
* @param {Object} $context The JQuery object to search for attachables.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.prototype.
attachContentHandlers_ = function($context) {
// Attach click handler for text fields and select menus
$context.find('.gridCellDisplay').click(
this.callbackWrapper(this.editItemHandler_));
// Attach keypress handler for text fields
$context.find(':input')
.keypress(this.callbackWrapper(this.inputKeystrokeHandler_))
.blur(this.callbackWrapper(this.inputBlurHandler_));
// Attach deletion handler
$context.find('.pkp_linkaction_delete').click(
this.callbackWrapper(this.deleteItemHandler_));
};
/**
* Save the Listbuilder's contents upon a "form submitted" event.
* @private
*
* @param {$.pkp.controllers.form.AjaxFormHandler} callingForm The form
* that triggered the event.
* @param {Event} event The event.
* @return {boolean} False if the form submission should abort.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.
prototype.formSubmitHandler_ = function(callingForm, event) {
// Save the contents
this.save();
// Prevent the submission of LB elements to the parent form
// (except potentially for :input[name='getSaveFieldName()'])
this.getHtmlElement().find('.gridRow :input').attr('disabled', 'disabled');
// Continue the default (form submit) behavior
return true;
};
/**
* Enable deactivated inputs.
* @private
*
* @param {$.pkp.controllers.form.AjaxFormHandler} callingForm The form
* that triggered the event.
* @param {Event} event The event.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.
prototype.formSubmittedHandler_ = function(callingForm, event) {
this.getHtmlElement().find('.gridRow :input').removeAttr('disabled');
};
/**
* Disable the add_* links and show the spinner before making AJAX calls.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.
prototype.disableControls = function() {
this.getHtmlElement().
find('span[class="options"] > a[id*="addItem"]').unbind('mousedown');
this.getHtmlElement().find('span[class="options"] > a[id*="addItem"]')
.mousedown(function() {return false;});
this.getHtmlElement().find('.h3').addClass('spinner');
};
/**
* Re-enable the add_* links and hide the spinner.
*/
$.pkp.controllers.listbuilder.ListbuilderHandler.
prototype.enableControls = function() {
// rebind our 'click' handler so we can add another item
// if needed
this.getHtmlElement().find('span[class="options"] > a[id*="addItem"]').
mousedown(this.callbackWrapper(this.addItemHandler_));
this.getHtmlElement().find('.h3').removeClass('spinner');
};
}(jQuery));
|