Неофициальный гайд по Active Admin

от автора

Статья про Ruby в блоге компании ДомКлик! Как так получилось, что в молодую компанию завезли мертвый язык? Секрет в том, что на Ruby можно быстро написать и протестировать бизнес-идею. И делается это не без помощи Rails и Active Admin — библиотеки, которая позволяет быстро создать админку с минимальными затратами сил и времени.

Часто можно встретить мнение, что Active Admin хорош только для 15-минутного блога. Мы в ДомКлик считаем (и доказываем на практике), что из этой библиотеки можно выжать намного больше.

Я расскажу про некоторые подходы, которые мы применяем при работе с Active Admin.

Active Admin базируется на нескольких библиотеках, среди которых я бы выделил arbre, formtastic, inherited_resources и ransack. Каждая из них отвечает за свою часть и заслуживает отдельного рассмотрения. Начнем по алфавиту — с библиотеки, которая отпочковалась от самого Active Admin.

Arbre: кастомизация компонентов

Одна из проблем Active Admin — на глазах распухающие файлы ресурсов: фильтры, дополнительные action’ы, верстка страниц, формы, и всё это в одном файле. Где-то вдали слышен протяжный стон одинокого пуриста «где же single responsibility?» Не завезли. Но давайте разберемся, как можно изолировать часть верстки в отдельных классах.

Arbre — библиотека для описания шаблонов с помощью Ruby. Вот пример простейшей страницы, написанной с помощью DSL Arbre:

html do   head do     title('Welcome page')   end   body do     para('Hello, world')   end end

DSL расширяется с помощью компонентов. Например, в Active Admin это tabs, table_for, paginated_collection и даже сами страницы. Продолжим знакомство с библиотекой рассмотрением структуры простейшего Arbre компонента.

Arbre: hello world компонент

Как и все компоненты Arbre, наш Admin::Components::HelloWorld наследован от класса Arbre::Component:

# app/admin/components/hello_world.rb module Admin   module Components     class HelloWorld < Arbre::Component       builder_method :hello_world        def build(attributes = {})         super(attributes)         text_node('Hello world!')         add_class('hello-world')       end        def tag_name         'h1'       end     end   end end

Начнем сверху вниз: builder_method определяет метод, с помощью которого мы сможем создать компонент при использовании DSL. Аргументы, переданные в компонент, попадут в метод #build.

В Arbre каждый компонент — это отдельный DOM-элемент (напоминает механизм работы современных frontend-фреймворков, только датируется 2012 годом). По умолчанию все компоненты представляют из себя div, чтобы изменить это поведение, можно переопределить метод #tag_name. Метод #add_class, как не сложно догадаться, добавляет атрибут class к корневому DOM-элементу.

Осталось вызвать наш новый компонент. Для примера, сделаем это в app/admin/dashboard.rb

# app/admin/dashboard.rb ActiveAdmin.register_page 'Dashboard' do   menu priority: 1, label: proc { I18n.t('active_admin.dashboard') }    content do     hello_world   end end

Теперь рассмотрим пример небольшого рефакторинга админки с использованием собственного компонента.

Arbre: пример из реальной жизни (почти)

Для того, чтобы понять, как использовать Arbre в условиях, приближенных к боевым, возьмем синтетический пример. Предположим, что у нас есть блог с записями (Article) и комментариями (Comment) со связью 1:M. Нам необходимо вывести 10 последних комментариев на странице конкретной записи (блок show).

# app/admin/articles.rb ActiveAdmin.register Article do   permit_params :title, :body    show do     attributes_table(:body, :created_at)      panel I18n.t('active_admin.articles.new_comments') do       table_for resource.comments.order(created_at: :desc).first(10) do         column(:author)         column(:text)         column(:created_at)       end     end   end end

А теперь вынесем таблицу с комментариями в отдельный компонент. Создадим новый класс и унаследуем его от ActiveAdmin::Views::Panel. Если создать новый компонент с нуля (как в hello_world выше) и в нем вызвать panel, то panel окажется внутри еще одного div, а это наверняка поломает верстку.

Мы в нашей команде разместили бы этот класс в app/admin/components/articles/new_comments.rb, но это вкусовщина. Просто знайте, что Active Admin автоматически загрузит всё, что находится внутри app/admin/**/*:

# app/admin/components/articles/new_comments.rb module Admin   module Components     module Articles       class NewComments < ActiveAdmin::Views::Panel         builder_method :articles_new_comments          def build(article)           super(I18n.t('active_admin.articles.new_comments'))            table_for last_comments(article) do             column(:author)             column(:text)             column(:created_at)           end         end          private          def last_comments(article)           article.comments                  .order(created_at: :desc)                  .first(10)         end       end     end   end end

Теперь заменим panel в app/admin/articles.rb на вызов нашего нового компонента и передадим в него resource:

# app/admin/articles.rb ActiveAdmin.register Article do   permit_params :title, :body    show do     attributes_table(:body, :created_at)      articles_new_comments(resource)   end end

Красота! Отмечу, что resource можно было бы не передавать в компонент, а использовать через контекст. Однако, явно передав resource, мы ослабили связность компонента, что позволит переиспользовать его в будущем.

К слову о переиспользовании, всё содержимое блока show (как и других блоков с шаблонами) можно вынести в partial:

# app/admin/articles.rb ActiveAdmin.register Article do   show do     render('show', article: resource)   end end

# app/views/admin/articles/_show.html.arb panel(ActiveAdmin::Localizers.resource(active_admin_config).t(:details)) do   attributes_table_for(article, :body, :created_at) end  articles_new_comments(article)

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

Arbre: что еще посмотреть

Прежде всего, я посоветовал бы ознакомиться с описанием компонентов Active Admin в официальной документации.

Для более глубокого изучения можно посмотреть код базовых компонентов из arbre и компонентов activeadmin, ведь часто именно на их основе будут строиться ваши собственные. Кроме того, обратите внимание на gem activeadmin_addons, в котором есть множество интересных компонентов.

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

Formtastic: кастомизация форм

Formtastic — библиотека для описания форм с помощью DSL. Простейшая форма выглядит вот так:

semantic_form_for object do |f|   f.inputs   f.actions end

В этом примере Formtastic автоматически вытаскивает все атрибуты из переданного объекта object и подставляет их в форму с типами input’ов по-умолчанию. Список доступных типов input’ов можно найти в README. Как и Arbre, Formtastic можно расширить с помощью создания собственных классов-компонентов. Для того, чтобы разобраться в базовых вещах, давайте создадим hello world компонент.

Formtastic: hello world компонент

По аналогии с компонентами Arbre, разместим новый класс в app/admin/inputs:

  # app/admin/inputs/hello_world_input.rb   class HelloWorldInput     include Formtastic::Inputs::Base      def to_html       "Input for ##{object.public_send(method)}"     end   end

Чтобы вызвать новый input, достаточно указать его название в параметре :as, например, так:

# app/admin/articles.rb ActiveAdmin.register Article do   form do |f|     f.inputs do       f.input(:id, as: :hello_world)       f.input(:title)       f.input(:body)     end     f.actions   end end

Все необходимые для отрисовки формы параметры (в том числе object и символ method) попадают в #initialize, определенный в модуле Formtastic::Inputs::Base. За отображение input’а отвечает метод #to_html.

Может показаться что этот пример бесполезен, но на самом деле на его основе мы в компании рендерим read-only поля. Давайте добавим всего пару методов, доступных в Formtastic, и превратим наш hello world в полезный read-only input. Следите за руками:

  # app/admin/inputs/hello_world_input.rb class HelloWorldInput   include Formtastic::Inputs::Base    def to_html     input_wrapping do       label_html <<         object.public_send(method).to_s     end   end end

Всё, что мы добавили — это два метода с говорящими названиями. input_wrapping пришел из модуля Formtastic::Inputs::Base::Wrapping и отвечает за обертку input’а. В том числе, он включает в себя элементы для вывода ошибок и подсказок. label_html из модуля Formtastic::Inputs::Base::Labelling рендерит лейбл для input’а. Эти два хелпера мгновенно превращают наш hello world в применимый в бою input (разве что нейминг класса бы еще поправить).

Теперь мы можем перейти к чуть более сложному примеру, который продемонстрирует, как можно интегрировать в форму JS-библиотеку.

Formtastic: пример из реальной жизни (почти)

Возьмем за основу очередной выдуманный пример, который продемонстрирует, как работать с HTML, CSS и JS. То есть покроет все шаги написания нового input’а.

Предположим, что к нам пришел запрос от редактора блога: при написании статьи он хотел бы прямо в форме ввода видеть количество слов. Как известно, в мире JavaScript’ов для всего существуют библиотеки, для нашей задачи такая тоже нашлась: Countable.js. Давайте возьмем стандартный input для текста и расширим его, добавив подсчет слов.

Прикинем, что нам потребуется для реализации нового input’а:

  • взять существующий текстовый input и добавить к нему div для вывода количества слов;
  • добавить CSS-стили для нового div;
  • вызвать Countable.js на нужном нам поле и записать с его помощью информацию о количестве слов в новый div.

Начнем с создания нового класса и наследуем его от Formtastic::Inputs::TextInput. Добавим дополнительный атрибут class="countable-input" к элементу textarea, и рядом с ним создадим новый пустой div с атрибутом class="countable-content":

# app/admin/inputs/countable_input.rb class CountableInput < Formtastic::Inputs::TextInput   def to_html     input_wrapping do       label_html <<         builder.text_area(method, input_html_options.merge(class: 'countable-input')) <<         template.content_tag(:div, '', class: 'countable-content')     end   end end

Посмотрим, что нового у нас добавилось. input_html_options— метод родительского класса с говорящим именем. builder — инстанс класса ActiveAdmin::FormBuilder, наследник ActionView::Helpers::FormBuilder. template — это контекст, в котором исполняются темплейты (то есть огромный набор view-helper’ов). Таким образом, если нам нужно создать кусочек формы, то обращаемся к builder. А если хотим использовать что-то типа link_to, то нам поможет template.

Библиотеку Countable.js завендорим: положим в директорию app/assets/javascripts/inputs/countable_input и добавим простенький .js файл, который будет вызывать Countable.js и закидывать информацию в div.countable-content (прошу сильно не пинать ногами за JS-спагетти):

// app/assets/javascripts/inputs/countable_input.js //= require ./countable_input/countable.min.js  const countable_initializer = function () {   $('.countable-input').each(function (i, e) {     Countable.on(e, function (counter) {       $(e).parent().find('.countable-content').html('words: ' + counter['words']);     });   }); }  $(countable_initializer); $(document).on('turbolinks:load', countable_initializer);

И теперь подтягиваем файл в app/assets/javascripts/active_admin.js:

// app/assets/javascripts/active_admin.js // ...  //= require inputs/countable_input

Последний штрих — добавляем CSS-файл и подгружаем его в app/assets/stylesheets/active_admin.scss:

// app/assets/stylesheets/inputs/countable_input.scss .countable-content {   float: right;   font-weight: bold; }

// app/assets/stylesheets/active_admin.scss // ...  @import "inputs/countable_input";

Вот и всё, наш input готов. Осталось только вызвать его в форме:

# app/admin/articles.rb ActiveAdmin.register Article do   form do |f|     f.inputs do       f.input(:id, as: :hello_world)       f.input(:title)       f.input(:body, as: :countable)     end     f.actions   end end

Таким образом мы создаем кастомные компоненты для форм в своих проектах. Например, файловые загрузчики или input’ы с хитрым автозаполнением. В подобных компонентах чуть больше кода, но подход остается неизменным.

Formtastic: пламенный привет пуристам

Как и в случае с компонентами Arbre, формы можно выносить в partial’ы, хотя синтаксис немного отличается:

# app/admin/articles.rb ActiveAdmin.register Article do   form(partial: 'form') end

# app/views/admin/articles/_form.html.arb active_admin_form_for resource do   inputs(:title, :body)   actions end

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

Formtastic: что еще посмотреть

Formtastic — достаточно обширная библиотека, и я настоятельно рекомендую прочитать подробный README, чтобы ознакомиться со всеми возможностями кастомизации. Также будет полезно посмотреть уже упомянутый activeadmin_addons. В этом gem’е есть множество дополнительных input’ов, которые наверняка пригодятся в хозяйстве.

Отдельно замечу, что хотя в статье я разделил Formtastic и Arbre по разным блокам, они прекрасно работают вместе, ведь вы можете создавать формы или части форм в качестве Arbre-компонентов.

Inherited Resources — кастомизация контроллеров

Чтобы понять. откуда берется магический resource, как поменять поведение при сохранении. и многое другое, нам будет необходимо познакомиться с еще одним gem’ом.

Inherited Resources — библиотека, призванная избавить контроллеры от однообразной CRUD-рутины.

Библиотека, с одной стороны, простая, а с другой обширная. Поэтому галопом по Европам рассмотрим несколько полезных методов:

class ArticlesController < InheritedResources::Base   respond_to :html   respond_to :json, only: :index   actions :index, :new, :create    def update     resource.updated_by = current_user     update! { articles_path }   end end

Итак, .respond_to отвечает за доступные форматы. Все вызовы .respond_to «складываются», а не переопределяют друг друга. Чтобы сбросить форматы, понадобится метод .clear_respond_to.

.actions определяет доступные CRUD-методы (index, show, new, edit, create, update и destroy).

resource — один из доступных хелперов, среди которых:

resource        #=> @article collection      #=> @articles resource_class  #=> Article

И наконец, #update! — это просто alias для #update, который можно использовать при перегрузке методов вместо super.

Отдельно рассмотрим применение метода .has_scope. Предположим, что в классе Article определен scope :published:

class Article < ApplicationRecord   scope :published, -> { where(published: true) } end

Тогда мы можем использовать в контроллере метод .has_scope:

class ArticlesController < InheritedResources::Base   has_scope :published, type: :boolean end

.has_scope добавляет возможность фильтрации с помощью query-параметров. В примере выше мы сможем применить scope :published, если обратимся к коллекции по URL /articles?published=true.

Подробное описание этих и других возможностей библиотеки можно найти в обширном README. А мы, пожалуй, остановимся на этом и перейдем, наконец, к взаимодействию с Active Admin.

Inherited Resources: расширение контроллера

Все контроллеры Active Admin наследованы от InheritedResources::Base, а это значит, что у нас есть возможность модифицировать их поведение, используя методы библиотеки.

Например, список доступных action’ов контроллера определяется следующим образом:

# app/admin/articles.rb ActiveAdmin.register Article do   actions :all, :except => [:destroy] end

Отлично, мы убрали action удаления статьи. Кажется, всё очевидно: используем ресурс Active Admin как контроллер. Но не будем спешить с выводами и попробуем добавить еще одну фичу.

По умолчанию Active Admin включает рендеринг всех страниц в качестве HTML, JSON и XML (а index доступен еще и в формате CSV). Попробуем избавимся от XML-рендеринга нашей страницы с помощью знакомых нам методов:

# app/admin/articles.rb ActiveAdmin.register Article do   clear_respond_to   respond_to :html, :json   respond_to :csv, only: :index end

Ой, теперь мы получили ошибку undefined method 'clear_respond_to' for #<ActiveAdmin::ResourceDSL>.

Дело в том, что когда мы описываем класс-ресурс, мы находимся в контексте ActiveAdmin::ResourceDSL, а не в контексте контроллера. Код из предыдущего примера работает только потому, что ActiveAdmin::ResourceDSL делегирует контроллеру метод #actions.

Но не отчаивайтесь, чтобы добраться до контроллера и выполнить код в его контексте, необходимо всего-навсего вызвать метод #controller:

# app/admin/articles.rb ActiveAdmin.register Article do   controller do     clear_respond_to     respond_to :html, :json     respond_to :csv, only: :index   end end

Вуаля, теперь localhost:3000/admin/articles.xml возвращает ошибку. А что на счет модификации поведения action’ов?

Inherited Resources: перегрузка методов

Предположим, что при сохранении нам необходимо задать атрибут Article#created_by_admin. Воспользуемся для этого возможностью перегрузки метода #create:

# app/admin/articles.rb ActiveAdmin.register Article do   controller do     def create       build_resource       @article.created_by_admin = true       create!     end   end end

Итак, мы вызываем build_resource — метод, который инициализирует новый объект и присваивает его переменной @article. Далее задаем атрибут created_by_admin и вызываем create! (он же super), который продолжает оперировать созданным нами @article.

Хотелось бы отдельно отметить: будьте внимательны с хелперами. Inherited Resources активно использует instance-переменные для кеширования. В данном случае это помогло нам создать и модифицировать объект, но при неаккуратном использовании, результаты могут быть неожиданными (проверено на собственной шкуре).

А теперь вернемся на пару шагов назад, к моменту, когда мы отключали XML-рендеринг статей. Что, если мы хотим убрать рендеринг XML из всех ресурсов? Не будем же мы писать один и тот же код в каждом новом классе?

Расширение базового контроллера

Не будем! Давайте создадим модуль, который скорректирует поведение класса ActiveAdmin::ResourceController:

# lib/active_admin/remove_xml_rendering_extension.rb module ActiveAdmin   module RemoveXmlRenderingExtension     def self.included(base)       base.send(:clear_respond_to)       base.send(:respond_to, :html, :json)       base.send(:respond_to, :csv, only: :index)     end   end end

В метод .included будет передан расширяемый класс, к которому будут применены нужные нам модификаторы. Воспользуемся инициализатором Active Admin и подключим новый модуль к ActiveAdmin::ResourceController:

# config/initializers/active_admin.rb require 'lib/active_admin/remove_xml_rendering_extension'  ActiveAdmin::ResourceController.send(   :include,   ActiveAdmin::RemoveXmlRenderingExtension ) # ...

Немного магии метапрограммирования с #include и #included, и готово! Теперь ни один ресурс не ответит на формат .xml.

К слову, если вы думали, что #prepend, #include и #extend — это методы из вопросов, которыми на собеседованиях валят неугодных, то боюсь вас разочаровать. Когда возникает необходимость модифицировать код внешней библиотеки, подобные подходы нередко становятся единственным доступным инструментом.

Inherited Resources: что еще посмотреть

Прежде всего обратите внимание на подробный README. Помимо этого посмотрите на то, как устроены контроллеры в Active Admin, обратите внимание на логику авторизации и другие мелочи, вроде дополнительных хелперов.

Ransack: кастомизация фильтров

По умолчанию Active Admin на каждой index-странице предоставляет развесистый блок с фильтрацией, из которого чаще приходится убирать лишнее, нежели добавлять что-то свое. Но на самом деле это лишь верхушка айсберга под названием Ransack.

Ransack — библиотека для создания поисковых форм, которая позволяет собирать сложные SQL-запросы, интерпретируя переданные имена параметров. Звучит сложно, но я уверен, пример позволит быстро разобраться. о чем идет речь.

Предположим, что нам необходимо фильтровать записи блога (Article) по вхождению строки в название (title). С помощью Ransack мы можем это сделать следующим образом:

Article.ransack(title_cont: 'Домклик').result

Постфикс _cont — это один из множества предикатов, доступных в Ransack. Предикаты определяют то, какой SQL-запрос будет сгенерирован для поиска. Подробно обо всех доступных предикатах можно прочитать в официальной wiki.

А теперь чуть усложним задачу: заказчик попросил нас добавить фильтр, который позволит искать вхождение строки одновременно и в заголовке, и в теле (body). С Ransack это проще некуда:

Article.ransack(title_or_body_cont: 'active admin').result

Помимо этого, Ransack позволяет искать записи, обращаясь к связанным моделям. Для демонстрации, добавим возможность искать статьи по тексту комментариев (Comment#text):

Article.ransack(comments_text_cont: 'I hate type annotations!').result

Как несложно догадаться, подобные конструкции могут быстро разрастись. Да и использование сложных параметров в нескольких местах может привести к проблемам. В качестве решения Ransack предлагает использовать #ransack_alias. Добавим к поиску по тексту комментария поиск по его автору и дадим короткий alias: comments, который в дальнейшем можно будет использовать с нужными нам предикатами:

# app/models/article.rb class Article < ActiveRecord::Base   has_many :comments    ransack_alias :comments, :comments_text_or_comments_author end  Article.ransack(comments_cont: 'Matz').result

Разобравшись с тем, как Ransack позволяет структурировать запросы, перейдем, наконец, к тому, как мы можем использовать это в Active Admin.

Ransack: использование составных фильтров

Возьмем за основу примеры выше и используем их в качестве фильтров для ресурса Active Admin:

# app/admin/articles.rb ActiveAdmin.register Article do   preserve_default_filters!   filter :title_or_body_cont,          as: :string,           label: I18n.t('active_admin.filters.title_or_body_cont')   filter :comments,           as: :string end

Вот и всё, весьма прямолинейно. Разве что отмечу метод #preserve_default_filters!, который оставляет на месте стандартные фильтры.

Ransack: использование scope-фильтров

По умолчанию Ransack позволяет фильтровать по всем атрибутам и связям модели. Это может быть опасно с точки зрения безопасности, поэтому обратите внимание на возможность ограничения доступа к определенным полям и связям с помощью методов ransackable_attributes, ransackable_associations и ransackable_scopes. Вопросы авторизации я хотел бы оставить за рамками данной статьи (тем более, что у Active Admin в документации есть подробный раздел), поэтому обратим внимание лишь на метод ransackable_scopes.

В отличие от других методов авторизации, ransackable_scopes по умолчанию запрещает использование любых scope’ов. Таким образом, чтобы иметь возможность фильтровать по scope (или по любому другому методу класса модели), необходимо вернуть его название из .ransackable_scopes.

Для примера, добавим фильтр по количеству комментариев с использованием scope:

# app/models/article.rb class Article < ActiveRecord::Base   has_many :comments    scope :comments_count_gt, (lambda do |comments_count|      joins(:comments)        .group('articles.id')        .having('count(comments.id) > ?', comments_count)   end)    def self.ransackable_scopes(auth_object = nil)     [:comments_count_gt]   end end

Обратите внимание на auth_object: в теории, это объект по которому можно определить стратегию авторизации. Я бы ожидал, что сюда будет передаваться current_user, однако Active Admin этого не делает.

Мы добавили scope и вернули его название в .ransackable_scopes, осталось только добавить фильтр в ресурс Active Admin:

# app/admin/articles.rb ActiveAdmin.register Article do   filter :comments_count_gt,           as: :number,          label: I18n.t('active_admin.filters.comments_count_gt') 

Осталась одна мелочь: если мы попробуем отфильтровать все статьи с двумя и более комментариями — всё отлично, но если попробовать подать единицу, то мы получим ошибку:

Это нам «помогло» приведение типов, которое по историческим причинам делает Ransack. Чтобы отключить сомнительную фичу, мы добавим инициализатор с заданным параметром sanitize_custom_scope_booleans:

# /config/initializers/ransack.rb Ransack.configure do |config|   config.sanitize_custom_scope_booleans = false end

Готово, теперь наш фильтр работает, даже если мы подадим 1 в качестве аргумента, и мы умеем использовать фильтры на основе scope’ов.

Ransack: что еще посмотреть

Прежде всего, стоит еще раз заглянуть в документацию Active Admin про фильтры. Продолжить обзор можно в официальных README и wiki, в которых, помимо всего прочего, вы сможете найти view-хелперы для создания своих поисковых форм.

Для особо запущенных случаев вы можете обратить внимание на то, как создавать собственные предикаты, и на Ransackers — расширения, которые преобразуют параметры напрямую в Arel (внутренняя библиотека ActiveRecord, используемая для конструирования SQL-запросов).

Итоги

Надеюсь, что после этой статьи вы взглянули на Active Admin с новой стороны и, возможно, захотели зарефакторить класс-другой в своих проектах. Ведь Active Admin позволяет быстро запустить рабочую систему и направить все силы frontend-разработчиков на полезный для конечного пользователя продукт.

Я старался не сильно пересекаться с официальной документацией Active Admin, в которой можно найти описание множества интересных возможностей библиотеки, например, авторизацию или использование декораторов.

Также в очередной раз упомяну activeadmin_addons, в котором, помимо множества компонентов, доступна симпатичная тема для Active Admin. Обратите внимание на то, как она устроена, если захотите сделать свою тему для админки и использовать ее во всех проектах (именно так и сделано у нас в Домклике).

ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/514506/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *