Getting Started
This is a tutorial of making an Ovto app. We create a static app (.html + .js) here,
but you can embed Ovto apps into a Rails or Sinatra app (See ./examples/*
).
This is the final Ruby code.
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
item :celsius, default: 0
def fahrenheit
(celsius * 9 / 5.0) + 32
end
end
class Actions < Ovto::Actions
def set_celsius(value:)
return {celsius: value}
end
def set_fahrenheit(value:)
new_celsius = (value - 32) * 5 / 9.0
return {celsius: new_celsius}
end
end
class MainComponent < Ovto::Component
def render
o 'div' do
o 'span', 'Celcius:'
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_celsius(value: e.target.value.to_i) },
value: state.celsius
}
o 'span', 'Fahrenheit:'
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_fahrenheit(value: e.target.value.to_i) },
value: state.fahrenheit
}
end
end
end
end
MyApp.run(id: 'ovto')
Let's take a look step-by-step.
Prerequisites
- Ruby
- Bundler (
gem install bundler
)
Setup
Make a Gemfile:
source "https://rubygems.org"
gem "ovto", github: 'yhara/ovto' # Use git master because ovto gem is not released yet
gem 'rake'
Run bundle install
.
HTML
Make an index.html:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script type='text/javascript' src='app.js'></script>
</head>
<body>
<div id='ovto'></div>
<div id='ovto-debug'></div>
</body>
</html>
Write code
app.rb:
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
end
class Actions < Ovto::Actions
end
class MainComponent < Ovto::Component
def render # Don't miss the `:`. This is not a typo but
o 'div' do # a "mandatory keyword argument".
o 'h1', "HELLO" # All of the Ovto methods take keyword arguments.
end
end
end
end
MyApp.run(id: 'ovto')
- The name
MyApp
is arbitrary. - The id
ovto
corresponds to thediv
tag inindex.html
.
Compile
Generate app.js from app.rb.
$ bundle exec opal -c -g ovto app.rb > app.js
(Compile will fail if there is a syntax error in your app.rb
.)
Now you can run your app by opening index.html
in your browser.
Trouble shooting
If you see HELLO, the setup is done. Otherwise, check the developer console and you should see some error messages there.
For example if you misspelled class State
to class Stat
, you will see:
app.js:5022 Uncaught $NameErrorĀ {name: "State", message: "uninitialized constant MyApp::State", stack: "State: uninitialized constant MyApp::State"}
because an Ovto app must have a State
class in its namespace.
(Tips: auto-compile)
If you get tired to run bundle exec opal
manually, try ifchanged
gem:
- Add
gem "ifchanged"
to Gemfile bundle install
bundle exec ifchanged ./app.rb --do 'bundle exec opal -c -g ovto app.rb > app.js'
Now you just edit and save app.rb
and it runs opal -c
for you.
Add some state
In this tutorial, we make an app that convers Celsius and Fahrenheit degrees to
each other. First, add an item to MyApp::State
.
class State < Ovto::State
item :celsius, default: 0
end
Now an item celsius
is added to the global app state. Its value is 0
when
the app starts. You can read this value by state.celsius
. Let's display the
value with MyApp::MainComponent
.
class MainComponent < Ovto::Component
def render
o 'div' do
o 'span', 'Celcius:'
o 'input', type: 'text', value: state.celsius
end
end
end
Now you should see Celsius: [0 ]
in the browser.
Add a method to State
Next, we want to know what degree is it in Fahrenheit. Let's add a method to convert.
class State < Ovto::State
item :celsius, default: 0
def fahrenheit
(celsius * 9 / 5.0) + 32
end
end
Now you can know the value by state.fahrenheit
. Update MainComponent
to show the value too.
class MainComponent < Ovto::Component
def render
o 'div' do
o 'span', 'Celcius:'
o 'input', type: 'text', value: state.celsius
o 'span', 'Fahrenheit:'
o 'input', type: 'text', value: state.fahrenheit
end
end
end
Add an action
Now we know 0 degrees Celsius is 32 degrees Fahrenheit. But how about 10 degrees or 100 degrees Celsius? Let's update the app to we can specify a Celsius value.
You may think that you can change the value with state.celsius = 100
, but this is
prohibited. In Ovto, you can only modify app state with Actions.
Our first action looks like this. An action is a method defined in MyApp::Actions
.
It takes an old state (and its own parameters) and returns a Hash that describes
the updates to the state. This return value is merge
d into the global app state.
class Actions < Ovto::Actions
def set_celsius(value:)
return {celsius: value}
end
end
This action can be called by actions.set_celsius
from MainComponent. Replace the
first input tag with this:
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_celsius(value: e.target.value.to_i) },
value: state.celsius
}
onchange:
is a special attribute that takes an event handler as its value.
The argument e
is an instance of Opal::Native
and wraps the event object of
JavaScript. In this case you can get the input string by e.target.value
.
Now reload your browser and input 100
to the left input box. Next, press Tab key
(or click somewhere in the page) to commit the value. Then you should see 212
in the right input box. 100 degrees Celsius is 212 degrees Fahrenheit!
What has happend
In case you are curious, here is what happens when you give 100 to the input box.
- JavaScript's
onchange
event is executed. - Ovto calls the event handler.
- It calls
actions.set_celsius
.actions
is an instance ofOvto::WiredActions
. It is a proxy to theMyApp::Actions
. It has the same methods as those inMyApp::Actions
but does some more:
- It passes
state
to the user-defined action. - It merges the result to the global app state.
- It schedules re-rendering the view to represent the new state.
Reverse conversion
It is easy to update the app to support Fahrenheit-to-Celsius conversion. The second input should be updated to:
o 'input', {
type: 'text',
onchange: ->(e){ actions.set_fahrenheit(value: e.target.value.to_i) },
value: state.fahrenheit
}
Then add an action set_fahrenheit
to MyApp::Actions
. This action convers the
Fahrenheit degree into Celsius and set it to the global state.
def set_fahrenheit(value:)
new_celsius = (value - 32) * 5 / 9.0
return {celsius: new_celsius}
end
Now your app should react to the change of the Fahrenheit value too.
Install
Use Ovto with static files
$ gem i ovto
$ ovto new myapp --static
Use Ovto with Sinatra
$ gem i ovto
$ ovto new myapp --sinatra
Install Ovto into Rails apps
Edit Gemfile
gem 'opal-rails'
gem 'ovto'
Run bundle install
Remove app/assets/javascripts/application.js
Create app/assets/javascripts/application.js.rb
require 'opal'
require 'rails-ujs'
require 'activestorage'
require 'turbolinks'
require_tree '.'
Create app/assets/javascripts/foo.js.rb
(file name is arbitrary)
require 'ovto'
class Foo < Ovto::App
class State < Ovto::State
end
class Actions < Ovto::Actions
end
class MainComponent < Ovto::Component
def render(state:)
o 'h1', "HELLO"
end
end
end
Edit app/views/<some controller/<some view>.html.erb
<div id='foo-app'></div>
<%= opal_tag do %>
Foo.run(id: 'foo-app')
<% end %>
This should render HELLO
in the browser.
You also need to edit config/environments/production.rb like this before deploy it to production.
#config.assets.js_compressor = :uglifier
config.assets.js_compressor = Uglifier.new(harmony: true)
Ovto::App
First of all, you need to define a subclass of Ovto::App
and define class State
,
class Actions
and class MainComponent
in it.
Example
This is a smallest Ovto app.
require 'opal'
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
end
class Actions < Ovto::Actions
end
class MainComponent < Ovto::Component
def render
o 'input', type: 'button', value: 'Hello'
end
end
end
MyApp.run(id: 'ovto')
It renders a button and does nothing else. Let's have some fun:
require 'opal'
require 'ovto'
class MyApp < Ovto::App
COLORS = ["red", "blue", "green"]
class State < Ovto::State
item :color_idx, default: 0
end
class Actions < Ovto::Actions
def update_color
new_idx = (state.color_idx + 1) % COLORS.length
return {color_idx: new_idx}
end
end
class MainComponent < Ovto::Component
def render
o 'input', {
type: 'button',
value: 'Hello',
style: {background: COLORS[state.color_idx]},
onclick: ->{ actions.update_color },
}
end
end
end
MyApp.run(id: 'ovto')
Here we added color_idx
to app state and update_color
action to change it.
The button is updated to have the color indicated by color_idx
and
now has onclick
event handler which executes the action.
Calling actions on startup
To invoke certain actions on app startup, define MyApp#setup
and use MyApp#actions
.
Example:
class MyApp < Ovto::App
def setup
actions.fetch_data()
end
...
end
MyApp.run(id: 'ovto')
Ovto::State
Ovto::State
is like a hash, but members are accessible with name rather than []
.
Example
class State < Ovto::State
item :foo
item :bar
end
state = State.new(foo: 1, bar: 2)
state.foo #=> 1
state.bar #=> 2
Default value
class State < Ovto::State
item :foo, default: 1
item :bar, default: 2
end
state = State.new
state.foo #=> 1
state.bar #=> 2
Immutable
State objects are immutable. i.e. you cannot update value of a key. Instead, use State#merge
.
state = State.new(foo: 1, bar: 2)
new_state = state.merge(bar: 3)
new_state.foo #=> 1
new_state.bar #=> 3
Nesting state
For practical apps, you can nest State like this.
class Book < Ovto::State
item :title
item :author
end
class State < Ovto::State
item :books, []
end
book = Book.new('Hello world', 'taro')
state = State.new(books: [book])
Defining instance methods of state
You can define instance methods of state.
class Book < Ovto::State
item :title
item :author
def to_text
"#{self.title} (#{self.author})"
end
end
book = Book.new('Hello world', 'taro')
book.to_text #=> "Hello world (taro)"
Defining class methods of state
Ovto does not have a class like StateList
. Just use Array to represent a list of state.
You can define class methods to manipulate a list of state.
class Book < Ovto::State
item :title
item :author
def self.of_author(books, author)
books.select{|x| x.author == author}
end
end
# Example
taro_books = Book.of_author(books, "taro")
Ovto::Actions
Actions are the only way to change the state. Actions must be defined as methods of
the Actions
class. Here is some more conventions:
- You must use keyword arguments
- You must return state updates as a Hash. It will be merged into the app state.
- You can get the current state by
state
method
Example:
require 'opal'
require 'ovto'
class MyApp < Ovto::App
class State < Ovto::State
item :count, default: 0
end
class Actions < Ovto::Actions
def increment(by:)
return {count: state.count + by}
end
end
class MainComponent < Ovto::Component
def render
o 'span', state.count
o 'button', onclick: ->{ actions.increment(by: 1) }
end
end
end
MyApp.run(id: 'ovto')
Calling actions
Actions can be called from components via actions
method. This is an instance of
Ovto::WiredActions
and has methods of the same name as your Actions
class.
o 'button', onclick: ->{ actions.increment(by: 1) }
Arguments are almost the same as the original but you don't need to provide state
;
it is automatically passed by Ovto::WiredActions
class. It also updates the app
state with the return value of the action, and schedules rendering the view.
Skipping state update
An action may return nil
when no app state changes are needed.
Promises are also special values which does not cause state changes (see the next section).
Async actions
When calling server apis, you cannot tell how the app state will change until the server responds.
In such cases, you can call another action via actions
to tell Ovto to reflect the api result to the app state.
Example:
class Actions < Ovto::Actions
def fetch_tasks
Ovto.fetch('/tasks.json').then {|tasks_json|
actions.receive_tasks(tasks: tasks_json)
}.fail {|e|
console.log("failed:", e)
}
end
def receive_tasks(tasks_json:)
tasks = tasks_json.map{|item| Task.new(**item)}
return {tasks: tasks}
end
end
Ovto::Component
An Ovto app must have MainComponent
class, a subclass of Ovto::Component
.
'render' method
render
is the only method you need to define in the MainComponent
class.
You can get the global app state by calling state
method.
class MainComponent < Ovto::Component
def render
o 'div' do
o 'h1', 'Your todos'
o 'ul' do
state.todos.each do |todo|
o 'li', todo.title
end
end
end
end
end
MoreThanOneNode error
If you missed the surrounding 'div' tag, Ovto raises an MoreThanOneNode
error. render
must create a single DOM node.
def render
o 'h1', 'Your todos'
o 'ul' do
state.todos.each do |todo|
o 'li', todo.title
end
end
end
#=> $MoreThanOneNodeĀ {name: "MoreThanOneNode", ...}
The 'o' method
Ovto::Component#o
describes your app's view. For example:
o 'div'
#=> <div></div>
o 'div', 'Hello.'
#=> <div>Hello.</div>
You can pass attributes with a Hash.
o 'div', class: 'main', 'Hello.'
#=> <div class='main'>Hello.</div>
There are shorthand notations for classes and ids.
o 'div.main'
#=> <div class='main'>Hello.</div>
o 'div#main'
#=> <div id='main'>Hello.</div>
You can also give a block to specify its content.
o 'div' do
'Hello.'
end
#=> <div>Hello.</div>
o 'div' do
o 'h1', 'Hello.'
end
#=> <div><h1>Hello.</h1></div>
Special attribute: style
There are some special keys for the attributes Hash. style:
key takes a hash as
its value and specifies styles of the tag.
o 'div', style: {color: 'red'}, 'Hello.'
#=> <div style='color: red;'>Hello.</div>
Special attribute: onxx
An attribute starts with "on"
specifies an event handler.
For example:
o 'input', {
type: 'button',
onclick: ->(e){ p e.target.value },
value: 'Hello.'
}
The argument e
is an instance of Opal::Native and wraps the JavaScript event object.
You can get the input value with e.target.value
here.
Lifecycle events
There are special events oncreate
, onupdate
, onremove
, ondestroy
.
https://github.com/hyperapp/hyperapp#lifecycle-events
Special attribute: key
https://github.com/hyperapp/hyperapp#keys
Sub components
o
can take another component class to render.
# Sub component
class TodoList < Ovto::Component
def render(todos:)
o 'ul' do
todos.each do |todo|
o 'li', todo.title
end
end
end
end
# Main component
class MainComponent < Ovto::Component
def render
o 'div' do
o 'h1', 'Your todos'
o TodoList, todos: state.todos
end
end
end
Text node
Sometimes you may want to create a text node.
#=> <div>Age: <span class='age'>12</a></div>
# ~~~~~
# |
# +--Raw text (not enclosed by an inner tag)
o
generates a text node when 'text'
is specified as tag name. The above
HTML could be described like this.
o 'div' do
o 'text', 'Age:'
o 'span', '12'
end
Ovto::PureComponent
It almost the same as Ovto::Component
, but it caches the render
method calling with arguments of the method.
When to use PureComponent?
Use it when your app is slow and need more speed.
Cache strategy
It compares render
method arguments and the previous arguments.
def render
o 'div' do
o Pure, foo: state.foo
o NotPure bar: state.bar
end
end
In this case, NotPure
component's render method is called even if state.foo
is changed.
Whereas Pure
component's render method is called only if state.foo
is changed.
State
state
method is not available in PureComponent
, because PureComponent
does not treat state as the cache key.
If you'd like to use state in PureComponent
, pass the state from the parent component.
Ovto::Middleware
When you are making a big app with Ovto, you may want to extract certain parts which are independent from the app. Ovto::Middleware is for such cases.
- A middleware has its own namespace of state and actions
- that is, you don't need to add prefixes to the names of states and actions of a middleware.
Example
# 1. Middleware name must be valid as a method name in Ruby
class MyMiddleware < Ovto::Middleware("my_middleware")
def setup
# Called on app startup
end
# 2. Make a subclass of MyMiddleware's State
class State < MyMiddleware::State
item :count, default: 0
end
# 3. Make a subclass of MyMiddleware's Actions
class Actions < MyMiddleware::Actions
def increment(by:)
return {count: state.count + by}
end
end
# 4. Make a subclass of MyMiddleware's Component
class SomeComponent < MyMiddleware::Component
def render
o 'div' do
o 'span', state.count
o 'button', onclick: ->{ actions.increment(by: 1) }
end
end
end
end
class MyApp < Ovto::App
# 5. Declare middlewares to use
use MyMiddleware
class State < Ovto::State; end
class Actions < Ovto::Actions; end
class MainComponent < Ovto::Component
def render
o 'div.counter' do
o MyMiddleware::SomeComponent
end
end
end
end
Advanced
Getting middlware state from app
class MyApp < Ovto::App
def MainComponent < Ovto::Component
def render
o 'span', state._middlewares.middleware1.some_state
end
end
end
Calling middlware action from app
class MyApp < Ovto::App
# From actions
def Actions < Ovto::Actions
def some_action
actions.middleware1.some_action()
end
end
# From component
def MainComponent < Ovto::Component
def render
o 'button', onclick: ->{ actions.middleware1.some_action() }
end
end
end
Using a middleware from another middleware
class Middleware1 < Ovto::Middleware("middleware1")
use Middleware2
end
Ovto.fetch
Ovto provides wrapper of Fetch API, for convenience of calling typical server-side APIs (eg. those generated by rails scaffold
command.)
Ovto.fetch
returns Opal's Promise object that calls the API with the specified parameters.
Examples
GET
Ovto.fetch('/api/tasks').then{|json_data|
p json_data
}.fail{|e| # Network error, 404 Not Found, JSON parse error, etc.
p e
}
POST
Ovto.fetch('/api/new_task', 'POST', {title: "do something"}).then{|json_data|
p json_data
}.fail{|e| # Network error, 404 Not Found, JSON parse error, etc.
p e
}
PUT
Ovto.fetch('/api/tasks/1', 'PUT', {title: "do something"}).then{|json_data|
p json_data
}.fail{|e| # Network error, 404 Not Found, JSON parse error, etc.
p e
}
CSRF tokens
You don't need to care about CSRF tokens if the server is a Rails app because Ovto.fetch
automatically send X-CSRF-Token
header if the page contains a meta tag like <meta name='csrf-token' content='....' />
.
Debugging Ovto app
console.log
In an Ovto app, you can print any object to developer console by console.log
like in JavaScript.
console.log(state: State.new)
This is mostly equal to p state: State.new
but console.log
supports
JavaScript objects too.
(Note: this is not an official feature of Opal. You can do this setup by this:)
require 'console'; def console; $console; end
ovto-debug
If the page has a tag with id='ovto-debug'
, exception is shown in the tag.
Ovto.debug_trace
If Ovto.debug_trace
is set to true
, some diagnostic messages are shown in the browser console.
Ovto.debug_trace = true
MyApp.run(id: 'ovto')
Development notes
How to run unit test
- git clone
- bundle install
- bundle exec rake
How to rebuild docs
bundle exec doc:build