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 +
+
+ Delete +
+
+ + +
+
+ + $(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 +
+ Mark Complete + Mark Incomplete +
+ + +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' %> -

<%= @title %>

+ +

<%= @title %>

jQuery version: <%= jquery_link '1.4.3' %> • @@ -16,3 +17,17 @@
    + + + \ No newline at end of file