Using Rails 5 ActionCable and RethinkDB to build a Reactive WebSocket App

Ruby Inside
Aug 4, 2016 · 11 min read

Setting up a basic ActionCable channel

Rails.application.routes.draw do
mount ActionCable.server => '/cable'
end
rails g channel active_users
rails g controller spreadsheet index
root 'spreadsheet#index'

Introducing RethinkDB to the application

Adding RethinkDB models to the application

gem 'nobrainer'
gem 'nobrainer_streams'
class User
include NoBrainer::Document
field :selected_cell
end
class ActiveUsersChannel < ApplicationCable::Channel
include NoBrainer::Streams
def subscribed
@user = User.create
stream_from User.all, include_initial: true
end
def unsubscribed
@user.destroy
end
end
<section id="active_users">
<h2>Active users</h2>
<ul id="active_users_list"></ul>
</section>
App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
received: (data) ->
if data.old_val && !data.new_val
App.spreadsheet.remove_user(data.old_val)
else if data.new_val
App.spreadsheet.new_user(data.new_val)
App.spreadsheet =
active_users: {}
new_user: (user) ->
@active_users[user.id] = user
@render_active_users()
remove_user: (user) ->
delete @active_users[user.id]
@render_active_users()
render_active_users: () ->
$('#active_users_list').html(
("<li>#{user.id}</li>" for id,user of @active_users).join("")
)

Implementing a multi-user spreadsheet

<% content_for(:head) do %>
<%= javascript_include_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.js" %>
<%= stylesheet_link_tag "https://cdnjs.cloudflare.com/ajax/libs/handsontable/0.26.1/handsontable.full.css" %>
<% end %><!-- ... --><section id="spreadsheet">
</section>
App.spreadsheet =
# ...
setup: () ->
container = document.getElementById('spreadsheet')
@hot = new Handsontable(container,
minSpareCols: 1
minSpareRows: 1
rowHeaders: true
colHeaders: true
contextMenu: true
)
$ -> App.spreadsheet.setup()

Streaming field selections as colorful cells

class ActiveUsersChannel < ApplicationCable::Channel
# ...
def select_cells(message)
@user.update! selected_cells: message['selected_cells']
end
end
App.active_users = App.cable.subscriptions.create "ActiveUsersChannel",
# ...
select_cells: (cells) ->
@perform('select_cells', selected_cells: cells)
App.spreadsheet =
setup: () ->
# ...
@hot = new Handsontable(container,
afterSelection: () => @select_cells(arguments)
afterDeselect: () => @deselect_cells()
# ...
select_cells: (cells) ->
App.active_users.select_cells(r: cells[0], c: cells[1], r2: cells[2], c2: cells[3])
deselect_cells: () ->
App.active_users.select_cells(null)
render_selected_cells: () ->
for cells in @selected_cells
cell = @hot.getCell(cells.r, cells.c)
if cell.classList.contains("current")
cell.classList = "current"
else
cell.classList = ""
@selected_cells = []
for id, user of @active_users
if id != @current_user.id && (cells = user.selected_cells)
@selected_cells.push(cells)
cell = @hot.getCell(cells.r, cells.c)
cell.classList.add('user-' + user.num)
@mixin colored-border($color) {
box-shadow:inset 0px 0px 0px 2px $color;
}
.user-1 { @include colored-border(#33a02c);}
.user-2 { @include colored-border(#e31a1c);}
.user-3 { @include colored-border(#ff7f00);}
.user-4 { @include colored-border(#6a3d9a);}
.user-5 { @include colored-border(#b15928);}
.user-6 { @include colored-border(#a6cee3);}
.user-7 { @include colored-border(#b2df8a);}
.user-8 { @include colored-border(#fb9a99);}
.user-9 { @include colored-border(#fdbf6f);}
.user-10 { @include colored-border(#cab2d6);}
.user-11 { @include colored-border(#ffff99);}
.user-12 { @include colored-border(#1f78b4);}

Transmitting the field values

class SpreadsheetCell
include NoBrainer::Document
field :location
field :value
end
class SpreadSheetCellsChannel < ApplicationCable::Channel
include NoBrainer::Streams
def subscribed
stream_from SpreadsheetCell.all, include_initial: true
end
def set_cell_value(message)
location = message['location']
SpreadsheetCell.upsert! location: location, value: message['value']
end
end
App.spread_sheet_cells = App.cable.subscriptions.create "SpreadSheetCellsChannel",
received: (data) ->
App.spreadsheet.update_cell(data.new_val)
set_cell_value: (location, value) ->
@perform('set_cell_value', location: location, value: value)
App.spreadsheet = 
# ...
setup: () ->
# ...
@hot = new Handsontable(container,
afterChange: (changes, source) =>
if source != 'remote' && changes
for change in changes
App.spread_sheet_cells.set_cell_value(
{ r: change[0], c: change[1] },
change[3]
)
# ...
)

update_cell: (update) ->
location = update.location
value = update.value
@hot.setDataAtCell(location.r, location.c, value, 'remote')

Implementing locks to prevent concurrent edits

class User
include NoBrainer::Document
field :selected_cells
before_destroy :unlock_cell def lock_cell(location)
NoBrainer.run do |r|
SpreadsheetCell.rql_table
.get(location)
.replace do |row|
r.branch(
row.eq(nil),
{ location: location, lock: id },
row.merge(
r.branch(row['lock'].eq(nil), {lock: id},{})
))
end
end
end
def unlock_cell
SpreadsheetCell.where(lock: id).update_all lock: nil
end
end
App.spreadsheet =
# ...
setup: () ->
@selected_cells = []
@cell_lock_callback = {}
container = document.getElementById('spreadsheet')
@hot = new Handsontable(container, ..)
@hot.acquireEditLock = (editor, callback) =>
location = {r: editor.row, c: editor.col}
@cell_lock_callback[location] = callback
App.active_users.lock_cell(location)
@hot.releaseEditLock = (editor, callback) =>
location = {r: editor.row, c: editor.col}
App.active_users.unlock_cell(location)
callback()

update_cell: (update) ->
location = r: update.location[0], c: update.location[1]
value = update.value
@hot.setDataAtCell(location.r, location.c, value, 'remote')
if update.lock == @current_user.id
@cell_lock_callback[location]?()
delete @cell_lock_callback[location]

# ...

Bleeding edge technology

Conclusion

Ruby Inside

Ruby articles and posts

Ruby Inside

Written by

A free, once–weekly e-mail round-up of Ruby news and articles.

Ruby Inside

Ruby articles and posts