A simple application to experiment with Turbo.
This application uses:
- ruby 3.3.0
- sqlite 3
- redis
Have them installed, clone repo and run:
$ bundle
$ rails db:setup
You can run rails db:seed
many times to have more data.
Use rails s
to run the server.
Run $ rspec
for tests.
Run $ rubocop
for linter check.
Links:
https://hotwired.dev/
https://turbo.hotwired.dev/handbook/drive
- go to any web page
- analyse content of Network tab in Inspector during navigating through sub-pages
- run workshop app with
rails s
- analyse content of Network tab in Inspector during navigating through sub-pages
- add at the bottom of
app/javascript/application.js
Turbo.session.drive = false
- analyse content of Network tab in Inspector during navigating through sub-pages
Links:
https://turbo.hotwired.dev/handbook/frames
https://rubydoc.info/github/hotwired/turbo-rails/Turbo%2FFramesHelper:turbo_frame_tag
https://apidock.com/rails/ActionView/RecordIdentifier/dom_id
Add turbo frames for cards to enable edit in place
-
Update
app/views/cards/_card.html.erb
- wrap all the code into turbo frame tag block:Updated file:
<%= turbo_frame_tag dom_id(card) do %> <div class="card card-body"> <div class="d-flex justify-content-between align-items-center mb-2"> <h5 class="card-title mb-0"> <%= card.title %> </h5> <div class="d-flex gap-2"> <%= link_to edit_card_path(card), class: 'text-default' do %> <i class="fa-solid fa-pencil"></i> <% end %> <%= link_to card_path(card), data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' }, class: 'text-danger' do %> <i class="fa-solid fa-trash"></i> <% end %> </div> </div> <div class="card-text text-primary-grey-600"> <%= card.description %> </div> </div> <% end %>
-
Update
app/views/cards/edit.html.erb
- wrap βformβ into turbo frame tag block:Updated file:
<h1 class="text-primary-dark-500">Edit Card</h1> <div class="row w-100 justify-content-center mt-3"> <%= turbo_frame_tag dom_id(@card) do %> <div class="border border-primary-grey-200 bg-light p-2"> <%= render 'form' %> </div> <% end %> </div>
Add turbo frames to board column headers to edit column names in place
-
Update
app/views/board_columns/_column_header.html.erb
- wrap all the code into turbo frame tag block:Updated file:
<%= turbo_frame_tag dom_id(board_column, :edit) do %> <div class="d-flex flex-row"> <h5 class="d-flex flex-col"> <%= board_column.name %> </h5> <div class="d-flex flex-col ms-auto gap-2"> <%= link_to edit_board_column_path(board_column) do %> <div class="fa-solid fa-pencil text-primary-dark-600"></div> <% end %> <%= link_to board_column_path(board_column), data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } do %> <div class="fa-solid fa-trash text-primary-dark-600"></div> <% end %> </div> </div> <% end %>
-
Update
app/views/board_columns/edit.html.erb
- wrap βformβ into turbo frame tag block:Updated file:
<h1 class="text-primary-dark-500">Edit Board Column</h1> <div class="row w-100 justify-content-center mt-3"> <%= turbo_frame_tag dom_id(@board_column, :edit) do %> <div class="border border-primary-grey-200 bg-light p-2"> <%= render 'form' %> </div> <% end %> </div>
Add turbo frames to board headers to edit board name in place
-
Update
app/views/boards/index.html.erb
- wrap .card-header into turbo frame tag block (line 17):Updated file:
<div class="row w-100"> <div class="d-flex justify-content-between"> <%= link_to 'New Board', new_board_path, class: 'btn btn-outline-primary invisible' %> <h1 class="text-primary-dark-500"> Boards </h1> <div> <%= link_to 'New Board', new_board_path, class: 'btn btn-outline-primary' %> </div> </div> </div> <div class="row w-100"> <% @boards.each do |board| %> <div class="col-3 my-3"> <div class="card border border-primary-grey-200"> <%= turbo_frame_tag dom_id(board, :edit) do %> <div class="card-header bg-primary-grey-200"> <div class="d-flex justify-content-between align-items-center"> <h5 class="card-title mb-0"> <%= link_to board.name, board, class: 'link-underline link-underline-opacity-0' %> </h5> <div class="d-flex gap-2"> <%= link_to edit_board_path(board), class: 'text-default' do %> <i class="fa-solid fa-pencil text-primary-dark-600"></i> <% end %> <%= link_to board, data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' }, class: 'text-danger' do %> <i class="fa-solid fa-trash text-primary-dark-600"></i> <% end %> </div> </div> </div> <% end %> <div class="card-body bg-primary-grey-100"> <div class="card-text"> <p> <%= "Columns: #{board.board_columns.size}" %> </p> <p> <%= "Cards: #{board.board_columns.joins(:cards).count}" %> </p> </div> </div> </div> </div> <% end %> </div>
-
Update
app/views/boards/edit.html.erb
- wrap βformβ into turbo frame tag block:Updated file:
<h1 class="text-primary-dark-500">Edit Board</h1> <div class="row w-100 justify-content-center mt-3"> <%= turbo_frame_tag dom_id(@board, :edit) do %> <div class="border border-primary-grey-200 bg-light p-2"> <%= render 'form' %> </div> <% end %> </div>
Branch with all edits-in-place: git checkout turbo-frames-edits
-
Update
app/views/boards/index.html.erb
- adddata: { turbo_frame: '_top' }
to show link:Updated file:
<h5 class="card-title mb-0"> <%= link_to board.name, board, data: { turbo_frame: '_top'}, class: 'link-underline link-underline-opacity-0' %> </h5>
Branch with fixed link: git checkout turbo-frames-top
Links:
https://turbo.hotwired.dev/handbook/streams
-
Update
app/controllers/cards_controller.rb#destroy
- add turbo stream format responseUpdated file:
respond_to do |format| format.html { redirect_to board_url(board), notice: "Card was successfully destroyed." } format.turbo_stream end
-
Create
app/views/cards/destroy.turbo_stream.erb
Updated file:
<%= turbo_stream.remove dom_id(@card) %>
-
Update
app/controllers/board_columns_controller.rb#destroy
- add turbo stream format response with inline turbo stream renderUpdated file:
respond_to do |format| format.html { redirect_to board_url(board), notice: "BoardColumn was successfully destroyed." } format.turbo_stream { render turbo_stream: turbo_stream.remove(@board_column) } end
-
Update
app/views/board_columns/_board_column.html.erb
- add unique ID for board columns:Updated file:
<div id= <%= dom_id(board_column)%> class="board-column" data-sortable-column-id-value="<%= board_column.id %>"> <%= render partial: 'board_columns/column_header', locals: { board_column: board_column } %> <div id= <%= dom_id(board_column, :column_body) %>, class="board-column-body", data-sortable-target="cardsContainer"> <% board_column.cards.order(:position).each do |card| %> <div class="draggable_card my-1" data-sortable-id="<%= card.id %>"> <%= render partial: 'cards/card', locals: { card: card } %> </div> <% end %> </div> <div class="board-column-footer"> <%= link_to new_card_url(board_column_id: board_column.id), class: 'text-success fs-2' do %> <div class="fa-solid fa-plus"></div> <% end %> </div> </div>
-
Update
app/controllers/boards_controller.rb#destroy
- add turbo stream format response with inline turbo stream renderUpdated file:
def destroy @board.destroy! respond_to do |format| format.html { redirect_to boards_url, notice: "Board was successfully destroyed." } format.turbo_stream { render turbo_stream: turbo_stream.remove(@board) } end end
-
Update
app/views/boards/index.html.erb
- add unique IDs for each board:Updated file:
<div class="row w-100"> <div class="d-flex justify-content-between"> <%= link_to 'New Board', new_board_path, class: 'btn btn-outline-primary invisible' %> <h1 class="text-primary-dark-500"> Boards </h1> <div> <%= link_to 'New Board', new_board_path, class: 'btn btn-outline-primary' %> </div> </div> </div> <div class="row w-100"> <% @boards.each do |board| %> <div id= <%= dom_id(board) %> class="col-3 my-3"> <div class="card border border-primary-grey-200"> <%= turbo_frame_tag dom_id(board, :edit) do %> <div class="card-header bg-primary-grey-200"> <div class="d-flex justify-content-between align-items-center"> <h5 class="card-title mb-0"> <%= link_to board.name, board, data: { turbo_frame: '_top'}, class: 'link-underline link-underline-opacity-0' %> </h5> <div class="d-flex gap-2"> <%= link_to edit_board_path(board), class: 'text-default' do %> <i class="fa-solid fa-pencil text-primary-dark-600"></i> <% end %> <%= link_to board, data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' }, class: 'text-danger' do %> <i class="fa-solid fa-trash text-primary-dark-600"></i> <% end %> </div> </div> </div> <% end %> <div class="card-body bg-primary-grey-100"> <div class="card-text"> <p> <%= "Columns: #{board.board_columns.size}" %> </p> <p> <%= "Cards: #{board.board_columns.joins(:cards).count}" %> </p> </div> </div> </div> </div> <% end %> </div>
Branch with all deletes fixed: git checkout turbo-frames-deletes
-
Extract 'New Card link' into partial - create
app/views/cards/_new_card.html.erb
:Created file:
<%= link_to new_card_url(board_column_id: board_column.id), class: 'text-success fs-2' do %> <div class="fa-solid fa-plus"></div> <% end %>
-
Use new partial in
app/views/board_columns/_board_column.html.erb
:Updated file:
<div class="board-column-footer"> <%= render partial: 'cards/new_card', locals: { board_column: board_column } %> </div>
-
Render new card form in place: wrap link to New Card into turbo frame in
app/views/cards/_new_card.html.erb
:Updated file:
<%= turbo_frame_tag dom_id(board_column, :new_card) do %> <%= link_to new_card_url(board_column_id: board_column.id), class: 'text-success fs-2' do %> <div class="fa-solid fa-plus"></div> <% end %> <% end %>
-
Wrap 'form' into turbo frame in
app/views/cards/new.html.erb
:Updated file:
<h1 class="text-primary-dark-500">New Card</h1> <div class="row w-100 justify-content-center mt-3"> <%= turbo_frame_tag dom_id(@card.board_column, :new_card) do %> <div class="border border-primary-grey-200 bg-light p-2"> <%= render 'form' %> </div> <% end %> </div>
-
Create turbo_stream response - update
app/controllers/cards_controller.rb#create
-Updated file:
respond_to do |format| if service.call @card = service.card format.html { redirect_to board_url(@card.board), notice: "Card was successfully created." } format.turbo_stream else @card = service.card format.html { render :new, status: :unprocessable_entity } end end
-
Create
app/views/cards/create.turbo_stream.erb
:Created file:
<%= turbo_stream.append dom_id(@card.board_column, :column_body) do %> <%= render 'cards/card', card: @card %> <% end %> <%= turbo_stream.replace dom_id(@card.board_column, :new_card) do %> <%= render 'cards/new_card', board_column: @card.board_column %> <% end %>
Add create-in-place for boards.
Updated files
No solution here.
Try to implement it on your own. You can do it! πͺ
Or, checkout to branch with solution.
Add create-in-place for board columns. Ideally, new columns should be added right to existing ones.
Updated files
No solution here.
Try to implement it on your own. You can do it! πͺ
Or, checkout to branch with solution.
Branch with all records creation: git checkout turbo-frames-creates
Links:
https://www.rubydoc.info/gems/turbo-rails/Turbo/Broadcastable
https://www.hotrails.dev/turbo-rails/turbo-streams
- Update
app/views/boards/show.html.erb
- Add turbo stream tag to connect user to websocket channel at the top of file
New line:
<%= turbo_stream_from dom_id(@board) %>
also within the same file add turbo stream tag that we will use to append broadcasted columns
New line in file placement:
<% @board_columns.each do |board_column| %>
<%= render partial: 'board_columns/board_column', locals: { board_column: board_column } %>
<% end %>
<%= turbo_frame_tag dom_id(@board, 'columns') # newly added line %>
<%= turbo_frame_tag dom_id(BoardColumn.new) %>
- Update
app/models/board_column.rb
- include ActionView::RecordIdentifier library to usedom_id
in model, add broadcast callback to model
Updated file:
class BoardColumn < ApplicationRecord
include ActionView::RecordIdentifier
# ... leave old code
broadcasts_to ->(board_column) { "board_#{board_column.board_id}" },
target: ->(board_column) { "columns_board_#{board_column.board.id}" },
inserts_by: :append
- Update
app/models/card.rb
- add callback that will touch and update associated columns while modifying cards
Updated file:
class Card < ApplicationRecord
include ActionView::RecordIdentifier
# ... leave old code
after_commit :touch_affected_board_columns
private
def touch_affected_board_columns
if previous_changes[:board_column_id].present?
board.board_columns.find_by(id: previous_changes[:board_column_id]&.first)&.touch
board.board_columns.find_by(id: previous_changes[:board_column_id]&.last)&.touch
else
board_column.touch
end
end
end
Branch with broadcasts: git checkout turbo-broadcasts