Bluesky

Fork me on GitHub

Working Draft – January 2017

Introduction

Bluesky is an opinionated application framework based on Clearwater.

This project grew out of about a years work on a large project using Opal and Clearwater.

Being opinionated means that this framework is not for everyone. The authors background is in desktop and mobile app development and that is of course reflected in the design. If things feel weird or you find yourself working against the framework then maybee this is not for you.

This documentation is built using Spec Markdown

Gem Version

Contents
  1. 1Getting started
    1. 1.1Using the bluesky gem
    2. 1.2Manual files
  2. 2Application
    1. 2.1Application delegate
  3. 3ViewController
    1. 3.1View events
    2. 3.2Standard dispatch methods
    3. 3.3NavigationController
  4. 4PureComponent
    1. 4.1Motivating example
    2. 4.2Extended DSL
    3. 4.3Custom DSL
    4. 4.4Dispatching
    5. 4.5Caching
  5. 5Dispatcher
    1. 5.1Asynchrous by design
    2. 5.2Blocks vs Promise
    3. 5.3Intercept dispatch requests
  6. AExamples
    1. A.1Fetch data using REST and keep UI in sync

1Getting started

1.1Using the bluesky gem

Install Bluesky using

gem install bluesky

and create a new project

bluesky new hello

1.2Manual files

# Gemfile
source 'https://rubygems.org'

gem 'rack'
gem 'opal-sprockets'
gem 'bluesky'

Then create a index.html.erb file

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Bluesky</title>
  </head>
  <body>
    <%= javascript_include_tag 'application' %>
  </body>
</html>

and a config.ru file

require 'bundler'
Bundler.require

# Instructions: bundle in this directory
# then run bundle exec rackup to start the server
# and browse to localhost:9292

run Opal::Server.new { |s|
  s.main = 'application'
  s.append_path '.'
  s.index_path = 'index.html.erb'
}

Finally create an application.rb containing

require 'bluesky'
include Bluesky

class HelloView < PureComponent

  attribute :name

  def render
    div [h1("Hello #{name}"), name_input]
  end

  def name_input
    handler = -> (event) { dispatch(:change_name, event.target.value) }
    label ['Change name: ', input({ type:    'text',
                                    value:   name,
                                    oninput: handler })]
  end

end

class HelloController < ViewController

  attribute :name, 'World'

  def view
    HelloView(name: name)
  end

  def change_name(name)
    self.name = name
  end

end


$app = Class.new(Application) do
  def root_view_controller
    @root_view_controller ||= HelloController.new
  end
end.new

$app.debug!
$app.run

2Application

$app = Class.new(Bluesky::Application) do

  def root_view_controller
    @root_view_controller ||= NavigationController.new(LayoutController.new)
  end

end.new

$app.debug!
$app.run

2.1Application delegate

Application supports adding a delegate to get notified of different actions.

class ApplicationDelegate

  def dispatch(target, actions, *payload)
  end

  def dispatch_resolved(target, actions, *payload, result)
  end

  def dispatch_rejected(error, target, actions, *payload)
  end

end

dispatch

Called when Adding a dispatch request to the dispatch queue.

dispatch_resolved

Called when the dispatch request has been performed.

3ViewController

ViewControllers are responsible for the UI logic in an application. ViewControllers feed data into presentational components (PureComponent) and respond to user interactions.

class HelloController < ViewController

  attribute :name

  def view
    HelloView(name: name)
  end

  def change_name(name)
    self.name = name
  end
end

3.1View events

A ViewController gets notified when changes are made to its views visibility.

view_will_appear

Called before the views render method is called for the first time.

view_did_appear

Called after the first render is completed and the DOM has reconciled.

view_will_disappear

Called when the view is about to be removed from the render. DOM nodes are still mounted.

view_did_disappear

Called when the view has been removed from the render and the DOM has reconciled. All previously mounted DOM nodes have been unmounted.

DOM nodes may still be reused for other views so make sure you have cleaned up after yourself.

3.2Standard dispatch methods

A ViewController supports the following dispatch methods out of the box.

There is a more complete description of the dispatch mechanism in Section 5.

refresh

Triggers a rerendering of the entire application. Caching still applies and refresh should rarely be of any use.

force_update

Triggers a force rerendering of the application and disables caching for the ViewControllers view. This could be used when something changed outside of your views that the views depend on. For example changing language in the app.

3.3NavigationController

NavigationController is modelled after the iOS UINavigationController and fills a similar function in Bluesky.

NavigationController manages a stack of ViewControllers that can be pushed and popped.

class HelloController < ViewController
  # ...
end

class RootController < ViewController
  # ...
end

navigation_controller = NavigationController.new(RootController)

navigation_controller.push_view_controller(HelloController.new)

navigation_controller.pop_view_controller

All descendants of ViewController can access its nearest ancestor of type NavigationController using the navigation_controller property.

class HelloController < ViewController
  def back
    navigation_controller.pop_view_controller
  end
end

4PureComponent

Views or presentational components are what the user will see and interact with. Views are built using Clearwater tags (h1, div, ...), composites (could be plain functions) and PureComponents where PureComponents are the most powerful of them all.

4.1Motivating example

require 'bluesky'
include Bluesky

class HelloView < PureComponent
  attribute :name, 'World'
  def render
    h1 ["Hello #{name}"]
  end
end

class HelloController < ViewController

  def view
    HelloView()
  end

end

Class.new(Application) do
  def root_view_controller
    @root_view_controller ||= HelloController.new
  end
end.new.run

4.2Extended DSL

Bluesky uses Clearwater for rendering so all tags in Clearwater are available by default. Bluesky also extends the DSL with Builders.

def render
  form do |form|
    form << div do |div|
      div << label do |label|
        label.for = 'emailField'
        label << 'Email address'
      end
      div << input do |input|
        input.type        = :email
        input.id          = 'emailField'
        input.class       = 'form-control'
        input.placeholder = "Enter emailHelp"
      end
    end
  end
end

4.3Custom DSL

Build custom modules

module CustomDSL

  include Bluesky::DSL # Optional, but gives access to h1, div and all PureComponents

  def Button(text)
    button({ onclick: -> (event) { yield event if block_given? } }, [text])
  end

  def LabelInput(name, label, type)
    handler = -> (event) { dispatch(:form_change, event.target.name, event.target.value)}
    label({ for: name }, [label, input({ name: name,
                                         type: type,
                                         oninput: handler,
                                         onchange: handler })])
  end
end

class CustomComponent < PureComponent

  include CustomDSL

  def render
    div [ Button("hello") { puts 'hello' },
          LabelInput('name', 'Change name', :text) { |name| puts name } ]
  end

end

Extend the Bluesky DSL.

module Bluesky
  module DSL
    def textarea(attributes = {}, contents = nil)
      attributes[:class] = "form-control"
      tag('textarea', attributes, contents)
    end
  end
end

4.4Dispatching

require 'bluesky'
include Bluesky

class HelloView < PureComponent

  attribute :name

  def render
    div [title, name_input]
  end

  def title
    h1 ["Hello #{name}"]
  end

  def name_input
    label [
      'Your name: ',
      input({ type: 'text', value: name, oninput: -> (event) {
        dispatch(:change_name, event.target.value)
      }})
    ]
  end

end

class HelloController < ViewController

  attribute :name, 'World'

  def view
    HelloView(name: name)
  end

  def change_name(name)
    self.name = name
  end
end

Class.new(Application) do
  def root_view_controller
    @root_view_controller ||= HelloController.new
  end
end.new.run

In a PureComponent the dispatch target is fixed to @delegate. While it is possible to call @delegate.dispatch(target, action, ...) it is not recommended. Tying your view to a dispatch target is bad design.

4.5Caching

5Dispatcher

Dispatcher is the main tool for performing actions in Bluesky.

All dispatch requests (unless intercepted) go through the dispatch queue in Application.

5.1Asynchrous by design

A dispatch request is never performed synchronously. Instead it has to pass through the application dispatch queue where each request is executed in turn and on the main event loop.

Dispatch targets are just objects and actions are just methods on those objects.

dispatch("hello", :upcase).then do |str|
  puts str # HELLO
end

Dispatcher plays an important part when separating views from controllers and models. A view never need to know anything about the target of the dispatch except an ubiquitous naming of the actions. In fact it is strongly recommended to design a view with just a data object and a mock dispatcher. They should be completely independent of any controller or model.

Dispatch always returns a promise that you can chain. At the end of each dispatch chain there is a refresh attached so you do not have to worry about keeping your UI refreshed.

Here is a larger example of a Store that might have to do asynchronous work before returning a value. The dispatch client does not see any difference.

module Store
  include DOMHelpers
  extend self

  def fetch(what)
    case what
    when :fruit
      [:apple, :orange, :banana].sample
    when :beverage
      delay(seconds: 3) { :milk }
    end
  end
end

dispatch(Store, :fetch, :fruit).then do |fruit|
  puts fruit
end

dispatch(Store, :fetch, :beverage).then do |beverage|
  puts beverage
end

Promises are easy to chain so multiple asynchronous requests can be made before reaching the target.

5.2Blocks vs Promise

Dispatch returns a promise and thus can be chained using .then but sometimes the dispatch target can take a block and this can lead to some confusion.

module Store
  include DOMHelpers
  extend self

  def foo
    puts "foo #{block_given?}"
  end

  def bar
  end
end

dispatch(Store, :foo)                     # 'foo false'
dispatch(Store, :foo).then {}             # 'foo false'
dispatch(Store, :foo) {}                  # 'foo true'

dispatch(Store, :bar).then { puts 'bar' } # 'bar'
dispatch(Store, :bar) { puts 'bar' }      # <nothing>

The distinction here is important. If you supply a block by mistake when you intended to chain the promise, the block will never be called, or worse if the target action supports optional blocks, alter the dispatch result.

5.3Intercept dispatch requests

This is an advanced topic not for everyday use

A ViewController may intercept any dispatch request traveling up the parent hierarchy by implementing:

def dispatch(target, action, *payload, &block)
end

It is possible to route or filter dispatch requests here by issuing a new dispatch.

def dispatch(target, action, *payload, &block)
  case target
  when :store
    # Route target to Store
    parent.dispatch(Store, action, *payload, &block)
  else
    # Otherwise send the dispatch up the chain
    parent.dispatch(target, action, *payload, &block)
  end
end
You should always send your dispatch up the ancestor chain by calling dispatch on parent.

AExamples

A.1Fetch data using REST and keep UI in sync

This example renders a list of people on the planet Tatooine. Data is supplied by The Starwars API. Since this is REST several roundtrips are needed to get all the residents. Dispatch ensures that all requests are done and the final callback invoked before calling refresh on the entire application causing a render.

require 'bluesky'
include Bluesky

require 'bowser/http'

module PersonStore
  include DOMHelper

  extend self

  def residents_of_tatooine
    Bowser::HTTP.fetch('http://swapi.co/api/planets/1/').then do |response|
      Promise.when(*response.json[:residents].map do |resident_uri|
        Bowser::HTTP.fetch(resident_uri).then do |resident|
          resident.json[:name]
        end
      end)
    end
  end

end

class Person < PureComponent
  attribute :name
  def render
    li [name]
  end
end

class PersonList < PureComponent
  attribute :persons
  def render
    ol persons.map { |person| Person(person) }
  end
end

class PersonController < ViewController
  attribute :names, []
  def view
    PersonList(persons: names)
  end
  def view_will_appear
    dispatch(PersonStore, :residents_of_tatooine).then do |residents|
      self.names = residents.map { |name| { name: name } }
    end
  end
end

$app = Class.new(Application) do
  def root_view_controller
    @root_view_controller ||= PersonController.new
  end
end.new

$app.debug!
$app.run
  1. 1Getting started
    1. 1.1Using the bluesky gem
    2. 1.2Manual files
  2. 2Application
    1. 2.1Application delegate
  3. 3ViewController
    1. 3.1View events
    2. 3.2Standard dispatch methods
    3. 3.3NavigationController
  4. 4PureComponent
    1. 4.1Motivating example
    2. 4.2Extended DSL
    3. 4.3Custom DSL
    4. 4.4Dispatching
    5. 4.5Caching
  5. 5Dispatcher
    1. 5.1Asynchrous by design
    2. 5.2Blocks vs Promise
    3. 5.3Intercept dispatch requests
  6. AExamples
    1. A.1Fetch data using REST and keep UI in sync