Последняя основная версия Rails дает своим разработчикам именно то, на что они надеялись: максимально уйти от JavaScript, насколько это возможно программно.

Шутки в сторону, Rails 7 восхищает меня с момента его выпуска, и я надеюсь, что смогу передать это впечатление и в этой статье, где я вновь проследю свои шаги в разработке — барабанная дробь — приложения для блога!< br /> Я знаю, я знаю, что это большой сюрприз для разработчика Ruby on Rails, который пишет блог, но без меня, потому что в этом приложении я буду применять свежеиспеченную ✨магию Rails✨ в форма вложенных комментариев со всеми действиями, встроенными и усиленными!

(Примечание: я не буду вдаваться в подробности о том, что такое Turbo Frames или Turbo Streams, потому что существует множество очень хорошо объясненных статей, посвященных только этому, и переписывание этого своими словами здесь добавит слишком много накладных расходов. к этой статье)

(TLDR; репозиторий GitHub для тех, кто спешит)

Вначале был Rails Generator!

Без дальнейших церемоний, давайте сразу приступим к созданию нового приложения Rails 7 (вы можете проверить версию Rails вашей машины, предварительно запустив rails -v в терминале, просто чтобы убедиться, что у вас установлено нужное приложение — или вообще любое) :

rails new nested_comments

В этой статье я сосредоточусь исключительно на комментариях, поэтому все остальное будет просто старым добрым Rails.
Итак, давайте продолжим и из каталога nested_commentsroot откроем новый терминал и сгенерируем простой скаффолд для Post с заголовком и содержанием:

rails generate scaffold Post title:string content:text

И модель Comment со ссылкой на Post, которой она принадлежит, контентом и ссылкой на parent в случае, если комментарий является ответом на другой комментарий:

rails generate model Comment post:references parent:references content:text

Теперь, прежде чем мы зайдем слишком далеко вперед и запустим миграцию БД, нам нужно изменить комментарий, потому что parent в данный момент не имеет смысла, если мы не укажем, на какую таблицу мы ссылаемся, а также нам нужно удалить null: false ограничение также из него, поскольку комментарий не всегда должен быть ответом на другой комментарий:

class CreateComments < ActiveRecord::Migration[7.0]
  def change
    create_table :comments do |t|
      t.references :post, null: false, foreign_key: { on_delete: :cascade }
      t.references :parent, foreign_key: { to_table: :comments, on_delete: :cascade }
      t.text :content

      t.timestamps
    end
  end
end

Модель comment.rb следует изменить, чтобы она соответствовала обновленной миграции:

class Comment < ApplicationRecord
  belongs_to :post, inverse_of: :comments
  belongs_to :parent, optional: true, class_name: 'Comment', inverse_of: :comments

  has_many :comments, foreign_key: :parent_id, dependent: :destroy, inverse_of: :parent
end

И, как вы могли заметить в строке belongs_to :post выше, мы также должны добавить связь в модель post.rb:

class Post < ApplicationRecord
  has_many :comments, inverse_of: :post
end

Наконец, мы можем запустить rails db:migrate, чтобы обновить нашу трудолюбивую БД.

(Забавный факт: знаете ли вы, что даже команда rails new является генератором сама по себе? Технически вы можете сгенерировать довольно красивое приложение, но не цитируйте меня по этому поводу)

Горячий, проводной и турбированный

Теперь, когда у нас есть «M» от «MVC», давайте сосредоточимся на «C» — контроллере.

Прежде чем поиграться с шаблонами представлений и контроллерами, мне нравится открывать консоль rails c и создавать несколько манекенов:

post = Post.create(title: 'A Post', content: 'some post content')
parent = Comment.create(post: post, content: 'a comment')
reply = Comment.create(post: post, parent: parent, content: 'a reply')
Comment.create(post: post, parent: reply, content: 'another reply')

Comment.create(post: post, content: 'another comment')

После этого добавим предстоящие конечные точки в routes.rb :

Rails.application.routes.draw do
  resources :posts
  resources :comments
end

Комментарии#индекс

В каталоге app/controllers давайте создадим новый comments_controller.rb с действием index для комментариев к сообщению.

class CommentsController < ApplicationController
  before_action :set_post

  def index
    @comments = @post.comments
  end

  private

  def set_post
    @post = Post.find_by(id: params[:post_id])
  end
end

Имеет смысл set_post для всех конечных точек, потому что на любом этапе пути, где мы будем иметь дело с комментариями, они будут из-под поста.

Чего мы хотим добиться в качестве первого шага, так это того, чтобы под постом все его комментарии располагались один под другим (не вложенными… пока!). Поэтому давайте, наконец, прокачаем views/posts/show.html.erb:

<p style="color: green"><%= notice %></p>

<%= render @post %>

<div>
  <%= link_to "Edit this post", edit_post_path(@post) %> |
  <%= link_to "Back to posts", posts_path %>

  <%= button_to "Destroy this post", @post, method: :delete %>
</div>

<h3>Comments</h3>
<%= turbo_frame_tag dom_id(@post, :comments), src: comments_path(post_id: @post.id) %>

Обратите внимание на последнюю строку; что это в основном делает, так это подключается к конечной точке comments_controller#index, а затем выполняет поиск по всей странице, пока не найдет соответствующий уникальный идентификатор dom_id(@post, :comments), чтобы подключить его к этому месту.

Однако на данный момент нет подходящих идентификаторов для dom_id(@post, :comments), потому что нам нужно добавить шаблон views/comments/index.html.erb:

<%= turbo_frame_tag dom_id(@post, :comments) do %>
  <% @comments.each do |comment| %>
    <p><%= comment.content %></p>
  <% end %>
<% end %>

И теперь, открывая ваш браузер по адресу localhost:3000/posts/1, вы должны отобразить комментарии к сообщению прямо под ним (при условии, что ваш порт по умолчанию 3000, и вы не пропустили часть создания макетов)

Комментарии#показать

Чтобы сделать еще один (более чистый) шаг вперед, мы можем извлечь <p><%= comment.content %></p> в назначенное ему действиеcomments_controller#show:

class CommentsController < ApplicationController
  ...
  before_action :set_comment, only: :show

  ...

  def show; end

  private

  ...

  def set_comment
    @comment = Comment.find_by(id: params[:id])
  end
end

Шаблон views/comments/show.html.erb:

<%= turbo_frame_tag @comment do %>
  <p><%= @comment.content %></p>
<% end %>

И, наконец, обновленный views/comments/index.html.erb:

<%= turbo_frame_tag dom_id(@post, :comments) do %>
  <% @comments.each do |comment| %>
    <%= turbo_frame_tag comment, src: comment_path(comment, post_id: @post.id) %>
  <% end %>
<% end %>

Комментарии#новый

Хотя приятно смотреть на комментарии, созданные в консоли, еще приятнее иметь возможность создавать их прямо со страницы, как, знаете, обычный человек. Итак, давайте добавим конечную точку new в comments_controller.rb:

class CommentsController < ApplicationController
  ...

  def new
    @comment = Comment.new
  end

  ...
end

И шаблон views/comments/new.html.erb:

<%= turbo_frame_tag dom_id(@post, :new_comment) do %>
  <%= render "form", comment: @comment, post: @post %>
<% end %>

Форма views/comments/_form.html.erb:

<%= form_with(model: comment) do |form| %>
  <%= hidden_field_tag :post_id, post.id %>

  <div>
    <%= form.label :content, style: "display: block" %>
    <%= form.text_area :content %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

И, наконец, обновленный views/posts/show.html.erb с comments_controller#new turbo_frame над comments_controller#index:

...
<%= turbo_frame_tag dom_id(@post, :new_comment), src: new_comment_path(post_id: @post.id) %>
<%= turbo_frame_tag dom_id(@post, :comments), src: comments_path(post_id: @post.id) %>

Комментарии#создать

Прямо сейчас форма комментариев есть, но она ничего не делает при отправке, потому что, как вы уже догадались, нет конечной точки create, поэтому давайте добавим ее:

class CommentsController < ApplicationController
  ...

  def create
    @comment = @post.comments.create(comment_params)
  end

  private

  ...

  def comment_params
    params.require(:comment).permit(:content)
  end
end

Здесь все становится немного острее, и происходит ✨магия Rails✨ с его замечательным turbo_streams. Создадим новый шаблон — обратите внимание на расширение — views/comments/create.turbo_stream.erb следующего содержания:

<%= turbo_stream.prepend dom_id(@post, :comments) do %>
  <%= turbo_frame_tag @comment, src: comment_path(@comment, post_id: @post.id) %>
<% end %>

<%= turbo_stream.replace dom_id(@post, :new_comment) do %>
  <%= turbo_frame_tag dom_id(@post, :new_comment), src: new_comment_path(post_id: @post.id) %>
<% end %>

Что делают эти 2 блока: 1) добавляют новый комментарий к комментариям к сообщениям и 2) заменяют форму новой, и все это без необходимости обновлять страницу! Разве это не волшебство?

Комментарии#уничтожить

Вы просто не можете безрассудно создавать, не имея возможности также и разрушать, поэтому давайте быстро добавим новую конечную точку destroy к нашей любимой comments_controller.rb:

class CommentsController < ApplicationController
  before_action :set_comment, only: %i[show destroy]

  ...

  def destroy
    @comment.destroy
  end

  ...
end

С соответствующим шаблоном Turbofied views/comments/destroy.turbo_stream.erb:

<%= turbo_stream.remove @comment %>

(Примечание. Технически вы можете визуализировать этот шаблон, поскольку он такой маленький, непосредственно в методе контроллера, но мне нравится упрямо делать что-то тем или иным способом ради единообразия, и я предпочитаю, чтобы мои вещи были как можно более несвязанными, так что вы придется со мной потерпеть)

И views/comments/show.html.erb с кнопкой "Уничтожить":

<%= turbo_frame_tag @comment do %>
  <p><%= @comment.content %></p>

  <div>
    <%= button_to "Destroy", @comment, method: :delete %>
  </div>
<% end %>

Комментарии#изменить

Моя сестра однажды сказала мне: «Изменения — это хорошо — принимайте изменения, особенно когда они встроены и не требуют обновления страницы».

Шучу, у меня нет сестры, но я согласен с этой цитатой… в основном потому, что я только что придумал ее, но давайте не будем фокусироваться на деталях, а вместо этого сосредоточимся на добавлении новой конечной точки edit к comments_controller.rb, которая принесет эта цитата из жизни:

class CommentsController < ApplicationController
  ...
  before_action :set_comment, only: %i[show edit destroy]
  
  ...

  def edit; end

  ...
end

Шаблон comments/edit.html.erb:

<%= turbo_frame_tag @comment do %>
  <%= render "form", comment: @comment, post: @post %>
<% end %>

views/comments/show.html.erb со ссылкой «Редактировать»:

<%= turbo_frame_tag @comment do %>
  ...

  <div>
    <%= link_to "Edit", edit_comment_path(@comment, post_id: @post.id) %> |
    <%= button_to "Destroy", @comment, method: :delete %>
  </div>
<% end %>

И давайте добавим ссылку «Отмена» прямо под отправкой в ​​views/comments/_form.html.erb для хорошего измерения:

(Примечание: форма используется как для new, так и для edit, но поскольку нам не нужна ссылка «Отмена» для действия new, мы можем проверить, редактируем ли мы или создаем комментарий, добавив условиеif comment.persisted?новый комментарий будет еще не сохраненным)

<%= form_with(model: comment) do |form| %>
  ...

  <div>
    <%= form.submit %>
    <%= link_to "Cancel", comment_path(comment, post_id: post.id) if comment.persisted? %>
  </div>
<% end %>

Комментарии#обновление

Как новая форма бесполезна без конечной точки создания, так и форма редактирования без конечной точки обновления, поэтому давайте добавим действие в наш comments_controller.rb:

class CommentsController < ApplicationController
  ...
  before_action :set_comment, only: %i[show edit update destroy]

  ...

  def update
    @comment.update(comment_params)
  end

  ...
end

И шаблон views/comments/update.turbo_stream.erb:

<%= turbo_stream.replace @comment do %>
  <%= turbo_frame_tag @comment, src: comment_path(@comment, post_id: @post.id) %>
<% end %>

Почти готово…

Пока все очень хорошо! Мы просто добавили все действия CRUD для комментариев, и все это без необходимости касаться одной строки JavaScript. Какое прекрасное время для разработки Ruby on Rails, не так ли?

Теперь давайте, наконец, перейдем к заголовку этой статьи: вложенности (если вы еще не сдались после всех моих ужасных «шуток», то вы можете преодолеть и эту)

Гнездо меня, комментарии!

Во-первых, прежде чем отправиться в путешествие, я хочу представить себе свою цель — A.K.A. придумывать пользовательские истории, но только мысленно, потому что время имеет решающее значение:

  • Я хочу иметь ссылку «Ответить» для каждого комментария
  • Когда я нажимаю ссылку «Ответить» в комментарии, я хочу, чтобы под ним волшебным образом появлялась новая форма.
  • Когда я отправляю форму, я хочу, чтобы ответ был добавлен к ответам на комментарий

Именно здесь я обычно пишу тестовые примеры перед тем, как приступить к разработке самой вещи (вы знаете, как хорошо воспитанный разработчик, который следует парадигме TDD), но на этот раз я оставлю это в качестве домашнего задания, поэтому давайте начнем. и добавьте ссылку «Ответить» на views/comments/show.html.erb:

<%= turbo_frame_tag @comment do %>
  ...

  <div>
    <%= link_to "Edit", edit_comment_path(@comment, post_id: @post.id) %> |
    <%= button_to "Destroy", @comment, method: :delete %> |
    <%= link_to "Reply", new_comment_path(parent_id: @comment.id, post_id: @post.id), data: { turbo_frame: dom_id(@comment, :new_comment) } %>
  </div>
<% end %>

Здесь происходит несколько новых вещей, поэтому позвольте мне попытаться объяснить, начиная со слона в коде r̶o̶o̶m̶: что это за data: { turbo_frame: dom_id(@comment, :new_comment) } вещь?

Видите ли, когда data встречается с turbo_frame… Шутка, в основном это выполняется через действие comments_controller#new, а затем ищет в его шаблоне соответствующий dom_id(@comment, :new_comment) turbo_frame, и если он находит его, он заменяет его на turbo_frame с тот же идентификатор на текущей странице, на которой находится ссылка.
Если бы thedata: { turbo_frame: ... } не было, то — с помощью ✨магии Rails✨ — приложение узнало бы чтобы заменить ближайший родительский turbo_frame (в данном случае turbo_frame_tag @comment) на кадр из comments_controller#newpage, соответствующий идентификатору @comment.

В настоящее время нам не хватает обоих turbo_frame с идентификатором dom_id(@comment, :new_comment), потому что даже в форме комментариев идентификатор равен dom_id(@post, :new_comment), поэтому, если мы нажмем ссылку «Ответить», вся страница будет заменена любым turbo_frame, который есть в comments_controller#new.

(Примечание: когда нет соответствующего turbo_frame для обмена, приложение по умолчанию использует _top turbo_frame, который представляет всю страницу целиком, даже если технически это не turbo_frame, поэтому на самом деле здесь происходит обмен между turbo_frame _top и турбо_фрейм dom_id(@post, :new_comment))

Хватит болтать, давайте вернемся к кодированию, освободив место для ответа в views/comments/show.html.erb, добавив следующий turbo_frame:

<%= turbo_frame_tag @comment do %>
  ...

  <%= turbo_frame_tag dom_id(@comment, :new_comment) %>
<% end %>

И обновленный views/comments/new.html.erb:

<%= turbo_frame_tag dom_id(@parent || @post, :new_comment) do %>
  <%= render "form", comment: @comment, post: @post, parent: @parent %>
<% end %>

Теперь вместо того, чтобы комментировать непосредственно @post, мы будем комментировать @parent — пока он существует, иначе мы по умолчанию вернемся к @post.

views/comments/_form.html.erb необходимо обработать новую родительскую переменную, передав ее контроллеру, например:

<%= form_with(model: comment) do |form| %>
  ...
  <%= hidden_field_tag :parent_id, local_assigns[:parent]&.id %>

  ...

  <div>
    <%= form.submit %>

    <% target = local_assigns[:parent] || comment %>
    <% if target&.persisted? %>
      <%= link_to "Cancel", comment_path(target, post_id: post.id) %>
    <% end %>
  </div>
<% end %>

Обратите внимание, что я также адаптировал ссылку «Отмена», иначе она не отображалась бы для формы ответа, поскольку объект формы comment не сохраняется, однако сохраняется parent, который в случае ответа является фактической целью, которую он должен вернуться обратно.

Передача его comments_controller.rb :

class CommentsController < ApplicationController
  ...

  def new
    @parent = Comment.find_by(id: params[:parent_id])
    @comment = Comment.new
  end

  ...
end

Красивый! Теперь у нас есть ссылка «Ответить», которая отображает форму комментариев по клику. Осталось только создать ответ и отобразить его во вложенном формате.

Ответь, ответь, ответь…

Если бы вы отправили форму ответа сейчас, комментарий был бы создан, но без набора parent_id, и мы можем исправить это, изменив comments_controller.rb:

class CommentsController < ApplicationController
  ...
  before_action :set_parent, only: %i[new create]

  ...

  def new
    @comment = Comment.new
  end

  ...

  def create
    @comment = @post.comments.new(comment_params)
    @comment.parent = @parent
    @comment.save
  end

  private

  ...

  def set_parent
    @parent = Comment.find_by(id: params[:parent_id])
  end

  ...
end

А теперь давайте дадим ответам немного места под комментарием в views/comments/show.html.erb:

<%= turbo_frame_tag @comment do %>
  ...

  <%= turbo_frame_tag dom_id(@comment, :new_comment) %>

  <%= turbo_frame_tag dom_id(@comment, :comments) do %>
    <% @comment.comments.each do |comment| %>
      <%= turbo_frame_tag dom_id(comment), src: comment_path(comment, post_id: @post.id, parent_id: @comment.id) %>
    <% end %>
  <% end %>
<% end %>

Вы могли заметить, что комментарии, у которых есть родители (то есть те, которые являются ответами), появляются дважды, и это потому, что и comments_controller#index, и turbo_frame_tag dom_id(@comment, :comments) отображают их, поэтому теперь мы можем сказать index игнорировать комментарии с родителями, в comments_controller.rb :

class CommentsController < ApplicationController
  ...

  def index
    @comments = @post.comments.where(parent_id: nil)
  end

  ...
end

Хорошо, пока все хорошо, за исключением того, что комментарии по-прежнему не отображаются как вложенные; Это потому, что comments_controller#show не имеет понятия родителя и даже не знает, что с ним делать с самого начала, но мы собираемся изменить это, сначала добавив его к comments_controller.rb:

class CommentsController < ApplicationController
  ...
  before_action :set_parent, only: %i[show new create]

  ...
end

И, во-вторых, сообщая views/comments/show.html.erbtemplate, что с ним делать — просто добавить пробел и границу слева в случае, если у комментария есть родитель:

<%= turbo_frame_tag @comment do %>
  <div style="<%= 'padding-left: 2px; border-left: 2px solid grey;' if @parent %>">
    ...
  </div>
<% end %>

И вуаля! Теперь вы можете обновить страницу и посмотреть, как разворачивается волшебство. К настоящему времени вы должны быть в состоянии создавать новые комментарии в качестве ответов на другие комментарии и видеть их вложенными!
Но есть небольшая проблема: форма ответа не исчезает после создания.

Осталось 99 ошибок… Исправить 1… Осталось 101 ошибка

Не паникуйте, мы как раз собираемся исправить эту проблему, немного поработав (или, скорее, заменив все) вокруг views/comments/create.turbo_stream.erb:

<%= turbo_stream.prepend dom_id(@parent || @post, :comments) do %>
  <%= turbo_frame_tag dom_id(@comment), src: comment_path(@comment, post_id: @post.id, parent_id: @parent&.id) %>
<% end %>

<%= turbo_stream.replace dom_id(@parent || @post, :new_comment) do %>
  <% if @parent %>
    <%= turbo_frame_tag dom_id(@parent, :new_comment) %>
  <% else %>
    <%= turbo_frame_tag dom_id(@post, :new_comment), src: new_comment_path(post_id: @post.id) %>
  <% end %>
<% end %>

С точки зрения неспециалистов, приведенное выше изменение можно перевести так: «Добавьте новый комментарий к @parent сначала, если он существует, в противном случае по умолчанию вернитесь к @post и добавьте его. там. Затем, если это ответ на другой комментарий (@parent существует), замените форму просто заполнителем, в противном случае оставьте его там и просто сбросьте его до пустого (замените на новая форма)

И, наконец, конец…
…почти наступил

Играясь с ответами и прочим, мы немного испортили функцию «Редактировать». Попробуйте, и вы заметите, что при нажатии на ссылку «Изменить» не только сам комментарий заменяется формой, но и ответы исчезают.
Это потому, что при нажатии на ссылку «Изменить », turbo_frame_tag @comment заменяется формой комментариев, и в настоящее время у нас все вложено в этот turbo_frame.

…Осталась 1 ошибка

Итак, в качестве заключительной бравады, давайте начнем исправлять последнее неудобство и немного рефакторим код, извлекая комментарий (без ответов) из views/comments/show.html.erb и изменяя некоторые идентификаторы:

<%= turbo_frame_tag dom_id(@comment, :wrapper) do %>
  <div style="<%= 'padding-left: 2px; border-left: 2px solid grey;' if @parent %>">
    <%= render @comment, parent: @parent, post: @post %>

    <%= turbo_frame_tag dom_id(@comment, :new_comment) %>

    <%= turbo_frame_tag dom_id(@comment, :comments) do %>
      <% @comment.comments.each do |comment| %>
        <%= turbo_frame_tag dom_id(comment, :wrapper), src: comment_path(comment, post_id: @post.id, parent_id: @comment.id) %>
      <% end %>
    <% end %>
  </div>
<% end %>

Обратите внимание, что теперь мы идентифицируем оболочку комментария для использования dom_id(@comment, :wrapper), потому что нам понадобится идентификатор comment для ядра комментария.

Извлеченный комментарий помещается в новый views/comments/_comment.html.erb с идентификатором turbo_frame comment:

<%= turbo_frame_tag comment do %>
  <p><%= comment.content %></p>

  <div style="display: flex; flex-direction: row">
    <%= link_to "Edit", edit_comment_path(comment, post_id: post.id) %> |
    <%= button_to "Destroy", comment, method: :delete %> |
    <%= link_to "Reply", new_comment_path(parent_id: comment.id, post_id: post.id), data: { turbo_frame: dom_id(comment, :new_comment) } %>
  </div>
<% end %>

Теперь нам нужно сообщить views/comments/index.html.erb, чтобы он отрендерил turbo_frame для обертки комментария:

<%= turbo_frame_tag dom_id(@post, :comments) do %>
  <% @comments.each do |comment| %>
    <%= turbo_frame_tag dom_id(comment, :wrapper), src: comment_path(comment, post_id: @post.id) %>
  <% end %>
<% end %>

И views/comments/create.turbo_stream.erb для добавления нового ответа к родительской оболочке:

<%= turbo_stream.prepend dom_id(@parent || @post, :comments) do %>
  <%= turbo_frame_tag dom_id(@comment, :wrapper), src: comment_path(@comment, post_id: @post.id, parent_id: @parent&.id) %>
<% end %>

...

И views/comments/destroy.turbo_stream.erb для удаления всей оболочки:

<%= turbo_stream.remove dom_id(@comment, :wrapper) %>

И наконец, но на этот раз по-настоящему…

Конец

Спасибо за чтение! Я надеюсь, что эта статья помогла вам так же, как помогла мне, написав ее.

Не стесняйтесь вносить предложения и сообщать мне об ошибках, если вы их заметите; Я всегда жажду новых знаний, и, в конце концов, весь код просто справочный :)