getting the most out of jquery widgets
DESCRIPTION
Richard Lindsey's presentation from the 2013 jQuery Conference in Austin, Tx.TRANSCRIPT
Richard Lindsey @Velveeta http://conqueringtheclient.com/PLATFORM FEE ARCHITECT | THE ADVISORY BOARD COMPANY
jQuery Widgets
GETTING THE MOST OUT OF
Let’s say we’re making Widgets…
Richard Lindsey @Velveeta http://conqueringtheclient.com/
What’s a Widget?
Richard Lindsey @Velveeta http://conqueringtheclient.com/
ELEMENTS / COMPOUNDS /CELLS / ORGANISMS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Thinksmall.Thinkmodular.
Communicate through
events.KEEP COMPONENTS DECOUPLED / MAKE THEM SUBSCRIBE AND RESPOND
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Communicate through
events.KEEP COMPONENTS DECOUPLED / MAKE THEM SUBSCRIBE AND RESPOND
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Observe and
mediate.BUNDLE SMALLER MODULES / PROVIDE PUBLIC API / DIRECT REFERENCES SHOULD ONLY GO DOWNWARDS / EACH LAYER CONSUMES LOWER-LEVEL EVENTS & PUBLISHES UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Observe and
mediate.BUNDLE SMALLER MODULES / PROVIDE PUBLIC API / DIRECT REFERENCES SHOULD ONLY GO DOWNWARDS / EACH LAYER CONSUMES LOWER-LEVEL EVENTS & PUBLISHES UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Observe and
mediate.BUNDLE SMALLER MODULES / PROVIDE PUBLIC API / DIRECT REFERENCES SHOULD ONLY GO DOWNWARDS / EACH LAYER CONSUMES LOWER-LEVEL EVENTS & PUBLISHES UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Observe and
mediate.BUNDLE SMALLER MODULES / PROVIDE PUBLIC API / DIRECT REFERENCES SHOULD ONLY GO DOWNWARDS / EACH LAYER CONSUMES LOWER-LEVEL EVENTS & PUBLISHES UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
$.widget(‘abc.autocomplete’, {_create: function () {
this._widgets = {dataloader: {loader:{}},optionlist: {results:{}},input: {search:{}}
};this._createWidgets();this._routeTraffic();
},_routeTraffic: function () {
this._on(this.element, { autocompletesuccess: this._showOptionList });this._on(this._widgets.search, { inputkeydown: _.debounce(this._updateDataloaderSearchParam, 100) });this._on(this._widgets.results, { optionlistselected: this._updateInput });
},_updateDataloaderSearchParam: function (e, search) {
var deferred = this._widgets.loader .dataloader(‘updateParams’, ‘search’, search) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},_showOptionList: function () {
this._widgets.results.optionlist(‘show);this._trigger(‘showresults’);
},_updateInput: function (e, value) {
this._widgets.search.input(‘setValue’, value);this._trigger(‘change’, value);
},setData: function (data) {
var deferred = this._widgets.loader .dataloader(‘setData’, data) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},setValue: function (value) {
this._updateInput(null, value);}
});
$(function () {$(‘abc-autocomplete’).autocomplete();
});
$.widget(‘abc.autocomplete’, {_create: function () {
this._widgets = {dataloader: {loader:{}},optionlist: {ddl:{}},input: {search:{}}
};this._createWidgets();this._routeTraffic();
},_routeTraffic: function () {
this._on(this.element, { autocompletesuccess: this._showOptionList });this._on(this._widgets.search, { inputkeydown: _.debounce(this._updateDataloaderSearchParam, 100) });this._on(this._widgets.results, { optionlistselected: this._updateInput });
},_updateDataloaderSearchParam: function (e, search) {
var deferred = this._widgets.loader .dataloader(‘updateParams’, ‘search’, search) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},_showOptionList: function () {
this._widgets.results.optionlist(‘show);this._trigger(‘showresults’);
},_updateInput: function (e, value) {
this._widgets.search.input(‘setValue’, value);this._trigger(‘change’, value);
},setData: function (data) {
var deferred = this._widgets.loader .dataloader(‘setData’, data) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},setValue: function (value) {
this._updateInput(null, value);}
});
$(function () {$(‘abc-autocomplete’).autocomplete();
});
$.widget(‘abc.autocomplete’, {_create: function () {
this._widgets = {dataloader: {loader:{}},optionlist: {ddl:{}},input: {search:{}}
};this._createWidgets();this._routeTraffic();
},_routeTraffic: function () {
this._on(this.element, { autocompletesuccess: this._showOptionList });this._on(this._widgets.search, { inputkeydown: _.debounce(this._updateDataloaderSearchParam, 100) });this._on(this._widgets.results, { optionlistselected: this._updateInput });
},_updateDataloaderSearchParam: function (e, search) {
var deferred = this._widgets.loader .dataloader(‘updateParams’, ‘search’, search) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},_showOptionList: function () {
this._widgets.results.optionlist(‘show);this._trigger(‘showresults’);
},_updateInput: function (e, value) {
this._widgets.search.input(‘setValue’, value);this._trigger(‘change’, value);
},setData: function (data) {
var deferred = this._widgets.loader .dataloader(‘setData’, data) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},setValue: function (value) {
this._updateInput(null, value);}
});
$(function () {$(‘abc-autocomplete’).autocomplete();
});
$.widget(‘abc.autocomplete’, {_create: function () {
this._widgets = {dataloader: {loader:{}},optionlist: {ddl:{}},input: {search:{}}
};this._createWidgets();this._routeTraffic();
},_routeTraffic: function () {
this._widgets.loader.on(‘dataloadersuccess’, this._updateDropdownlist);this._widgets.ddl.on(‘dropdownlistselected’, this._updateInput);this._widgets.search.on(‘inputkeydown’, _.debounce(this._updateDataloaderSearchParam, 300));
},_updateDataloaderSearchParam: function (e, search) {
var deferred = this._widgets.loader .dataloader(‘updateParams’, ‘search’, search) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},_showOptionList: function () {
this._widgets.results.optionlist(‘show);this._trigger(‘showresults’);
},_updateInput: function (e, value) {
this._widgets.search.input(‘setValue’, value);this._trigger(‘change’, value);
},setData: function (data) {
var deferred = this._widgets.loader .dataloader(‘setData’, data) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},setValue: function (value) {
this._updateInput(null, value);}
});
$(function () {$(‘abc-autocomplete’).autocomplete();
});
$.widget(‘abc.autocomplete’, {_create: function () {
this._widgets = {dataloader: {loader:{}},optionlist: {ddl:{}},input: {search:{}}
};this._createWidgets();this._routeTraffic();
},_routeTraffic: function () {
this._widgets.loader.on(‘dataloadersuccess’, this._updateDropdownlist);this._widgets.ddl.on(‘dropdownlistselected’, this._updateInput);this._widgets.search.on(‘inputkeydown’, _.debounce(this._updateDataloaderSearchParam, 300));
},_updateDataloaderSearchParam: function (e, search) {
var deferred = this._widgets.loader .dataloader(‘updateParams’, ‘search’, search) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},_showOptionList: function () {
this._widgets.results.optionlist(‘show);this._trigger(‘showresults’);
},_updateInput: function (e, value) {
this._widgets.search.input(‘setValue’, value);this._trigger(‘change’, value);
},setData: function (data) {
var deferred = this._widgets.loader .dataloader(‘setData’, data) .dataloader(‘fetch’);
this._trigger(‘fetch’, deferred);},setValue: function (value) {
this._updateInput(null, value);}
});
$(function () {$(‘abc-autocomplete’).autocomplete();
});
Richard Lindsey @Velveeta http://conqueringtheclient.com/
BAD IDEAAHEAD
Decorate ALL thefunction
s!
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Richard Lindsey @Velveeta http://conqueringtheclient.com/
MODIFY THE FACTORY FUNCTION IF YOU NEED TO
Decorate ALL thefunction
s!
var widgetFactory = $.widget;
$.widget = function (name, base, prototype) {var targetPrototype = prototype || base;
$.each(targetPrototype, function (key, callback) {if (typeof callback === ‘function’) {
targetPrototype[key] = function () {if (someConditionPasses) {fireSomeFunction();}
var result = callback.apply(this, arguments);
if (someOtherConditionPasses) {fireSomeOtherFunction();}
return result;};
}});return widgetFactory.apply(this, arguments);
};
// The widget factory function itself has some function members itself,// like $.widget.bridge and $.widget.extend. Don’t forget to copy those// items over from the original factory to our new implementation!$.each(widgetFactory, function (key, value) {
$.widget[key] = value;});
var widgetFactory = $.widget;
$.widget = function (name, base, prototype) {var targetPrototype = prototype || base;
$.each(targetPrototype, function (key, callback) {if (typeof callback === ‘function’) {
targetPrototype[key] = function () {if (someConditionPasses) {fireSomeFunction();}
var result = callback.apply(this, arguments);
if (someOtherConditionPasses) {fireSomeOtherFunction();}
return result;};
}});return widgetFactory.apply(this, arguments);
};
// The widget factory function itself has some function members itself,// like $.widget.bridge and $.widget.extend. Don’t forget to copy those// items over from the original factory to our new implementation!$.each(widgetFactory, function (key, value) {
$.widget[key] = value;});
var widgetFactory = $.widget;
$.widget = function (name, base, prototype) {var targetPrototype = prototype || base;
$.each(targetPrototype, function (key, callback) {if (typeof callback === ‘function’) {
targetPrototype[key] = function () {if (someConditionPasses) {fireSomeFunction();}
var result = callback.apply(this, arguments);
if (someOtherConditionPasses) {fireSomeOtherFunction();}
return result;};
}});return widgetFactory.apply(this, arguments);
};
// The widget factory function itself has some function members itself,// like $.widget.bridge and $.widget.extend. Don’t forget to copy those// items over from the original factory to our new implementation!$.each(widgetFactory, function (key, value) {
$.widget[key] = value;});
var widgetFactory = $.widget;
$.widget = function (name, base, prototype) {var targetPrototype = prototype || base;
$.each(targetPrototype, function (key, callback) {if (typeof callback === ‘function’) {
targetPrototype[key] = function () {if (someConditionPasses) {fireSomeFunction();}
var result = callback.apply(this, arguments);
if (someOtherConditionPasses) {fireSomeOtherFunction();}
return result;};
}});return widgetFactory.apply(this, arguments);
};
// The widget factory function itself has some function members itself,// like $.widget.bridge and $.widget.extend. Don’t forget to copy those// items over from the original factory to our new implementation!$.each(widgetFactory, function (key, value) {
$.widget[key] = value;});
var widgetFactory = $.widget;
$.widget = function (name, base, prototype) {var targetPrototype = prototype || base;
$.each(targetPrototype, function (key, callback) {if (typeof callback === ‘function’) {
targetPrototype[key] = function () {if (someConditionPasses) {fireSomeFunction();}
var result = callback.apply(this, arguments);
if (someOtherConditionPasses) {fireSomeOtherFunction();}
return result;};
}});return widgetFactory.apply(this, arguments);
};
// The widget factory function itself has some function members itself,// like $.widget.bridge and $.widget.extend. Don’t forget to copy those// items over from the original factory to our new implementation!$.each(widgetFactory, function (key, value) {
$.widget[key] = value;});
Richard Lindsey @Velveeta http://conqueringtheclient.com/
ALWAYS TRY TO USE PUBLIC API FOR FORWARD COMPATIBILITY
Decorate ALL thefunction
s!
Richard Lindsey @Velveeta http://conqueringtheclient.com/
WHO CARES ABOUT INTERNAL IMPLEMENTATIONS?
Feel free to
mix it up.
Richard Lindsey @Velveeta http://conqueringtheclient.com/
OVERRIDE FUNCTIONALITY IN ONE OF TWO WAYS:
Feel free to
mix it up.
$.widget Factory
Widget Options
• Overrides prototype, affects all instances
• Maintains pointer to overridden function via _super and _superApply
• Overrides instance-level functionality only
• Provides easy access to consumers to override functionality
$.widget(‘abc.dataloader’, {options: {url: null,success: function (results) {this.element.html(JSON.stringify(results));},// etc},fetch: function () {this.element.addClass(‘loading’);return this._load().done($.proxy(function (results) {this.options.success.call(this, results);}, this).always($.proxy(function () {this.element.removeClass(‘loading’);}, this));},_load: function () {return $.ajax(this.options);}
});$.widget(‘abc.dataloader’, abc.dataloader, {
_load: function () {var deferred = $.Deferred();this.element.data(‘backboneCollection’).fetch({reset: true,success: function (collection) {deferred.resolve(collection.toJSON());},error: function (collection, response) {deferred.reject(response);}});return deferred.promise();}
});
var myTemplate = Handlebars.compile($(‘#myTemplate’).html());$(‘#myDiv’).dataloader({
success: function (results) {this.element.html(myTemplate(results));}
});
$.widget(‘abc.dataloader’, {options: {url: null,success: function (results) {this.element.html(JSON.stringify(results));},// etc},fetch: function () {this.element.addClass(‘loading’);return this._load().done($.proxy(function (results) {this.options.success.call(this, results);}, this).always($.proxy(function () {this.element.removeClass(‘loading’);}, this));},_load: function () {return $.ajax(this.options);}
});$.widget(‘abc.dataloader’, abc.dataloader, {
_load: function () {var deferred = $.Deferred();this.element.data(‘backboneCollection’).fetch({reset: true,success: function (collection) {deferred.resolve(collection.toJSON());},error: function (collection, response) {deferred.reject(response);}});return deferred.promise();}
});
var myTemplate = Handlebars.compile($(‘#myTemplate’).html());$(‘#myDiv’).dataloader({
success: function (results) {this.element.html(myTemplate(results));}
});
$.widget(‘abc.dataloader’, {options: {
url: null,success: function (results) {this.element.html(JSON.stringify(results));},// etc
},fetch: function () {
this.element.addClass(‘loading’);return this._load().done($.proxy(function (results) {this.options.success.call(this, results);}, this).always($.proxy(function () {this.element.removeClass(‘loading’);}, this));
},_load: function () {
return $.ajax(this.options);}
});$.widget(‘abc.dataloader’, abc.dataloader, {
_load: function () {var deferred = $.Deferred();this.element.data(‘backboneCollection’).fetch({reset: true,success: function (collection) {deferred.resolve(collection.toJSON());},error: function (collection, response) {deferred.reject(response);}});return deferred.promise();
}});
var myTemplate = Handlebars.compile($(‘#myTemplate’).html());$(‘#myDiv’).dataloader({
success: function (results) {this.element.html(myTemplate(results));
}});
$.widget(‘abc.dataloader’, {options: {url: null,success: function (results) {this.element.html(JSON.stringify(results));},// etc},fetch: function () {this.element.addClass(‘loading’);return this._load().done($.proxy(function (results) {this.options.success.call(this, results);}, this).always($.proxy(function () {this.element.removeClass(‘loading’);}, this));},_load: function () {return $.ajax(this.options);}
});$.widget(‘abc.dataloader’, abc.dataloader, {
_load: function () {var deferred = $.Deferred();this.element.data(‘backboneCollection’).fetch({reset: true,success: function (collection) {deferred.resolve(collection.toJSON());},error: function (collection, response) {deferred.reject(response);}});return deferred.promise();}
});
var myTemplate = Handlebars.compile($(‘#myTemplate’).html());$(‘#myDiv’).dataloader({
success: function (results) {this.element.html(myTemplate(results));}
});
Make it
testable!
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Make it
testable!DOES IT PERFORM A LOGICAL OPERATION OR CALCULATION? / IS IT PART OF THE WIDGET’S PUBLIC-FACING API?
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Make it
testable!DOES IT PERFORM A LOGICAL OPERATION OR CALCULATION? / IS IT PART OF THE WIDGET’S PUBLIC-FACING API?
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Richard Lindsey @Velveeta http://conqueringtheclient.com/
PUBLIC FUNCTIONS SHOULD HAVE UNIT TESTS / STORE PROTOTYPES IN OBJECT NAMESPACES / TEST LOGICAL FUNCTIONS SEPARATELY
expose it!
Richard Lindsey @Velveeta http://conqueringtheclient.com/
PUBLIC FUNCTIONS SHOULD HAVE UNIT TESTS / STORE PROTOTYPES IN OBJECT NAMESPACES / TEST LOGICAL FUNCTIONS SEPARATELY
expose it!
Richard Lindsey @Velveeta http://conqueringtheclient.com/
PUBLIC FUNCTIONS SHOULD HAVE UNIT TESTS / STORE PROTOTYPES IN OBJECT NAMESPACES / TEST LOGICAL FUNCTIONS SEPARATELY
expose it!
ABC = {};(function ($) {
ABC.Prototypes = ABC.Prototypes || {};ABC.Prototypes.demo = {
_create: function () {if (this. _getInstanceCount() === 1) {this._attachListeners();}},_getInstanceCount: function () {return $(‘:abc-demo’).length;},_attachListeners: function () {$(‘body’).on(‘click.demo’, ‘:abc-demo’, $.proxy(this._clickHandler, this));},_clickHandler: function () {console.log(this._getInstanceCount() + ‘ demo widgets instantiated!’);},destroy: function () {if (this._getInstanceCount() === 1) {$(‘body’).off(‘.demo’);this._super();}}
};$(function () {
$(‘.demo’).demo();});
}(jQuery));(function ($) {
$.each(ABC.Prototypes, function (widgetName, widgetPrototype) {$.widget(‘abc.’ + widgetName, widgetPrototype);
});}(jQuery));
ABC = {};(function ($) {
ABC.Prototypes = ABC.Prototypes || {};ABC.Prototypes.demo = {
_create: function () {if (this. _getInstanceCount() === 1) {this._attachListeners();}},_getInstanceCount: function () {return $(‘:abc-demo’).length;},_attachListeners: function () {$(‘body’).on(‘click.demo’, ‘:abc-demo’, $.proxy(this._clickHandler, this));},_clickHandler: function () {console.log(this._getInstanceCount() + ‘ demo widgets instantiated!’);},destroy: function () {if (this._getInstanceCount() === 1) {$(‘body’).off(‘.demo’);this._super();}}
};$(function () {
$(‘.demo’).demo();});
}(jQuery));(function ($) {
$.each(ABC.Prototypes, function (widgetName, widgetPrototype) {$.widget(‘abc.’ + widgetName, widgetPrototype);
});}(jQuery));
ABC = {};(function ($) {
ABC.Prototypes = ABC.Prototypes || {};ABC.Prototypes.demo = {
_create: function () {if (this. _getInstanceCount() === 1) {this._attachListeners();}},_getInstanceCount: function () {return $(‘:abc-demo’).length;},_attachListeners: function () {$(‘body’).on(‘click.demo’, ‘:abc-demo’, $.proxy(this._clickHandler, this));},_clickHandler: function () {console.log(this._getInstanceCount() + ‘ demo widgets instantiated!’);},destroy: function () {if (this._getInstanceCount() === 1) {$(‘body’).off(‘.demo’);this._super();}}
};$(function () {
$(‘.demo’).demo();});
}(jQuery));(function ($) {
$.each(ABC.Prototypes, function (widgetName, widgetPrototype) {$.widget(‘abc.’ + widgetName, widgetPrototype);
});}(jQuery));
module(‘demo core’);
test(‘_getInstanceCount’, 2, function () {var container = $(‘<div></div>’).appendTo(‘body’),
deferred = $.Deferred(),myDemo;
stop();
deferred.done(function (instanceCount) {equal(instanceCount, 1, ‘Returns proper value with 1 instance’);}).fail(function () {ok(false, ‘Returns proper value with 1 instance’);}).always(function () {container.remove();start();});
equal(ABC.Prototypes.demo._getInstanceCount(), 0, ‘Returns proper value with no instances’);
myDemo = $(‘<div></div>’).appendTo(container).on(‘democreate’, function () {deferred.resolve(ABC.Prototypes.demo._getInstanceCount());}).demo();
setTimeout(function () {deferred.reject();
}, 250);});
module(‘demo core’);
test(‘_getInstanceCount’, 2, function () {var container = $(‘<div></div>’).appendTo(‘body’),
deferred = $.Deferred(),myDemo;
stop();
deferred.done(function (instanceCount) {equal(instanceCount, 1, ‘Returns proper value with 1 instance’);}).fail(function () {ok(false, ‘Returns proper value with 1 instance’);}).always(function () {container.remove();start();});
equal(ABC.Prototypes.demo._getInstanceCount(), 0, ‘Returns proper value with no instances’);
myDemo = $(‘<div></div>’).appendTo(container).on(‘democreate’, function () {deferred.resolve(ABC.Prototypes.demo._getInstanceCount());}).demo();
setTimeout(function () {deferred.reject();
}, 250);});
module(‘demo core’);
test(‘_getInstanceCount’, 2, function () {var container = $(‘<div></div>’).appendTo(‘body’),
deferred = $.Deferred(),myDemo;
stop();
deferred.done(function (instanceCount) {equal(instanceCount, 1, ‘Returns proper value with 1 instance’);}).fail(function () {ok(false, ‘Returns proper value with 1 instance’);}).always(function () {container.remove();start();});
equal(ABC.Prototypes.demo._getInstanceCount(), 0, ‘Returns proper value with no instances’);
myDemo = $(‘<div></div>’).appendTo(container).on(‘democreate’, function () {deferred.resolve(ABC.Prototypes.demo._getInstanceCount());}).demo();
setTimeout(function () {deferred.reject();
}, 250);});
module(‘demo core’);
test(‘_getInstanceCount’, 2, function () {var container = $(‘<div></div>’).appendTo(‘body’),
deferred = $.Deferred(),myDemo;
stop();
deferred.done(function (instanceCount) {equal(instanceCount, 1, ‘Returns proper value with 1 instance’);}).fail(function () {ok(false, ‘Returns proper value with 1 instance’);}).always(function () {container.remove();start();});
equal(ABC.Prototypes.demo._getInstanceCount(), 0, ‘Returns proper value with no instances’);
myDemo = $(‘<div></div>’).appendTo(container).on(‘democreate’, function () {deferred.resolve(ABC.Prototypes.demo._getInstanceCount());}).demo();
setTimeout(function () {deferred.reject();
}, 250);});
Wrap it up already, will ya?
Richard Lindsey @Velveeta http://conqueringtheclient.com/
ONLY MAKE COMPONENTS AS LARGE AS THEY NEED TO BE / KEEP THEM AS DECOUPLED AS POSSIBLE / CONSUME DOWNWARDS, COMMUNICATE UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
ONLY MAKE COMPONENTS AS LARGE AS THEY NEED TO BE / KEEP THEM AS DECOUPLED AS POSSIBLE / CONSUME DOWNWARDS, COMMUNICATE UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
ONLY MAKE COMPONENTS AS LARGE AS THEY NEED TO BE / KEEP THEM AS DECOUPLED AS POSSIBLE / CONSUME DOWNWARDS, COMMUNICATE UPWARDS
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
DECORATE THE FACTORY, BUT BE CAREFUL ABOUT TYING TO IMPLEMENTATIONS.
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
MAKE FUNCTIONS & OPTIONS GRANULAR AND ROBUST FOR POTENTIAL OVERRIDES.
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
TEST, TEST, AND TEST! MAKE EVERY ATTEMPT TO ENSURE BACKWARD COMPATIBILITY FOR CONSUMERS.
Richard Lindsey @Velveeta http://conqueringtheclient.com/
Wrap it up
already…
thanks!Presentation available online: http://bit.ly/jqwidgets
Richard Lindsey @velveeta http://conqueringtheclient.com/PLATFORM FEE ARCHITECT | THE ADVISORY BOARD COMPANY