Skip to content

Instantly share code, notes, and snippets.

@isochronous
Created September 13, 2013 18:49
Show Gist options
  • Save isochronous/6554546 to your computer and use it in GitHub Desktop.
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.
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