footer: Noel Rappin | Hotwire: HTML Over The Wire | RailsConf 2021 | http://www.noelrappin.com | @noelrap
- Discuss Hotwire's structure
- Use a bunch of Turbo features on a site
- Augment them with a little Stimulus
- See where we are on time
- Regular HTML
- Navigation via turbo frames
- Form submit and turbo stream
- Client-side CSS switch
- Generic Stimulus controller
- Specific Stimulus controller
<%= turbo_frame_tag(dom_id(concert)) do %>
THE WHOLE FILE
<% end %>
def show
if params[:inline]
render(@concert)
end
end
def create
@concert = Concert.new(concert_params)
respond_to do |format|
if @concert.save
format.html { redirect_to @concert, notice: "" }
format.json { render :show, status: :created, location: @concert }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @concert.errors, status: :unprocessable_entity }
end
end
end
<turbo-frame id="concert_16">
</turbo-frame>
It also means we could have sent the whole page in response to the form submit, and it would have worked, but had worse performance
<turbo-frame id="concert_16" src="/concerts/16" loading="lazy">
LOADING DISPLAY
</turbo-frame>
<%= link_to(concert.name, concert, "data-turbo-frame": "_top") %>
<%= turbo_frame_tag("favorite-concerts") do %>
REST OF FILE
<% end %>
<% if current_user.favorite(concert) %>
<%= button_to(
"Remove Favorite",
favorite_path(id: current_user.favorite(concert)),
method: "delete",
form: {data: {"turbo-frame": "favorite-concerts"}},
class: SimpleForm.button_class)%>
<% else %>
<%= button_to("Make Favorite",
favorites_path(concert_id: concert.id),
method: "post",
form: {data: {"turbo-frame": "favorite-concerts"}},
class: SimpleForm.button_class) %>
<% end %>
class FavoritesController < ApplicationController
def index
end
def create
Favorite.create(user: current_user, concert_id: params[:concert_id])
render(partial: "favorites/list", locals {user: current_user})
end
def destroy
@favorite = Favorite.find(params[:id])
@favorite.destroy
render(partial: "favorites/list", locals: {user: current_user})
end
private def favorite_params
params.require(:concert_id)
end
end
<turbo-stream action="ACTION" target="TARGET">
<template>
OUR HTML GOES HERE
</template>
</turbo-stream>
class FavoritesController < ApplicationController
def index
end
def create
@favorite = Favorite.create(
user: current_user,
concert_id: params[:concert_id]
)
end
def destroy
@favorite = Favorite.find(params[:id])
@favorite.destroy
end
private def favorite_params
params.require(:concert_id)
end
end
<%= turbo_stream.append("favorite-concerts-list", @favorite) %>
<%= turbo_stream.replace(dom_id(@favorite.concert)) do %>
<%= render(@favorite.concert, user: @favorite.user) %>
<% end %>
<%= turbo_stream.remove(dom_id(@favorite)) %>
<%= turbo_stream.replace(dom_id(@favorite.concert)) do %>
<%= render(@favorite.concert, user: @favorite.user) %>
<% end %>
<%= turbo_stream.append("favorite-concerts-list") do %>
<%= render(@favorite, animate_in: true) %>
<% end %>
<%= turbo_stream.replace(dom_id(@favorite.concert)) do %>
<%= render(@favorite.concert, user: @favorite.user) %>
<% end %>
<%- concert = favorite.concert %>
<%- animate_in ||= false %>
<article class="my-6 animate__animated
<%= animate_in ? "animate__slideInRight" : "" %>"
id="<%= dom_id(favorite) %>"
data-animate-out="animate__slideOutRight">
# AND SO ON
</article>
document.addEventListener("turbo:before-stream-render", (event) => {
if (event.target.action === "remove") {
const targetFrame = document.getElementById(event.target.target)
if (targetFrame.dataset.animateOut) {
event.preventDefault()
const elementBeingAnimated = targetFrame
elementBeingAnimated.classList.add(targetFrame.dataset.animateOut)
elementBeingAnimated.addEventListener("animationend", () => {
targetFrame.remove()
})
}
}
})
development:
adapter: async
<%= turbo_stream_from(@user, :favorites) %>
after_create_commit -> do
Turbo::StreamsChannel.broadcast_stream_to(
user, :favorites,
content: ApplicationController.render(
:turbo_stream,
partial: "favorites/create",
locals: {favorite: self}
)
)
end
after_destroy_commit -> do
Turbo::StreamsChannel.broadcast_stream_to(
user, :favorites,
content: ApplicationController.render(
:turbo_stream,
partial: "favorites/destroy",
locals: {favorite: self}
)
)
end
<%= turbo_stream.append("favorite-concerts-list") do %>
<%= render(favorite, animate_in: true) %>
<% end %>
<%= turbo_stream.replace(dom_id(favorite.concert)) do %>
<%= render(favorite.concert, user: favorite.user) %>
<% end %>
<%= turbo_stream.remove(dom_id(favorite)) %>
<%= turbo_stream.replace(dom_id(favorite.concert)) do %>
<%= render(favorite.concert, user: favorite.user) %>
<% end %>
def create
@favorite = Favorite.create(
user: current_user,
concert_id: params[:concert_id]
)
head(:ok)
end
def destroy
@favorite = Favorite.find(params[:id])
@favorite.destroy
head(:ok)
end
<span class="<%= SimpleForm.button_class %> blue-hover ml-6"
data-controller="flip"
data-flip-status-value="true"
data-flip-true-class="rotate-90"
data-flip-false-class="-rotate-90"
data-action="click->flip#toggle click->fade#fade">
<%= image_pack_tag(
"chevron-right.svg",
width: 25,
height: 25,
class: "inline transform transition-transform duration-1000",
"data-flip-target": "elementToChange" ) %>
</span>
import { Controller } from "stimulus"
export default class FlipController extends Controller {
static classes = ["true", "false"]
static targets = ["elementToChange"]
static values = { status: Boolean }
toggle() {
this.statusValue = !this.statusValue
}
statusValueChanged() {
this.elementToChangeTarget.classList.toggle(
this.trueClass,
this.statusValue
)
this.elementToChangeTarget.classList.toggle(
this.falseClass,
!this.statusValue
)
}
}
elementToChangeTarget
elementToChangeTargets
hasElementToChangeTarget
this.statusValue
this.statusValueChanged()
(called on startup)this.trueClass
this.hasTrueClass
<section class="my-4"
id="favorite-section"
data-controller="fade"
data-fade-status-value="true"
data-fade-clean-value="true"
data-fade-fade-in-class="fadeInDown"
data-fade-fade-out-class="fadeOutUp">
<div id="favorite-concerts-list"
data-fade-target="elementToChange"
data-action="animationend->fade#animationend
animationstart->fade#animationstart">
import { Controller } from "stimulus"
export default class FadeController extends Controller {
static classes = ["fadeIn", "fadeOut"]
static targets = ["elementToChange"]
static values = { status: Boolean, clean: Boolean }
fade() {
this.statusValue = !this.statusValue
this.cleanValue = false
}
animationend() {
this.toggleClass("max-h-0", !this.statusValue)
}
animationstart() {
this.toggleClass("max-h-0", false)
}
statusValueChanged() {
if (this.cleanValue) {
return
}
this.toggleClass("animate__animated", true)
this.toggleClass(`animate__${this.fadeInClass}`, this.statusValue)
this.toggleClass(`animate__${this.fadeOutClass}`, !this.statusValue)
}
toggleClass(cssClass, state) {
this.elementToChangeTarget.classList.toggle(cssClass, state)
}
}
<div id="favorite-concerts-list"
data-controller="sort"
data-fade-target="elementToChange"
data-action="animationend->fade#animationend animationstart->fade#animationstart">
<article class="my-6 animate__animated
<%= animate_in ? "animate__slideInRight" : "" %>"
id="<%= dom_id(favorite) %>"
data-animate-out="animate__slideOutRight"
data-sort-target="sortElement"
data-sort-value="<%= favorite.sort_date %>">
import { Controller } from "stimulus"
export default class SortController extends Controller {
static targets = ["sortElement"]
initialize() {
const target = this.element
const observer = new MutationObserver((mutations) => {
observer.disconnect()
Promise.resolve().then(start)
this.sortTargets()
})
function start() {
observer.observe(target, { childList: true, subtree: true })
}
start()
}
sortTargets() {
if (this.targetsAlreadySorted()) {
return
}
this.sortElementTargets
.sort((a, b) => {
return this.sortValue(a) - this.sortValue(b)
})
.forEach((element) => this.element.append(element))
}
targetsAlreadySorted() {
let [first, ...rest] = this.sortElementTargets
for (const next of rest) {
if (this.sortValue(first) > this.sortValue(next)) {
return false
}
first = next
}
return true
}
sortValue(element) {
if ("sortValue" in element.dataset) {
return parseInt(element.dataset.sortValue, 10)
} else {
return parseInt(element.children[0].dataset.sortValue, 10)
}
}
}
def update
respond_to do |format|
if @concert.update(concert_params)
format.turbo_stream {}
format.html { render(@concert, locals: {user: current_user}) }
format.json { render :show, status: :ok, location: @concert }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @concert.errors, status: :unprocessable_entity }
end
end
end
<%= turbo_stream.replace(dom_id(@concert)) do %>
<%= render(@concert, user: current_user) %>
<% end %>
<%= turbo_frame_tag(
dom_id(concert),
class: "concert",
"data-sort-target": "sortElement",
"data-sort-value": concert.start_time.to_i,
"data-#{concert.start_time.by_example("2006-01-02")}": true) do %>
<section data-controller="sort">
<% @concerts.sort_by(&:start_time).each do |concert| %>
<%= render concert, user: @user %>
<% end %>
</section>
<style>
<% @schedule.schedule_days.each do |schedule_day| %>
<% today = "data-#{schedule_day.day.by_example("2006-01-02")}" %>
.concert[<%= today %>]:first-child::before,
.concert:not([<%= today %>]) + [<%= today %>]::before
{
content: "<%= schedule_day.day.by_example("Monday, January 2, 2006") %>";
font-size: 1.875rem;
line-height: 2.25rem;
font-weight: 700;
}
<% end %>
</style>