:
+ # react_component(:HelloMessage, {:name => 'John'}, :span)
+ # react_component(:HelloMessage, {:name => 'John'}, :tag => :span)
+ #
+ # # Add HTML attributes:
+ # react_component(:HelloMessage, {}, {:class => 'c', :id => 'i'})
+ #
+ # # (ERB) Customize child elements:
+ # <%= react_component :HelloMessage do -%>
+ # Loading...
+ # <% end -%>
+ def react_component(name, args = {}, options = {}, &block)
+ options = {:tag => options} if options.is_a?(Symbol)
+
+ html_options = options.reverse_merge(:data => {})
+ html_options[:data].tap do |data|
+ data[:react_class] = name
+ data[:react_props] = args.to_json unless args.empty?
+ end
+ html_tag = html_options.delete(:tag) || :div
+
+ content_tag(html_tag, '', html_options, &block)
+ end
+ end
+ end
+end
+
+ActionView::Base.class_eval do
+ include ::React::Rails::ViewHelper
+end
diff --git a/react-rails.gemspec b/react-rails.gemspec
index 8948485cf..8f13609bd 100644
--- a/react-rails.gemspec
+++ b/react-rails.gemspec
@@ -14,9 +14,12 @@ Gem::Specification.new do |s|
s.author = ['Paul O’Shannessy']
s.email = ['paul@oshannessy.com']
- s.add_development_dependency "bundler", [">= 1.2.2"]
- s.add_development_dependency "appraisal"
- s.add_development_dependency "coffee-rails"
+ s.add_development_dependency 'bundler', '>= 1.2.2'
+ s.add_development_dependency 'appraisal'
+ s.add_development_dependency 'coffee-rails'
+ s.add_development_dependency 'turbolinks', '>= 2.0.0'
+ s.add_development_dependency 'es5-shim-rails', '>= 2.0.5'
+ s.add_development_dependency 'poltergeist', '>= 0.3.3'
s.add_dependency 'execjs'
s.add_dependency 'rails', '>= 3.1'
diff --git a/test/dummy/app/assets/javascripts/application.js b/test/dummy/app/assets/javascripts/application.js
index 5bc2e1c8b..8e6679e04 100644
--- a/test/dummy/app/assets/javascripts/application.js
+++ b/test/dummy/app/assets/javascripts/application.js
@@ -10,4 +10,11 @@
// Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
// about supported directives.
//
+//
+// es5-shim is necessary for PhantomJS to pass tests. See https://github.com/facebook/react/issues/303
+//
+//= require turbolinks
+//= require es5-shim/es5-shim
+//= require react
+//= require react_ujs
//= require_tree .
diff --git a/test/dummy/app/assets/javascripts/pages.js b/test/dummy/app/assets/javascripts/pages.js
new file mode 100644
index 000000000..79cc0a10e
--- /dev/null
+++ b/test/dummy/app/assets/javascripts/pages.js
@@ -0,0 +1,14 @@
+var HelloMessage = React.createClass({
+ getInitialState: function() {
+ return {greeting: 'Hello'};
+ },
+ goodbye: function() {
+ this.setState({greeting: 'Goodbye'});
+ },
+ render: function() {
+ return React.DOM.div({},
+ React.DOM.div({}, this.state.greeting, ' ', this.props.name),
+ React.DOM.button({onClick: this.goodbye}, 'Goodbye')
+ );
+ }
+});
diff --git a/test/dummy/app/controllers/pages_controller.rb b/test/dummy/app/controllers/pages_controller.rb
new file mode 100644
index 000000000..cdba6a88a
--- /dev/null
+++ b/test/dummy/app/controllers/pages_controller.rb
@@ -0,0 +1,5 @@
+class PagesController < ApplicationController
+ def show
+ @name = %w(Alice Bob)[params[:id].to_i % 2]
+ end
+end
diff --git a/test/dummy/app/views/pages/show.html.erb b/test/dummy/app/views/pages/show.html.erb
new file mode 100644
index 000000000..2c7ac3dfe
--- /dev/null
+++ b/test/dummy/app/views/pages/show.html.erb
@@ -0,0 +1,5 @@
+
+ - <%= link_to 'Alice', page_path(:id => 0) %>
+ - <%= link_to 'Bob', page_path(:id => 1) %>
+
+<%= react_component 'HelloMessage', :name => @name %>
diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb
index e619601c7..af83c9f3e 100644
--- a/test/dummy/config/application.rb
+++ b/test/dummy/config/application.rb
@@ -7,8 +7,10 @@
require "sprockets/railtie"
require "rails/test_unit/railtie"
-Bundler.require(*Rails.groups)
-require "react-rails"
+# Make sure gems in development group are required, for example, react-rails and turbolinks.
+# These gems are specified in .gemspec file by add_development_dependency. They are not runtime
+# dependencies for react-rails project but probably runtime dependencies for this dummy rails app.
+Bundler.require(*(Rails.groups | ['development']))
module Dummy
class Application < Rails::Application
diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb
index 76dbac302..7dd746607 100644
--- a/test/dummy/config/routes.rb
+++ b/test/dummy/config/routes.rb
@@ -1,56 +1,3 @@
Dummy::Application.routes.draw do
- # The priority is based upon order of creation: first created -> highest priority.
- # See how all your routes lay out with "rake routes".
-
- # You can have the root of your site routed with "root"
- # root 'welcome#index'
-
- # Example of regular route:
- # get 'products/:id' => 'catalog#view'
-
- # Example of named route that can be invoked with purchase_url(id: product.id)
- # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase
-
- # Example resource route (maps HTTP verbs to controller actions automatically):
- # resources :products
-
- # Example resource route with options:
- # resources :products do
- # member do
- # get 'short'
- # post 'toggle'
- # end
- #
- # collection do
- # get 'sold'
- # end
- # end
-
- # Example resource route with sub-resources:
- # resources :products do
- # resources :comments, :sales
- # resource :seller
- # end
-
- # Example resource route with more complex sub-resources:
- # resources :products do
- # resources :comments
- # resources :sales do
- # get 'recent', on: :collection
- # end
- # end
-
- # Example resource route with concerns:
- # concern :toggleable do
- # post 'toggle'
- # end
- # resources :posts, concerns: :toggleable
- # resources :photos, concerns: :toggleable
-
- # Example resource route within a namespace:
- # namespace :admin do
- # # Directs /admin/products/* to Admin::ProductsController
- # # (app/controllers/admin/products_controller.rb)
- # resources :products
- # end
+ resources :pages, :only => [:show]
end
diff --git a/test/jsxtransform_test.rb b/test/jsxtransform_test.rb
index e65d2dd55..973344aad 100644
--- a/test/jsxtransform_test.rb
+++ b/test/jsxtransform_test.rb
@@ -8,8 +8,7 @@
eos
EXPECTED_JS_2 = <
<\/span>/)
+
+ html = @helper.react_component('Foo', {}, {:class => 'test', :tag => :span, :data => {:foo => 1}})
+ assert html.match(/<\/span>/)
+ assert html.include?('class="test"')
+ assert html.include?('data-foo="1"')
+ end
+
+ test 'react_ujs works with rendered HTML' do
+ visit '/pages/1'
+ assert page.has_content?('Hello Bob')
+
+ page.click_button 'Goodbye'
+ assert page.has_no_content?('Hello Bob')
+ assert page.has_content?('Goodbye Bob')
+ end
+
+ test 'react_ujs works with Turbolinks' do
+ visit '/pages/1'
+ assert page.has_content?('Hello Bob')
+
+ # Try clicking links.
+ page.click_link('Alice')
+ assert page.has_content?('Hello Alice')
+
+ page.click_link('Bob')
+ assert page.has_content?('Hello Bob')
+
+ # Try Turbolinks javascript API.
+ page.execute_script('Turbolinks.visit("/pages/2");')
+ assert page.has_content?('Hello Alice')
+
+ page.execute_script('Turbolinks.visit("/pages/1");')
+ assert page.has_content?('Hello Bob')
+
+ # Component state is not persistent after clicking current page link.
+ page.click_button 'Goodbye'
+ assert page.has_content?('Goodbye Bob')
+
+ page.click_link('Bob')
+ assert page.has_content?('Hello Bob')
+ end
+end
diff --git a/vendor/assets/javascripts/react_ujs.js b/vendor/assets/javascripts/react_ujs.js
new file mode 100644
index 000000000..ed576e862
--- /dev/null
+++ b/vendor/assets/javascripts/react_ujs.js
@@ -0,0 +1,63 @@
+// Unobtrusive scripting adapter for React
+(function(document, window, React) {
+ var CLASS_NAME_ATTR = 'data-react-class';
+ var PROPS_ATTR = 'data-react-props';
+
+ // jQuery is optional. Use it to support legacy browsers.
+ var $ = (typeof jQuery !== 'undefined') && jQuery;
+
+ var findReactDOMNodes = function() {
+ var SELECTOR = '[' + CLASS_NAME_ATTR + ']';
+ if ($) {
+ return $(SELECTOR);
+ } else {
+ return document.querySelectorAll(SELECTOR);
+ }
+ };
+
+ var mountReactComponents = function() {
+ var nodes = findReactDOMNodes();
+ for (var i = 0; i < nodes.length; ++i) {
+ var node = nodes[i];
+ var className = node.getAttribute(CLASS_NAME_ATTR);
+ // Assume className is simple and can be found at top-level (window).
+ // Fallback to eval to handle cases like 'My.React.ComponentName'.
+ var constructor = window[className] || eval.call(window, className);
+ var propsJson = node.getAttribute(PROPS_ATTR);
+ var props = propsJson && JSON.parse(propsJson);
+ React.renderComponent(constructor(props), node);
+ }
+ };
+
+ var unmountReactComponents = function() {
+ var nodes = findReactDOMNodes();
+ for (var i = 0; i < nodes.length; ++i) {
+ React.unmountComponentAtNode(nodes[i]);
+ }
+ };
+
+ // Register page load & unload events
+ if ($) {
+ $(mountReactComponents);
+ $(window).unload(unmountReactComponents);
+ } else {
+ document.addEventListener('DOMContentLoaded', mountReactComponents);
+ window.addEventListener('unload', unmountReactComponents);
+ }
+
+ // Turbolinks specified events
+ if (typeof Turbolinks !== 'undefined') {
+ var handleEvent;
+ if ($) {
+ handleEvent = function(eventName, callback) {
+ $(document).on(eventName, callback);
+ }
+ } else {
+ handleEvent = function(eventName, callback) {
+ document.addEventListener(eventName, callback);
+ }
+ }
+ handleEvent('page:load', mountReactComponents);
+ handleEvent('page:before-change', unmountReactComponents);
+ }
+})(document, window, React);