Статьи

Вложенные комментарии с помощью Rails

Значок пузыря разговора

Комментарии есть везде. Блоги, социальные сети, фан-сайты, учебные ресурсы — все они имеют какую-то систему комментариев. Часто мы хотели бы предоставить пользователям возможность оставлять комментарии и отвечать. Самый естественный способ представления ответов — вкладывать их (как русская кукла ).

В этой статье показано, как реализовать вложенные комментарии в приложении Rails с помощью closure_tree . Кроме того, он опишет некоторые интересные функции, которые предоставляет самоцвет, точно определив при этом пути.

Исходный код доступен на GitHub .

Рабочую демонстрацию можно найти на http://nested-comments.radiant-wind.com/ .

Подготовка проекта

Я буду использовать Rails 4.0.4, но вы можете реализовать то же решение, используя Rails 3

Итак, вот план: мы собираемся создать простое приложение, которое позволит пользователям открывать новые цепочки обсуждений, а также оставлять свои комментарии к уже открытым. На первой итерации это приложение будет предоставлять только возможность запуска нового потока, а на второй добавит вложение.

Создайте новое приложение без набора тестов по умолчанию:

 $ rails new nested_comments -T 

Вот список драгоценных камней, которые мы собираемся использовать:

 gem 'closure_tree' gem 'bootstrap-sass' 

Не так много, а? Как всегда, я использую Twitter Bootstrap для некоторых базовых стилей — вы можете использовать любой другой CSS Framework или создать свой дизайн с нуля.

Камень closure_tree , созданный Мэтью МакИхеном, поможет нам создать вложение для нашей модели Comment . Есть несколько альтернатив этому драгоценному камню, в частности родословная, созданная Стефаном Кроесом. Некоторое время я использовал родословную, но она долгое время не обновлялась, хотя на GitHub есть открытые проблемы. closure_tree я решил переключиться на closure_tree (я заметил, что ancestry сейчас, похоже, развивается более активно). Также мне нравятся некоторые опции, которые предоставляет closure_tree . Тем не менее, решение, описанное здесь, может быть реализовано также и с ancestry .

Давайте создадим модель Comment :

 rails g model Comment title:string author:string body:text 

Здесь все просто: в комментарии есть title , author (имя или псевдоним автора) и body . Для целей этой статьи этого будет достаточно, но в реальном приложении вы, вероятно, захотите установить какую-то аутентификацию.

Следующим пунктом в нашем контрольном списке является настройка маршрутов:

конфиг / routes.rb

 root to: 'comments#index' resources :comments, only: [:index, :new, :create] 

После этого создайте контроллер:

Контроллеры / comments_controller.rb

 class CommentsController < ApplicationController def index @comments = Comment.all end def new end def create end end 

На данный момент методы new и create пусты, мы разработаем их позже. Метод index просто выбирает все комментарии из таблицы comments — довольно просто.

Давайте потратим пару минут и применим стиль Bootstrap к нашим страницам.

макеты / application.html.erb

 [...] <body> <div class="container"> <% flash.each do |key, value| %> <div class="alert alert-<%= key %>"> <button type="button" class="close" data-dismiss="alert">&times;</button> <%= value %> </div> <% end %> </div> <div class="container"> <%= yield %> </div> </body> [...] 

комментарии / index.html.erb

 <div class="jumbotron"> <div class="container"> <h1>Join the discussion</h1> <p>Click the button below to start a new thread:</p> <p> <%= link_to 'Add new topic', new_comment_path, class: 'btn btn-primary btn-lg' %> </p> </div> </div> <%= render @comments %> 

render @comments означает, что мы render @comments каждый элемент из массива @comments используя частичное
comments/_comment (соглашение о конфигурации скалы!). Эта часть еще не создана, поэтому давайте сделаем это сейчас:

комментарии / _comment.html.erb

 <div class="well"> <h2><%= comment.title %></h2> <p class="text-muted">Added by <strong><%= comment.author %></strong> on <%= l(comment.created_at, format: '%B, %d %Y %H:%M:%S') %></p> <blockquote> <p><%= comment.body %></p> </blockquote> </div> 

В части отображается заголовок комментария, имя автора, дата и время создания с использованием метода l (который на самом деле является сокращением для метода localize определенного в I18n ) и, наконец, body.

Теперь мы можем вернуться к методам контроллера:

Контроллеры / comments_controller.rb

 [...] def new @comment = Comment.new end def create @comment = Comment.new(comment_params) if @comment.save flash[:success] = 'Your comment was successfully added!' redirect_to root_url else render 'new' end end private def comment_params params.require(:comment).permit(:title, :body, :author) end [...] 

Обратите внимание, что если вы используете Rails 3 или гем protected_attributes с Rails 4, вам не нужно будет определять метод comment_params . Вместо этого вам нужно будет указать attr_accessible в модели Comment следующим образом:

models/comment.rb

 [...] attr_accessible :title, :body, :author [...] 

На взгляд:

комментарии / new.html.erb

 <h1>New comment</h1> <%= render 'form' %> 

А теперь — угадайте, что — нам нужно создать частичную form :

комментарии / _form.html.erb

 <%= form_for(@comment) do |f| %> <% if @comment.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(@comment.errors.count, "error") %> prohibited this comment from being saved:</h2> <ul> <% @comment.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div class="form-group"> <%= f.label :title %> <%= f.text_field :title, class: 'form-control' %> </div> <div class="form-group"> <%= f.label :author %> <%= f.text_field :author, class: 'form-control', required: true %> </div> <div class="form-group"> <%= f.label :body %> <%= f.text_area :body, class: 'form-control', required: true %> </div> <%= f.submit class: 'btn btn-primary' %> <% end %> 

Как вы можете видеть, это довольно простая форма — на самом деле не о чем говорить. Мы вернемся к нему через несколько минут.

На этом этапе вы можете проверить, как работает ваше приложение — продолжайте и создайте пару комментариев. Мы завершили closure_tree работу и готовы интегрировать closure_tree в приложение.

Делая это вложенным

Согласно документации closure_tree , первое, что нужно сделать, это добавить столбец parent_id в таблицу comments :

 $ rails g migration add_parent_id_to_comments parent_id:integer $ rake db:migrate 

В этом столбце будет храниться идентификатор непосредственного родителя для ресурса. В случае отсутствия родителя этот столбец будет иметь null значение. Нам также нужно создать новую таблицу, которая будет содержать иерархию комментариев.

 $ rails g migration create_comment_hierarchies 

Теперь откройте файл миграции и измените его:

мигрирует / create_comment_hierarchies.rb

 class CreateCommentHierarchies < ActiveRecord::Migration def change create_table :comment_hierarchies, :id => false do |t| t.integer :ancestor_id, :null => false # ID of the parent/grandparent/great-grandparent/... comments t.integer :descendant_id, :null => false # ID of the target comment t.integer :generations, :null => false # Number of generations between the ancestor and the descendant. Parent/child = 1, for example. end # For "all progeny of…" and leaf selects: add_index :comment_hierarchies, [:ancestor_id, :descendant_id, :generations], :unique => true, :name => "comment_anc_desc_udx" # For "all ancestors of…" selects, add_index :comment_hierarchies, [:descendant_id], :name => "comment_desc_idx" end end 

Не забудьте запустить миграцию:

 $ rake db:migrate 

Теперь нам нужно сказать нашей модели, что она должна быть вложенной. Это просто:

модели / comment.rb

 acts_as_tree order: 'created_at DESC' 

Обратите внимание, что мы используем order здесь. Это необязательно, но вы, вероятно, захотите указать какой-то порядок для ресурсов. В этом случае самый разумный порядок — по убыванию даты создания.

На данный момент мы можем отобразить ссылку «Ответить». На самом деле создание ответа — это то же самое, что создание корневого комментария. Единственное отличие заключается в parent_id атрибута parent_id , поэтому давайте передадим его как параметр GET.

комментарии / _comment.html.erb

 [...] <blockquote> <p><%= comment.body %></p> </blockquote> <p><%= link_to 'reply', new_comment_path(comment.id) %></p> [...] 

К сожалению, это не сработает сразу, потому new_comment_path метод new_comment_path не ожидает new_comment_path каких-либо аргументов. Мы должны немного изменить маршруты:

routes.rb

 [...] resources :comments, only: [:index, :create] get '/comments/new/(:parent_id)', to: 'comments#new', as: :new_comment [...] 

Я переопределил new маршрут, добавив необязательный параметр GET parent_id .

Теперь немного подправим new метод:

Контроллеры / comments_controller.rb

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

Мы также должны добавить этот parent_id в форму. Мы не хотим, чтобы наши пользователи видели это, поэтому используйте вспомогательный метод hidden_field :

комментарии / _form.html.erb

 [...] <%= f.hidden_field :parent_id %> <div class="form-group"> <%= f.label :title %> <%= f.text_field :title, class: 'form-control' %> </div> [...] 

Метод create необходимо изменить:

Контроллеры / comments_controller.rb

 [...] def create if params[:comment][:parent_id].to_i > 0 parent = Comment.find_by_id(params[:comment].delete(:parent_id)) @comment = parent.children.build(comment_params) else @comment = Comment.new(comment_params) end if @comment.save flash[:success] = 'Your comment was successfully added!' redirect_to root_url else render 'new' end end [...] 

Здесь мы проверяем наличие атрибута parent_id чтобы создать либо корневой комментарий, либо вложенный. Обратите внимание на использование params[:comment].delete(:parent_id) . delete — метод, который удаляет элемент с определенным ключом из хеша, возвращая элемент в результате. В результате parent_id будет find_by_id методу find_by_id в качестве аргумента. Мы удаляем его из хэша params потому что мы не разрешили parent_id в нашем частном методе comment_params .

Есть еще одна вещь, которую можно улучшить. Если я нажму ссылку «ответить», я буду перенаправлен на новую страницу с формой. Это нормально, но я бы хотел увидеть реальный комментарий, на который я отвечаю:

комментарии / new.html.erb

 <h1>New comment</h1> <% if @comment.parent %> <%= render 'comment', comment: @comment.parent, from_reply_form: true %> <% end %> <%= render 'form' %> 

@comment.parent вернет nil если у комментария нет родителя, поэтому ссылка не будет отображаться. Также обратите внимание на переменную from_reply_form которую мы передаем частичному. Мы собираемся использовать его, чтобы сообщить частичному комментарию, что он выводится из формы, поэтому нет необходимости снова указывать ссылку «ответить» — пользователь уже отвечает на комментарий! Теперь измените это частичное:

комментарии / _comment.html.erb

 [...] <% from_reply_form ||= nil %> <% unless from_reply_form %> <p><%= link_to 'reply', new_comment_path(comment.id) %></p> <% end %> [...] 

Здесь мы используем «нулевую охрану» — оператор ||= . Если значение from_reply_form имеет значение, оно ничего не делает с ним. Если from_reply_form не определен, он присваивает ему значение nil . Нам нужно использовать «nil guard», потому что эта часть также вызывается из index.html.erb без передачи from_reply_form .

Теперь проверьте, работает ли ответ. Ну, это работает, но есть проблема — комментарии не являются вложенными. parent_id устанавливается, но наши комментарии отображаются один за другим, что, безусловно, должно быть исправлено.

К счастью, closure_tree предоставляет нам метод hash_tree который создает вложенный хэш наших ресурсов. Имейте в виду, что если ваша таблица comments достаточно велика, сервер может завершить загрузку всех ресурсов одновременно. Если это происходит, используйте параметр limit_depth для управления уровнем вложенности следующим образом:

 Comment.hash_tree(limit_depth: 2) 

Перейдите и настройте метод index :

Контроллеры / comments_controller.rb

 def index @comments = Comment.hash_tree end 

Хеш-дерево будет выглядеть следующим образом:

 {a => {b => {c1 => {d1 => {} }, c2 => {d2 => {}} }, b2 => {} } } 

Теперь сложная часть. Мы не знаем, сколько уровней вложенности могут иметь наши комментарии, но мы хотим отобразить их все. Чтобы достичь этого, мы должны реализовать алгоритм рекурсии, который будет копаться во вложенных комментариях, пока они присутствуют. Для этого неплохо создать вспомогательный метод:

хелперы / comments_helper.rb

 module CommentsHelper def comments_tree_for(comments) comments.map do |comment, nested_comments| render(comment) + (nested_comments.size > 0 ? content_tag(:div, comments_tree_for(nested_comments), class: "replies") : nil) end.join.html_safe end end 

На каждой итерации принимайте комментарий и его дочерние nested_comments сохраняя его в переменных comment и nested_comments . Затем _comment.html.erb визуализацию комментария ( _comment.html.erb частичное _comment.html.erb ) и проверьте, есть ли еще какие-либо вложенные комментарии. Если да, мы снова вызываем тот же метод comments_tree_for передавая переменную nested_comments . Также обратите внимание, что мы оборачиваем результат этого вызова метода тегом div с классом replies .

Когда метод map html_safe свою работу, объедините все предоставленные комментарии и html_safe поскольку в противном случае будет отображаться простой HTML.

Теперь этот помощник можно использовать:

комментарии / index.html.erb

 [...] <%= comments_tree_for @comments %> 

Это работает, но мы должны визуально вкладывать ответы. С ответами, завернутыми в тег div.replies , мы можем добавить к нему очень простой стиль:

таблицы стилей / application.css.scss

 .replies {margin-left: 50px;} 

Если вы хотите ограничить эту визуальную вложенность, скажем, 5 уровнями, добавьте эту строку:

таблицы стилей / application.css.scss

 /* 5 levels nesting */ .replies .replies .replies .replies .replies {margin-left: 0;} 

closure_tree предоставляет некоторые другие необычные методы для наших ресурсов. Например, мы могли бы проверить, есть ли у комментария какие-либо ответы, используя leaf? метод. Он вернет true если этот ресурс является последним в цепочке вложений.

Используя этот метод, мы можем предложить пользователям ответить на комментарий:

комментарии / _comment.html.erb

 [...] <% from_reply_form ||= nil %> <% unless from_reply_form %> <% if comment.leaf? %> <small class="text-muted">There are no replies yet - be the first one to reply!</small> <% end %> <p><%= link_to 'reply', new_comment_path(comment.id) %></p> <% end %> [...] 

Вы также можете проверить, является ли ресурс корневым узлом с помощью root? метод:

комментарии / _comment.html.erb

 <div class="well"> <h2><%= comment.title %></h2> <p class="text-muted"><%= comment.root? ? "Started by" : "Replied by" %> <strong><%= comment.author %></strong> on <%= l(comment.created_at, format: '%B, %d %Y %H:%M:%S') %></p> [...] 

В настоящее время мы не предоставляем возможность удалить комментарий, но вам может быть интересно, что произойдет с вложенными комментариями, если рут будет удален. Ну, по умолчанию их столбцы parent_id будут иметь значение null поэтому эти комментарии станут корневыми узлами. Вы можете изменить это поведение, передав dependent опцию методу acts_as_tree . Возможные значения:

  • :nullify parent_id — установить для parent_id значение null .
  • :delete_all — удалить все вложенные ресурсы без выполнения обратных вызовов.
  • :destroy — удалить все вложенные ресурсы и запустить соответствующие обратные вызовы для каждого ресурса.

Визуализация графика

Вы даже можете визуализировать вложение с помощью графика. closure_tree предоставляет метод to_dot_digraph который создает файл .dot с соответствующими инструкциями для Graphviz — потрясающего и бесплатного в использовании инструмента визуализации графиков. На самом деле он может строить сложные графы с множеством узлов и отношений между ними.

Каждый узел графа имеет свою собственную метку — для ее to_digraph_label используется метод to_digraph_label . Давайте откроем нашу модель и переопределим ее так:

модели / comment.rb

 [...] def to_digraph_label title end 

Теперь у каждого узла будет заголовок комментария. Теперь вы можете попробовать это:

  • Откройте консоль ( rails console ) и введите следующую команду:
     File.open("example.dot", "w") do |f| f.write(Comment.root.to_dot_digraph) end 

    Он возьмет первый корневой комментарий со всеми его потомками и построит их отношения, сохранив его в файле example.dot .

  • Скачайте и установите Graphviz.
  • Откройте командную строку, перейдите в каталог, где находится example.dot и запустите dot -Tpng example.dot > example.png .
  • Посмотрите на визуализированный график внутри example.png !

график

Вывод

Вот и все для этой статьи, ребята. Мы взглянули на closure_tree и closure_tree включили его в наше приложение. Не забывайте, что этот драгоценный камень может использоваться во многих различных случаях — например, при создании вложенных меню или задании отношений другого типа.

Вы использовали closure_tree в своем приложении? Поделитесь своим опытом в комментариях!

Спасибо за чтение, до скорой встречи!