diff --git a/README.md b/README.md
index e75848cb..a04957a5 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,162 @@
Unobtrusive scripting adapter for jQuery
========================================
-This unobtrusive scripting support file is developed for the Ruby on Rails framework, but is not strictly tied to any specific backend. You can drop this into any application to:
+See the [original docs][orig_docs] for basic features, requirements, and installation.
-- force confirmation dialogs for various actions;
-- make non-GET requests from hyperlinks;
-- make forms or hyperlinks submit data asynchronously with Ajax;
-- have submit buttons become automatically disabled on form submit to prevent double-clicking.
+Changes described here build on existing unobtrusive footwork to dry and empower dynamic functionality.
-These features are achieved by adding certain ["data" attributes][data] to your HTML markup. In Rails, they are added by the framework's template helpers.
+Widgets
+-------
-Full [documentation is on the wiki][wiki], including the [list of published Ajax events][events].
+The data- attributes are used to give default functionality to an element. That functionality is described in a jQuery plugin, and connected to the element through naming convention. This is useful in connecting code and functionality to otherwise static code.
-Requirements
-------------
+ $.fn.samplewidget = function(value){
+ console.log('widget is called on dom ready and dom modify')
+ console.log('the value is:', value); // value will be '123';
+ }
+
+ $("#feature").append($('
', {
+ 'data-sampleWidget': '123'
+ }));
-- [jQuery 1.4.3][jquery] or later;
-- for Ruby on Rails only: `<%= csrf_meta_tag %>` in the HEAD of your HTML layout;
-- HTML5 doctype (optional).
+Callbacks
+---------
-If you don't use HTML5, adding "data" attributes to your HTML4 or XHTML pages might make them fail [W3C markup validation][validator]. However, this shouldn't create any issues for web browsers or other user agents.
+Ajax calls have already been made easy and unobtrusive (see original docs). These changes make ajax callbacks easily so.
-In Ruby on Rails 3, the `csrf_meta_tag` helper generates two meta tags containing values necessary for [cross-site request forgery protection][csrf] built into Rails. If you're using Rails 2, here is how to implement that helper:
+ $("#feature").append($('', { //this is the link
+ 'data-remote': true,
+ 'data-action': 'refresh'
+ }));
+
+ $(document).appoint('refresh', function(){ //this is the handler
+ console.log('ajax successful');
+ $('#section').html(this.xhr.responseText);
+ });
- # app/helpers/application_helper.rb
- def csrf_meta_tag
- if protect_against_forgery?
- out = %(\n)
- out << %()
- out % [ Rack::Utils.escape_html(request_forgery_protection_token),
- Rack::Utils.escape_html(form_authenticity_token) ]
- end
- end
+The appoint function applies to all matching actions within the scope.
-Installation
-------------
+Several things are contained by the callback object:
-For automated installation, use the "jquery-rails" generator:
+- the typical e, data, status, and xhr
+- the closest data-feature as $feature
+- the closest data-widget as $widget
- # Gemfile
- gem 'jquery-rails', '>= 0.2.6'
+Framework
+---------
-And run this command (add `--ui` if you want jQuery UI):
+It is realized that CRUD ajax links and links which modify nearby content are comon. Given that, the structure is assumed to be of features with actions:
- $ bundle install
- $ rails generate jquery:install
+
+
+ $(document).appoint('delete', function(){
+ this.$feature.remove();
+ });
+
+ $(document).appoint('create', function(){
+ this.find('textarea, input:text, input:file').val("");
+ this.find('.errors').empty();
+
+ $(this.xhr.responseText).prependTo(this.$feature);
+ });
-This will remove the Prototype.js library from Rails, add latest jQuery library and fetch the adapter. Be sure to choose to overwrite the "rails.js" file.
+Create, Update, and Delete are handled by default, and can be removed with:
+*dismiss(action, [ajaxEvent], [fn]);*
-### Manual installation
+Selectors
+---------
-[Download jQuery][jquery] and ["rails.js"][adapter] and place them in your "javascripts" directory.
+Custom selectors are added:
-Configure the following in your application startup file:
+- $(':action') and $(':action(update)') match data-action=* and data-action=update
+- $(':feature') and $(':feature(todos)') match data-feature=* and data-feature=todos
+- $(':widget') and $(':widget(cycle)') match data-\*=\* (excepting jquery-ujs reserved *data-* keywords) and data-cycle=*
- config.action_view.javascript_expansions[:defaults] = %w(jquery rails)
+Details
+-------
-Now the template helper `javascript_include_tag :defaults` will generate SCRIPT tags to load jQuery and rails.js.
+There is a fair amount of customizability.
+The idea of an object with a series of states is fairly simple, and included by default. External plugins with the same name need simply to be included after this file.
+
+ $.fn.cycle = function(selected) {
+ console.log('now showing the link with this action:', selected );
+ this.children().hide().filter(':action(' + selected + ')').show();
+ };
-[data]: http://dev.w3.org/html5/spec/elements.html#embedding-custom-non-visible-data-with-the-data-attributes "Embedding custom non-visible data with the data-* attributes"
-[wiki]: https://github.com/rails/jquery-ujs/wiki
-[events]: https://github.com/rails/jquery-ujs/wiki/ajax
-[jquery]: http://docs.jquery.com/Downloading_jQuery
-[validator]: http://validator.w3.org/
-[csrf]: http://api.rubyonrails.org/classes/ActionController/RequestForgeryProtection.html
-[adapter]: https://github.com/rails/jquery-ujs/raw/master/src/rails.js
+
+
+
+Widgets are actuated on page load (or dom modify), showing the finish link by default. It is a pleasant approach, as it allows complicated links to be generated all together by rails methods.
+
+**$.fn.actuate = function([args]);**
+
+Any arguments will be passed to the widget call, overriding the default value specified in the tag.
+Widget names are determined from the tag.
+
+
+**$.fn.appoint = function(action, [ajaxEvent], [handlerOrWidgetArgs])**
+
+action: the data-actions to search for
+ajaxEvent: One of: beforeSend, success, error, complete. [Their wiki][ajax_events] has details on them.
+handlerOrWidgetArgs: A callback function, or arguments for the nearest (ancestral) widget.
+
+Handling can be appointed to either a callback you provide, or by the associated widget. With this feature, this code is reduced:
+
+ // wordy
+ $(':feature(todos)').appoint('finish', function(){
+ //this widget shows either a checked box or an unchecked one
+ this.$widget.actuate('unfinish');
+ });
+
+ // handy alternative:
+ $(':feature(todos)').appoint('finish', 'unfinish');
+
+
+By default, the target with be the element with data-action specified. However, if the element has the target attribute set, that instead will be used as the context for the callback. The method searches for a feature of the same name, and if none found, one with matching id. It falls back to itself without matching id.
+
+**$.fn.dismiss = function(action, [ajaxEvent], [fn]) {**
+
+As usual, the default ajax event is success.
+If no function is given, the default capability is removed.
+To remove a custom handler, pass in your function. Note that passing your function to other jquery unbinding methods would not work, as there's an appointee wrapper method.
+
+
+Test code exists for jQuery extensions, appointments, and wigets.
+
+
+Bugs & Areas of Activity
+------------------------
+Links generated with the rails button-to cannot be actuated with jquery-ujs. This is because data-remote and data-action must be on the form, but can only be added to the button. This cold be accomodated for here or from rails core.
+
+Targets could be used to silently implement ajax history. Only links with differing targets -- like the framesets of yore -- would change history.
+
+Data-remote may be now optional or obsolete. Any example with data-action must be remote, so is could be made unnesessary.
+
+Widget names with capital letters are not currently possible. This is because attibute names downcase. Updates here are just a matter of finding the best approach.
+
+Current default handlers could be given some more capability. Specifically, json datatype as well as raw html. The proper behavior of create is not obvious -- should new content be added on top or bottom? Or decided on a call-by-call basis, or set somewhere?
+
+
+- - -
+
+
+Contact: Peter Ehrlich — [@ehrlicp]
+Further Reading: [http://blog.pehrlich.com/the-missing-handlers-of-rails-jquery-ujs][blog]
+
+
+[orig_docs]: http://github.com/rails/jquery-ujs
+[ajax_events]: https://github.com/rails/jquery-ujs/wiki/ajax
+[@ehrlicp]: http://www.twitter.com/#!/ehrlicp
+[blog]: [http://blog.pehrlich.com/the-missing-handlers-of-rails-jquery-ujs]
\ No newline at end of file
diff --git a/src/rails.js b/src/rails.js
index 5f14c5ae..d56e82a0 100644
--- a/src/rails.js
+++ b/src/rails.js
@@ -156,4 +156,225 @@
$('form').live('ajax:complete.rails', function(event) {
if (this == event.target) enableFormElements($(this));
});
+
+
+ //=======================================================================================
+ // begin pehrlich modifications
+ //=======================================================================================
+
+ // jQuery extensions:
+
+ $.expr[':'].action = function(obj, index, meta, stack) {
+ if (meta[3] == undefined) {
+ return ($(obj).attr('data-action') != undefined); //allow any value
+ } else {
+ return ($(obj).attr('data-action') == meta[3]); //match exact
+ }
+ };
+ $.expr[':'].feature = function(obj, index, meta, stack) {
+ if (meta[3] != undefined) {
+ return ($(obj).attr('data-feature') == meta[3]); //match exact
+ } else {
+ return ($(obj).attr('data-feature') != undefined); //allow any value
+ }
+ };
+ $.expr[':'].widget = function(obj, index, meta, stack) {
+ if (meta[3] == undefined) { // any widget name
+ //this returns false if there's no widget attribute!
+ // could be complications with prototype, where o.hasOwnProperty would be needed.
+ // http://www.webdeveloper.com/forum/showthread.php?t=193474
+ for (widgetName in getWidgetData(obj)){
+ return true;
+ }
+ return false;
+ } else { // argument as widget name
+ return ( $(obj).attr('data-' + meta[3]) != undefined );
+ }
+ };
+
+ var getWidgetData = function(htmlElement){
+ // this could be refactored in to jq extension, if the complication could be justified
+ // todo: this should allow comma separated arguments to be passed back.
+ // for now it just returns an array of one, for use with apply by the actuate funtion.
+ var widgetAttribute = new RegExp(/^data-(.*)/i),
+ reservedKeywords = ['feature', 'action', 'method', 'confirm', 'with', 'method', 'remote', 'type', 'disable-with', 'actuate'],
+ widgetData = {};
+
+ jQuery.each(htmlElement.attributes, function(index, attribute) {
+ var widgetName = attribute.name.match(widgetAttribute);
+ if (widgetName != null && reservedKeywords.indexOf(widgetName[1]) == -1) {
+ widgetData[widgetName[1]] = [attribute.value];
+ }
+ });
+ return widgetData;
+ };
+
+ var default_actions = {
+ // todo: handle different data types
+ delete: function(){
+ this.$feature.remove();
+ },
+ update: function(){
+ this.$feature.html(this.xhr.responseText);
+ },
+ create: function(){
+ // todo: blanking select menus
+ this.$feature.find('textarea,input:text,input:file').val("");
+ this.$feature.find('.errors').empty();
+
+ $(this.xhr.responseText).insertBefore(this); //could also use insertAfter as a default.
+ // Neither is clearly better. It could be automatically decided based on the index of the form
+ // among its siblings, except for that difficulty arises when there are no siblings },
+ },
+ create_error: function(){
+ // http://www.alfajango.com/blog/rails-3-remote-links-and-forms-data-type-with-jquery/
+ this.$feature.find('.errors').html(this.xhr.responseText);
+ }
+ }
+
+ // looks for all decendant data-action elements and adds a handler to them
+ // if passed a callback function, it is used as an ajax handler
+ // if passed a string, it activates the widget, with string as the args
+ // todo: upgrade to use deferred objects? http://api.jquery.com/category/deferred-object/
+ // note: could the e variable name cause ie trouble?
+ // todo: allow this to be called on upn the data-action itself, not just the wrapper, to avoid confusions.
+ $.fn.appoint = function(action, ajaxEvent, handlerOrWidgetArgs) {
+ if (arguments.length < 2){ // in this case, add default actions
+ ajaxEvent = 'success';
+ if (action){
+ handlerOrWidgetArgs = default_actions[action];
+ }else{
+ $(this).appoint('create').appoint('create', 'ajax:error', default_actions.create_error).appoint('update').appoint('delete');
+ return this;
+ }
+
+ }else if (arguments.length < 3) {
+ handlerOrWidgetArgs = arguments[1];
+ ajaxEvent = 'success';
+ }
+
+ var appointee = function(e, data, status, xhr) {
+ var target = $(this).attr('target');
+ if (target){
+ target = $(':feature('+target+')') || $('#'+target) || this;
+ }else{
+ target = this;
+ }
+
+ // hmm. one is tempted to use the Delegate data attachment here
+ // but as the function allows a different target to be specified from the element, this way must be used
+
+ //todo: handle different data from https://github.com/rails/jquery-ujs/wiki/ajax
+
+ target.$feature = $(this).closest(':feature');
+ target.$widget = $(this).closest(':widget');
+ target.e = e;
+ target.data = data;
+ target.status = status;
+ target.xhr = xhr;
+
+ if (handlerOrWidgetArgs.prototype != undefined) { //is function?
+ handlerOrWidgetArgs.call(target);
+ } else { //is argument to pass to widget
+ // console.log(handlerOrWidgetArgs);
+ // target.$widget.actuate.apply(target.$widget, handlerOrWidgetArgs);
+ target.$widget.actuate(handlerOrWidgetArgs);
+ }
+ };
+
+ $(this).delegate(':action(' + action + ')', 'ajax:' + ajaxEvent, appointee);
+
+ // set public pointer to callback so that it can be used later by anyone to undelegate.
+ handlerOrWidgetArgs.callbackAppointee = appointee;
+ return this;
+ };
+
+ $(document).appoint(); // set up the default behaviors
+
+ // removes current ujs behavior, including default values.
+ // individual handlers can be removed, through tracking of ujs-made handlers via the ujsCallbackWrapper variable.
+ // this is possibly borderline good practice?
+ $.fn.dismiss = function(action, ajaxEvent, fn) {
+ if (!ajaxEvent) {
+ ajaxEvent = 'success';
+ }else if (typeof ajaxEvent != "string"){
+ fn = ajaxEvent;
+ ajaxEvent = 'success';
+ }
+
+ if(fn){
+ $(this).undelegate(':action(' + action + ')', 'ajax:' + ajaxEvent, fn.callbackAppointee);
+ }else{
+ $(this).dismiss(action, ajaxEvent, default_actions[action])
+ }
+ }
+
+
+ // ========== end appointments ============================================
+ // ========== begin widgets ===============================================
+
+ $(function() {
+ // add handlers to DOM insertion methods: appendChild insertBefore
+ // add on DOM ready -- no need for them sooner
+ // (, or could this bring to light an edge-case bug, if someone modifies the dom while loading?)
+
+ var orig_insertBefore = Element.prototype.insertBefore;
+ Element.prototype.insertBefore = function(new_node, existing_node) {
+ var r = orig_insertBefore.call(this, new_node, existing_node); //run the old method
+ $(new_node).trigger('domModify');
+ return r;
+ };
+
+ var orig_appendChild = Element.prototype.appendChild;
+ Element.prototype.appendChild = function(child) {
+ var r = orig_appendChild.call(this, child);
+ $(child).trigger('domModify');
+ return r;
+ };
+
+ //todo: note: could also respond to an attr_changed event, or such behavior may be unnecessary.
+ $('*').actuate();
+ $(document).delegate('*', 'domModify', function() {
+ $(this).find('*').add(this).actuate();
+ return false; // this stops the propagation of live event to ancestors that match '*', ie, all of them.
+ });
+
+ });
+
+
+ // takes arguments to immediately pass on to next $.fn as specified by data- attr. doesn't take a selector as argument for itself.
+ $.fn.actuate = function() {
+ var widgetArgs = arguments;
+
+ //iterate selected jQuery elements:
+ jQuery.each(this, function(index, htmlElement) {
+
+ var widgetData = getWidgetData(htmlElement);
+
+ for (widgetName in widgetData){
+ try {
+ // NOTE: TODO: Attribute keys are all lower case, and function names are case sensitive. This is a problem!!
+ // either find a way to downcase-map the function names, or an alternate way of pilfering capitalization data
+ // note: todo: block catches exceptions from inside of widget, which is not desired
+ var args = (widgetArgs.length > 0) ? widgetArgs : widgetData[widgetName];
+ $(htmlElement)[ widgetName ].apply( $(htmlElement), args); // can this be dryed?
+ }catch(e) {
+ if (e instanceof TypeError) { //todo: check cross browser compatibilities
+ if (console && console.log)
+ // if you see this error, this script is looking for $.fn.actionName to execute with the context of the object.
+ console.log('jquery-ujs error, unfound jquery extend method: "' + widgetName + '"; or possible TypeError from within widget');
+ } else {
+ throw(e);
+ }
+ }
+ }
+
+ });
+ return this;
+ };
+
+ $.fn.cycle = function(selected) {
+ this.children().hide().filter(':action(' + selected + ')').show();
+ };
+
})( jQuery );
diff --git a/test/public/test/appointments.js b/test/public/test/appointments.js
new file mode 100644
index 00000000..de5e9414
--- /dev/null
+++ b/test/public/test/appointments.js
@@ -0,0 +1,106 @@
+(function(){
+
+ module('appointments', {
+ setup: function() {
+ $('#qunit-fixture').append($('', {
+ 'data-feature': 'feature',
+ 'id': 'feature'
+ }))
+ $('#feature').append($('', {
+ href: '/echo',
+ 'data-remote': true,
+ 'data-action': 'delete',
+ 'id': 'delete_link'
+ }));
+ $('#feature').append($('', {
+ href: '/echo',
+ 'data-remote': true,
+ 'data-action': 'update',
+ 'id': 'update_link'
+ }));
+ },
+ teardown: function() {
+ $('#delete_link').die('ajax:complete'); //these don't combine to 1 line
+ $('#update_link').die('ajax:complete');
+ }
+ });
+
+ // test coverage is not complete here. For example, custom target is not tested.
+
+ // remove default functionality before queueing any tests. This makes tests able to run regardless of sequence.
+ $(document).dismiss('delete');
+
+ asyncTest('default behavior is removable', 2, function(){
+ var feature_orig = $('#feature').html();
+
+ $("#delete_link").live('ajax:complete', function(){
+ start();
+ ok(true, 'ajax:complete');
+ ok( ($('#feature').html() === feature_orig), 'feature is just the same');
+ }).trigger('click');
+ });
+
+ asyncTest('default delete action deletes closest feature', 2, function(){
+ $('#feature').appoint();
+ $("#delete_link").live('ajax:complete', function(){ // why wont bind work here?
+ start();
+ ok(true, 'ajax:complete');
+ ok( ($('#feature').length === 0), 'feature has been removed');
+ }).trigger('click');
+ });
+
+ asyncTest('update replaces closest feature', 2, function(){
+ $("#update_link").live('ajax:complete', function(e, data, status, xhr){
+ start();
+ ok(true, 'ajax:complete');
+ ok( $("#feature").html().match('REQUEST_METHOD') , 'feature has updated content');
+ }).trigger('click');
+ });
+
+ asyncTest('create prepends a new feature', 2, function(){
+ $('#feature').append($('', {
+ href: '/echo',
+ 'data-remote': true,
+ 'data-action': 'create',
+ 'id': 'create_link'
+ }));
+
+ $("#create_link").live('ajax:complete', function(e, data, status, xhr){
+ start();
+ ok(true, 'ajax:complete');
+ ok( $("#feature").html().match('REQUEST_METHOD') , 'feature has updated content');
+ }).trigger('click');
+ });
+
+ asyncTest('new behavior can be added', 2, function(){
+ $("#feature").appoint('delete', function(){
+ this.$feature.html('hello world!');
+ });
+
+ $("#delete_link").live('ajax:complete', function(){
+ start();
+ ok(true, 'ajax:complete');
+ ok( ($('#feature').html() === 'hello world!'), 'feature has custom content');
+ }).trigger('click');
+ });
+
+ asyncTest('widgets can be appointed as callbacks', 2, function(){
+ $.fn.testwidget = function(value){
+ start();
+ ok(true, 'testwidget actuated');
+ equal(value, '456', 'custom value passed');
+ $.fn.testwidget = undefined;
+ }
+
+ $('#feature').attr('data-testwidget', '123');
+ $(':widget(testwidget)').append($('', {
+ href: '/echo',
+ 'data-remote': true,
+ 'data-action': 'testAction'
+ }));
+ $(document).appoint('testAction', '456');
+
+ $(':action(testAction)').trigger('click');
+ });
+
+})();
\ No newline at end of file
diff --git a/test/public/test/jQuery-extensions.js b/test/public/test/jQuery-extensions.js
new file mode 100644
index 00000000..2ccb4308
--- /dev/null
+++ b/test/public/test/jQuery-extensions.js
@@ -0,0 +1,49 @@
+(function(){
+ var jQEqual= function(actual, expected, message){
+ expected.each(function(index, element){
+ strictEqual(actual.get(index), element, message);
+ });
+ strictEqual(actual.length, expected.length, '..and length matches')
+ }
+
+ module('jQuery-extensions', {
+ setup: function(){
+ $.fn.moped = function(){};
+ $('#qunit-fixture')
+ .append($('', {
+ id: 'link1',
+ class: 'action feature widget',
+ 'data-action': 'create',
+ 'data-feature': 'todo_list',
+ 'data-moped': 'handlebars',
+ text: 'my address1'
+ }));
+ $('#qunit-fixture')
+ .append($('', {
+ id: 'link2',
+ class: 'action feature widget',
+ 'data-action': 'update',
+ 'data-feature': 'todo_item',
+ 'data-cycle': 'refridgerator',
+ text: 'my address2'
+ }));
+ $.fn.moped = undefined;
+ }
+ });
+
+ test("':action' filter", function(){
+ jQEqual($("#link1"), $(":action(create)"), ':action selector should pull particular element');
+ jQEqual($(".action"), $(":action"), ':action selector should pull all actionable elements');
+ });
+
+ test("':feature' filter", function(){
+ jQEqual($(":feature(todo_list)"), $("#link1"), ':feature selector should pull particular element');
+ jQEqual($(":feature"), $(".feature"), ':feature selector should pull all feature elements');
+ });
+
+ test("':widget' filter", function(){
+ jQEqual($(":widget(cycle)"), $("#link2"), ':widget selector should pull particular element');
+ jQEqual($(":widget"), $(".widget"), ':widget selector should pull all feature elements');
+ });
+
+})();
\ No newline at end of file
diff --git a/test/public/test/widgets.js b/test/public/test/widgets.js
new file mode 100644
index 00000000..b2914370
--- /dev/null
+++ b/test/public/test/widgets.js
@@ -0,0 +1,60 @@
+// test on dom modify event
+
+(function(){
+ module('widgets', {
+ setup: function() {
+ $('#qunit-fixture').append($('', {
+ 'data-feature': 'feature',
+ 'id': 'feature'
+ }));
+ $('#feature').append($('', {
+ href: '/echo',
+ 'data-remote': true,
+ 'data-action': 'create',
+ 'id': 'create_link'
+ }));
+ }
+ });
+
+ asyncTest('custom domModify event', 1, function(){
+ //QUnit is pretty annoying here. if you don't un
+ $(document).delegate('#new_element', 'domModify', function(e) {
+ $(document).undelegate('#new_element', 'domModify');
+
+ start();
+ ok(true, 'dom modify is triggered');
+ return false; //prevent bubble up
+ });
+
+ $('#feature').append($('', {
+ 'id': 'new_element'
+ }));
+ });
+
+
+ asyncTest('added widgets are initiated', 2, function(){
+ $.fn.samplewidget = function(){
+ start();
+ ok(true, 'widget is called on dom modify');
+ equal(arguments[0], "sampleWidgetArgs", 'arguments passed on from widget attr value');
+ $.fn.sampleWidget = undefined;
+ }
+ $("#feature").append($('', {
+ 'data-sampleWidget': 'sampleWidgetArgs'
+ }));
+ });
+
+ test('cycle widget', function(){
+ $("#feature").attr('data-cycle', 'two').append($('', {
+ 'data-action': 'one'
+ })).append($('', {
+ 'data-action': 'two'
+ })).actuate();
+ // console.log($(':action(two)'));
+
+ equal($(':action(two)').attr('style'), "display: inline; ", 'second action link is shown');
+ equal($(':action(one)').attr('style'), "display: none; ", 'first action link is hidden');
+
+ });
+
+})();
\ No newline at end of file
diff --git a/test/views/index.erb b/test/views/index.erb
index 935748a8..7a4a03f6 100644
--- a/test/views/index.erb
+++ b/test/views/index.erb
@@ -1,8 +1,9 @@
<% @title = "jquery-ujs test" %>
-<%= test 'data-confirm', 'data-remote', 'data-disable', 'call-remote', 'call-remote-callbacks', 'data-method' %>
+<%= test 'data-confirm', 'data-remote', 'data-disable', 'call-remote', 'call-remote-callbacks', 'data-method', 'jQuery-extensions', 'appointments', 'widgets' %>
-
+
+
jQuery version:
<%= jquery_link '1.4.3' %> •
@@ -16,3 +17,17 @@
+
+
+
\ No newline at end of file