diff --git a/Appraisals b/Appraisals index 5086c4d45..0c14cd5e1 100644 --- a/Appraisals +++ b/Appraisals @@ -1,5 +1,8 @@ appraise "rails-3.1" do gem 'rails', '~> 3.1' + # Buggy sprockets can break tests under Ruby 2.0. Use a decent version. + # See https://github.com/sstephenson/sprockets/issues/352 + gem 'sprockets', '>= 2.2.2' end appraise "rails-3.2" do diff --git a/README.md b/README.md index 5d6cf9433..32a3d3e7a 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,50 @@ Alternatively, you can include it directly as a separate script tag: <%= javascript_include_tag "react" %> ``` - ### JSX To transform your JSX into JS, simply create `.js.jsx` files, and ensure that the file has the `/** @jsx React.DOM */` docblock. These files will be transformed on request, or precompiled as part of the `assets:precompile` task. +### Unobtrusive javascript + +`react_ujs` will call `React.renderComponent` for every element with `data-react-class` attribute. React properties can be specified by `data-react-props` attribute in JSON format. For example: + +```erb + +
+``` + +`react_ujs` will also scan DOM elements and call `React.unmountComponentAtNode` on page unload. If you want to disable this behavior, remove `data-react-class` attribute in `componentDidMount`. + +To use `react_ujs`, simply `require` it after `react` (and after `turbolinks` if [Turbolinks](https://github.com/rails/turbolinks) is used): + +```js +// app/assets/application.js + +//= require turbolinks +//= require react +//= require react_ujs +``` + +### Viewer helper + +There is a viewer helper method `react_component`. It is designed to work with `react_ujs` and takes React class name, properties, HTML options as arguments: + +```ruby +react_component('HelloMessage', :name => 'John') +#
+``` + +By default, a `
` element is used. Other tag and HTML attributes can be specified: + +```ruby +react_component('HelloMessage', {:name => 'John'}, :span) +# + +react_component('HelloMessage', {:name => 'John'}, {:id => 'hello', :class => 'foo', :tag => :span}) +# +``` + ## Configuring diff --git a/gemfiles/rails_3.1.gemfile b/gemfiles/rails_3.1.gemfile index 58db7a878..365e8838d 100644 --- a/gemfiles/rails_3.1.gemfile +++ b/gemfiles/rails_3.1.gemfile @@ -3,5 +3,6 @@ source "http://rubygems.org" gem "rails", "~> 3.1" +gem "sprockets", ">= 2.2.1" gemspec :path=>"../" \ No newline at end of file diff --git a/lib/react/rails.rb b/lib/react/rails.rb index 21190dd43..18466040c 100644 --- a/lib/react/rails.rb +++ b/lib/react/rails.rb @@ -1,2 +1,3 @@ require 'react/rails/railtie' require 'react/rails/engine' +require 'react/rails/view_helper' diff --git a/lib/react/rails/view_helper.rb b/lib/react/rails/view_helper.rb new file mode 100644 index 000000000..8863bb3bb --- /dev/null +++ b/lib/react/rails/view_helper.rb @@ -0,0 +1,53 @@ +module React + module Rails + module ViewHelper + # Render a react component named +name+. Returns a HTML tag and some + # javascript to render the component. + # + # HTML attributes can be specified by +options+. The HTML tag is +div+ + # by default and can be changed by +options[:tag]+. If +options+ is a + # symbol, use it as +options[:tag]+. + # + # Static child elements can be rendered by using a block. Be aware that + # they will be replaced once javascript gets executed. + # + # ==== Examples + # + # # // defined in a .jsx file: + # # var HelloMessage = React.createClass({ + # # render: function() { + # # return
{'Hello ' + this.props.name}
; + # # } + # # }); + # react_component(:HelloMessage, :name => 'John') + # + # # Use instead of
: + # 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);