Created
September 13, 2013 18:49
-
-
Save isochronous/6554546 to your computer and use it in GitHub Desktop.
This is just a widget I wrote (won't have any meaning outside of the project I'm working on) that I put up to show an example of how I write jQuery widgets, and contains some optimizations to make it as performant as possible by minimizing DOM operations.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
define([ | |
'jquery', | |
'underscore', | |
'text!./skeletonMarkup.html', | |
'jqueryui/widget', | |
'jqueryui/sortable', | |
'jqueryui/spinner' | |
], function ($, _, markup) { | |
'use strict'; | |
/** | |
* @module jquery.contentconfig | |
* @augments jquery | |
* @exports jquery | |
* @requires jquery | |
* @requires underscore | |
* @requires jqueryui/widget | |
* @requires jqueryui/sortable | |
* @requires jqueryui/spinner | |
*/ | |
$.widget('triton.contentconfig', { | |
options: { | |
'debug': true, | |
'defaultDescriptionLength': 500, | |
'defaults': [], | |
'initCallback': null, | |
'languageId': 17, | |
'logger': console, | |
'modelPropertyPrefix': 'ugcsubmissiontype', | |
'rowAddedCallback': null, | |
'rowRemovedCallback': null, | |
'sortableOptions': { | |
'axis': 'y', | |
'containment': 'parent', | |
'handle': '.contentconfig-sort-handle', | |
'revert': true | |
}, | |
'state': {}, | |
'typeIconClasses': { | |
'photo': 'icon-camera', | |
'video': 'icon-film', | |
'audio': 'icon-microphone', | |
'text': 'icon-font' | |
}, | |
'typeMap': { | |
'photo': 1, | |
'video': 2, | |
'audio': 3, | |
'text': 4 | |
} | |
}, | |
_create: function () { | |
// Define our node references | |
var n = this.nodes = {}; | |
n.root = $(this.element); | |
n.wrapper = $(markup).appendTo(n.root); | |
n.buttonBar = n.wrapper.find('#contentconfig-content-bar'); | |
n.list = n.wrapper.find('#contentconfig-item-list'); | |
n.typeInput = n.wrapper.find('#contentconfig-contest-type'); | |
// Clone and save the empty row, we'll use it as a template to create new rows | |
this.rowTemplate = n.list.find('li').detach(); | |
// Negative numbers are used to represent fields that haven't been saved yet. | |
// -1 is used as the default row on new contests, so start at -2 and go down from there. | |
this.mostRecentId = -2; | |
// Create a clone of the state option | |
this.state = _.clone(this.options.state); | |
this._addHandlers(); | |
}, | |
_init: function () { | |
var that = this; | |
var n = this.nodes; | |
var opts = this.options; | |
var state = this.state; | |
this._syncMostRecentId(); | |
// Start off with an empty list | |
n.list.empty(); | |
// Use this to track the current set of inputs in the list so we don't have to crawl the whole thing | |
// whenever we scrape it for values - we'll add to and remove from this list per row instead of regenerating | |
// the whole thing every time the list changes | |
n.allInputs = $(); | |
// Now get all of the defaults for the current language | |
this.defaults = _(opts.defaults).where({'languageid': opts.languageId}); | |
var sortIterator = function (val){ | |
return val.sortorder; | |
}; | |
if (state && !_.isEmpty(state)) { | |
_(state) | |
.chain() | |
.sortBy(sortIterator) | |
.each(function (data) { | |
that._addNewRow(data); | |
}); | |
} | |
if (n.list.is(':ui-sortable')) { | |
n.list.sortable('refresh'); | |
} else { | |
n.list.sortable(opts.sortableOptions); | |
} | |
this._updateSortOrder(); | |
if (this.nodes.root.is('.ui-state-disabled')) { | |
this.disable(); | |
} | |
this._trigger('initialized'); | |
if (opts.initCallback && _.isFunction(opts.initCallback)) { | |
opts.initCallback(this); | |
} | |
}, | |
_syncMostRecentId: function () { | |
var id = this.mostRecentId; | |
_(this.state).chain() | |
.pluck('usergeneratedcontestsubmissiontypeid') | |
.each(function(value) { | |
if (+value <= id) { | |
id = value-1; | |
} | |
}); | |
this.mostRecentId = id; | |
}, | |
/** | |
* | |
* @param key | |
* @param value | |
* @private | |
*/ | |
_setOption: function (key, value) { | |
var list = this.nodes.list; | |
this._super(key, value); | |
switch (key) { | |
// purposefully falling through here | |
case "state": | |
case "languageId": | |
case "defaults": | |
case "modelPropertyPrefix": | |
case "typeMap": | |
this._init(); | |
break; | |
case "sortableOptions": | |
list.sortable(this.options.sortableOptions); | |
break; | |
} | |
}, | |
/** Adds the handlers for adding new rows and deleting existing rows. */ | |
_addHandlers: function () { | |
var n = this.nodes; | |
this._on(n.list, { | |
'click .contentconfig-remove-item': this._removeRow, | |
'sortstop': this._updateSortOrder, | |
'change input': this._triggerUpdate | |
}); | |
this._on(n.buttonBar, { | |
'click button': this._addNewRow | |
}); | |
}, | |
_triggerUpdate: function () { | |
this._trigger('updated'); | |
}, | |
/** | |
* Gets an ID to assign to a new row. The "default" entry passed along for new contests should always have an | |
* id of -1, as rows that have not yet been saved always have negative IDs, so any new entries past that default | |
* should start at -2 and just get more negative from there. | |
* @returns {number} | |
*/ | |
_getIdForNewRow: function () { | |
return this.mostRecentId--; | |
}, | |
/** | |
* Dual purpose method here: one is just a simple method that _init can call for loading a previously configured | |
* row from state, the other usage is as an event handler from the "add a new row" buttons. Tests to see if the | |
* param passed to it is an event or not to switch between modes. What it actually *does* is to just get the | |
* right parameters to pass to the `_configureAndInsertRow` method, then passes them along. | |
* @param {$.Event|object} e - either an event or an entry from the state object | |
*/ | |
_addNewRow: function (e) { | |
var type; | |
if (e instanceof $.Event) { | |
var $btn = $(e.target); | |
$btn = $btn.is('button') ? $btn : $btn.closest('button'); | |
type = $btn.data('mediaType'); | |
this._configureAndInsertRow(type, {}); | |
// We only scrape & pack data in this case, because in the other case we want to wait until all of the | |
// rows are initialized before scraping/packing to improve performance | |
this._triggerUpdate(); | |
} else if (_.isObject(e)) { | |
type = _.invert(this.options.typeMap)[e.submissiontypeid]; | |
this._configureAndInsertRow(type, e); | |
} else { | |
this._error("unknown argument type passed to _addNewRow: ", e); | |
} | |
}, | |
/** | |
* Creates and configures a row with the provided type and rowData, and adds it to the list. Uses the | |
* provided defaults for the specified language when adding a new row. | |
* @param {string} type - The type of the new row, e.g. "photo", "video", etc. | |
* @param {{ | |
* usergeneratedcontestsubmissiontypeid: number, | |
* submissiontypeid: number, | |
* itemtitle: string, | |
* allowdescription: boolean, | |
* itemdescription: string, | |
* isrequired: boolean, | |
* sortorder: number, | |
* defaultimage: boolean, | |
* descriptionlength: number, | |
* isdeleted: boolean | |
* }|{}} rowData - The data with which to configure the new row | |
*/ | |
_configureAndInsertRow: function (type, rowData) { | |
var opts = this.options; | |
var n = this.nodes; | |
// Get the defaults for this particular submission type - we've already filtered for languageId | |
var defaults = _(this.defaults).findWhere({'submissiontypeid': opts.typeMap[type]}); | |
var list = n.list; | |
var existingRows = list.find('li'); | |
var newId = rowData.usergeneratedcontestsubmissiontypeid || this._getIdForNewRow(); | |
var $row = this.rowTemplate.clone(); | |
var $allInputs = $row.find('input'); | |
var $rowId = $row.find('input[data-id="id"]'); | |
var $rowIcon = $row.find('.contentconfig-type-icon'); | |
var $rowTitle = $row.find('.contentconfig-item-title input'); | |
var $rowDefaultImage = $row.find('[data-id="defaultimage"]'); | |
var $rowLength = $row.find('.contentconfig-text-length input'); | |
var $rowLengthToolTip = $rowLength.siblings('a.ui-tooltip-icon'); | |
var $rowRequired = $row.find('.contentconfig-required-chk input'); | |
var $rowOrder = $row.find('input[data-id="order"]'); | |
var $rowType = $row.find('input[data-id="type"]'); | |
// First fix all input names to integrate the new item's ID | |
$allInputs.attr('data-api-field', function (i, currVal) { | |
return currVal.replace('{0}', newId) | |
}); | |
$allInputs.filter('[name]').attr('name', function (i, currVal) { | |
return currVal.replace('{0}', newId) | |
}); | |
// set the input for the duplicate enumeration property | |
$rowId.val(newId); | |
// Set the sort order | |
$rowOrder.val(rowData.sortorder || (existingRows.length + 1)); | |
// Set the "defaultimage" state | |
$rowDefaultImage.val(existingRows.length === 0); | |
// Set the type ID | |
$rowType.val(opts.typeMap[type]); | |
// show the correct icon for the type | |
$rowIcon.addClass(opts.typeIconClasses[type]); | |
// Set the itemtitle in the text input | |
// use ' ' as the final default instead of empty string so it doesn't evaluate to false | |
$rowTitle.val(rowData.itemtitle || defaults.itemtitle || ' '); | |
// Check the required checkbox | |
$rowRequired.prop('checked', !!rowData.isrequired || !!defaults.isrequired); | |
// Hide the "text length" field for non-text items | |
// For text items, set the input value | |
var length = rowData.descriptionlength || defaults.descriptionlength || opts.defaultDescriptionLength; | |
if (type === 'text') { | |
$rowLength.val(length); | |
this._triggerValidationAddition($row); | |
} else { | |
$allInputs = $allInputs.not($rowLength); | |
$rowLength.remove(); | |
$rowLengthToolTip.remove(); | |
} | |
$row.appendTo(list); | |
this._log("row added to UGC submission type widget: ", $row); | |
// We can't assume that this will be sortable already (in case this is widget init) but we have to check | |
// because if it's not widget init then nothing else is going to refresh sortable | |
if (list.is(':ui-sortable')) { | |
list.sortable('refresh'); | |
} | |
if (opts.rowAddedCallback && _.isFunction(opts.rowAddedCallback)) { | |
opts.rowAddedCallback($row, this); | |
} | |
// Now add the inputs in this row to the `allInputs` collection in the `nodes` object | |
n.allInputs = n.allInputs.add($allInputs); | |
this._updateContestType(); | |
}, | |
_triggerValidationAddition: function ($row) { | |
var $input = $row.find('[name]'); | |
var data = {}; | |
data.name = $input.attr('name'); | |
data.label = 'Entry Character Limit'; | |
data.config = { | |
'required': true, | |
'min': 0 | |
}; | |
this._trigger('addtovalidationconfig', null, data); | |
}, | |
/** | |
* Removes the row containing the clicked element | |
* @param {$.Event} e - the click event | |
*/ | |
_removeRow: function (e) { | |
var $el = $(e.target); | |
var $row = $el.closest('li'); | |
var $requiredEl = $row.find('[name]'); | |
if ($requiredEl.length) { | |
this._triggerValidationRemoval($requiredEl); | |
} | |
$row.detach(); | |
var $inputs = $row.find('input'); | |
var $allInputs = this.nodes.allInputs; | |
var cb = this.options.rowRemovedCallback; | |
if (cb && _.isFunction(cb)) { | |
cb($row, this); | |
} | |
// Make sure we remove the inputs in this row from the `allInputs` collection | |
this.nodes.allInputs = $allInputs.not($inputs); | |
$row.remove(); | |
this._updateContestType(); | |
this._updateSortOrder(); | |
}, | |
_triggerValidationRemoval: function ($input) { | |
this._trigger('removefromvalidationconfig', null, $input.attr('name')); | |
}, | |
/** | |
* Callback for the sortable widget's `sort` event. Just finds the hidden 'sort order' input in each row after | |
* sorting and updates the input's value to match the new order of items. Also turns on/off the "defaultimage" | |
* value on each row - it's true for the very first item and false for all others. | |
*/ | |
_updateSortOrder: function () { | |
var $items = this.nodes.list.find('li'); | |
if ($items.length) { | |
$items.each(function (ind) { | |
var $el = $(this); | |
var $sortOrderInput = $el.find('[data-id="order"]'); | |
var $defaultImageInput = $el.find('[data-id="defaultimage"]'); | |
$sortOrderInput.val(ind+1); | |
$defaultImageInput.val(ind === 0); | |
}); | |
} | |
this._triggerUpdate(); | |
}, | |
/** | |
* Finds all of the named elements within the list, creates an object that stores their values via keys | |
* of the same name, and then embeds the object using jQuery's `data` method into the root element. | |
* We reduce the cost of this method by keeping the `allInputs` collection up-to-date so that this method | |
* doesn't have to do any DOM scraping, it's already got direct references to all of the elements needed. | |
*/ | |
_scrapeAndPackData: function () { | |
var $allInputs = this.nodes.allInputs; | |
var dataObj = {}; | |
$allInputs.each(function () { | |
var $el = $(this); | |
var name = $el.attr('data-api-field'); | |
dataObj[name] = ($el.is('[type="checkbox"]')) ? $el.is(':checked') : $el.val(); | |
}); | |
this.state = dataObj; | |
this._log("new state is ", dataObj); | |
this._trigger('packed'); | |
}, | |
_updateContestType: function () { | |
var $typeInput = this.nodes.typeInput; | |
var $allRowTypes = this.nodes.allInputs.filter('[data-id="type"]'); | |
if ($allRowTypes.length === 0) { | |
$typeInput.val(''); | |
return; | |
} | |
var $firstInput = $allRowTypes.first(); | |
var type = $firstInput.val(); | |
$allRowTypes.each(function () { | |
var $el = $(this); | |
// weak comparison on purpose so int strings will match ints | |
if ($el.val() != type) { | |
type = 5; | |
return false; // breaks the loop | |
} | |
}); | |
$typeInput.val(type).trigger('change'); | |
}, | |
_log: function () { | |
var args = Array.prototype.slice.call(arguments); | |
var opts = this.options; | |
opts.debug && opts.logger.log.apply(opts.logger, args); | |
}, | |
_error: function () { | |
var args = Array.prototype.slice.call(arguments); | |
var opts = this.options; | |
opts.debug && opts.logger.error.apply(opts.logger, args); | |
}, | |
/** | |
* Gets or sets the value of the widget | |
* @param {object} val - If setting the value, this should be the value of the ugcsubmissiontype model attr | |
* @returns {undefined|state} | |
*/ | |
value: function (val) { | |
if (val) { | |
delete this.state; | |
this.state = val; | |
this._init(); | |
} else { | |
this._scrapeAndPackData(); | |
return this.state; | |
} | |
}, | |
disable: function () { | |
this._super(); | |
this.nodes.list.sortable('disable'); | |
$(this.element).find('.remove, input, button') | |
.prop({'disabled': true, 'aria-disabled': true}) | |
.addClass('disabled ui-state-disabled'); | |
this._log("contentconfig widget disabled"); | |
}, | |
enable: function () { | |
this._super(); | |
this.nodes.list.sortable('enable'); | |
this.nodes.root.find('.remove, input, button') | |
.prop({'disabled': false, 'aria-disabled': false}) | |
.removeClass('disabled ui-state-disabled'); | |
this._log("contentconfig widget enabled"); | |
}, | |
destroy: function () { | |
var n = this.nodes; | |
this._off(n.list, 'click sortstop change'); | |
this._off(n.buttonBar, 'click'); | |
n.list.find('[name]').each(function (ind, el) { | |
this._triggerValidationRemoval($(el)); | |
}.bind(this)); | |
this.nodes.wrapper.remove(); | |
this.nodes.root.data('submissionTypeData', null); | |
delete this.state; | |
delete this.nodes; | |
delete this.rowTemplate; | |
delete this.mostRecentId; | |
} | |
}); | |
return $; | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment