The library itself is not bundled to it's own gem yet, beacuse I'm writing it in a way of building real life like app featurese, meeting problems, ammend the lib to solve them.
You can easilly extract it manually.
The lib itself is usable already.
This repo comes as Rails with the app used for developing.
Opal wrapper for React.js, React-router, custom Opal model layer, plugins and components for input (form) managment, authorization and etc.
Clone it to see how demo application is built and works.
To use separately.
To extract to your app for now copy javascripts folder and delete everything in components, models.
basically you need only: react.js, vendor/react_wrapper, vendor/model and that's it, copy to your app and require to pipeline.
your models should inherit from Model
.
Model has .parse
class method that traverses Hash || Array or stringified JSON coming from server and
instantiates models if it meets them.
Rails, should respond with json root true (can be enabled in config) eg {user: {id: 1}} not the default {id: 1} (this can be done in Rails config or manually on serialization to JSON)
When Model.parse
is called, if it will meet {model_name: {atr: "some", foo: "some"}}
it will instantiate that #{model_name}
and and its attributes will go to @attributes
of #{model_name}
. If attribute holds another model it will also be instantiated.
example:
x = Model.parse({user: {id: 1, friend: {user: {id: 2}}}})
p x
=> <User_instance>
p x.attributes
=>{id: 1, friend: <User_instance>}
p x.pure_attributes
=> {user: {id: 1, friend: {user: {id: 2}}}}
# if array of models given to model parse it will retun ModelCollection wich
# is basicaly the array of models, that is tored in `:data` accessor, and has separate methods for working with collection.
x = Model.parse([{user: {id: 1}}, {user: {id: 2}}])
p x.data
[<User_instance>, <User_instance>]
attributes are stored in @attributes
accessor which is a simple hash.
each model has .attributes
runtime called class method that defines getter setter methods which will basically get/set values from @attributes
In order to access/set arbitrary value just set/get them as you would do with Hash.
class User
attributes :id, :name
end
x = User.new(id: 1, name: "Joe", nickname: "Schmoe")
p x.attributes
=> {id: 1, name: "Joe", nickname: "Schmoe"}
p x.name
=> "Joe"
x.name = "Foo"
p x.nickname
=> method missing
p x.attributes[:nickname]
=> "Schmoe"
x.attributes[:nickname] = "Bar"
p x.attributes[:nickname]
=> "Bar"
p x.pure_attributes
=> {user: {id: 1, name: "Foo", nickname: "Bar"}}
to update attributes call update_attributes({hash})
which will merge it to @attributes
@attributes
is a hash containing what was given to Model.parse
but when model was parsed, as mentionedf above it will instantiate all meeting instances (if they're defined). E.g.
x = Model.parse({user: {id: 1, friend: {user: {id: 2}}}})
p x.attributes
#=>{id: 1, friend: <User_instance>}
p x.friend.id
#=> 2
p x.friend.attributes
#=> {id: 2}
***if you need "pure" attributes structure, simply call #pure_attributes***
p x.pure_attributes
#=> {user: {id: 1, friend: {user: {id: 2}}}}
You can instantiate a model with attributes .via new as well, argument will be parsed instantiating all the models that it meets.
ALL route calls are managed by RequestHandler class which configures everything on each route call
ALL route calls return Opal Promise so you have to handle responses in .then .fail.
ALL route calls urls will be prefixed with "/api/" by default, this can be configured in model
User.show(id: 1).then do |response|
p response.json
{user: {id: 1, name: "F"}}
user = Model.parse(x)
p user
<User:instance>
p user.attributes
{id: 1, name: "F"}
user.name = "Foo"
user.save.then do |response|
p response.status_code
=> 200
end
end.fail do |r|
raise "failed"
end
you define the map of HTTP calls via class .route method like this which shall be called in class body definition:
class User
route :find #=> will define method instance method
route :Find #=> (capitalized) will define method class method .find
end
second arg is http method and url pair
class User
route :Show, get: "/users"
end
User.show
=> makes HTTP.get "/api/users" request to server
also you can pass wilds to url:
route :Show, post: "users/:id"
this works as you expect it to. But then you'll have to provide the :id
if calling that route like so:
Model.show(wilds: {id: 1}) => makes HTTP.post "/api/users/1" request to server
you can pass {defaults: [:your_wild, :your_2nd_wild]}
in route definition option and it will be resolved automatically
if you have corresponding method defined (e.g. called attributes :id):
class User
attributes :id, :name
route :update, put: "users/:id", {defaults: [:id]}
end
user = User.show(id: 10).then do |user|
user.name = "Joe"
user.update
=> makes HTTP.put "/api//users/10" request to server
end
Behind the scenes it follows: if you will not supply wild when calling route, wild will be taken from return value of .send #{wild}
When making request you can add some payload to it. There are two ways:
To add the payload to request you should pass hash containing what you want to send, as 2nd arg. Usually that'll be #pure_attributes, but it can be anything
user = User.new
user.name = "Joe"
user.save(payload: user.pure_attributes)
=> makes HTTP.post request to "/api/users", with payload: {user: {name: "Joe"}}
Simply define the following method either class or instance, corresponding your defined routes.
Such method shall have one argument, before the request will be made RequestHandler
instance for that request is passed to that method
you can access requst options by using request_handler
's accessor req_options
, basically treat it like the second arg for route call
attributes :id
route "update", put: "users/:id", defaults: [:id]
def on_before_update(r) ## #{route_name}
r.req_options = {payload: self.attributes}
#same as manual => user.update({}, {payload: user.pure_attributes})
end
now you can simply do it like so:
User.show(id: 1).then do |response|
user = User.parse(response.json)
user.name = "Foo"
user = User.update.then do |response|
#=> makes put request to "/api/users/1", with payload {user: {id: 1, name: "Foo"}}
user = User.parse response.json
end
end
Your responses can also be automatically resolved via this way:
define class or instance methods with this naming rules
responses_on_#{route_name}
route "Show", get: "users/:id"
route "update", put: "users/:id", defaults: [:id]
def self.responses_on_show(request_handler)
if request_handler.response.ok?
request_handler.promise.resolve Model.parse(request_handler.response.json)
else
"raise http error"
end
end
def responses_on_update(r)
if r.response.ok?
r.promise.resolve Model.parse(r.response.json)
else
#handle error
end
end
def on_before_update(r) ## #{route_name}
r.req_options = {payload: self.attributes}
#same as manual => user.update({}, {payload: user.pure_attributes})
end
Now it'll get pretty simple, after you've done auto payload conf and reponse handling you'll be able to:
User.show(id: 1).then do |user|
#=> get request to "users/1"
p user.attributes
#=> {user: {id: 1, name: "joe"}}
user.name = "Schmoe"
user.update.then do |user|
#=> put request to "users/1", payload: {user: {id: 1, name: "Schmoe"}}
p user.attributes
#=> {user: {id: 1, name: "Schmoe"}}
#and e.g. set_state user: user
end
end
As it was mentioned all route requests are managed by RequestHandler
class, which'll spawn object for each requesy being done/
RequestHandler
has everything needed (passed from invoking model) as: response
, url
and etc Model || model from which RequestHadler was
initialized; you can call it by request_handler.caller
If you call Model model route from component, or any other object you can pass it to RequestHandler including component: self in argument.
User.find({component: self, payload: {foo: "bar"}});
then the component will be available as instance accessor @component
for example:
#in some component
user.update({component: self})
#in User
def on_before_update(r)
r.component.spinner.on
end
def responses_on_update(r)
r.component.spinner.off
end
You can monkeypatch Model in Helpers, (you mostly will need it for defaults before and after response handling modethods); they are
defaults_before_request
(as expected)
defaults_on_response
(just add if @response.ok else and will run defaultly)
Default on_before_#{rest_action_name}
and responses_on_#{rest_action_name}
for standard REST actions are predefined in model
They are:
create
before: will payload: pure_attributes
after: on 200 instantiate model and yield it to then
Index
afer: on 200 instantiate collection and yield it to then
destroy
after: on 200 will yield model self to then
Show
after: on 200 will instatiate model and yield it to then
update
before: will payload: pure_attributes
after: on 200 instantiate model and yield it to then
to use Rail's accepts_nested_attributes_for capabilities
has_one :friend
has_many :dogs
accepts_nested_attributes_for :friend, :dogs
user = User.new
p user.pure_attributes
#=> {user: {friend: {}, dogs: []}}
dog = Dog.new(nick: "Doge")
user.dogs << dog
friend = User.new(name: "bar")
user.friend = friend
p user.attributes
#=> {dogs: [<Dog_instance>], friend: <User_instance>}
p user.pure_attributes
#=> {user: {friend_attributes: {name: "bar"}, dogs_attributes: [{nick: "Doge"}]}}
To some attributes to be defaultly giving you an Array, use the .has_many method
need sending payload as form data (maybe you need to send JS File via route)
user.update(serialize_as_form: true)
=> will payload valid FormData object to HTTP request no matter how many stuff is nested
if your model holds in one of it's attributes (e.g. has_many dogs) you can search them via #where method
x = user.where do |dog|
dog.try(:nickname) == "Doge"
end
p x => <Dog_instance>
same way you can search ModelCollection
simple array of model instances, iteratable and treated as array, everything stored in @data accessor, which is an array
col = Model.parse([{user: {name: "joe the dog"}}, {user: {name: "Luke", dogs: [{dog: {nick: "mr. woofer"}}]}}])
p Model.data
#=>[<User_instance>, <User_instance>]
p col[0].dogs[0].nick
#=>"mr. woofer"
dogs = col.where do |dog|
dog.is_a? Dog
end
p dogs
#=> [<Dog_instance>]
Your model should implement validate_#{attr_name}
for the attr you need or want to validate
This method should recieve optional option: Hash arg
and it should result in add_error(:attr_name, "bad error")
if conditions not met
or doing it manually, example
def validate_name(opt)
if name.length < 8
add_error :name, "too short"
end
end
for convinience errors are assumed to have structure repeating it's attributes hash
model.attributes #=> {name: "foo"}
model.validate
model.has_erros? #=> true
model.errors #=> {name: ["too short"]}
model.attributes #=> {name: "foo"}
if you don't have corresponding attributes defined those validation won't run
User.new
user.validate
user.has_errors?
=> false
user.name = "f"
user.has_errors?
=> true
validate will validate any nested model as well with their own validation rules for convenience if nested model will have errors, they will be defined on that model, ass well will be attached to root model.
If your server responsds with errors in json e.g. user was validated on rails and responded with {user: {errors: {name: ["too short"]}}}
when Model.parse 'ed those errors will present
User.new
user.name = "f"
user.validate(only: ["email"]) # skipping :name validation
user.has_errors? #=> false
user.create.then do |user|
user.validate
user.has_errors?
#=> true
end
to reset errors call #reset_errors
example in react component
class FormSample < RW
expose
include Plugins::Formable
def get_initial_state
form_model: User.new
end
render
t(:div, {},
input(Forms::Input, state.form_model, :name),
t(:button, onClick: ->{handle_inputs})
)
end
def handle_inputs
collect_inputs #given by plugin will collect all inputs, form_model.reset_errors (to clear errors from previous validate) and call validate
#or your own way of collecting inputs and then
#state.form_model.validate
unless state.form_model.has_errors?
state.model.create.then do |user|
user.validate
unless user.has_errors? #or add validate in responses_on_create and just use in .then has_errors?
App::Router.replaceState({}, "/users/#{user.id}")
end
end
else
set_state form_model: state.form_model
#formable plugin will render errors for each input if attr on model has corresponding error
end
end
end
validate can accept options like only: [:name]
will validate only those and etc.
if you have attribute that supposed to recive JS File from file input
in order to send it via XHR (route).
your model should has_file = true
it can be done via
attributes :avatar
def validate_avatar
if avatar
self.has_file = true
end
end
than validate your model and next time you'll call route payload will be serialized to form data/
Model has more stuff, will document them later/ And it can be used solely as model layer for any app or another JS library/
SNEAK PEAK ON HOW MODEL CAN BE USED IN RW COMPONENT
class User < Model
attributes :id, :email, :password, :password_confirmation
route "sign_up", post: "users"
route "Show", get: "users/:id"
route "create", post: "users"
route "update", put: "users/:id", defaults: [:id]
has_one :profile, :avatar
has_many :roles
accepts_nested_attributes_for :profile, :avatar
def validate_email(options={})
unless email.match /^[-a-z0-9~!$%^&*_=+}{\'?]+(\.[-a-z0-9~!$%^&*_=+}{\'?]+)*@([a-z0-9_][-a-z0-9_]*(\.[-a-z0-9_]+)*\.(aero|arpa|biz|com|coop|edu|gov|info|int|mil|museum|name|net|org|pro|travel|mobi|[a-z][a-z])|([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}))(:[0-9]{1,5})?$/i
add_error :email, "you should provide a valid email"
end
end
def has_role?(role)
roles.each do |_role|
return true if role.include? _role.name
end
end
def validate_password
if password.length < 6
add_error :password, "password is too short"
end
if password != password_confirmation
add_error :password_confirmation, "confirmation does not match"
end
end
end
module Users
class New < RW
expose
include Plugins::Formable <= makes forms extra EASY
include Plugins::DependsOnCurrentUser #<= will load the permision on component_did_mount automatically
set_roles_to_fetch :admin #<= needed for above will redirect to unauthorized if user doesn't have specified role.
def get_initial_state
{
form_model: User.new(profile: Profile.new}, avatar: Avatar.new})
}
end
def component_will_mount
AppController.sub_to(:general_channel, self)
end
def general_channel(msg)
if msg = "reset"
force_update
end
end
def component_will_unmount
AppController.unsub_from(:generall_channel, self)
end
def render
t(:div, {},
spinner, #component included to show automatic spinners when requests are made and pending and hiding them on their finish
t(:div, {className: "form"},
input(Forms::Input, state.form_model.profile, :name),
input(Forms::Input, state.form_model, :email, {type: "text"}),
input(Forms::Input, state.form_model, :password, {type: "password"}),
input(Forms::Input, state.form_model, :password_confirmation, {type: "password"}),
input(Forms::WysiTextarea, state.form_model.profile, :bio), #component included Wysi with image upload image browse fulltext search
input(Forms::Input, state.form_model.avatar, :file, {type: "file", has_file: true, preview_image: true}), #< preview your avatar before saving user
if state.current_user.has_role? :admin <== that'll is set by plugin #<= will automatically show if has thanks to DependsOnCurrentUser plugin
input(Forms::Select, state.form_model, :role, {multiple: true, load_from_server: {url: "/api/test"}}) #select option will be feeded by server
end,
t(:br, {}),
t(:button, {onClick: ->{handle_inputs}}, "create user")
)
)
end
def handle_inputs
collect_inputs
unless state.form_model.has_errors?
state.form_model.create({component: self #needed for spinner only#, serialize_as_form: true #unnecessary but if wont to xhr file is nneded#}).then do |model|
if model.has_errors?
set_state form_model: model
else
pub_to(:user_created_channel, model)
end
end
else
set_state form_model: state.form_model
end
end
#####THIS WILL ALL serialize beatifully to form data, will send with all accepts_nested_attributes_for Rails
# everything will be validated shown and checked before sent to server, (even if not defined on client but are on server after request will also be shown where what and how many ),
#highlighted. and your dealing with several models at once, image upload preview, WYSIWG (Voog) rte, and just look how DRY it is, everything is reusable!
#all files via XHR without hassle.
end
end
View part is all about react components. That is done via simple Opal wrapping of the React by RW class (short for React Wrapper). The main idea of wrapping/accessing React's functions taken from zetachang/react.rb) .
Wrapping React I tried to keep it as close to React as possible without unnecessary stuff. You should treat and write the components the way you would do with React, but with all the Ruby beautiness (long live Opal!).
class HelloName < RW
expose
def get_initial_state
{
name: "Johny"
}
end
def render
t(:div, {},
t(:p, {}, "Hello #{state.name}!")
)
end
end
Your RW component should call expose in definition... always ...or it will not work. That expose method exposes RW class to be acessible from outside of Opal (e.g. by Reactrails ujs). Calling expose will make Users::LogIn to window["Users_LogIn"], so you can access it from outside.
Name one native method, snake_case it, get the name of RW method.
getInitialState #=> will beacome get_initial_state
#should return hash representing state
def get_initial_state
{ foo: "bar", user: User.new({name: "Foo"})}
end
getDefaultProps #=> self.get_default_props
should return hash representing props
def self.get_default_props
{name: "Defaulteria"}
end
componentWillUpdate -> component_will_update
All React's native functions are wrapped in instance methods, except for get_default_props which is a class one, createElement can by used by #t
method.
All the methods behave absolutely same as vanilla React. And can be used with predictable result. You can easily mix with JS React Compoonents easily (more on that later).
t(<component_name: Klass < RW || `VanillaComponent(any)` || String || Symbol>, <props : Hash> = {}, *<children>)
t is short for tag and it's short, in case you've wondered why t.
children
behave as*args
, so that means you have to separate them with comma.
html tags called as strings or symbols (
"div"
,:button
etc).
To create RW component simply pass it: t(Components::Users::Index, {})
For vanilla pass their name with backsticks.
class Components::Users::Messager < RW
expose
def render
t(:div, {},
t(Components::Users::Message, {name: "Johny"},
t(:p, {}, "first Johny's child"),
t("h3", {}, "second Johny's child")
),
t(`SomeReactJsClass`, {meg_prop: "foo"}, "Hello was said to Johny")
)
end
end
that's comma separating looks weird.
yeah it does Sir, BUT you'll get used to it in no time, and compiler will show you to the exact line at compile time, so it will not bring you to much of a problem.
props are passed as hash to second arg of #t
, if no props given be kind to supply an emty Hash {}
.
t(:div, {className: "la-la-la", id: "baz", style: {display: "none"}.to_n}, "THE CONTENT")
functions to events are added as lambdas:
class Users::Show
expose
def initial_state
{ user: User.new(name: "Foo") }
end
def render
t(:div, {className: "fancy-class"},
t(:button, {onClick: ->{handler(state.user)}},
"click me for me to shout at you"
)
)
end
def handler(e)
alert "get outa here you little #{e.name}"
end
end
Components::PropsAsMeth::Example < RW
expose
def render
t(:div, {},
t(Components::SomeComponent::Screamer, { on_shout: event(->(wat){shout(wat)}) },
)
)
end
def on_shout(wat)
alert wat
end
end
class Components::SomeComponent::Screamer < RW
expose
def render
t(:button, {onClick: ->{shout_from_parent}}, "shout from parent!")
end
def shout_from_parent
emit(:on_shout, "what")
end
end
That event
and emit
constructs are needed for following reasons: for some reason in some situation I couldn't reproduct the Lambda/Proc passed to prop was lost, or to_n to js function. with #event it wraps it into special class RW:EventWrapper, emit calls it's Lambda. And as a bonus it is more readable.
class Users::Foo
def init
@full_name = "#{props.user.first_name} #{props.user.last_name}"
end
end
#init will be called parallel to native getInitiaState() in a lifecycle.
refs are defined traditionally and are so awesome to use with ruby class Users::Messager < RW def render t(:div, {}, t(:input, {type: "text", ref: "the_input"}), t(:button, {onClick: ->{show_me_input}}) ) end
def show_me_input alert ref(:the_input).value end end
BEWARE STRANGER: state, ref, props are accessed as wrapped in Native, they are not ruby structures (but you wont meet any problems with that). to get refs as Ruby Hash, you can call
#refs_as_hash
ruby hash of props is get viaprops_to_h
, basically it could be made that you would access them as hash, but this way it's more close to React and data will have consistency. Just remember that state {foo: Some.new} will result in native js object{foo: #{<someclassinstance>}
, the values of keys are not tranformed to native. If you want it to be native pass it explicilty: example you want some prop to hold JS object pass it like:{foo: {bar: 'bar'}.to_n}
or{foo:
{bar: 'baz'}}
But you know what's even more awesome? that you can get the opal instance backing the component
class Foo < RW
expose
def initial_state
{ message_of_truth: ""}
end
def render
t(:p, {}, state.message_of_truth)
end
end
class TheMainComponent < RW
expose
def render
t(:div, {},
t(Foo, {ref: "foo"}),
t(:p, {onClick: ->{change_state_of_foo}} )
)
end
def change_state_of_foo
ref(:foo).rb.set_state message_of_truth: "you are human!"
end
end
beacuse we roll our little special #t
method, traditional iterations won't work
def initial_state
x = []
100.times do |i|
x << i
end
{ ar: x }
end
def render
t(:div, {},
*splat_each(ar) do |v|
t(:p, {}, v)
end
)
end
there are defined splat_each(enum)
and splat_each_with_index(enum)
, but you can easily add your
own in simply patching the RW class (or monkey patch the Array or Hash classes), so your iteration returns an array of values returned from it. look at source and youll see why. and star * also should be added before your splat_iteration_method
(as well as to any arrays that are being passed as children)
*SHORT QA*
q: ...
a: shh, don't even ask, you'll get used to it super fast. look at it as the situation where vanilla React render function should return single element
def initial_state
{
user: false
}
end
def component_did_mount
User.show({id: 1}).then do |_user|
set_state user: _user
end
end
def render
t(:div, {},
if state.user
t(:p, {}, state.user.name)
else
t(:p, {}, "not loaded yet")
end
)
end
it's shame to call them so, but I'm used to that name, and it's simply a couplea line class which has an accessor to Opal RW instance passed to it on new.
I didn't yet find that much of necessity in them, so basically components are controller views themselves. But for the sake of incapsulation they may be used.
RW components have assign_controllers(ControllerName)
, which ll be called upon their instantiation,
which pass themselve to provided contorller.
*Don't worry when component will unmount everything will be autamatically cleared.
React, ReactRouter, History, Zepto are included by default. (javascripts/vendor/)
now it includes couple of plugins
PubSubBus:
simple observable like pub sub messaging system, refer to vendor/PubSubBus, for working example refer to components/users/login_info and model/CurrentUser
Plugins::Formable:
super easy form handling, currently inputs for: input {file: true}, text input, textarea, select with feed from server, wysi_textarea for wrapped Voog's wysihtml. look at source in components/forms and plugins/formable
bootstrap modal and dropdowns, working example menues/index, pages/new click on insert image on textarea
Pagination (integration wih will paginate) for working : for working example pages/index
DependsOnCurrentUser , render stuff that depends on user permissions, for working example dashboards/admin click add user with admin and non admin user
Many other stuff that is undocumented yet.
For more info refer to source itself. I've tried to comment code as much as I could.
start from components/app/router components/app/main, load click everywhere, watch the demo components source, than proceed to vendor/model, vendor/react_wrapper.
Thank you for scrolling to here!
###LICENSE
The MIT License (MIT) Copyright (c)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.