require 'opal' require 'browser/interval' # gives us wrappers on javascript methods such as setTimer and setInterval require 'jquery' require 'opal-jquery' # gives us a nice wrapper on jQuery which we will use mainly for HTTP calls require "json" # json conversions require 'reactive-ruby' # and the whole reason we are gathered here today! Document.ready? do # Document.ready? is a opal-jquery method. The block will run when doc is loaded # render an instance of the CommentBox component at the '#content' element. # url and poll_interval are the initial params for this comment box React.render( React.create_element( CommentBox, url: "comments.json", poll_interval: 2), Element['#content'] ) end class CommentBox # A react component is simply a class that has a "render" method. # But including React::Component mixin provides a nice dsl, and many other features include React::Component # Components can have parameters that are passed in when the component is first "mounted" # and then updated as the application state changes. In this case url, and poll_interval will # never change since this is the top level component. required_param :url required_param :poll_interval # Components also may have internal state variables, which are like instance variables, # with one added feature: Changing state causes a rerender to occur. # The "comments" state is being initialized by parsing the javascript object at window.initial_comments # This is not a react feature, but was just set up in the HTML header (see config.ru for how this was done). define_state comments: JSON.from_object(`window.initial_comments`) # The following call backs are made during the component lifecycle: # before_mount before component is first rendered # after_mount after component is first rendered, after DOM is loaded. ONLY CALLED ON CLIENT # before_receive_props when component is being about to be rerendered by an outside state change. CANCELLABLE # before_update just before a rerender, and not cancellable. # after_update after DOM has been updated. # before_unmount before component instance will be removed. Use this to kill low level handlers etc. # just to show off how these callbacks work we have separated setting up a repeating fetch into three pieces. # before mounting we will initialize a polling loop, but we don't want to start it yet. before_mount do @fetcher = every(poll_interval) do # we use the opal browser utility to call the server every poll_interval seconds HTTP.get(url) do |response| # notice that params poll_interval, and url are accessed as instance methods if response.ok? comments! JSON.parse(response.body) # comments!(value) updates the state and notifies react of the state change else puts "failed with status #{response.status_code}" end end end end # once we have things up and displayed lets start polling for updates after_mount do puts "start me up!" @fetcher.start end # finally our component should be a good citizen and stop the polling when its unmounted before_unmount do @fetcher.stop end # components can have their own methods like any other class # in this case we receive a new comment and send it the server def send_comment_to_server(comment) HTTP.post(url, payload: comment) do |response| puts "failed with status #{response.status_code}" unless response.ok? end comment end # every component must implement a render method. The method must generate a single # react virtual DOM element. React compares the output of each render and determines # the minimum actual DOM update needed. # A very common mistake is to try generate two or more elements (or none at all.) Either case will # throw an error. Just remember that there is already a DOM node waiting for the output of the render # hence the need for exactly one element per render. def render # the dsl syntax is simply a method call, with params hash, followed by a block # the built in dsl methods correspond to the standard HTML5 tags such as div, h1, table, tr, td, span etc. #return div.comment { h1 {"hello"} } div class: "commentBox" do # just like
Comments
# Custom components use their class name, as the tag. Notice that the comments state is passed to # to the CommentList component. This is the normal React paradigm: Data flows towards the leaf nodes. CommentList comments: comments # Sometimes its necessary for data to move upwards, and react provides several ways to do this. # In this case we need to know when a new comment is submitted. So we pass a callback proc. # The callback takes the new comment and sends it to the server and then pushes it onto the comments list. # Again the comments! method is used to signal that the state is changing. The use of the "bang" pseudo # operator is important as the value of comments has NOT changed (its still tha same array), but its # internal state has. CommentForm submit_comment: lambda { |comment| comments! << send_comment_to_server(comment)} end end end # Our second component! class CommentList include React::Component # As we saw above a CommentList component takes a comments parameter # Here we introduce optional parameter type checking. The syntax [Hash] means "Array of Hashes" # In our case each comment is a hash with an author and text key. # Failure to match the type puts a warning on the console not an error, # and only in development mode not production. required_param :comments, type: Array # This is a good place to think more about the component lifecycle. The first time # CommentList is mounted, comments will be the initial array of author, text hashes. # As new comments are added the component will receive new params. However the component # does NOT reinitialize its state. If changes in state are needed as result of incoming param changes # the before_receive_props call back can be used. def render # Lets render some comments - all we need to do is iterate over the comments array using the usual # ruby "each" method. # This is a good place to clarify how the DSL works. Notice that we use comments.each NOT comments.collect # When a tag method (such as div, or Comment) is called its "output" is internally pushed into a render buffer. # This simplifies the DSL by separating the control flow from the output, but can sometimes be a bit confusing. div.commentList.and_another_class.and_another do # you can also include the class haml style (tx to @dancinglightning!) comments.each do |comment| # By now we are getting used to the react paradigm: Stuff comes in, is processed, and then # passed to next lower level. In this case we pass along each author-text pair to the Comment component. Comment author: comment[:author], text: comment[:text], hash: comment end end end end # Notice that the above CommentList component had no state. Each time its parameters change, it simply re-renders. # CommentForm does have internal state as we will see... class CommentForm include React::Component # While declaring the type of a param is optional its handy not only for debug, but also to let React create # appropriate helpers based on the type. In this case we are passing in a Proc, and so React will treat the # "submit_comment" param specially. Instead of submit_comment returning its value (as the previous params have done) # it will call the associated Proc, thus allow CommentForm to communicate state changes back to the parent. required_param :submit_comment, type: Proc # We are going to have 2 state variable. One for each field in the comment. As the user types, # these state variables will be updating causing a rerender of the CommentForm (but no other components.) define_state :author, :text def render div do div do "Author: ".span # Note the shorthand for span { "Author" }. You can do this with br, span, th, td, and para (for p) tags # Now we are going to generate an input tag. Notice how the author state variable is provided. Referencing # author is what will cause us to re-render and update the input as the value of author changes. # React will optimize the updates so parts that are not changing will not be effected. input.author_name(type: :text, value: author, placeholder: "Your name", style: {width: "30%"}). # and we attach an on_change handler to the input. As the input changes we simply update author. on(:change) { |e| author! e.target.value } end div do # lets have some fun with the text. Same deal as the author except we will use a text area... div(style: {float: :left, width: "50%"}) do textarea(value: text, placeholder: "Say something...", style: {width: "90%"}, rows: 30). on(:change) { |e| text! e.target.value } end # and lets use Showdown to allow for markdown, and display the mark down to the left of input # we will define Showdown later, and it will be our first reusable component, as we will use it twice. div(style: {float: :left, width: "50%"}) do Showdown markup: text end end # Finally lets give the use a button to submit changes. Why not? We have come this far! # Notice how the submit_comment proc param allows us to be ignorant of how the update is made. # Notice that (author! "") updates author, but returns the current value. # This is usually the desired behavior in React as we are typically interested in state changes, # and before/after values, not simply doing a chained update of multiple variables. button { "Post" }.on(:click) { submit_comment :author => (author! ""), :text => (text! "") } end end end # Wow only two more components left! This one is a breeze. We just take the author, and text and display # them. We already know how to use our Showdown component to display the markdown so we can just reuse that. class Comment include React::Component required_param :author required_param :text required_param :hash, type: Hash def render div.comment do h2.comment_author { author } # NOTE: single underscores in haml style class names are converted to dashes # so comment_author becomes comment-author, but comment__author would be comment_author # this is handy for boot strap names like col-md-push-9 which can be written as col_md_push_9 Showdown markup: text end end end # Last but not least here is our ShowDown Component class Showdown include React::Component required_param :markup def render # we will use some Opal lowlevel stuff to interface to the javascript Showdown class # we only need to build the converter once, and then reuse it so we will use a plain old # instance variable to keep track of it. @converter ||= Native(`new Showdown.converter()`) # then we will take our markup param, and convert it to html raw_markup = @converter.makeHtml(markup) if markup # React.js takes a very dim view of passing raw html so its purposefully made # difficult so you won't do it by accident. After all think of how dangerous what we # are doing right here is! # The span tag can be replaced by any tag that could sensibly take a child html element. # You could also use div, td, etc. span(dangerously_set_inner_HTML: {__html: raw_markup}) end end