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.
This documentation is built using Spec Markdown
Install Bluesky using
gem install bluesky
and create a new project
bluesky new hello
# 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
$app = Class.new(Bluesky::Application) do
def root_view_controller
@root_view_controller ||= NavigationController.new(LayoutController.new)
end
end.new
$app.debug!
$app.run
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.
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
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.
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.
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.
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
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
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
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
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.Dispatcher is the main tool for performing actions in Bluesky.
All dispatch requests (unless intercepted) go through the dispatch queue in Application
.
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.
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.
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
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