Записки Вредного программиста

enjoy, motherfuckers ;)

Rails Полиморфная связь и TokenInput jQuery

Представьте ситуацию, что у вас имеется некая модель, к которой вы хотите добавлять по средствам связей другие модели. Хорошо, когда данную связь можно описать обычным has_many, другое дело, когда нужно привязать разнородные модели, к примеру, у вас имеется модель вопросов, к которой нужно привязать город или страну (не очень наглядный пример, вряд ли он вам в жизни встретится, но я поделюсь своим опытом).

Как вы, наверное, догадались, основным инструментом будет выступать ruby an rails. Из гемов будем использовать simple_form для удобного создания форм, inherited_resources, чтобы писать намного меньше кода в контроллерах (сейчас не представляю без этого гема жизни).

Также нам понадобится плагин для jQuery tokenInput, который будет отображать выпадающий список с autoComplete’ом, из которого мы сможем выбрать одно значение из стран или городов (список будет один).

Реализация

Начнем с контроллера QuestionableController (не очень, конечно, удачное название), который будет отдавать солянку из городов и стран с возможностю поиска.

1
2
3
4
5
6
7
8
9
10
11
12
class QuestionableController < ApplicationController
  def index
    countries = Country.find(:all, conditions: ['name LIKE(?)', "%#{ params[:q]
    cities = City.find(:all, conditions: ['name LIKE(?)', "%#{ params[:q] }%"])

    @questionable = countries + cities

    respond_to do |format|
      format.json { render }
    end
  end
end

Лучше вынести всю логику в модель, но данный пример создан исключительно для демонстрации. Что мы делаем в этом контроллере? Ищем страны и города, удовлетворяющих параметру q и заполняем инстанс переменную @questionable

Также давайте заполним нашу вьюшку index.json.erb

1
<%= sanitize @questionable.to_json(methods: [:id_with_class_name], only: [:id, :name]) %>

в которой мы рендерим наш json, т.к. нам не нужны все поля модели мы используем только необходимые (only: [:id, :name]), ключ methods: указывает на то, что мы помимо физических свойст, будем использовать метод модели id_with_class_name.

Пока не забыл, давайте пропишем пару строк в наш config/routes.rb

1
2
resources :questions # для того, чтобы создавать/показывать/удалять вопросы
resources :questionable, only: :index # для того, чтобы отображать json

Перейдем к моделям:

models/question.rb

1
2
3
4
5
6
7
8
9
10
11
12
13
class Question < ActiveRecord::Base
  attr_accessor :location

  belongs_to :questionable, polymorphic: true
  attr_accessible :name, :questionable_id, :questionable_type

  before_save do
    return unless self.location =~ /_/
    location_id, location_type = self.location.split('_')
    self.questionable_id   = location_id   unless location_id.nil?
    self.questionable_type = location_type unless location_type.nil?
  end
end

Самая интересная строчка – это та, которая начинается с belongs_to, в ней мы объявляем полиморфную связь questionable. Дальше идет метод, который мы вызываем каждый раз перед сохранением в базу данных. В нем мы парсим строку из autoComplete’а, которая будет иметь вид id_ModelName и сохраняем отдельно id и ModelName.

Модель city.rb

1
2
3
4
5
6
7
8
class City < ActiveRecord::Base
  has_many :questions, as: :questionable
  attr_accessible :name

  def id_with_class_name
    "#{ id }_#{ self.class.name }"
  end
end

В ней объявляется связь has_many через questionable, который мы обявили в модели question полиморфной. Далее идем метод, который возвращает id записи и название класса модели (чтобы можно было отделить зерно от плевел).

Модель country.rb почти такая же

1
2
3
4
5
6
7
8
class Country < ActiveRecord::Base
  has_many :questions, as: :questionable
  attr_accessible :name

  def id_with_class_name
    "#{ id }_#{ self.class.name }"
  end
end

Связь обявлена также как и в предыдущей модели.

Наш маленький контроллер QuestionController

1
2
class QuestionsController < InheritedResources::Base
end

где остальное спросите вы. Это все. Это все, что нам нужно для того, чтобы создавать, удалять, редактировать вопросы. Как вы, наверное, заметили наш контроллер наследуется не от ApplicationController’а, а от inherited_resources. Что это значит? А это значит, если абстрагироваться от 80% возможностей этого гема, то, что гем берет всю “грязную” работу на себя, предоставляя нам в пользование переменные resource и collection, хранятся в которых ссылка на текущую запись (resource) и список всех записей (collection) соответственно. Для того, чтобы мы смогли создать свой первый вопрос нам необходимы лишь вьюшки. Начнем с _form.html.erb, которую мы будем подключать и в создании вопроса, и в редактировании.

app/views/questions/form.html.erb_

1
2
3
4
5
6
7
8
<%= simple_form_for resource do |f| %>
  <% pre = if resource.questionable %>
    <% [resource.questionable].to_json(only: [:id, :name]) %>
  <% end %>
  <%= f.input :name %>
  <%= f.input :location, input_html: { 'data-pre' => pre, class: 'token-input-questionable' } %>
  <%= f.submit nil %>
<% end %>

Как видите, почти никаких различий с нативным form_for нет. Во второй строке мы заполняем переменную pre json’ом выбранного города или страны, в предпоследней создаем инпут с атрибутом data-pre, равным json’у и классом, чтобы мы могли найти этот элемент без проблем.

new.html.erb и edit.html.erb абсолютно одинаковы и представляют собой следующее:

1
<%= render 'form' %>

в них мы отрисовываем только форму.

Далее кинем наш скачанный jquery.tokeninput.js в папку app/assets/javascripts и допишем в application.js следующее:

1
2
3
4
5
6
7
8
$(function() {
  var $input = $('.token-input-questionable');
   $input.tokenInput('/questionable.json', {
      tokenLimit: 1,
      tokenValue: 'id_with_class_name',
      prePopulate: $input.data('pre')
    });
});

Первым параметром к tokenInput мы указываем откуда брать данные, tokenLimit:1 указывает на то, что одной записи нам будет достаточно (одного элемента из выпадающего списка), tokenValue – откуда мы будем брать имя, которое будем отображать из json’а, prePopulate используем для того, чтобы заполнить элемент, если мы его уже выбрали (редактирование).

Сумбурным получилось изложения материала, потому что для меня это в новинку и писалось это для того, чтобы не забыть полезную “плюшку” в дальнейшем. Если заметка кому-нибудь еще пригодится, я буду очень рад :)

P.S.: Если данную заметку читает Александр, мало ли, то, надеюсь, он будет не против, что я позаимствовал его идею :)

Демо проект “лежит” на heroku, репозиторий, как всегда на github (там же лежат и миграции, нужные для запуска проекта).

Удачи вам в любых начинаниях и до новых встреч :)

Комментарии