7.11.202216 min

Dmitriy ShcherbakanSoftware EngineerRailsware

Jak robić frontend z Ruby on Rails, praktycznie bez JavaScriptu

Railsy wcale nie stają się niszową technologią. Wykorzystaj je do robienia frontendu z minimalnym użyciem JavaScript.

Jak robić frontend z Ruby on Rails, praktycznie bez JavaScriptu

Wbrew temu co często możesz usłyszeć wśród deweloperów, Railsy wcale nie stają się niszową technologią. Nigdzie się nie wybierają i wciąż są świetnym narzędziem do budowania nowych projektów. Jednym z powodów jest ogrom dostępnych narzędzi, przy pomocy których można zbudować wszystkie istotne elementy typowych aplikacji webowych. Nie musisz się przejmować tym jak obsługiwać zapytania HTTP, jak traktować dane od użytkownika w bezpieczny sposób, jak pobierać dane z baz danych, jak generować HTML widoczny dla użytkownika czy też jak stworzyć nowy interfejs użytkownika..


Rails - narzędzia na start: rails-ujs, Turbolinks

Rails UJS

Dawno temu gdy próbowałem stworzyć swoją pierwszą stronę, Rails miał fajne narzędzie - jquery-ujs (unobtrusive javascript), obecnie znane jako rails-ujs. Działało świetnie z backendem zbudowanym w Railsach gdy chciałeś dodać odrobinę zapytań AJAXowych niewielkim kosztem.

Mógłbyś to zrobić na przykład w taki sposób:

app/controllers/money_controller.rb

class MoneyController < ApplicationController
  def show
    @money = GetAllMoney.call
  end

  def destroy
    SpendAllMoney.call
  end
end


views/money/show.html.erb

<div class="money">
  <h3>Your money</h3>
  <span id="money-amount"><%= @money %></span>
  <span>$</span>

  <%= link_to 'Spend all money',
              money_path,
              method: 'delete',
              remote: true,
              data: { confirm: 'Do you want to spend all money?' },
              class: 'spend-money-button' %>
</div>


views/money/destroy.js

document.querySelector('#money-amount').innerHTML = 0


 


Właśnie stworzyłeś zapytanie AJAX przy użyciu jedynie kilku atrybutów HTML oraz pliku JS z pojedynczą linijką kodu. Fajne, nie?

Turbolinks

Kolejny “staruszek” w railsowym świecie to Turbolinks. Nie są już aktywnie rozwijane, ale do ich następcy jeszcze dojdziemy. W skrócie, Turbolinks zapewniają Ci doświadczenie nawigacji między stronami bez pełnego przeładowania strony i bez praktycznie żadnego kodu po stronie klienta. A wygląda to dokładniej tak:

  • ładują JSem treść nowej strony i zamieniają ją na stronie bez przeładowania strony
  • przechowują strony w pamięci podręcznej, dzięki czemu przy kolejnej wizycie ładują się one praktycznie natychmiastowo
  • pozwalają zachować elementy na stronie podczas nawigowania


Dwie pierwsze funkcjonalności są aktywne od razu, ostatnią natomiast musisz wyraźnie zdefiniować w kodzie. Pokażę Ci może trochę naciągany przykład jak to zrobić.

Załóżmy, że mamy centrum powiadomień gdzieś na stronie.

app/helpers/application_helper.rb

module ApplicationHelper
  def notifications_count
    sleep 3 # emulate some calculations

    10
  end

  def articles
    Article.last(5)
  end
end


app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
<head>
  <title>Turbolinks</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>

  <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
  <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>

<body>
<div class="container">
  <nav class="navigation">
    <ul>
      <%- articles.each do |article| %>
        <li>
          <%= link_to article.title, article_path(article.id) %>
        </li>
      <% end %>
    </ul>
    <div class="notifications">
      <div class="notifications-badge">
        <%= notifications_count %>
      </div>
    </div>
  </nav>
  <section class="content">
    <%= yield %>
  </section>
</div>
</body>
</html>


Policzenie liczby powiadomień może chwilę zająć, ale moim zdaniem warto, bo dzięki temu mamy aktualne dane.

Następnie, pewnie chcesz aktualizować ilość powiadomień poprzez otrzymywanie aktualizacji w czasie rzeczywistym. W Railsach służy do tego już wbudowany Action Cable.

Z racji na to, że wszystko dzieje się na frontendzie, nie musisz zliczać powiadomień, które obsługują Turbolinks między jedną stroną, a drugą. Nie uruchamiamy więc kodu, jeśli strona została wcześnie zażądana przez Turbolinks. Uniemożliwimy im też aktualizowanie tej strony.

app/helpers/application_helper.rb

module ApplicationHelper
  def notifications_count
+   return nil if request.headers['Turbolinks-Referrer'].present?
+
    sleep 3 # emulate some calculations

    10
  end

  def articles
    Article.last(5)
  end
end


app/views/layouts/application.html.erb

<div class="notifications">
-   <div class="notifications-badge" id="notifications-badge">
+   <div class="notifications-badge" id="notifications-badge" data-turbolinks-permanent>
    <%= notifications_count %>
  </div>
</div>


Ale to nam nie wystarcza

Wspomniani emeryci Rails dają radę i w wielu aplikacjach zbudowano na nich skomplikowane interfejsy bez potrzeby dodawania JSowych frameworków. Mimo tego, brakuje nam trochę funkcjonalności, które uczyniłyby nasze aplikację łatwiejszymi do utrzymania i ułatwiłyby rozbudowę interfejsów.


Nowe narzędzia od zespołu Rails


Na początku 2021 roku DHH zrobił niemałe zamieszanie, gdy ogłosił Hotwire - nowy sposób budowania interfejsów użytkownika w Railsach. Mimo tego, że sama nazwa Hotwire to wspólna nazwa dla rodziny bibliotek, rodzina ta jest dość malutka. W październiku 2021 roku były tam tylko dwie biblioteki:


Zespół Rails zbudował obie biblioteki i można je bez problemu zintegrować z istniejącą monolityczną aplikacją. Powiem może więcej o Turbo z racji na to, że jest to coś nowego i w końcu zastąpi Turbolinks.

Turbo

Jeśli miałeś wrażenie, że Turbolinks straciły “links” ze swojej nazwy, bo nie chodzi w nich już tylko o nawigację to jak najbardziej miałeś rację. Turbo dzieli się na kilka części, każda z nich ma jeden cel - dostarczyć Twojej aplikacji HTML wyrenderowany na serwerze. Zasadniczo różnią się tym kiedy i jak to robią:

  • Turbo Drive - to stare dobre Turbolinks, które już znamy
  • Turbo Frames - fragmenty interfejsu, tzw. ramki, które mogą być asynchronicznie ładowane i aktualizowane, gdy w odpowiedzi na interakcję użytkownika serwer zwraca ramkę z tym samym id.
  • Turbo Streams - w przeciwieństwie do Turbo Frames pozwalają na aktualizację, dodanie kilku fragmentów interfejsu w ramach jednej odpowiedzi na zapytanie HTTP lub zdarzeń wysłanych z serwera przez Websocket.
  • Turbo Native - wrapper dla Twojej aplikacji webowej z Turbo, który zamienia ją w aplikację mobilną.


Turbo Drive

Tak jak pisałem wcześniej, Turbo Drive zastępuje Turbolinks i przejmuje nawigację strony. Z racji na to, że prawie nic się nie zmieniło, migracja jest również dość prosta. Zastępujesz atrybuty data-turbolinks… zaczynającymi się od data-turbo

Musisz jedynie dodać pakiet z npm.

yarn add @hotwired/turbo

Użyj Turbo zamiast Turbolinks w kodzie javascript.

app/javascript/packs/application.js

import Rails from "@rails/ujs"
- import Turbolinks from "turbolinks"
+ import * as Turbo from "@hotwired/turbo"
 
  Rails.start()
- Turbolinks.start()


A zamiast atrybutu data-turbolinks… użyj data-turbo

app/views/layouts/application.html.erb

-    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
-    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
+    <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>


Jedna rzecz, na którą warto zwrócić uwagę to sposób wysyłania formularza. Robi to za Ciebie również Turbo Drive. Po pierwsze, oczekuje, że przekierowania po wysłaniu formularza będą miały kod statusu 303, tak żeby Fetch API mogło przekierowywać je automatycznie. 

Jest to poprawny status HTTP dla nie-idempotentnych zapytań (takich, które nie używają metod GET ani HEAD), jeśli chcesz przekierowywać metodą GET. W przeciwnym wypadku, tylko zapytania POST będą przekierowywane poprawnie z racji na to, że pozwalają również na statusy 301 i 302. Warto więc moim zdaniem dodać konkretny kod statusu to swojego przekierowania.

app/controllers/any_controller.rb

redirect_to money_path
+    redirect_to money_path, status: :see_other


Formularze w Rails używają tak czy inaczej metody POST i dodają <input type="hidden" name="_method" value="patch"> żeby zdefiniować akcję kontrolera, której użyjemy. Oznacza to, że formularze wciąż będą działać. Być może natknąłeś się na zażartą dyskusję na ten temat, w której dyskutowano o potrzebie poprawy kodów statusu.

Druga rzecz, na którą warto zwrócić uwagę to to, że Turbo nie uznaje parametru local: true, którego być może używasz, żeby uniemożliwić kontrolę JSa nad formularzem. Jeśli tak faktycznie jest, musisz wprowadzić jeszcze jedną drobną zmianę.

app/views/_any_form.html.erb

- <%= form_with(url: money_path, local: true) do |f| %>
+ <%= form_with(url: money_path, data: { turbo: false }) do |f| %>


Turbo Frames

No i wreszcie dotarliśmy do czegoś nowego w Railsach. Turbo Frame to proste narzędzie do tworzenia kontenerów z treściami, które mogą być osobno ładowane i aktualizowane. Działa to tak jak render_async gem albo odpowiedź .ejs w Railsach, ale z mniejszą ilością kodu.

Popatrzmy na przykład tego, jak rozbić naszą stronę na kilka asynchronicznie ładowanych części, które są ładowane tylko wtedy, gdy dojdzie do nich użytkownik. Wyobraź sobie stronę produktową z ogólnymi informacjami, specyfikacją produktu i recenzjami klientów. Nie ma gwarancji, że użytkownik zobaczy każdą z tych sekcji. Możemy więc je załadować dopiero, gdy użytkownik do nich dotrze.

Pominę część przykładu, gdzie definiuje się model, ścieżki, instalację Bootstrapa i dodawanie CSSa. Zakładam, że nie interesują Cię takie dość podstawowe sprawy.

Nasz plik products/show.html.erb wygląda następująco:

<div class="product">
  <ul class="nav nav-tabs" id="product-tab" role="tablist">
    <li class="nav-item" role="presentation">
      <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
    </li>
  </ul>

  <div class="tab-content">
    <div class="tab-pane active p-3" id="general" role="tabpanel" aria-labelledby="general-tab">
      <turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
    <div class="tab-pane p-3" id="properties" role="tabpanel" aria-labelledby="properties-tab">
      <turbo-frame id="<%= dom_id(@product, 'properties') %>" loading="lazy" src="<%= properties_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
    <div class="tab-pane p-3" id="reviews" role="tabpanel" aria-labelledby="reviews-tab">
      <turbo-frame id="<%= dom_id(@product, 'reviews') %>" loading="lazy" src="<%= reviews_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
  </div>
</div>


Jest to najzwyklejszy pasek zakładek z Bootstrapa. Ciekawa rzecz dzieje się jednak w elemencie .tab-page. Dodaliśmy tag turbo-frame, który jest naszym kontenerem służącym do ładowania i aktualizowania. Każda ramka musi mieć swój unikalny atrybut id, a funkcja dom_id jest przydatnym narzędziem, które sprawia, że nie musimy myśleć o nazewnictwie.

Żeby załadować ramkę asynchronicznie, musimy dodać atrybut src, a odpowiedź z tej ścieżki powinna zwracać ramkę o tym samym id.

No i skoro zależy nam tylko na załadowaniu tylko widocznej części strony, dodajemy loading="lazy". Dzięki temu ramka załaduje się dopiero gdy będzie widoczna na stronie. Zwróć uwagę na to, że nie ma znaczenia w jaki sposób element stanie się widoczny. Użytkownik może przewinąć stronę aż do tego elementu i wtedy treść strony zostanie załadowana. Styl macierzysty może zmienić się z display: none na display: block. Aplikacja może wstawić go na stronę przez JavaScript, ale możesz też rekurencyjnie wyświetlać jedną ramkę w drugiej.

Dobrym przykładem jest spinner, który składa się z div-a z animacją w CSSie. Nie musisz nim zarządzać, bo będzie się kręcił aż treść ramki zostanie załadowana i dodana na stronę.

app/views/common/_spinner.html.erb

<div class="text-center mt-5">
  <div class="spinner-grow text-secondary" role="status">
    <span class="visually-hidden">Loading...</span>
  </div>
</div>


Alternatywnie, możesz użyć atrybutu busy dodanego do ramki kiedy ta się ładuje i dodać niestandardowy CSS, żeby pokazać stan ładowania.

Nasz kontroler jest dość prosty.

app/controllers/products_controller.rb

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
  end

  def general
    @product = Product.find(params[:id])

    render partial: 'products/general'
  end

  def properties
    @product = Product.find(params[:id])

    render partial: 'products/properties'
  end

  def reviews
    @product = Product.find(params[:id])

    render partial: 'products/reviews'
  end
end


app/views/products/_general.html.erb

<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
  <div class="product--general">
    <h1>
      <%= @product.title %>
    </h1>
    <div class="row mt-4">
      <div class="col">
        <div class="product--image">
          <%= image_tag @product.image %>
        </div>

      </div>
      <div class="col">
        <h3>
          <%= @product.price %>
        </h3>

        <%= @product.content %>
      </div>
    </div>
  </div>
</turbo-frame>


app/views/products/_properties.html.erb

<turbo-frame id="<%= dom_id(@product, 'properties') %>">
  <h1>
    <%= @product.title %> properties
  </h1>
  <dl class="row mt-4">
    <%- @product.properties.each do |name, value| %>
      <dt class="col-sm-3"><%= name.to_s.titleize %></dt>
      <dd class="col-sm-9"><%= value %></dd>
    <% end %>
  </dl>
</turbo-frame>


app/views/products/_review.html.erb

<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
  <%- @product.reviews.each do |review| %>
    <div class="card mb-3">
      <div class="card-body">
        <div class="card-title">
          <%= review.author %>
        </div>
        <div class="card-text">
          <%= review.content %>
        </div>
      </div>
    </div>
  <% end %>
</turbo-frame>


Renderujemy tutaj część strony, ale zamiast tego możesz też wyświetlić całą stronę. Najważniejsze jest, żeby załadować tag turbo-frame z takim samym id jak w tagu z zapytania.

No i w sumie to wszystko, czego potrzebujesz, żeby zbudować stronę z lazy loading.

Niestety nie udało mi się znaleźć wygodnej metody obsługi błędów z Turbo Frames, ale poniższe rozwiązanie może Ci się przydać:

app/controllers/any_controller.rb

def general
  @product = Product.find(params[:id])

  raise StandardError, 'Some error'

  render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
rescue StandardError
  render partial: 'common/turbo_error',
         locals: { id: dom_id(@product, 'general'), error_message: 'Oops. Something went wrong' }
end


app/views/common/_turbo_error.html.erb

<turbo-frame id="<%= id %>">
  <%= error_message %>
</turbo-frame>


Kolejną fajną rzeczą, którą możemy zrobić z Turbo Frames to podmienienie części strony jako odpowiedź na wysłanie formularza. Pomysł jest bardzo podobny. Akcja kontrolera powinna zwrócić tag turbo-frame, a turbo zastąpi go na stronie.

Rozbudujmy trochę poprzedni przykład, żeby mieć możliwość dodania przedmiotu do koszyka, a następnie jego usunięcia.

app/controllers/products_controller.rb

class ProductsController < ApplicationController
  include ActionView::RecordIdentifier

  def show
    @product = Product.find(params[:id])
  end

  def general
    @product = Product.find(params[:id])

-   render partial: 'products/general'
+   render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
  end

  def properties
    @product = Product.find(params[:id])

    render partial: 'products/properties'
  end

  def reviews
    @product = Product.find(params[:id])

    render partial: 'products/reviews'
  end

+  def add_to_cart
+    @product = Product.find(params[:id])
+
+    session[:cart] = (session[:cart] || []) << @product.id
+
+    render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+  end
+
+  def remove_from_cart
+    @product = Product.find(params[:id])
+
+    session[:cart] = (session[:cart] || []).reject { |id| @product.id == id }
+
+    render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+  end
+
+  private
+
+  def product_in_cart?(product)
+    return false unless product && session[:cart]
+
+    session[:cart].include?(product.id)
+  end
end


app/views/products/_general.html.erb

<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
  <div class="product--general">
    <h1>
      <%= @product.title %>
    </h1>
    <div class="row mt-4">
      <div class="col">
        <div class="product--image">
          <%= image_tag @product.image %>
        </div>

      </div>
      <div class="col">
        <h3>
          <%= @product.price %>
        </h3>

+        <%- if in_cart %>
+          <%= form_with(url: remove_from_cart_product_path) do |f| %>
+            <%= f.submit 'Remove from cart', class: 'my-3 btn btn-danger' %>
+          <%- end %>
+        <%- else %>
+          <%= form_with(url: add_to_cart_product_path) do |f| %>
+            <%= f.submit 'Add to cart', class: 'my-3 btn btn-success' %>
+          <%- end %>
+        <% end %>
        <%= @product.content %>
      </div>
    </div>
  </div>
</turbo-frame>


Jak widzisz, nasz kontroler ma teraz akcje dodawania przedmiotów do koszyka i ich usuwania. Obie te metody po prostu renderują fragment general i w magiczny sposób zastępują go na stronie. Przypomina to procedury wynikające z użycia szablonów js.erb, ale osobiście wolę to robić z pomocą Turbo. Dzięki temu nie generuję dodatkowego kodu JSa, który znajdzie się poza plikami .js.


Turbo Streams

Turbo przyniosło nam jeszcze jedno interesujące narzędzie do modyfikowania HTML na stronie - Turbo Streams. Daje ono więcej możliwości modyfikowania DOM i nie jest ograniczone tylko do generowania jednej ramki jak to miało miejsce w Turbo Frames. Ta manipulacja DOM jest nazywana action i powinno się ją wykonywać wokół targets - elementów otrzymywanych przez ten sam selektor. 

Turbo Streams zapewnia 7 akcji, które możesz wykonać:

  • append – dodaj HTML na początku targetu
  • prepend – dodaj HTML na końcu targetu
  • replace – zastąp cały target zapewnionym HTMLem
  • update – zaktualizuj HTML wewnątrz targetu
  • remove – usuń cały target
  • before – dodaj HTML przed targetem
  • after – dodaj HTML po targecie


Z reguły możesz usłyszeć o Turbo Streams, gdy mowa jest o aktualizacjach w czasie rzeczywistym albo kolejnej aplikacji czatu. Ale możemy zacząć od prostszego przykładu i zobaczyć jak Turbo Streams pozwalają odwzorować wysyłanie formularza w interfejsie użytkownika.

Kontynuujmy poprzedni przykład i dodajmy możliwość wysłania recenzji oraz pokazania liczby wszystkich recenzji.

Na początku dodam liczbę recenzji oraz formularz do dodawania kolejnych.

app/views/products/show.html.erb

<ul class="nav nav-tabs" id="product-tab" role="tablist">
    <li class="nav-item" role="presentation">
      <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
    </li>
    <li class="nav-item" role="presentation">
-      <button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
+      <button
+        class="nav-link"
+        id="reviews-tab"
+        data-bs-toggle="tab"
+        data-bs-target="#reviews"
+        type="button"
+        role="tab"
+        aria-controls="reviews"
+        aria-selected="false"
+      >
+        Reviews
+        <span id=<%= dom_id(@product, 'reviews_count') %>>
+          <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
+        </span>
+      </button>
    </li>
  </ul>


app/views/products/_reviews.html.erb

<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>

  <div id="<%= dom_id(@product, 'reviews_list') %>">
    <%- @product.reviews.each do |review| %>
      <%= render(partial: 'products/reviews/card', locals: { review: review }) %>
    <% end %>
  </div>
</turbo-frame>


app/views/products/reviews/_count_badge.html.erb

<span class="badge bg-primary">
  <%= count %>
</span>


app/views/products/reviews/_form.html.erb

<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
  <%= f.text_area :review, class: "form-control mb-1" %>
  <%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>


app/views/products/reviews/_card.html.erb

<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
  <%= f.text_area :review, class: "form-control mb-1" %>
  <%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>


Wygląda to tak:

Teraz możemy dodać trochę interaktywności przy użyciu Turbo Streams.

app/controllers/products_controller.rb

+ def add_review
+    @product = Product.find(params[:id])
+    @review = @product.add_review(author: 'You', content: params[:review])
+ end


app/views/products/add_review.turbo_stream.erb

<%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
  <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
<% end %>

<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>

<%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
  <%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
<% end %>


Najciekawszym plikiem jest tutaj  _add_review.turbo_stream.erb. Jeśli wcześniej nie spotkałeś się z Turbo Streams to format turbo_stream może być dla Ciebie czymś nowym. Turbo wymaga, żeby odpowiedź HTTP miała content-type text/vnd.turbo-stream.html. W ten sposób możesz przekazać content_type: “text/vnd.turbo-stream.html do metody render w akcji kontrolera albo dodać rozszerzenie .turbo_stream.erb dla pliku widoku. Osobiście bardziej przypadła mi do gustu ta druga opcja.

Główną akcją w _add_review.turbo_stream.erb jest metoda turbo_stream. Używamy jej, żeby wywołać wcześniej wspomniane akcje. A konkretnie - generuje ona tagi XML, które opisują jaka dokładnie manipulacja DOM powinna zostać wykonana.

Ten plik wykonuje trzy rzeczy:

  • Aktualizuje całkowitą liczbę recenzji - aktualizuje treść taga z id dom_id(@product, 'reviews_count')
  • Resetuje formularz do wysyłania recenzji - zastępuje cały tag z id dom_id(@product, 'reviews_form')
  • Wyśwetla nowe review na stronie - dodaje treść na początku taga z id dom_id(@product, 'reviews_list')


W zasadzie to wszystko, czego potrzebujesz, żeby zbudować w pełni interaktywną aplikację webową, bez żadnego JSa. Będzie to wystarczające dla większości aplikacji.

Turbo Streams daje nam możliwość wprowadzania zmian na stronach przez WebSocket. Nie będzie to wymagało wielu działań z naszej strony, zaktualizujmy więc nasze recenzje we wszystkich otwartych oknach przeglądarki w momencie gdy nowa recenzja jest dodana.


Zanim zaczniesz, dodaj gem “turbo-rails” do swojego Gemfile i uruchom następujące polecenie:

bundle exec rails turbo:install

Zainstaluje ono @hotwired/turbo-rails i zamieni asynchroniczny adapter Action Cable (domyślny) na redis.

Jesteśmy gotowi na aktualizowanie treści w czasie rzeczywistym.

Jako pierwsze musimy zacząć otrzymywać aktualizacje produktu. Jest to banalnie proste dzięki metodzie turbo_stream_from.

app/views/products/show.html.erb

<div class="product">
+  <%= turbo_stream_from @product %>

  <ul class="nav nav-tabs" id="product-tab" role="tablist">


Następnie, zamiast zwracać tagi turbo-frame, które precyzują, jaka akcja powinna być wykonana w interfejsie użytkownika, będziemy transmitować te akcje do wszystkich subskrybentów (wszystkich otwartych stron produktu).

app/controllers/products_controller.rb

def add_review
  @product = Product.find(params[:id])
  @review = @product.add_review(author: 'You', content: params[:review])

+  Turbo::StreamsChannel.broadcast_update_to(
+    @product,
+    target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_count'),
+    partial: 'products/reviews/count_badge',
+    locals: { count: @product.reviews.count }
+  )
+
+  Turbo::StreamsChannel.broadcast_prepend_to(
+    @product,
+    target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_list'),
+    partial: 'products/reviews/card',
+    locals: { review: @review }
+  )
end


Żeby uniknąć dublowania niektórych akcji musimy usunąć je z odpowiedzi HTTP.

app/views/products/add_review.turbo_stream.erb

- <%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
-   <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
- <% end %>

<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>

- <%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
-   <%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
- <% end %>


Możliwość aktualizacji formularza nie zniknie gdyż chcemy jedynie wyczyścić input po wysłaniu formularza, ale nie czyścić formularza dla wszystkich użytkowników.

To wszystko, czego potrzebujesz żeby dodać komunikację w czasie rzeczywistym w aplikacji zbudowanej przy użyciu Rails. Magia Railsów to ich całe piękno!

Zespół Rails zrobił świetną robotę, pozwalając na większą dowolność w wyborze własnego podejścia do frontendu. Jednocześnie stworzyli framework, który jest świetnym narzędziem do budowania nowoczesnych aplikacji webowych bez pisania ani jednej linijki kodu JS. 

Pewnie, w realnych zastosowaniach prawdopodobnie (a może raczej na pewno) będziesz potrzebować więcej niż Turbo jest w stanie Ci zaoferować. Dlatego też zespół Rails stworzył Stimulus i request.js, aby uprzyjemnić Ci życie, gdy musisz pisać kod JS w aplikacji na Railsach. Ale o tym może w innym artykule.

<p>Loading...</p>