ruby-hyperloop / ruby-hyperloop.io

The project has moved to Hyperstack!! - Ruby Hyperloop Website and Documentation
https://hyperstack.org/
22 stars 18 forks source link

What is the history and organization of the catprint team? #102

Open catmando opened 6 years ago

catmando commented 6 years ago

This history should be edited, and inserted somewhere in the docs. I apologize for the rather stilted 3rd person narrative, hopefully, once it's integrated into the docs this will read a bit less narcissistically.

Please update if I have missed any significant people or events.

In February of 2015, David Chang (@zetachang) started building a DSL wrapper for React.js using the Opal Ruby to JS transpiler. At this time the gem was called React.rb

Back in 2014 Mitch VanDuyn (@catmando) started experimenting with Opal as a way to simplify client-side development, by focusing on a single language. Some successful initial improvements to the CatPrint.com website were done in straight Opal, but it was clear that some kind of framework would be useful.

After experiments with other solutions, @catmando stumbled on React.rb and started replacing some of the straight Opal code with React components written in Ruby. Here is one piece of code that remains close to its original form on the catprint website (click on the Chat button to see it in action.)

module Components
  class Chat < React::Component
    POLL_INTERVAL = 5 * 60

    param online_text: nil
    param offline_text: nil
    param inner_elements: nil
    param name: ''
    param email: ''

    state :online, scope: :shared, reader: true

    after_mount do
      Chat.start_polling
    end

    class << self
      def start_polling
        unless @started
          check_status
          every(POLL_INTERVAL) { check_status(false) }
          @started = true
        end
      end

      def open(opts = {}, &failure)
        check_status do |current_online_state|
          if current_online_state
            chat_params = []
            unless opts[:email].strip.blank?
              chat_params << "interaction[email]=#{opts.delete(:email)}"
            end
            unless opts[:name].strip.blank?
              chat_params << "interaction[name]=#{opts.delete(:name)}"
            end
            url =
              "http://support.catprint.com:80/customer/widget/chats/new?#{chat_params.join('&')}"
            opts = { resizable: 1, status: 0, toolbar: 0, width: 640, height: 700 }.merge opts
            opts_string = opts.collect { |k, v| "#{k}=#{v}" }.join(', ')
            `window.open(#{url}, 'assistly_chat', #{opts_string});`
          elsif failure
            yield failure
          end
        end
      end

      def check_status(force_update = true)
        HTTP.get("#{configuration[:desk_online_agents_url]}#{'&force_update=true' if force_update}")
            .then do |response|
              new_online_state =
                JSON.parse(response.body[1..-2])[:routing_agents].to_i > 0 rescue nil
              yield new_online_state if block_given?
              Chat.online! new_online_state if Chat.online ^ new_online_state
            end
      end
    end

    render do
      if Chat.online
        A do
          SPAN { params.online_text }
          if params.inner_elements
            SPAN(class: params.inner_elements[:classes]) { params.inner_elements[:text] }
          end
        end.on(:click) do
          Chat.open(email: params.email, name: params.name) { alert('no chat right now') }
        end
      elsif params.offline_text
        A(class: 'chat-disabled') do
          SPAN { params.offline_text }
          if params.inner_elements
            SPAN(class: params.inner_elements[:classes]) { params.inner_elements[:text] }
          end
        end
      end
    end
  end
end

In React.js there is no built-in way to for components to be notified of global event changes. Typically React based frameworks develop a subscription-notification mechanism, where components subscribe to a central store's data change event and uses the notification to update internal state in the component. Based on @ryanstout's Volt computations @catmando added in 2015 a central state manager that allows components to automatically and dynamically subscribe and unsubscribe to each other's state changes. Because this concept is so fundamental to what makes Hyperloop special, its worth a bit of detailed explanation:

class Clicker < Hyperloop::Component
  state clicks: 0, scope: :shared, reader: true
  # there is a single click state shared by all instances, that
  # is initialized to zero, and has a class level reader method (called clicks)
  state :maxed_out, scope: :shared, reader: true
  # same store for maxed_out, but its initial value is the default - nil.

  MAXCLICKS = 5

  def click
    mutate.clicks state.clicks+1
    mutate.maxed_out true if state.clicks >= Clicker::MAXCLICKS
  end

  render do
    if state.maxed_out
      DIV { "No More Clicks For You" }
    else
      BUTTON { "Click Me" }.on(:click, &:click)
    end
  end
end

class DisplayClicks < Hyperloop::Component
  render do
    if Clicker.maxed_out # read the maxed_out state, and remember that we care
      "#{Clicker::MAXCLICKS} Clicks"
    elsif Clicker.clicks.zero? # if execution gets to here we will also care about clicks
      "No clicks yet"
    elsif Clicker.clicks == 1
       "1 Click"
    else
       "#{Clicker.clicks} Clicks"
    end
  end
end

This example while contrived does show that as DisplayClicks renders it always interrogates Clicker's maxed_out state. If maxed_out is falsly then Clicker's clicks state is also read. When a "global" state like this is read the Hyperloop state manager remembers that the component's rendering is dependent on that state's value, and should it change the component must be re-rendered. Each time through the rendering process a new set of dependencies will be recorded. The result is a component automatically and dynamically subscribes to state changes as needed.

Meanwhile, the big problem that Catprint.com faced was a huge legacy base of AR models, that interacted with the user via a combination of classic page loads, and ad-hoc ajax code. In order to rewrite the UI in React.rb, a complete set of API's, client-side stores, and some kind of flux loop would have to be built.

Out of this was born the HyperModel concept of compiling the AR models into client-side proxies using Opal, and directly accessing them in the React Components. Hence the react component acts very much like a rails view, where the view code accesses AR data directly and renders it to the view. Using the automatic subscription notification components could be updated as data in the AR models arrived from the server or were updated by local actions on the client.

The first iteration of this concept was called ReactiveRecord. Later when the ability to handle push synchronization from the server was added, the name was changed to Synchromesh. Synchromesh uses a combination of Channels and Policies to describe what data will get pushed to who. Channels are simply a class or individual user like objects. For example a User, a Team, all Administrators, etc. Each client will be subscribed to a number of channels based on the current logged in User. Then each Model describes what data changes a user may see based on Policies.

Besides work on ReactiveRecord and Synchromesh during this period there were a number of other significant contributors especially Adam Jahn (@ajjahn) who did a lot of code clean up. @zetachang had to drop out of the project for 2 years for personal reasons, and @catmando became the lead. React.rb went through a number of name changes, before settling on Hyperloop, a name proposed by Barrie Hadfield (@barriehadfield). Synchromesh was then renamed to Hypermesh, and React.rb ended up becoming HyperComponent.

BTW currently many of these gem names are simply wrappers around the original Gem names, as we have just not had time to do the full rename of all the internal code. So you will see names like Synchromesh still floating around.

Meanwhile, the Catprint team was working very hard on their V4 website upgrade, which was to be done entirely in Opal using React.rb. As bugs or features were found, these were added the base set of Hyperloop gems.

@barriehadfield introduced the idea of Trailblazer operations and during 2016 there was a lot of discussion on how to correctly incorporate these concepts and use ideas like the Flux loop. It was realized that by adding a broadcast capability to Operations, they became a perfect way to encode the Flux Action and Dispatcher concepts. Then it was realized that there was no reason an Operation could not run (if needed) on the Server, but the broadcast is made to all clients. So Operations became both a way to structure application code on the client or server, across client and server, and also became the underlying primitive for all hyperloop client-server communication. Hypermesh was refactored so it used Operations, and was renamed to HyperModel.

Using Operations the above Chat component could be simplified and clarified. In the current legacy implementation, the server side code is opaque and is hidden behind an API endpoint, with Operations we would simply write the methods we needed executed on the server, and call them in the Chat Component.

Having global states (like shown in the above Clicker example) did not provide a good separation of concerns, and that is why in 2017 state management was moved to a separate HyperStore gem so that states could be defined separately from components.

In 2016 @loicboutet built the hyperloop rails generator that would install hyperloop config files into a rails app, and provide a generator for a skeleton component. This made building a new Hyperloop App or integrating in a brownfield rails app became a lot easier.

Starting in late 2016 @barriehadfield and @fzingg have worked tirelessly on pulling together the documentation site.

Throughout 2016 @catmando experimented with how to test Hyperloop code. Hyperloop had now evolved to be a Full Stack Single Language framework. It encompassed both client and server code and made the client-server barrier as transparent as possible. In order to test a component it seemed that you needed full stack test helpers that would allow a single test spec on the server to test the component as it would behave within the stack. A set of ad-hoc spec helpers were developed both for testing the CatPrint application and the evolving set of gems, tutorials and examples. For instance here is a spec for a Bleed component that displays the value of the Bleed Option (aka Print to Edge) on the CatPrint website.

require 'spec_helper'

describe 'Bleed component', js: true do
  before(:each) do
    client_option layout: 'test'
    size_window(:large)
  end

  it 'displays No bleed option selected (in danger-text) if no bleed is selected' do
    mount 'Bleed', bleed: nil
    page.should have_selector('.danger-text', text: 'No bleed option selected')
  end

  it 'displays Full bleed if Bleed Option is Yes' do
    mount 'Bleed', bleed: FactoryGirl.create(:full_bleed_option, name: 'Yes')
    page.should_not have_content('No bleed option selected', wait: 0)
    page.should have_content('Full Bleed', wait: 0)
  end

  it 'displays No bleed if Bleed Option is No' do
    mount 'Bleed', bleed: FactoryGirl.create(:full_bleed_option, name: 'No')
    page.should_not have_content('No bleed option selected', wait: 0)
    page.should have_content('No Bleed', wait: 0)
  end

  it 'adds cursor pointer to danger text if No bleed option selected' do
    mount 'Bleed', bleed: nil
    page.find('.danger-text')['outerHTML'].should include('cursor: pointer')
  end

  it 'raises the clear error event if the user clicks on danger text' do
    mount 'Bleed', bleed: nil
    page.find('.danger-text').click
    event_history_for(:clear_error).length.should eq(1)
  end
end

The mount method is one of a couple of specialized helpers that work across the client and server. Thus allowing the test to run isomorphically. A criticism of this approach made by @ajjahn was that it does not allow white-box testing of the components, so mount was extended to allow blocks of client code to be inserted during the test. Here is an example of the Chat dialog component:

  it 'switches from offline to online' do
    mount('Chat', online_text: 'ONLINE', offline_text: 'OFFLINE') do
      module Components
        class Chat < React::Component::Base
          POLL_INTERVAL = 1 # override default of 5 minutes
        end
      end
    end

    page.should have_content('OFFLINE')
    wait_for_ajax
    @agents_online = 2
    Rails.cache.delete('desk_agents_online')
    page.should have_content('ONLINE')
  end

Any code in the mount block is re-compiled via Opal and attached to the client code, thus allowing the internal behavior of client side code to be modified.

@barriehadfield has provided a lot of focus on how to integrate with Webpack. At the moment with a little configuration setup, you can use NPM / webpack to seamlessly manage native JS assets, and rails sprockets to manage ruby code (including hyperloop assets.)

In the middle of 2017 the CatPrint team launched the new V4 website written entirely in Ruby using Hyperloop. Instead of a 70K line mix of JS, HTML, ERB, Coffeescript, and some HAML, the much more functional website is written in 25K of Ruby.

During 2017 a number of breaking API changes primarily to HyperComponents with the intent to normalize the syntax, and eliminate common errors. To accommodate models, components, operations and stores, the location of all hyperloop code was moved to be under the app/hyperloop directory. Our goal is to lock the API as it exists now for the Hyperloop 1.0 release.

Also during 2017 other gems were added or consolidated out of various snippets like HyperConsole, and HyperSpec. Also, @adamcreekroad wrapped the latest React-Router in a Ruby DSL.

In late 2017 @janbiedermann has been working through a number of performance iand configuration issues.

Another key contributor that deserves mention is @fkchang (no relation to David) who was an early supporter of Hyperloop and has incorporated it into his Opal Playground.

The current structure of the Hyperloop team is just a small gang of core contributors. Pull Requests are welcome from all, and if someone shows sufficient interest in hyperloop via PRs, they are invited the core team.

We try to keep the code in code shape through an extensive set of tests (just under 1000 at last count) and hopefully with the release of 1.0 everything will be running through a CI service.

In general, except for very sensitive issues, the Core Team has decided to keep all discussions in the public Gitter forum. Lengthy discussions are moved off the forum and onto a specific issue.

Currently, we are working towards a 1.0 release with a number of release candidates (called laps for hopefully obvious reasons.) Our goal is to give the world a nice Christmas present, although we may be on the old Russian calendar

zw963 commented 6 years ago

Cool