yummy-guide-generic-administrate

yummy-guide-generic-administrate は、Yummy Guide 系 Rails アプリで共通利用する Administrate 拡張をまとめた Rails Engine です。

Administrate 本体を置き換えるものではなく、ダッシュボードの共通既定値、 フィルター UI、一覧表示 helper、共通 field などを再利用しやすくするための補助 gem です。

前提

  • Ruby >= 3.2.2
  • Rails >= 7.0, < 7.2
  • Administrate >= 0.19, < 0.21
  • sprockets-rails

インストール

Gemfile に追加します。

gem "yummy-guide-generic-administrate"

その後、依存 gem をインストールします。

bundle install

提供するもの

  • YummyGuide::Administrate::ApplicationDashboard
    • 一覧画面の既定ソートと固定列数の共通基底クラス
  • YummyGuide::Administrate::DefaultSorting
    • dashboard 側の既定ソート設定を controller に反映する concern
  • YummyGuide::Administrate::DatetimeFilterParameters
    • 日付、時、分の分割パラメータを 1 つの datetime 文字列へ正規化する concern
  • YummyGuide::Administrate::CollectionHelper
    • 一覧テーブルの固定列数、リンク生成、action partial 解決を補助する helper
  • YummyGuide::Administrate::FilterFormHelper
    • datetime フィルターや checkbox group の組み立てを補助する helper
  • YummyGuide::Administrate::FilterControlsHelper
    • dashboard の Field 型フィルター定義から Filter ボタンとモーダルフォームを描画する helper
  • YummyGuide::Administrate::DatetimeInputHelper
    • 管理画面フォーム用の date + time 入力 helper
  • YummyGuide::Administrate::NumberInputHelper
    • 管理画面フォーム用の number input text 化 helper
  • 共通 partial / assets
    • collection partial
    • fixed table header partial
    • filter form partial
    • clipboards.js
    • datetime_input.js
    • fixed_submit_actions.js
    • filter_controls.js
    • filter_form.js
    • sticky_left_columns.js
    • sticky_table_headers.js
    • components.css
  • 共通 field
    • YummyGuide::Administrate::Fields::JsonPrettyField
    • YummyGuide::Administrate::Fields::VersionItemField
    • YummyGuide::Administrate::Fields::VersionWhodunnitField
    • YummyGuide::Administrate::Fields::Area::PictureField

利用方法

Dashboard の基底クラス

共通の既定ソートと固定列数設定を使う場合は、dashboard を YummyGuide::Administrate::ApplicationDashboard から継承します。

class ApplicationDashboard < YummyGuide::Administrate::ApplicationDashboard
end

必要に応じて各 dashboard 側で上書きできます。

class Admin::ArticleDashboard < ApplicationDashboard
  COLLECTION_ATTRIBUTES = %i[id title status created_at].freeze
  COLLECTION_SORTABLE_ATTRIBUTES = %i[id title status created_at].freeze
  INDEX_FIXED_COLUMNS_COUNT = 2

  def default_sorting_attribute
    :published_at
  end

  def default_sorting_direction
    :desc
  end
end

Controller concern / helper

管理画面の基底 controller で共通 concern と helper を読み込みます。

class Admin::ApplicationController < Administrate::ApplicationController
  include YummyGuide::Administrate::DefaultSorting
  include YummyGuide::Administrate::DatetimeFilterParameters

  helper YummyGuide::Administrate::CollectionHelper
  helper YummyGuide::Administrate::DatetimeInputHelper
  helper YummyGuide::Administrate::FilterControlsHelper
  helper YummyGuide::Administrate::FilterFormHelper
  helper YummyGuide::Administrate::NumberInputHelper
end

NumberInputHelperAdministrate::ApplicationController の view helper として 自動適用されます。Administrate::ApplicationController を継承しない独自 admin controller で同じ挙動が必要な場合だけ、上記のように明示的に読み込んでください。

Collection partial

ホストアプリ側の app/views/administrate/application/_collection.html.erb から、 engine の共通 partial に委譲します。

<%= render "yummy_guide/administrate/administrate/application/collection",
           collection_presenter: collection_presenter,
           page: page,
           resources: resources,
           table_title: table_title,
           namespace: namespace,
           resource_class: resource_class,
           collection_field_name: collection_field_name %>

固定ヘッダーの設定

1. 最小構成

gem 付属の collection partial をそのまま使う場合、table wrapper と table 本体に 必要な data-* 属性はすでに入っています。そのため、JS / CSS を読み込めば固定 ヘッダーは自動で有効になります。

内部的には以下のような構造になります。

<div class="scroll-table" data-fixed-header-scroll>
  <table
    aria-labelledby="<%= table_title %>"
    data-fixed-columns-count="<%= yummy_guide_administrate_collection_table_fixed_columns_count(page: page, collection_presenter: collection_presenter) %>"
    data-fixed-header-source
  >
    ...
  </table>
</div>

2. ヘッダー位置を明示したい場合

固定ヘッダーの表示位置をページ上部の特定箇所に合わせたい場合は、 data-fixed-table-header を持つ slot を .main-content 配下に置きます。 gem には専用 partial があります。

<header class="main-content__header">
  <h1 id="page-title">Articles</h1>
  <%= render "yummy_guide/administrate/administrate/application/fixed_table_header" %>
</header>

<section class="main-content__body main-content__body--flush">
  <%= render "yummy_guide/administrate/administrate/application/collection",
             collection_presenter: collection_presenter,
             page: page,
             resources: resources,
             table_title: "page-title",
             namespace: :admin,
             resource_class: resource_class,
             collection_field_name: resource_name %>
</section>

fixed_table_header partial 自体は以下の 1 行です。

<div class="yummy-guide-administrate-fixed-table-header" data-fixed-table-header hidden></div>

この slot を置かない場合でも、JS が table の直前に自動生成します。配置を制御 したいときだけ明示してください。desktop では LMJ と同じく、main-content__body--flush と組み合わせることで fixed header のクリップ幅が一覧 body と揃い、一覧 wrapper 自体には縦スクロールを持たせません。

3. 自前の table partial を使う場合

独自の collection partial を書く場合は、少なくとも以下を満たしてください。

  • 横スクロール wrapper に data-fixed-header-scroll を付ける
  • table に data-fixed-header-source を付ける
  • table に data-fixed-columns-count を付ける
  • header の aria-labelledby がページタイトルと対応している
<%= render "yummy_guide/administrate/administrate/application/fixed_table_header" %>

<div class="scroll-table" data-fixed-header-scroll>
  <table
    aria-labelledby="page-title"
    data-fixed-header-source
    data-fixed-columns-count="<%= yummy_guide_administrate_collection_table_fixed_columns_count(page: page, collection_presenter: collection_presenter) %>"
  >
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th class="sticky actions-column">Actions</th>
      </tr>
    </thead>
    <tbody>
      <% resources.each do |resource| %>
        <tr>
          <td><%= resource.id %></td>
          <td><%= resource.name %></td>
          <td class="sticky actions-column">
            <%= link_to "Show", [:admin, resource] %>
          </td>
        </tr>
      <% end %>
    </tbody>
  </table>
</div>

sticky actions-column を action 列に付けると、右端列も固定できます。 左端の固定列数は dashboard 側の INDEX_FIXED_COLUMNS_COUNT で制御します。

Datetime filter

filter form の枠と datetime 入力 partial を組み合わせて利用できます。

<%= render "yummy_guide/administrate/filter_forms/frame",
           path: path,
           form: form,
           method: method,
           current_values: search_options do |f, values| %>
  <tr>
    <td><%= f.label :start_at, "開始日時" %></td>
    <td>
      <%= render "yummy_guide/administrate/filter_forms/datetime_field",
                 form_scope: form,
                 field_name: :start_at,
                 current_value: values["start_at"],
                 css_class: "#{form}_start_at" %>
    </td>
  </tr>
<% end %>

Datetime input helper

通常フォームで日付と時刻を分けた入力 UI を使う場合は、DatetimeInputHelper を読み込んで admin_datetime_* helper を使います。既存の LMJ 互換として、 helper 名は admin_ prefix のまま提供しています。

<%= admin_datetime_field_tag(
      "coupon[expiration_date]",
      @coupon.expiration_date,
      required: true,
      default_current_time: @coupon.new_record?
    ) %>

<%= admin_split_datetime_field_tag(
      date_name: "coupon[expiration_date_date]",
      hour_name: "coupon[expiration_date_hour]",
      minute_name: "coupon[expiration_date_minute]",
      value: @coupon.expiration_date
    ) %>

<%= admin_date_and_time_field_tag(
      date_name: "reservation_task[due_date_on]",
      time_name: "reservation_task[due_time]",
      value: @reservation_task.due_at
    ) %>

<%= admin_time_field_tag("option[valid_from_time]", @option.valid_from_time) %>

datetime_input.js は visible な date / time 入力と hidden の送信値を同期し、 不正な日付や時刻の submit を抑止します。

controller 側では、必要な filter key を指定して datetime パラメータを正規化します。

def search_term
  resource_params = params[resource_name] || params[resource_name.to_sym] || {}
  raw_filters = resource_params[:search] || resource_params["search"] || {}

  normalized_filters = normalize_datetime_filter_params(
    raw_filters,
    keys: %i[start_at end_at]
  )

  normalized_filters
end

Dashboard driven filter controls

dashboard に FILTER_ATTRIBUTES を定義すると、admin_filter_controls で既存の Administrate index header に Filter ボタンとモーダルフォームを描画できます。

Filter の設定方法

  1. dashboard に送信先と filter 対象項目を定義します。
class AdspendDashboard < ApplicationDashboard
  FILTER_PATH = ->(view, _locals) { view.admin_adspends_path }
  FILTER_CLEAR_PATH = ->(view, _locals) { view.admin_adspends_path }

  FILTER_ATTRIBUTES = {
    campaign_name: YummyGuide::Administrate::Filters::Text.with_options(label: "Campaign name"),
    yearmonth: YummyGuide::Administrate::Filters::Text.with_options(
      label: "Year month",
      inputmode: "numeric",
      pattern: "\\d{6}",
      placeholder: "YYYYMM"
    )
  }.freeze
end
  1. index header などで admin_filter_controls を呼び出します。page から dashboard を 解決し、FILTER_ATTRIBUTES が存在する場合だけ Filter ボタンとフォームを描画します。
<%= admin_filter_controls(
      page: page,
      search_options: search_options
    ) %>

送信先は helper の path: / clear_path: で明示することもできます。helper の指定は dashboard の FILTER_PATH / FILTER_CLEAR_PATH より優先されます。

<%= admin_filter_controls(
      page: page,
      path: admin_adspends_path,
      clear_path: admin_adspends_path,
      search_options: search_options,
      filter_locals: { show_discarded_filter: false },
      root_hidden_fields: { "reservation[order]" => current_order[:order] },
      extra_actions: [button_tag("Download PDF", type: "button", class: "button")]
    ) %>

標準 Field は Text, Select, Checkbox, RadioGroup, CheckboxGroup, DateRange, DatetimeRange, DatetimeLocalRange, Custom です。 主な option は次のとおりです。

  • 共通: label, default, if, class, id, placeholder, inputmode, pattern
  • 選択系: collection または options, select_options
  • checkbox: checked_value, unchecked_value
  • checkbox group: group
  • range: from, to, from_default, to_default, css_class

label, collection, default, if などの option には Proc も指定できます。Proc は arity に応じて call, call(view_context), call(view_context, filter_locals) のいずれかで評価されます。

FILTER_ATTRIBUTES = {
  owner_id: YummyGuide::Administrate::Filters::Text.with_options(
    label: "Owner ID",
    inputmode: "numeric",
    if: ->(view, _locals) { !view.current_user&.owner? }
  ),
  status: YummyGuide::Administrate::Filters::Select.with_options(
    label: "Status",
    collection: ->(_view, locals) { locals[:status_collection] },
    select_options: { include_blank: true }
  )
}.freeze

カスタム Filter の作成方法

既存の Field 型で表現できない場合は、partial を使う方法と独自クラスを定義する方法があります。

partial だけで足りる場合は Custom を使います。partial には form, form_scope, field, current_values, filter_locals が渡されます。

FILTER_ATTRIBUTES = {
  price_range: YummyGuide::Administrate::Filters::Custom.with_options(
    partial: "admin/filters/price_range"
  )
}.freeze
<tr>
  <td><%= form.label field.name, field.label_text(self, filter_locals) %></td>
  <td>
    <%= form.number_field :min_price, value: current_values["min_price"] %>
    <%= form.number_field :max_price, value: current_values["max_price"] %>
  </td>
  <%= filter_field_clear_cell if respond_to?(:filter_field_clear_cell) %>
</tr>

複数画面で再利用する filter 型は YummyGuide::Administrate::Filters::Base を継承して作ります。 単一 input なら input を実装し、行全体を制御したい場合は row または input_cell を上書きします。

class CurrencyFilter < YummyGuide::Administrate::Filters::Base
  private

  def input(view_context, form, current_values, locals)
    form.select(
      name,
      view_context.options_for_select(options.fetch(:collection), current_value(current_values)),
      { include_blank: true },
      html_options(view_context, locals)
    )
  end
end

FILTER_ATTRIBUTES = {
  payout_currency: CurrencyFilter.with_options(
    label: "Currency",
    collection: [["JPY", "JPY"], ["USD", "USD"]]
  )
}.freeze

Number input helper

Admin/Administrate 画面では、number_field / number_field_tagtype="text" かつ inputmode="decimal" の input として描画します。これにより、 ブラウザ標準の number spinner や mouse wheel による意図しない数値変更を避けます。

class, id, name, value, data, required, disabled, readonly, placeholder などの通常 option は維持されます。min, max, step, in, withintype="number" 前提のブラウザ制御なので出力しません。

range_field / range_field_tag は対象外で、従来どおり type="range" として描画 されます。raw HTML の <input type="number"> は helper を通らないため対象外です。

Asset の読み込み

この engine の asset はホストアプリ側で明示的に読み込んでください。

//= require yummy_guide_administrate/clipboards
//= require yummy_guide_administrate/datetime_input
//= require yummy_guide_administrate/fixed_submit_actions
//= require yummy_guide_administrate/filter_controls
//= require yummy_guide_administrate/filter_form
//= require yummy_guide_administrate/sticky_left_columns
//= require yummy_guide_administrate/sticky_table_headers
 *= require yummy_guide_administrate/components

固定更新ボタン

管理画面の new / edit 系フォームでは、submit セクションを画面下に固定表示できます。 この機能は fixed_submit_actions.jscomponents.css に含まれる style によって動作します。

固定対象を明示指定する場合

固定したい submit セクションに data-fixed-submit-actions="true" を付けます。

<div class="form-actions" data-fixed-submit-actions="true">
  <%= f.submit %>
</div>

この指定がある場合、gem はその submit セクションを最優先で固定対象にします。 1 ページに複数フォームがあっても、明示指定した submit セクションだけが固定表示されます。

設定しなかった場合の挙動

data-fixed-submit-actions="true" が 1 件もない場合、gem は admin の new / edit ページで submit セクションを自動選択します。

  • 各フォーム内の .form-actions / .form_submit を候補にする
  • form-actions--top が付いた上部 submit は除外する
  • data-fixed-submit-exclude="true" が付いた submit セクションも除外する
  • 各フォームでは最後の submit セクションだけを候補にする
  • 複数フォームがあるページでは、現在表示中のフォームに対応する submit を固定する

上部 submit と下部 submit の両方があるフォームでは、下部 submit が自動で選ばれます。

fixed 帯に表示されるボタン

fixed 帯には submit セクションのクローンを表示します。

  • 元のフォーム内ボタンの見た目は変更しない
  • fixed 帯にだけ大きいボタンサイズを適用する
  • 1 つの submit セクションに複数 submit ボタンがある場合は全部表示する

自動選択から除外したい場合

自動選択の候補から外したい submit セクションには、data-fixed-submit-exclude="true" を付けます。

<div class="form-actions" data-fixed-submit-exclude="true">
  <%= f.submit "Preview" %>
</div>

Custom field

dashboard から共通 field を利用できます。

ATTRIBUTE_TYPES = {
  metadata: YummyGuide::Administrate::Fields::JsonPrettyField,
  item: YummyGuide::Administrate::Fields::VersionItemField.with_options(namespace: :admin),
  whodunnit: YummyGuide::Administrate::Fields::VersionWhodunnitField.with_options(
    namespace: :admin,
    user_class: "User"
  ),
  pictures: YummyGuide::Administrate::Fields::Area::PictureField
}.freeze

各 field の用途

JsonPrettyField

JSON 文字列または JSON 互換オブジェクトを、整形済みの文字列として表示します。

VersionItemField

PaperTrail の itemreify 結果をもとに、対象 resource のラベルと詳細画面への リンクを表示します。namespace:admin が既定です。

VersionWhodunnitField

PaperTrail の whodunnit から操作ユーザーを解決して表示します。

  • 既定では User クラスを参照します
  • user_class で別クラスを指定できます
  • user_label に proc を渡すと表示ラベルを上書きできます

Area::PictureField

画像添付用の field です。Active Storage の添付オブジェクト、または attachments を返すオブジェクトを前提としています。

必要に応じて以下の option を指定できます。

  • max_uploads
  • input_name
  • purge_input_name
  • attachment_url
  • preview_url

参照

リリース手順

このリポジトリは bundler/gem_tasks を利用しているため、標準の gem リリースタスク で公開できます。

  1. lib/yummy_guide/administrate/version.rbVERSION を更新する
  2. 必要な変更を commit 済みの状態にする
  3. 必要に応じて bundle exec rake spec で確認する
  4. bundle exec rake release を実行する

bundle exec rake release を実行すると、現在の VERSION をもとに v<version> の git tag を作成し、gem を build して rubygems.org へ push します。

事前に RubyGems への push 権限があること、ローカル環境で gem push が利用できる ことを確認してください。

注意点

  • asset は precompile 対象に追加されますが、ホストアプリ側での読み込み設定は別途必要です
  • VersionWhodunnitField は対象ユーザー class や表示ラベルをアプリ事情に合わせて調整してください
  • Area::PictureField の URL 解決は、必要なら option で明示的に上書きしてください