diff --git a/CHANGELOG.md b/CHANGELOG.md index c2797df..7b827c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## react.js v0.13 +* Add `React::Testing` helpers + ## 0.3.0 * Depends on `sprockets < 3` thus source map can still work * Remove sprockets-es6 from dependency diff --git a/Gemfile b/Gemfile index d926697..08edb90 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,4 @@ source 'https://rubygems.org' -gemspec \ No newline at end of file +gem 'opal', '0.8.0.rc1' +gem 'opal-rspec', github: 'opal/opal-rspec' +gemspec diff --git a/Gemfile.lock b/Gemfile.lock index fe04ccd..d1b82e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,19 @@ +GIT + remote: git://github.com/opal/opal-rspec.git + revision: e2290d56ff5147e5a2b96258ef5bdc2b54a9e6af + specs: + opal-rspec (0.4.2) + opal (>= 0.7.0, < 0.9) + PATH remote: . specs: react.rb (0.3.0) - opal (~> 0.6.0) + opal (>= 0.7.0, < 0.9) opal-activesupport (~> 0) react-jsx (~> 0.8.0) - sprockets (>= 2.2.3, < 3.0.0) + react-source (~> 0.13) + sprockets (~> 3.1) therubyracer (~> 0) GEM @@ -13,19 +21,18 @@ GEM specs: execjs (2.5.2) hike (1.2.3) - json (1.8.2) + json (1.8.3) libv8 (3.16.14.7) - multi_json (1.11.0) - opal (0.6.3) - source_map - sprockets + opal (0.8.0.rc1) + hike (~> 1.2) + sourcemap (~> 0.1.0) + sprockets (~> 3.1) + tilt (>= 1.4) opal-activesupport (0.1.0) opal (>= 0.5.0, < 1.0.0) opal-jquery (0.2.0) opal (>= 0.5.0, < 1.0.0) - opal-rspec (0.3.0.beta3) - opal (>= 0.6.0, < 1.0.0) - rack (1.6.0) + rack (1.6.1) rack-protection (1.5.3) rack rake (10.4.2) @@ -33,31 +40,27 @@ GEM execjs (>= 2.0.2) json (>= 1.8.0) react-source (>= 0.4.1) - react-source (0.12.2) + react-source (0.13.3) ref (1.0.5) sinatra (1.4.6) rack (~> 1.4) rack-protection (~> 1.4) tilt (>= 1.3, < 3) - source_map (3.0.1) - json - sprockets (2.12.3) - hike (~> 1.2) - multi_json (~> 1.0) + sourcemap (0.1.1) + sprockets (3.2.0) rack (~> 1.0) - tilt (~> 1.1, != 1.3.0) therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref - tilt (1.4.1) + tilt (2.0.1) PLATFORMS ruby DEPENDENCIES + opal (= 0.8.0.rc1) opal-jquery (~> 0) - opal-rspec (~> 0.3.0.beta3) + opal-rspec! rake (~> 10) - react-source (~> 0.12.0) react.rb! sinatra (~> 1) diff --git a/README.md b/README.md index 1bb5c61..b297442 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,27 @@ React.render(React.create_element('h1'){ "Hello World!" }, `document.body`) For integration with server (Sinatra, etc), see setup of [TodoMVC](example/todos) or the [official docs](http://opalrb.org/docs/) of Opal. +## React.js Dependency + +React.js v0.13 is required to use react.rb, you can access the pre-bundled source through `Opal::React.bundled_path` directory, example below shows setup for a basic rack app. + +```ruby +#config.ru +run Opal::Server.new {|s| + s.append_path './' + s.append_path Opal::React.bundled_path + s.main = 'example' + s.debug = true + s.index_path = "index.html.erb" +} +``` + +```erb + +<%= javascript_include_tag "react" %> +<%= javascript_include_tag "example" %> +``` + ## Usage ### A Simple Component @@ -46,12 +67,12 @@ puts React.render_to_static_markup(React.create_element(HelloMessage)) ### More complicated one -To hook into native ReactComponent life cycle, the native `this` will be passed to the class's initializer. And all corresponding life cycle methods (`componentDidMount`, etc) will be invoked on the instance using the snake-case method name. +To hook into native ReactComponent life cycle, the props will be passed as the first argument to the class's initializer. And all corresponding life cycle methods (`componentDidMount`, etc) will be invoked on the instance using the snake-case method name. ```ruby class HelloMessage - def initialize(native) - @native = Native(native) + def initialize(props) + puts props end def component_will_mount @@ -59,12 +80,13 @@ class HelloMessage end def render - React.create_element("div") { "Hello #{@native[:props][:name]}!" } + React.create_element("div") { "Hello #{self.props[:name]}!" } end end puts React.render_to_static_markup(React.create_element(HelloMessage, name: 'John')) +# => {"name"=>"John"} # => will_mount! # => '
Hello John!
' ``` diff --git a/Rakefile b/Rakefile index d4c0f54..7845cae 100644 --- a/Rakefile +++ b/Rakefile @@ -3,10 +3,9 @@ Bundler.require Bundler::GemHelper.install_tasks require 'opal/rspec/rake_task' -require "react/source" Opal::RSpec::RakeTask.new(:default) do |s| - s.append_path File.dirname(::React::Source.bundled_path_for("react-with-addons")) + s.append_path Opal::React.bundled_path s.append_path 'spec/vendor' s.index_path = 'spec/reactjs/index.html.erb' -end \ No newline at end of file +end diff --git a/config.ru b/config.ru index 1a647c1..ab9515e 100644 --- a/config.ru +++ b/config.ru @@ -2,7 +2,6 @@ require 'bundler' Bundler.require require "opal-rspec" -require "react/source" Opal.append_path File.expand_path('../spec', __FILE__) @@ -10,7 +9,7 @@ run Opal::Server.new { |s| s.main = 'opal/rspec/sprockets_runner' s.append_path 'spec' s.append_path 'spec/vendor' - s.append_path File.dirname(::React::Source.bundled_path_for("react-with-addons.js")) + s.append_path Opal::React.bundled_path s.debug = true s.index_path = 'spec/reactjs/index.html.erb' } diff --git a/examples/todos/app/application.rb b/examples/todos/app/application.rb index 6985307..296a165 100755 --- a/examples/todos/app/application.rb +++ b/examples/todos/app/application.rb @@ -14,7 +14,8 @@ Vienna::Router.new.tap do |router| router.route('/:filter') do |params| - component.set_props(filter: params[:filter].empty? ? "all" : params[:filter]) + element = React.create_element(TodoAppView, filter: params[:filter].empty? ? "all" : params[:filter]) + component = React.render(element, `document.getElementById('todoapp')`) end end.update diff --git a/examples/todos/app/components/app.react.rb b/examples/todos/app/components/app.react.rb index 09fd045..baa8766 100644 --- a/examples/todos/app/components/app.react.rb +++ b/examples/todos/app/components/app.react.rb @@ -17,6 +17,7 @@ class TodoAppView Todo.on(:create) { Todo.adapter.sync_models(Todo); reload_current_filter } Todo.on(:update) { Todo.adapter.sync_models(Todo); reload_current_filter } Todo.on(:destroy) { Todo.adapter.sync_models(Todo); reload_current_filter } + reload_current_filter end before_receive_props do |next_props| diff --git a/lib/opal/react.rb b/lib/opal/react.rb new file mode 100644 index 0000000..9adf9ba --- /dev/null +++ b/lib/opal/react.rb @@ -0,0 +1,8 @@ +require 'opal' +require "opal-activesupport" + +require "opal/react/jsx_support" +require "opal/react/source" + +Opal.append_path File.expand_path('../../../opal', __FILE__).untaint +Opal.append_path File.expand_path('../../../vendor', __FILE__).untaint diff --git a/lib/opal/react/jsx_support.rb b/lib/opal/react/jsx_support.rb new file mode 100644 index 0000000..51efec9 --- /dev/null +++ b/lib/opal/react/jsx_support.rb @@ -0,0 +1,22 @@ +require 'react/jsx' +require 'tilt' +require "sprockets" + +module Opal + module React + module JSX + class Template < Tilt::Template + self.default_mime_type = 'application/javascript' + + def prepare + end + + def evaluate(scope, locals, &block) + @output ||= ::React::JSX.compile(data) + end + end + end + end +end + +Sprockets.register_engine '.jsx', Opal::React::JSX::Template diff --git a/lib/opal/react/source.rb b/lib/opal/react/source.rb new file mode 100644 index 0000000..12db113 --- /dev/null +++ b/lib/opal/react/source.rb @@ -0,0 +1,9 @@ +require "react/source" + +module Opal + module React + def self.bundled_path + File.dirname(::React::Source.bundled_path_for("react-with-addons.js")) + end + end +end diff --git a/lib/opal/react/version.rb b/lib/opal/react/version.rb new file mode 100644 index 0000000..a95415a --- /dev/null +++ b/lib/opal/react/version.rb @@ -0,0 +1,5 @@ +module Opal + module React + VERSION = "0.3.0" + end +end diff --git a/lib/react.rb b/lib/react.rb index f99e731..cc5f398 100644 --- a/lib/react.rb +++ b/lib/react.rb @@ -1,18 +1,18 @@ -if RUBY_ENGINE == 'opal' - require "react/top_level" - require "react/component" - require "react/element" - require "react/event" - require "react/version" - require "react/api" - require "react/validator" -else - require "tilt" - require "opal" - require "react/version" - require "opal-activesupport" - require "react/ext/jsx_support" - - Opal.append_path File.expand_path('../', __FILE__).untaint - Opal.append_path File.expand_path('../../vendor', __FILE__).untaint -end +# if RUBY_ENGINE == 'opal' +# require "react/top_level" +# require "react/component" +# require "react/element" +# require "react/event" +# require "react/version" +# require "react/component_factory" +# require "react/validator" +# else +# require "tilt" +# require "opal" +# require "react/version" +# require "opal-activesupport" +# require "react/ext/jsx_support" +require "opal/react" +# Opal.append_path File.expand_path('../', __FILE__).untaint +# Opal.append_path File.expand_path('../../vendor', __FILE__).untaint +#end diff --git a/lib/react/api.rb b/lib/react/api.rb deleted file mode 100644 index ffbc5d7..0000000 --- a/lib/react/api.rb +++ /dev/null @@ -1,109 +0,0 @@ -module React - class API - @@component_classes = {} - - def self.create_element(type, properties = {}, &block) - params = [] - - # Component Spec or Nomral DOM - if `(typeof type === 'function')` - params << type - elsif type.kind_of?(Class) - raise "Provided class should define `render` method" if !(type.method_defined? :render) - params << self.native_component_class(type) - else - raise "#{type} not implemented" unless HTML_TAGS.include?(type) - params << type - end - - # Passed in properties - props = {} - properties.map do |key, value| - if key == "class_name" && value.is_a?(Hash) - props[lower_camelize(key)] = value.inject([]) {|ary, (k,v)| v ? ary.push(k) : ary}.join(" ") - else - props[React::ATTRIBUTES.include?(lower_camelize(key)) ? lower_camelize(key) : key] = value - end - end - params << props.shallow_to_n - - # Children Nodes - if block_given? - children = [yield].flatten.each do |ele| - params << ele.to_n - end - end - - return `React.createElement.apply(null, #{params})` - end - - def self.clear_component_class_cache - @@component_classes = {} - end - - def self.native_component_class(type) - @@component_classes[type.to_s] ||= %x{ - React.createClass({ - propTypes: #{type.respond_to?(:prop_types) ? type.prop_types.to_n : `{}`}, - getDefaultProps: function(){ - return #{type.respond_to?(:default_props) ? type.default_props.to_n : `{}`}; - }, - getInitialState: function(){ - return #{type.respond_to?(:initial_state) ? type.initial_state.to_n : `{}`}; - }, - componentWillMount: function() { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_will_mount if type.method_defined? :component_will_mount}; - }, - componentDidMount: function() { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_did_mount if type.method_defined? :component_did_mount}; - }, - componentWillReceiveProps: function(next_props) { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_will_receive_props(`next_props`) if type.method_defined? :component_will_receive_props}; - }, - shouldComponentUpdate: function(next_props, next_state) { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.should_component_update?(`next_props`, `next_state`) if type.method_defined? :should_component_update?}; - }, - componentWillUpdate: function(next_props, next_state) { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_will_update(`next_props`, `next_state`) if type.method_defined? :component_will_update}; - }, - componentDidUpdate: function(prev_props, prev_state) { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_did_update(`prev_props`, `prev_state`) if type.method_defined? :component_did_update}; - }, - componentWillUnmount: function() { - var instance = this._getOpalInstance.apply(this); - return #{`instance`.component_will_unmount if type.method_defined? :component_will_unmount}; - }, - _getOpalInstance: function() { - if (this.__opalInstance == undefined) { - var instance = #{type.new(`this`)}; - } else { - var instance = this.__opalInstance; - } - this.__opalInstance = instance; - return instance; - }, - displayName: #{type.to_s}, - render: function() { - var instance = this._getOpalInstance.apply(this); - return instance.$render(); - } - }) - } - end - - private - - def self.lower_camelize(snake_cased_word) - words = snake_cased_word.split("_") - result = [words.first] - result.concat(words[1..-1].map {|word| word[0].upcase + word[1..-1] }) - result.join("") - end - end -end diff --git a/lib/react/ext/jsx_support.rb b/lib/react/ext/jsx_support.rb deleted file mode 100644 index 7734f5a..0000000 --- a/lib/react/ext/jsx_support.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'react/jsx' -require 'tilt' -require "sprockets" - -module React - module JSX - - class Template < Tilt::Template - self.default_mime_type = 'application/javascript' - - def prepare - end - - def evaluate(scope, locals, &block) - @output ||= React::JSX.compile(data) - end - end - end -end - -Sprockets.register_engine '.jsx', React::JSX::Template diff --git a/lib/react/ext/string.rb b/lib/react/ext/string.rb deleted file mode 100644 index 4f979c1..0000000 --- a/lib/react/ext/string.rb +++ /dev/null @@ -1,8 +0,0 @@ -class String - def event_camelize - `#{self}.replace(/(^|_)([^_]+)/g, function(match, pre, word, index) { - var capitalize = true; - return capitalize ? word.substr(0,1).toUpperCase()+word.substr(1) : word; - })` - end -end \ No newline at end of file diff --git a/lib/react/version.rb b/lib/react/version.rb deleted file mode 100644 index 50ba0cd..0000000 --- a/lib/react/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -module React - VERSION = "0.3.0" -end diff --git a/opal/react.rb b/opal/react.rb new file mode 100644 index 0000000..2f30dbb --- /dev/null +++ b/opal/react.rb @@ -0,0 +1,6 @@ +require "react/top_level" +require "react/component" +require "react/element" +require "react/event" +require "react/component_factory" +require "react/validator" diff --git a/lib/react/callbacks.rb b/opal/react/callbacks.rb similarity index 100% rename from lib/react/callbacks.rb rename to opal/react/callbacks.rb diff --git a/lib/react/component.rb b/opal/react/component.rb similarity index 69% rename from lib/react/component.rb rename to opal/react/component.rb index fc52d8a..31b692e 100644 --- a/lib/react/component.rb +++ b/opal/react/component.rb @@ -1,7 +1,9 @@ -require "./ext/string" +require "react/ext/string" require 'active_support/core_ext/class/attribute' require 'react/callbacks' require "react/ext/hash" +require "react/component/api" +require "react/element_children_handle" module React module Component @@ -20,25 +22,15 @@ def self.included(base) base.extend(ClassMethods) end - def initialize(native_element) - @native = native_element - end - def params - Hash.new(`#{@native}.props`) - end - - def refs - Hash.new(`#{@native}.refs`) - end - - def state - raise "No native ReactComponent associated" unless @native - Hash.new(`#{@native}.state`) + Hash.new(`#{self}.props`).inject({}) do |memo, (k,v)| + memo[k.underscore] = v + memo + end end def emit(event_name, *args) - self.params["_on#{event_name.to_s.event_camelize}"].call(*args) + self.params["on_#{event_name.to_s}"].call(*args) end def component_will_mount @@ -89,22 +81,39 @@ def method_missing(name, *args, &block) if name == "_p_tag" name = "p" end - - @buffer = [] unless @buffer + + unless @buffer + puts "first render for #{name}" + @buffer = [] + @root_element = true + else + @root_element = false + end + if block current = @buffer + is_root = @root_element @buffer = [] result = block.call element = React.create_element(name, *args) { @buffer.count == 0 ? result : @buffer } @buffer = current + @root_element = is_root else element = React.create_element(name, *args) end @buffer << element - element + + if @root_element + element + else + React::ElementChildrenHandle.new(@buffer, @buffer.length - 1) + end + end + + def to_n + self end - module ClassMethods def prop_types @@ -146,12 +155,10 @@ def define_state(*states) states.each do |name| # getter define_method("#{name}") do - return unless @native self.state[name] end # setter define_method("#{name}=") do |new_state| - return unless @native hash = {} hash[name] = new_state self.set_state(hash) @@ -161,49 +168,5 @@ def define_state(*states) end end end - - module API - include Native - - alias_native :dom_node, :getDOMNode - alias_native :mounted?, :isMounted - alias_native :force_update!, :forceUpdate - - def set_props(prop, &block) - raise "No native ReactComponent associated" unless @native - %x{ - #{@native}.setProps(#{prop.shallow_to_n}, function(){ - #{block.call if block} - }); - } - end - - def set_props!(prop, &block) - raise "No native ReactComponent associated" unless @native - %x{ - #{@native}.replaceProps(#{prop.shallow_to_n}, function(){ - #{block.call if block} - }); - } - end - - def set_state(state, &block) - raise "No native ReactComponent associated" unless @native - %x{ - #{@native}.setState(#{state.shallow_to_n}, function(){ - #{block.call if block} - }); - } - end - - def set_state!(state, &block) - raise "No native ReactComponent associated" unless @native - %x{ - #{@native}.replaceState(#{state.shallow_to_n}, function(){ - #{block.call if block} - }); - } - end - end end end diff --git a/opal/react/component/api.rb b/opal/react/component/api.rb new file mode 100644 index 0000000..3c726c9 --- /dev/null +++ b/opal/react/component/api.rb @@ -0,0 +1,44 @@ +module React + module Component + module API + def state + Hash.new(`#{self}.state`) + end + + def props + Hash.new(`#{self}.props`) + end + + def force_update! + `#{self}.forceUpdate()` + end + + def set_state(state, &block) + %x{ + #{self}.setState(#{state.shallow_to_n}, function(){ + #{block.call if block} + }); + } + end + + def refs + hash = {} + + %x{ + var refs = self.refs; + for (var property in refs) { + if (refs.hasOwnProperty(property)) { + #{hash[`property`] = `refs[property]`} + } + } + } + + hash + end + + def dom_node + raise "`dom_node` is deprecated in favor of `React.find_dom_node`" + end + end + end +end diff --git a/opal/react/component_factory.rb b/opal/react/component_factory.rb new file mode 100644 index 0000000..394698a --- /dev/null +++ b/opal/react/component_factory.rb @@ -0,0 +1,73 @@ +module React + class ComponentFactory + @@component_classes = {} + + def self.clear_component_class_cache + @@component_classes = {} + end + + def self.native_component_class(klass) + klass.class_eval do + include(React::Component::API) + # In Opal 0.8, native_alias fails if the method isn't there but we don't want to force all of these to be implemented + optional_native_alias = lambda do |js, ruby| + not_there = `!(#{self}.$$proto['$' + #{ruby}])` + native_alias js, ruby unless not_there + end + optional_native_alias[:componentWillMount, :component_will_mount] + optional_native_alias[:componentDidMount, :component_did_mount] + optional_native_alias[:componentWillReceiveProps, :component_will_receive_props] + optional_native_alias[:shouldComponentUpdate, :should_component_update?] + optional_native_alias[:componentWillUpdate, :component_will_update] + optional_native_alias[:componentDidUpdate, :component_did_update] + optional_native_alias[:componentWillUnmount, :component_will_unmount] + native_alias :render, :render + end + %x{ + if (!Object.assign) { + Object.defineProperty(Object, 'assign', { + enumerable: false, + configurable: true, + writable: true, + value: function(target, firstSource) { + 'use strict'; + if (target === undefined || target === null) { + throw new TypeError('Cannot convert first argument to object'); + } + + var to = Object(target); + for (var i = 1; i < arguments.length; i++) { + var nextSource = arguments[i]; + if (nextSource === undefined || nextSource === null) { + continue; + } + + var keysArray = Object.keys(Object(nextSource)); + for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) { + var nextKey = keysArray[nextIndex]; + var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey); + if (desc !== undefined && desc.enumerable) { + to[nextKey] = nextSource[nextKey]; + } + } + } + return to; + } + }); + } + function ctor(props){ + this.constructor = ctor; + this.state = #{klass.respond_to?(:initial_state) ? klass.initial_state.to_n : `{}`}; + React.Component.apply(this, arguments); + #{klass}.$$alloc.prototype.$initialize.call(this, Opal.Hash.$new(props)); + }; + ctor.prototype = klass.$$proto; + ctor.displayName = #{klass.to_s}; + Object.assign(ctor.prototype, React.Component.prototype); + ctor.propTypes = #{klass.respond_to?(:prop_types) ? klass.prop_types.to_n : `{}`}; + ctor.defaultProps = #{klass.respond_to?(:default_props) ? klass.default_props.to_n : `{}`}; + } + @@component_classes[klass.to_s] ||= `ctor` + end + end +end diff --git a/lib/react/element.rb b/opal/react/element.rb similarity index 81% rename from lib/react/element.rb rename to opal/react/element.rb index 9d87e09..bd167c2 100644 --- a/lib/react/element.rb +++ b/opal/react/element.rb @@ -1,7 +1,34 @@ -require "./ext/string" +require "react/ext/string" module React class Element < `(function(){var r = React;var f = function(){};var c = r.createClass({render:function(){return null;}});f.prototype = Object.getPrototypeOf(r.createElement(c));return f;})()` + def self.attach_event_callback(element, event_name) + name = event_name.to_s.event_camelize + + prop_key = "on#{name}" + + if React::Event::BUILT_IN_EVENTS.include?(prop_key) + callback = %x{ + function(event){ + #{yield React::Event.new(`event`)} + } + } + else + callback = %x{ + function(){ + #{yield *Array(`arguments`)} + } + } + end + + new_prop = `{}` + `new_prop[prop_key] = callback` + + new_element = `React.cloneElement(#{element}, #{new_prop})` + + return new_element + end + def self.new raise "use React.create_element instead" end @@ -22,29 +49,8 @@ def ref Native(`self.ref`) end - def on(event_name) - name = event_name.to_s.event_camelize - - - if React::Event::BUILT_IN_EVENTS.include?("on#{name}") - prop_key = "on#{name}" - callback = %x{ - function(event){ - #{yield React::Event.new(`event`)} - } - } - else - prop_key = "_on#{name}" - callback = %x{ - function(){ - #{yield *Array(`arguments`)} - } - } - end - - `self.props[#{prop_key}] = #{callback}` - - self + def on(event_name, &block) + self.class.attach_event_callback(self, event_name, &block) end def children diff --git a/opal/react/element_children_handle.rb b/opal/react/element_children_handle.rb new file mode 100644 index 0000000..bcdcc78 --- /dev/null +++ b/opal/react/element_children_handle.rb @@ -0,0 +1,17 @@ +require "react/element" + +module React + class ElementChildrenHandle + def initialize(children, index) + @children = children + @index = index + end + + def on(event_name, &block) + old_element = @children[@index] + new_element = React::Element.attach_event_callback(old_element, event_name, &block) + @children[@index] = new_element + self + end + end +end diff --git a/lib/react/event.rb b/opal/react/event.rb similarity index 100% rename from lib/react/event.rb rename to opal/react/event.rb diff --git a/lib/react/ext/hash.rb b/opal/react/ext/hash.rb similarity index 100% rename from lib/react/ext/hash.rb rename to opal/react/ext/hash.rb diff --git a/opal/react/ext/string.rb b/opal/react/ext/string.rb new file mode 100644 index 0000000..728331e --- /dev/null +++ b/opal/react/ext/string.rb @@ -0,0 +1,24 @@ +class String + def event_camelize + `#{self}.replace(/(^|_)([^_]+)/g, function(match, pre, word, index) { + var capitalize = true; + return capitalize ? word.substr(0,1).toUpperCase()+word.substr(1) : word; + })` + end + + # 'class_name' => 'className' + def lower_camelize + # TODO Could be implemented more efficiently + words = self.split("_") + result = [words.first] + result.concat(words[1..-1].map {|word| word[0].upcase + word[1..-1] }) + result.join("") + end + + # 'className' => 'class_name' + def underscore + return `self.replace(/[A-Z]/g, function(){ + return '_' + arguments[0].toLowerCase(); + });` + end +end diff --git a/opal/react/testing.rb b/opal/react/testing.rb new file mode 100644 index 0000000..3915922 --- /dev/null +++ b/opal/react/testing.rb @@ -0,0 +1,14 @@ +module React + module Testing + `var ReactTestUtils = React.addons.TestUtils` + + def simulate_event(event_name, dom_element, event_data = {}) + simulator = Native(`ReactTestUtils.Simulate`) + simulator[event_name].call(dom_element, event_data) + end + + def render_to_document(element) + `ReactTestUtils.renderIntoDocument(element)` + end + end +end diff --git a/lib/react/top_level.rb b/opal/react/top_level.rb similarity index 50% rename from lib/react/top_level.rb rename to opal/react/top_level.rb index 1154f9c..45b5e8b 100644 --- a/lib/react/top_level.rb +++ b/opal/react/top_level.rb @@ -1,5 +1,6 @@ require "native" require 'active_support' +require "react/ext/string" module React HTML_TAGS = %w(a abbr address area article aside audio b base bdi bdo big blockquote body br @@ -10,26 +11,44 @@ module React output p param picture pre progress q rp rt ruby s samp script section select small source span strong style sub summary sup table tbody td textarea tfoot th thead time title tr track u ul var video wbr) - ATTRIBUTES = %w(accept acceptCharset accessKey action allowFullScreen allowTransparency alt - async autoComplete autoPlay cellPadding cellSpacing charSet checked classID - className cols colSpan content contentEditable contextMenu controls coords - crossOrigin data dateTime defer dir disabled download draggable encType form - formAction formEncType formMethod formNoValidate formTarget frameBorder height - hidden href hrefLang htmlFor httpEquiv icon id label lang list loop manifest - marginHeight marginWidth max maxLength media mediaGroup method min multiple - muted name noValidate open pattern placeholder poster preload radioGroup - readOnly rel required role rows rowSpan sandbox scope scrolling seamless - selected shape size sizes span spellCheck src srcDoc srcSet start step style - tabIndex target title type useMap value width wmode dangerouslySetInnerHTML) def self.create_element(type, properties = {}, &block) - React::API.create_element(type, properties, &block) + params = [] + + # Component Spec or Nomral DOM + if `(typeof type === 'function')` + params << type + elsif type.kind_of?(Class) + raise "Provided class should define `render` method" if !(type.method_defined? :render) + params << React::ComponentFactory.native_component_class(type) + else + raise "#{type} not implemented" unless HTML_TAGS.include?(type) + params << type + end + + # Passed in properties + props = {} + properties.map do |key, value| + if key == "class_name" && value.is_a?(Hash) + props[key.lower_camelize] = value.inject([]) {|ary, (k,v)| v ? ary.push(k) : ary}.join(" ") + else + props[key.lower_camelize] = value + end + end + params << props.shallow_to_n + + # Children Nodes + if block_given? + children = [yield].flatten.each do |ele| + params << ele + end + end + + return `React.createElement.apply(null, #{params})` end def self.render(element, container) - component = Native(`React.render(#{element}, container, function(){#{yield if block_given?}})`) - component.class.include(React::Component::API) - component + `React.render(#{element}, container, function(){#{yield if block_given?}})` end def self.is_valid_element(element) @@ -50,7 +69,11 @@ def self.unmount_component_at_node(node) def self.expose_native_class(*args) args.each do |klass| - `window[#{klass.to_s}] = #{React::API.native_component_class(klass)}` + `window[#{klass.to_s}] = #{React::ComponentFactory.native_component_class(klass)}` end end + + def self.find_dom_node(component) + `React.findDOMNode(component)` + end end diff --git a/lib/react/validator.rb b/opal/react/validator.rb similarity index 100% rename from lib/react/validator.rb rename to opal/react/validator.rb diff --git a/react.rb.gemspec b/react.rb.gemspec index 5b349ce..1f36ace 100644 --- a/react.rb.gemspec +++ b/react.rb.gemspec @@ -1,9 +1,9 @@ # -*- encoding: utf-8 -*- -require File.expand_path('../lib/react/version', __FILE__) +require File.expand_path('../lib/opal/react/version', __FILE__) Gem::Specification.new do |s| s.name = 'react.rb' - s.version = React::VERSION + s.version = Opal::React::VERSION s.author = 'David Chang' s.email = 'zeta11235813@gmail.com' s.homepage = 'https://github.com/zetachang/react.rb' @@ -16,13 +16,13 @@ Gem::Specification.new do |s| s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.require_paths = ['lib', 'vendor'] - s.add_runtime_dependency 'opal', '~> 0.6.0' + s.add_runtime_dependency 'opal', ['>= 0.7.0', '< 0.9'] s.add_runtime_dependency 'opal-activesupport', '~> 0' s.add_runtime_dependency 'therubyracer', '~> 0' s.add_runtime_dependency 'react-jsx', '~> 0.8.0' - s.add_runtime_dependency 'sprockets', '>= 2.2.3', '< 3.0.0' - s.add_development_dependency 'react-source', '~> 0.12.0' - s.add_development_dependency 'opal-rspec', '~> 0.3.0.beta3' + s.add_runtime_dependency 'sprockets', '~> 3.1' + s.add_runtime_dependency 'react-source', '~> 0.13' + s.add_development_dependency 'sinatra', '~> 1' s.add_development_dependency 'opal-jquery', '~> 0' s.add_development_dependency 'rake', '~> 10' diff --git a/spec/component_factory_spec.rb b/spec/component_factory_spec.rb new file mode 100644 index 0000000..71b1085 --- /dev/null +++ b/spec/component_factory_spec.rb @@ -0,0 +1,28 @@ +require "spec_helper" + +describe React::ComponentFactory do + describe "native_component_class" do + it "should bridge the defined life cycle methods" do + stub_const 'Foo', Class.new + Foo.class_eval do + def component_will_mount; end + def component_did_mount; end + def component_will_receive_props; end + def should_component_update?; end + def component_will_update; end + def component_did_update; end + def component_will_unmount; end + end + + ctor = React::ComponentFactory.native_component_class(Foo) + instance = `new ctor` + expect(`instance.$component_will_mount`).to be(`instance.componentWillMount`) + expect(`instance.$component_did_mount`).to be(`instance.componentDidMount`) + expect(`instance.$component_will_receive_props`).to be(`instance.componentWillReceiveProps`) + expect(`instance["$should_component_update?"]`).to be(`instance.shouldComponentUpdate`) + expect(`instance.$component_will_update`).to be(`instance.componentWillUpdate`) + expect(`instance.$component_did_update`).to be(`instance.componentDidUpdate`) + expect(`instance.$component_will_unmount`).to be(`instance.componentWillUnmount`) + end + end +end diff --git a/spec/component_spec.rb b/spec/component_spec.rb index 96fa5c9..3dcec79 100644 --- a/spec/component_spec.rb +++ b/spec/component_spec.rb @@ -1,10 +1,6 @@ require "spec_helper" describe React::Component do - after(:each) do - React::API.clear_component_class_cache - end - it "should define component spec methods" do stub_const 'Foo', Class.new Foo.class_eval do @@ -50,7 +46,7 @@ def bar2; end expect_any_instance_of(Foo).to receive(:bar) expect_any_instance_of(Foo).to receive(:bar2) - renderToDocument(Foo) + render_to_document(React.create_element(Foo)) end it "should invoke `after_mount` registered methods when `componentDidMount()`" do @@ -63,7 +59,7 @@ def bar4; end expect_any_instance_of(Foo).to receive(:bar3) expect_any_instance_of(Foo).to receive(:bar4) - renderToDocument(Foo) + render_to_document(React.create_element(Foo)) end it "should allow multiple class declared life cycle hooker" do @@ -84,7 +80,7 @@ def render expect_any_instance_of(Foo).to receive(:bar) - renderToDocument(Foo) + render_to_document(React.create_element(Foo)) end it "should allow block for life cycle callback" do @@ -96,8 +92,8 @@ def render end end - element = renderToDocument(Foo) - expect(element.state.foo).to be("bar") + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be("bar") end end @@ -121,8 +117,8 @@ def set_up end end - element = renderToDocument(Foo) - expect(element.state.foo).to be("bar") + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be("bar") end it "should define init state by passing a block to `define_state`" do @@ -130,8 +126,8 @@ def set_up define_state(:foo) { 10 } end - element = renderToDocument(Foo) - expect(element.state.foo).to be(10) + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be(10) end it "should define getter using `define_state`" do @@ -143,8 +139,8 @@ def bump end end - element = renderToDocument(Foo) - expect(element.state.foo).to be(30) + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be(30) end it "should define multiple state accessor by passing symols array to `define_state`" do @@ -157,9 +153,9 @@ def set_up end end - element = renderToDocument(Foo) - expect(element.state.foo).to be(10) - expect(element.state.foo2).to be(20) + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be(10) + expect(instance.state[:foo2]).to be(20) end it "should invoke `define_state` multiple times to define states" do @@ -168,9 +164,9 @@ def set_up define_state(:foo2) { 40 } end - element = renderToDocument(Foo) - expect(element.state.foo).to be(30) - expect(element.state.foo2).to be(40) + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be(30) + expect(instance.state[:foo2]).to be(40) end it "should raise error if multiple states and block given at the same time" do @@ -189,8 +185,8 @@ def render end end - element = renderToDocument(Foo) - expect(element.getDOMNode.textContent).to eq("10") + instance = render_to_document(React.create_element(Foo)) + expect(`#{React.find_dom_node(instance)}.textContent`).to eq("10") end it "should support original `setState` as `set_state` method" do @@ -200,21 +196,8 @@ def render end end - element = renderToDocument(Foo) - expect(element.state.foo).to be("bar") - end - - it "should support original `replaceState` as `set_state!` method" do - Foo.class_eval do - before_mount do - self.set_state(foo: "bar") - self.set_state!(bar: "lorem") - end - end - - element = renderToDocument(Foo) - expect(element.state.foo).to be_nil - expect(element.state.bar).to eq("lorem") + instance = render_to_document(React.create_element(Foo)) + expect(instance.state[:foo]).to be("bar") end it "should support originl `state` method" do @@ -264,8 +247,8 @@ def render end end - element = renderToDocument(Foo, prop: "foobar") - expect(element.getDOMNode.textContent).to eq("foobar") + instance = render_to_document(React.create_element(Foo, prop: "foobar")) + expect(`#{React.find_dom_node(instance)}.textContent`).to eq("foobar") end it "should access nested params as orignal Ruby object" do @@ -275,41 +258,8 @@ def render end end - element = renderToDocument(Foo, prop: [{foo: 10}]) - expect(element.getDOMNode.textContent).to eq("10") - end - end - - describe "Props Updating" do - before do - stub_const 'Foo', Class.new - Foo.class_eval do - include React::Component - end - end - - it "should support original `setProps` as method `set_props`" do - Foo.class_eval do - def render - React.create_element("div") { params[:foo] } - end - end - - element = renderToDocument(Foo, {foo: 10}) - element.set_props(foo: 20) - expect(element.dom_node.innerHTML).to eq('20') - end - - it "should support original `replaceProps` as method `set_props!`" do - Foo.class_eval do - def render - React.create_element("div") { params[:foo] ? "exist" : "null" } - end - end - - element = renderToDocument(Foo, {foo: 10}) - element.set_props!(bar: 20) - expect(element.dom_node.innerHTML).to eq('null') + instance = render_to_document(React.create_element(Foo, prop: [{foo: 10}])) + expect(`#{React.find_dom_node(instance)}.textContent`).to eq("10") end end @@ -349,9 +299,14 @@ def render; div; end var org_console = window.console; window.console = {warn: function(str){log.push(str)}} } - renderToDocument(Foo, bar: 10, lorem: Lorem.new) - `window.console = org_console;` - expect(`log`).to eq(["Warning: In component `Foo`\nRequired prop `foo` was not specified\nProvided prop `bar` was not the specified type `String`"]) + + begin + render_to_document(React.create_element(Foo, bar: 10, lorem: Lorem.new)) + + expect(`log`).to eq(["Warning: Failed propType: In component `Foo`\nRequired prop `foo` was not specified\nProvided prop `bar` was not the specified type `String`"]) + ensure + `window.console = org_console;` + end end it "should not log anything if validation pass" do @@ -371,9 +326,12 @@ def render; div; end var org_console = window.console; window.console = {warn: function(str){log.push(str)}} } - renderToDocument(Foo, foo: 10, bar: "10", lorem: Lorem.new) - `window.console = org_console;` - expect(`log`).to eq([]) + begin + render_to_document(React.create_element(Foo, foo: 10, bar: "10", lorem: Lorem.new)) + expect(`log`).to eq([]) + ensure + `window.console = org_console;` + end end end @@ -417,10 +375,9 @@ def render end end - element = React.create_element(Foo) - instance = renderElementToDocument(element) - simulateEvent(:click, instance) - expect(instance.state.clicked).to eq(true) + instance = render_to_document(React.create_element(Foo)) + simulate_event(:click, React.find_dom_node(instance)) + expect(instance.state[:clicked]).to eq(true) end it "should invoke handler on `this.props` using emit" do @@ -438,7 +395,7 @@ def render expect { |b| element = React.create_element(Foo).on(:foo_submit, &b) - renderElementToDocument(element) + render_to_document(element) }.to yield_with_args("bar") end @@ -457,7 +414,7 @@ def render expect { |b| element = React.create_element(Foo).on(:foo_invoked, &b) - renderElementToDocument(element) + render_to_document(element) }.to yield_with_args([1,2,3], "bar") end end @@ -477,23 +434,24 @@ def render end end - element = renderToDocument(Foo) - expect(element.refs.field).not_to be_nil + instance = render_to_document(React.create_element(Foo)) + expect(`#{React.find_dom_node(instance.refs[:field])}.tagName`).to eq('INPUT') end it "should access refs through `refs` method" do Foo.class_eval do def render React.create_element("input", type: :text, ref: :field).on(:click) do - refs[:field].value = "some_stuff" + input_field = Native(React.find_dom_node(refs[:field])) + input_field.value = "some_stuff" end end end - element = renderToDocument(Foo) - simulateEvent(:click, element) + instance = render_to_document(React.create_element(Foo)) + simulate_event(:click, React.find_dom_node(instance)) - expect(element.refs.field.value).to eq("some_stuff") + expect(`#{React.find_dom_node(instance.refs[:field])}.value`).to eq("some_stuff") end end @@ -575,23 +533,38 @@ def render expect(Kernel).to receive(:p).with("first") expect(Kernel).to receive(:p).with("second") - renderToDocument(Foo) + render_to_document(React.create_element(Foo)) end - end - - describe "isMounted()" do - it "should return true if after mounted" do + + it "should return React::Element for root element" do stub_const 'Foo', Class.new Foo.class_eval do include React::Component def render - React.create_element("div") + div end end + + expect(Foo.new.render).to be_a(React::Element) + end + + it "should return React::ElementChildrenHandle for inner children" do + 'var inner;' + + stub_const 'Foo', Class.new + Foo.class_eval do + include React::Component - component = renderToDocument(Foo) - expect(component.mounted?).to eq(true) + def render + div do + `inner = #{div {'lorem'}}` + end + end + end + + expect(Foo.new.render).to be_a(React::Element) + expect(`inner`).to be_a(React::ElementChildrenHandle) end end end diff --git a/spec/element_spec.rb b/spec/element_spec.rb index 6934842..9a7ce91 100644 --- a/spec/element_spec.rb +++ b/spec/element_spec.rb @@ -4,6 +4,7 @@ it "should be toll-free bridged to React.Element" do element = React.create_element('div') expect(`React.isValidElement(#{element})`).to eq(true) + expect(element.kind_of?(React::Element)).to eq(true) end describe "#new" do @@ -54,26 +55,29 @@ it "should be subscribable through `on(:event_name)` method" do expect { |b| element = React.create_element("div").on(:click, &b) - instance = renderElementToDocument(element) - simulateEvent(:click, instance) + instance = render_to_document(element) + simulate_event(:click, React.find_dom_node(instance)) }.to yield_with_args(React::Event) expect { |b| element = React.create_element("div").on(:key_down, &b) - instance = renderElementToDocument(element) - simulateEvent(:keyDown, instance, {key: "Enter"}) + instance = render_to_document(element) + simulate_event(:keyDown, React.find_dom_node(instance), {key: "Enter"}) }.to yield_control expect { |b| element = React.create_element("form").on(:submit, &b) - instance = renderElementToDocument(element) - simulateEvent(:submit, instance, {}) + instance = render_to_document(element) + simulate_event(:submit, React.find_dom_node(instance), {}) }.to yield_control end - it "should return self for `on` method" do - element = React.create_element("div") - expect(element.on(:click){}).to eq(element) + it "should return the copied ReactElement for `on` method" do + element = React.create_element("div", {foo: "bar"}) + + new_element = element.on(:click){} + expect(new_element).to be_a(React::Element) + expect(new_element.props[:foo]).to eq('bar') end end diff --git a/spec/event_spec.rb b/spec/event_spec.rb index 40f3c7f..11a14e3 100644 --- a/spec/event_spec.rb +++ b/spec/event_spec.rb @@ -16,7 +16,7 @@ expect(event).to respond_to(:prevent_default) expect(event).to respond_to(:stop_propagation) end - instance = renderElementToDocument(element) - simulateEvent(:click, instance) + instance = render_to_document(element) + simulate_event(:click, React.find_dom_node(instance)) end -end \ No newline at end of file +end diff --git a/spec/react_spec.rb b/spec/react_spec.rb index a0a7151..a5547cb 100644 --- a/spec/react_spec.rb +++ b/spec/react_spec.rb @@ -1,10 +1,6 @@ require "spec_helper" describe React do - after(:each) do - React::API.clear_component_class_cache - end - describe "is_valid_element" do it "should return true if passed a valid element" do element = `React.createElement('div')` @@ -47,14 +43,6 @@ expect(React.is_valid_element(element)).to eq(true) expect(element.children.length).to eq(3) end - - it "should render element with children as array when block yield Array of element" do - element = React.create_element('div') do - [React.create_element('span'), React.create_element('span'), React.create_element('span')] - end - instance = renderElementToDocument(element) - expect(instance.getDOMNode.childNodes.length).to eq(3) - end end describe "custom element" do before do @@ -123,8 +111,8 @@ def render end end - renderToDocument(Foo) - renderToDocument(Foo) + render_to_document(React.create_element(Foo)) + render_to_document(React.create_element(Foo)) expect(`count`).to eq(2) end @@ -141,9 +129,10 @@ def render expect(element.props[:foo]).to eq("bar") end - it "should not camel-case custom property" do - element = React.create_element("div", foo_bar: "foo") - expect(element.props[:foo_bar]).to eq("foo") + it "should camel-case all property" do + element = React.create_element("div", foo_bar: "foo", class_name: 'fancy') + expect(element.props[:fooBar]).to eq("foo") + expect(element.props[:className]).to eq("fancy") end end @@ -180,14 +169,6 @@ def render React.render(React.create_element('span') { "lorem" }, div) end - it "should return a React::Component::API compatible object" do - div = `document.createElement("div")` - component = React.render(React.create_element('span') { "lorem" }, div) - React::Component::API.public_instance_methods(true).each do |method_name| - expect(component).to respond_to(method_name) - end - end - pending "should return nil to prevent abstraction leakage" do div = `document.createElement("div")` expect { diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2ed31ff..6ed1d37 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,29 +1,9 @@ require 'react' - -module ReactTestHelpers - `var ReactTestUtils = React.addons.TestUtils` - - def renderToDocument(type, options = {}) - element = React.create_element(type, options) - return renderElementToDocument(element) - end - - def renderElementToDocument(element) - instance = Native(`ReactTestUtils.renderIntoDocument(#{element})`) - instance.class.include(React::Component::API) - return instance - end - - def simulateEvent(event, component, params = {}) - simulator = Native(`ReactTestUtils.Simulate`) - simulator[event.to_s].call(`#{component.to_n}.getDOMNode()`, params) - end - - def isElementOfType(element, type) - `React.addons.TestUtils.isElementOfType(#{element}, #{type.cached_component_class})` - end -end +require 'react/testing' RSpec.configure do |config| - config.include ReactTestHelpers + config.include React::Testing + config.after :each do + React::ComponentFactory.clear_component_class_cache + end end