管理画面開発Gem AdministrateでEnumを扱う方法4つ

## はじめに
先日、半年ぐらいプログラミングの勉強をしている友人に、「何か機能を追加したくて、実装方法がいくつかあるとき、どういうことを考えて選択するの？」という質問を受けましたが、結構壮大なテーマなんで、漠然とありきたりなことしか言わなかったことをちょっと後悔しているので、本来はこういう小さな機能でここまできっちり検討しないですが、具体例を提示するという意味で、思考を明文化する形でくどく書いていきます。

## Administrateとは
Railsにおけるの管理画面のCRUDや検索機能を自動で導入してくれるGem。[rails管理画面系gem比較してみた](https://qiita.com/baban/items/f751fb05c4d2367878aa)のように、Railsでは管理画面をGemでさっと作りたいとき、いくつか選択肢があるが、Administrateは比較的新興のGemで、以下のような利点がある。

- カスタマイズすることを前提としている
- でも色々よしなにやってくれて余計な操作が要らない
- [ドキュメント](https://administrate-prototype.herokuapp.com/)がある
- コードの見通しが良い
- 認証系のGemに依存していない
- 普通のRubyのコードなので、学習コストが低い
- Thoughtbotが開発しているので、今後の開発やメンテナンスの継続性についても個人で開発しているGemより期待できる

## 環境や前提
- Rails 5.2.1
- Ruby 2.5.1

```ruby:app/models/post.rb
class Post < ActiveRecord::Base
  enum status: { submitted: 0, approved: 1, rejected: 2 }
end
```

```yaml:config/locales/en.yml
ja:
  activerecord:
    models:
      user: ユーザー
      admin_user: 管理者
      post: ポスト
    attributes:
      post:
        date: 日付
      post/status:
        submitted: "承認待ち"
        approved: "承認済み"
        rejected: "取り消し"
```

- [AdministrateのSelectフィールドのコード](https://github.com/thoughtbot/administrate/blob/master/lib/administrate/field/select.rb)
- [AdministrateのBaseフィールドのコード](https://github.com/thoughtbot/administrate/blob/master/lib/administrate/field/base.rb)

実装に必要な要素は以下の3点で、

a. enumの値一覧の取得する
b. I18nを利用してenumの値一覧を元にロカライズされたテキスト一覧を取得する（form用）
c. enumの値からロカライズされたテキストを取得する（index/show用）

これらをどこで行うかによって異なる。

## 方法0: プラグインのGemを使う
[administrate-field-enum](https://github.com/valiot/administrate-field-enum)

- Administrate標準で提供されている機能で十分
- 実装に何分もかかるものではない
- このGemが継続的にメンテされる保証はない。

よってこの方法は採用しない。


## 方法1: 既存の機能のみで実装する
#### 作戦
- `a`は`PostDashboard`で実行
- `b`は行わない
- `c`は行わない

#### メリット
ファイルやコードを追加する必要がない。既存の機能のみで実装可能。

#### デメリット
テキストのカスタマイズができない。ロカライズできない。

`collection`オプションにネストされていない配列を渡すことでドロップダウンを作ることできるので、Enumの値を直接ドロップダウンの選択肢に出してよいのであれば、Selectフィールドを使うだけでできる。

```ruby:app/dashboards/post_dashboard.rb
ATTRIBUTE_TYPES = {
  status: Field::Select.with_options(collection: Post.statuses.keys)
}
```
ドロップダウンに現れる選択肢は"submitted", "approved", "rejected"になる。

```html:Output(admin/posts/new)
<select name="post[status]" id="post_status">
  <option selected="selected" value="submitted">submitted</option>
  <option value="approved">approved</option>
  <option value="rejected">rejected</option>
</select>
```

```html:Output(admin/posts)
<td class="cell-data cell-data--select">
  <a href="/admin/posts/202" class="action-show">
    submitted
  </a>
</td>
```

```html:Output(admin/posts/1)
<dd class="attribute-data attribute-data--select">submitted
</dd>
```

しかし、RailsのFormヘルパーのように、選択肢のテキストと値のペアの配列(e.g. [['Submitted', 'submitted'], ['Approved', 'approved'], ['Rejected', 'rejected']])を渡すことはできないので、テキストをいじることはできない。

要件次第で採用する。

## 方法2: カスタムビューを作る
#### 作戦
- `a`はPostで実行
- `b`はPostでメソッド定義してPostDashboardで実行
- `c`は_index/_showで実行

#### メリット
カスタムフィールドを作る必要がない。データを処理するコードがモデルとビューだけに置かれるので流れが明確。`collection`オプションに通常の配列も配列の配列も渡せるので、RailsのFormヘルパーのような使い心地になる。

#### デメリット
既存のフィールドのカスタマイズを行うので、デフォルトの挙動と異なることになる。チームで開発する場合は、他の誰かがハマる可能性があり、コメントやドキュメントを残すなどコミュニケーション手段が必要。

#### 実装
```zsh:Terminal
rails g administrate:views:field select
```

```ruby:app/models/post.rb
def self.localized_statuses
  I18n.t('activerecord.attributes.post/status')
end

# あるいは
def self.localized_statuses
  Post.statuses.keys.map {|k| [k, Post.human_attribute_name("post.status.#{k}")] }
end
```

```ruby:app/dashboards/post_dashboard.rb
status: Field::Select.with_options(
  collection: Post.localized_statuses.invert
)
```

```erb:app/views/fields/select/_form.html.erb
<div class="field-unit__label">
  <%= f.label field.attribute %>
</div>
<div class="field-unit__field">
  <%= f.select(
    field.attribute,
    field.collection,
    { selected: field.data.presence }
  ) %>
</div>
```

```erb:app/views/fields/select/_show.html.erb
<%= field.resource.class.human_attribute_name("#{field.resource.class.name.underscore}.#{field.attribute}.#{field.data}") %>
```

```erb:app/views/fields/select/_index.html.erb
<%= field.resource.class.human_attribute_name("#{field.resource.class.name.underscore}.#{field.attribute}.#{field.data}") %>
```

ちょっともう書くのが面倒くさくなって実装がテキトーになったが、上のような感じ。


## 方法3: カスタムフィールドを作る

すでに記事がいくつかある。

#### [Administrate で enumフィールドを多言語化して表示する](https://qiita.com/ya_s_u/items/dbae204ddbc106b1aee8)

- `a`はEnumで実行。
- `b`はEnumでメソッド定義してビューで実行。
- `c`については言及がない。
- コードの見通しや再利用性から言ってベストかどうかわからない

#### [administrate でenum 専用のfield を作ったよ](https://qiita.com/gessy0129/items/8fe2e9d2b2e9fe194702)

- `a`はPostで実行。
- `b`はEnumでメソッド定義してEnumの_formで実行。
- `c`は_index/_showで実行。
- Enumクラスを作る意味が薄い。
- 関係ないことだが、localeのYAMLファイルでのenumの書き方がRailsの慣習に従っていなくて、あとでカオスになる可能性あり。

#### 作戦
前述の通り、方法はいくつかあるが、せっかくEnumクラスを作ったとしたら、全部Enumクラスで処理したい。

- `a`はEnumで実行。
- `b`はEnumで定義して_formで実行
- `c`はEnumで定義して_index/_showで実行

#### メリット
`Field::Enum`と書ける自己満足感に浸れる。処理のコードが一つにまとまる。デフォルトでEnumクラスがないので、「カスタムフィールドですよ」と一目でわかる。

#### デメリット
手数が増える。管理するファイル・コードが増える。コードの再利用性が高くない（I18n関連のコードはアプリの他の部分でも使うと思うので、モデルにあったほうが良いと思うという意味で）。

#### 実装

```zsh:Terminal
bundle exec rails g administrate:field enum
```

```ruby:app/fields/enum.rb
require "administrate/field/select"

class Enum < Administrate::Field::Select
  def self.searchable?
    true
  end

  def selectable_options
    collection
  end

  def label_data
    translate data
  end

  private

  def collection
    @collection ||= options.fetch(:collection, default_collection)
  end

  def i18n_path
    options.fetch(:i18n_path, nil)
  end

  def pluralized_attribute
    options.fetch(:method, default_pluralized_attribute)
  end

  def translate(key)
    return I18n.t("#{i18n_path}.#{key}", default: key) if i18n_path
    default_translation
  end

  def default_collection
    resource.class.send(pluralized_attribute).map { |k, _| [translate(k), k] }
  end

  def default_pluralized_attribute
    attribute.to_s.downcase.pluralize
  end

  def default_translation
    resource.class.human_attribute_name("#{attribute}.#{key}")
  end
end
```

```erb:app/views/fields/enum/_form.html.erb
<div class="field-unit__label">
  <%= f.label field.attribute %>
</div>
<div class="field-unit__field">
  <%= f.collection_select(
    field.attribute,
    field.selectable_options,
    :last,
    :first,
    { value: field.data.presence }
  ) %>
</div>
```

```erb:app/views/fields/enum/_show.html.erb
<%= field.label_data %>
```

```erb:app/views/fields/enum/_index.html.erb
<%= field.label_data %>
```

#### 使用例
```ruby:app/dashboards/post_dashboard.rb
ATTRIBUTE_TYPES = {
  status: Field::Enum
  # status: Field::Enum(
  #   collection: [["custom", 1], ["collection", 2]], 
  #   i18n_path: "some.custom.path", 
  #   method: "custom_enum_methods"
  # ) というような使い方もできるようになります。
}
```

## 方法4: PRを送った
そもそも、AdministrateのSelectフィールドのコードを修正すれば、開発者が各々でカスタムフィールドを作るような無駄な作業を繰り返す必要がないので、PRを送った。マージされるかわからないが、[やり方はこんな感じになる](https://github.com/thoughtbot/administrate/pull/1220)。

#### 基本的な使い方

```ruby
status: Field::Select.with_options(collection: [ ['Pending',1], ['Approved',2], ['Rejected',3] ])
```

#### Enumを扱う場合

```ruby
status: Field::Select.with_options(collection: Post.localized_statuses.invert)
```

```ruby
class Post < ApplicationRecord
  enum status: { submitted: 0, approved: 1, rejected: 2 }
  def self.localized_statuses
    I18n.t 'activerecord.attributes.post/status'
  end

  # あるいは
  # def self.localized_statuses
  #   Post.statuses.keys.map {|k| [k, Post.human_attribute_name("post.status.#{k}")] }
  # end
end
```

```yaml
activerecord:
  attributes:
    post/status:
      submitted: "承認待ち"
      approved: "承認済み"
      rejected: "取り消し"
```

いまのところ、Administrateはフィールドごとに検索機能の挙動をカスタマイズすることを想定しておらず、そこが機能拡張のネックになっている部分もあるので、修正されたら良いなと思う。時間があれば修正したい。

## おわりに
スピードが優先なら方法1、普通にやるなら方法2、チームでやるなら方法2か3、もしPRがマージされれば方法4。Administrateは便利ですが、まだ色々整っていない部分もあります。PR送ってみるといいかもしれませんよ。