プロジェクト

全般

プロフィール

Redmine Glossaryプラグイン再構築

はじめに

Glossasry(用語集)プラグインは、Redmineのプラグインとしてはかなり初期の頃(Redmine 1.0.x、2010年)から公開されている、用語集(データ辞書)を作成・管理するプラグインです。用語集の作成は、Redmine標準のWiki機能を使って頑張ればプラグインを導入しなくてもできなくはないですが、このプラグインは、日本語・英語・略語展開・ルビ・説明・コーディング時名称といった枠で統一して管理でき、インデックスが自動生成され、用語の一覧詳細管理が容易にできるので、大変便利です。詳しい機能は次のプラグイン説明ページに記載されています。

Glossaryプラグインの説明

Glossaryプラグインのメインテナンスはオリジナルの作者によってRedmine 1.2.x まで行われていました。それ以降のRedmineバージョンアップに際しては、オリジナルの作者によるメインテナンスは停止しているので、GlossaryプラグインをフォークしてRedmine 2.0や3.0に対応したものが登場しています。筆者も次のリポジトリにフォークしたRedmine 3.x対応版を作ってします。

https://github.com/torutk/redmine_glossary

このRedmine Glossaryプラグイン・フォーク版は、現在Redmine 3.4で何とか動いています(細部機能では動かないものがちらほら)。しかし、Redmineの次のメジャーバージョンアップであるRedmine 4.0ではベースとなるRuby on Railsのバージョンが4.2から5.1に更新され、これまでのRedmineプラグインが利用してきた機能(API)が使えなくなる等、相当に手を入れないと動かない状況が見えています。

redmine trunkを落としてglossary pluginが動くか試してみる
redmine trunkを落としてglossary pluginが動くか試してみる(続)
redmine trunkを落としてglossary pluginが動くか試してみる(続々)
redmine trunkを落としてglossary pluginが動くか試してみる(続々々)

目的

Glossaryプラグインを、Redmine 4.0で利用できるようにすることを目的とします。

目標

これまでのRedmineバージョンアップにGlossaryプラグインを対応させる作業では、Redmineのバージョンアップ版にGlossaryプラグインを入れて、Glossaryプラグインがエラーとなる箇所を修正するアプローチで何とか対応してきました。しかし、来たるRedmine 4.0でGlossaryプラグインを動かそうとすると、相当に手を入れる必要があることが前述の試し打ちで判明しています。というかRuby、Ruby on Rails、RedmineプラグインAPIに対する相応の理解を要し、筆者の生半可な知識では挫折してしまいました。

そこで、今回Redmine 4.0にGlossaryプラグインを適応させるにあたり、Ruby、Redmineプラグイン、およびRuby on Railsの開発に習熟することとし、その方法として、Glossaryプラグインをゼロから再構築することで開発への習熟とGlossaryプラグインのRedmine 4.0対応能力を獲得することを狙います。

再構築の計画

Glossaryプラグインの再構築を行うにあたり、プラグイン作成技術をステップ・バイ・ステップで段階的に獲得しながら、機能の実装を進める計画とします。

  1. 用語モデルと一覧表示(プロジェクトの縛りなし)
  2. 国際化対応
  3. 一覧表示→詳細表示
  4. 用語の作成・変更
  5. カテゴリの導入
  6. プロジェクトの縛りを入れる
  7. 右サイドバー表示(設置、索引、新規作成リンクなど)
  8. セキュリティ、権限の導入
  9. 用語の属性を充実
  10. 用語をグループごとに分類表示
  11. 索引に日本語(あいうえお順)追加
  12. ファイル添付
  13. 活動への用語集変更イベント表示
  14. 用語説明のwiki表示化
  15. CSSで見栄え制御
  16. マクロ
  17. テスト(モデル)
  18. テスト(コントローラー)
  19. テスト(統合)
  20. 機能向上いろいろ
  21. 再構築前のデータベースマイグレーション
  22. 用語集を検索可能にする
  23. CSVファイルのインポート
  24. CSVファイルのエクスポート
  25. カテゴリ一覧に用語割当て数を表示
  26. バリデーション
  27. セキュリティ、権限の厳格化

プラグイン開発環境の準備は、Redmineプラグイン開発環境 を参照ください。

再構築の実施

再構築で使用するリポジトリ(Github)は次です。
https://github.com/torutk/redmine_glossary/tree/reconstruct


フェーズ1)最小限の用語モデルと一覧表示

オリジナルのGlossaryプラグインでは、モデルクラスとしてTerm、Category、GlossaryStyleの3つを使用します。フェーズ1では最小限のMVC構造を持つプラグインとして、1つのモデルクラス、1つのコントローラークラス、一覧表示のビューだけを作成します。

フェーズ1の概略

フェーズ1として次を作成します。

No クラス(役割) ファイル 内容
1 プラグイン情報 init.rb プラグイン情報・初期設定を記述
2 GlossaryTerm glossary_term.rb 用語モデルクラス
3 マイグレーション 001_create_glossary_terms.rb 用語モデルのテーブル作成
4 GlossaryTermsController glossary_terms_controller.rb 用語コントローラークラス
5 用語一覧のビュー index.html.erb 用語一覧を表示する埋め込みruby
6 ルーティング設定 routes.rb HTTPリクエストのURLに対応するコントローラー/メソッドの割当て
  • プラグイン名は、redmine_glossaryです(オリジナルと一緒)。
  • モデルクラス名は、オリジナルのクラス名はTermです。モデルクラス名とそれにちなむデータベースのテーブル名は、Redmine本体および全プラグインでフラットな空間に置かれるので、単純な名前では衝突可能性が高くなります。また、モデルクラス名およびテーブル名を見たときにどのプラグインのものか分かりにくいです。そこで、モデルクラス名をGlossaryTermとしました。
  • フェーズ1ではGlossaryTermモデルの持つ属性は最低限とし、用語名(name)、説明(description)、作成日時(created_at)、更新日時(updated_at)を持たせます。
  • コントローラークラス名は、モデル名の複数形にちなんでGlossaryTermsControllerとします。
  • フェーズ1ではGlossaryTermsControllerコントローラーの持つアクションは最低限とし、indexを持たせます。
  • i18n対応はフェーズ2とします。

データを作る機能はフェーズ1では用意しないので、動作確認時は、Railsのコンソール上でrubyのコードを入力実行してデータを作成します。


プラグイン雛形の生成

Redmineプラグインの雛形を生成するコマンドを実行し、redmine_glossaryプラグインの雛形を生成します。

redmine$ bundle exec rails generate redmine_plugin redmine_glossary
      create  plugins/redmine_glossary/app
      create  plugins/redmine_glossary/app/controllers
      create  plugins/redmine_glossary/app/helpers
      create  plugins/redmine_glossary/app/models
      create  plugins/redmine_glossary/app/views
      create  plugins/redmine_glossary/db/migrate
      create  plugins/redmine_glossary/lib/tasks
      create  plugins/redmine_glossary/assets/images
      create  plugins/redmine_glossary/assets/javascripts
      create  plugins/redmine_glossary/assets/stylesheets
      create  plugins/redmine_glossary/config/locales
      create  plugins/redmine_glossary/test
      create  plugins/redmine_glossary/test/fixtures
      create  plugins/redmine_glossary/test/unit
      create  plugins/redmine_glossary/test/functional
      create  plugins/redmine_glossary/test/integration
      create  plugins/redmine_glossary/README.rdoc
      create  plugins/redmine_glossary/init.rb
      create  plugins/redmine_glossary/config/routes.rb
      create  plugins/redmine_glossary/config/locales/en.yml
      create  plugins/redmine_glossary/test/test_helper.rb
redmine$

およそのディレクトリの説明を次にのべます。

  • plugins/redmine_glossary/
    プラグインの基点となるディレクトリです。名前に意味があり、名前を変えると設定ファイルと齟齬が発生しエラーとなります。また、開発作業上、一時名前を変えておくなどするとこれもエラーとなるので注意が必要です。
  • plugins/redmine_glossary/app/
    プラグインのコードを置くディレクトリのトップです。この下にMVC構造に合わせてmodels, views, controllersの各ディレクトリと、viewsのコードから利用するユーティリティ系コード用のhelpersディレクトリが置かれます。
  • plugins/redmine_glossary/db/migrate/
    データベースのスキーマを作成・変更するマイグレーション用スクリプトが置かれます。
  • plugins/redmine_glossary/lib/
    コントローラーやモデルなどから共通で利用するライブラリ系コード用のディレクトリです。
  • plugins/redmine_glossary/assets/
    HTMLの表示で使用するスタイルシートを置くstylesheetsディレクトリ、JavaScriptを置くjavascriptsディレクトリ、画像を置くimagesディレクトリなどが置かれます。
  • plugins/redmine_glossary/config/
    ルーティング設定ファイルroutes.rb、国際化対応の翻訳ファイルを置くlocalesディレクトリが置かれます。
  • plugins/redmine_glossary/test/
    テスト用コードが置かれます。

プラグイン設定ファイルの修正

生成された雛形に含まれるinit.rbのプラグイン情報の記述を、作成するプラグインの記述となるよう修正します。

Redmine::Plugin.register :redmine_glossary do
  name 'Redmine Glossary plugin'
  author 'Toru Takahashi'
  description 'This is a plugin for Redmine to create a glossary that is a list of terms in a project.'
  version '1.0.1'
  url 'https://github.com/torutk/redmine_glossary'
  author_url 'http://www.torutk.com'
end

Redmineを起動し[管理]メニュー > [プラグイン]をクリックし、上述init.rbの内容が表示されていることを確認します。

administration_plugin-1.png

rubyの書き方でびっくりしないために

init.rbも拡張子から類推されるようにRubyのコードです。
このファイルにあるrubyのコードは、なかなかに面喰う書き方です。また、rubyに慣れていないと他の言語の経験から類推できないこともあります。

まず、次の記述から見ていきましょう。

Redmine::Plugin.register :redmine_glossary do
  :
end

  • Redmineはモジュールです。Pluginは、Redmineモジュールの中に定義されるクラスです。よって、モジュール外からPluginクラスを参照するには、定数参照の演算子ダブルコロン(::)を用いて、Redmine::Pluginと参照します。Rubyでは、モジュールやクラス定義も定数と扱われるので、定数参照演算子を使用しています。
  • registerPluginクラスのメソッドです。メソッド呼び出しの演算子ピリオド(.)を用います。
  • registerメソッドの書式は次のとおりです。
    register(id, &block) => Object

    rubyではメソッド呼び出しの引数を囲む丸括弧を省略できるので、registerの直後に丸括弧が省略され、引数が並んでいます。
  • registerメソッドの第1引数は:redmine_glossaryです。
    :redmine_glossaryは先頭文字にコロン(:)が付いています。これは、文字列"redmine_glossary"をシンボル化するもので、以降プログラム中で:redmine_glossaryを使用すると同じ領域を参照(つまり一致)します。一方、文字列"redmine_glossary"を使用するとそれぞれ別のオブジェクトとなり一致しません。そのため、ハッシュのキーなど一致が必要な場合はシンボルを用います。
  • registerメソッドの第2引数はブロックで、do~endでブロックを定義し引数として渡しています。

次にブロック内の記述を見ていきましょう。

  name 'Redmine Glossary plugin'
  author 'Toru Takahashi'
  :

  • do~endのブロックの中は、Pluginクラスのdef_fieldで定義されたセッターメソッドの呼び出しが列挙されています。やはりメソッドの引数の括弧が省略されています。
  • rubyでの文字列は、シングルクォート・ダブルクォートどちらでも囲めます。ダブルクォートで囲うと、#{..}の中の式が展開されます。シングルクォートの場合は展開されません。init.rbではシングルクォートを使って文字列を表していますが、ここはどちらでも構いません。
  • セミコロンは不要です。

モデルクラスの作成

モデルクラスとマイグレーションファイルを生成します。モデルクラスはデータベースに情報を永続化するので、モデルクラスを作成するときはその対となるデータベースのテーブルを作成するマイグレーションファイルを作成します。

まず、Redmineプラグインのモデル雛形を生成するコマンドを実行し、redmine_glossaryプラグインのディレクトリの中にモデルクラスGlossaryTermとマイグレーションファイルの雛形を生成します。

redmine$ bundle exec rails generate redmine_plugin_model redmine_glossary GlossaryTerm name:string description:text                                                                               
      create  plugins/redmine_glossary/app/models/glossary_term.rb                                      
      create  plugins/redmine_glossary/test/unit/glossary_term_test.rb                                  
      create  plugins/redmine_glossary/db/migrate/001_create_glossary_terms.rb                          
redmine$

Ruby on Railsでは、モデルクラスはデータベースのテーブルに対応し、属性がテーブルのカラムに対応します。上述の生成コマンドでは属性名と型(カラム名と型に対応)を指定します。string型はおよそ255文字(バイト?)以下の文字列、text型は不定長の文字列に対応します。
カラムは後から変更もできるので、あまり気にせず必要そうなものを生成しておきます。

生成された雛形の中身は次です。まずはモデルクラスです。

  • app/models/glossary_term.rb
    class GlossaryTerm < ActiveRecord::Base                                          
    end
    

雛形生成コマンドで指定した名前のクラスが生成されます。ActiveRecord::Baseクラスを継承することで、RailsのActiveRecordの基本機能を保持します。モデルクラスは空ですが、対応するデータベースのカラムを属性として持ち、その属性の読み出し、書き込みメソッドが使用できます。

次は生成されたマイグレーションファイルの雛形です。

  • db/migrate/001_create_glossary_terms.rb
    class CreateGlossaryTerms < ActiveRecord::Migration[5.1]
      def change
        create_table :glossary_terms do |t|
          t.string :name
          t.text :description
        end
      end
    end
    
  • Redmineのマイグレーションファイルのファイル名は、3桁の数字が付く規約です。001から順番に数字を増やしていきます。プラグインの進化でスキーマを変更するときは、既存のマイグレーションファイルを変更するのではなく、既存のマイグレーションファイルに付け加える処理を3桁の数字を増やして新しいマイグレーションファイルに記述します。
  • データベースに作成されるテーブル名(glossary_terms)は、モデルクラス名(GlossaryTerm)をスネークケースにし複数形にしたものとなります。
  • Redmine 4.0(Rails 5.1)からは、継承するクラスの指定にActiveRecord::Migrationだけでなく、バージョンに関する情報が必要になります。
  • rubyでメソッドを定義するキーワードはdefとなります。ここではchangeメソッドを定義しています。
  • create_tableメソッド呼び出し式では、丸括弧が省略されまくっているので、ruby(あるいは同様にメソッド呼び出しで引数の括弧が省略できる言語)に慣れないと把握が難しいです。
  • create_tableメソッドでは第2引数のブロックで縦棒記号(@)が使われています。これはブロックパラメータで、@create_tableメソッドで生成されたテーブルオブジェクトが(たぶん)入ります。ブロックの中でこのオブジェクトを変数名tでアクセス可能です。

フェーズ1では、雛形に次を追加します。

  • カラムに作成日時・更新日時を追加
  • nameカラムにNOT NULL制約を追加
  • 作成日時・更新日時にNOT NULL制約を追加

001_create_glossary_terms.rb を修正します。

  class CreateGlossaryTerms < ActiveRecord::Migration[5.1]
    def change
      create_table :glossary_terms do |t|
-       t.string :name
+       t.string :name, null: false
        t.text :description
+       t.timestamps null: false
      end
    end
  end                                                                         

マイグレーションファイルで、t.timestampsと記述すると、カラムcreated_atupdated_atが生成されます。
NOT NULL制約は、null: falseを追記します。

Rails 5からは、timestampsはデフォルトでNOT NULL制約が課せられます。上述マイグレーションコードでt.timestamps行のnull: false部分は記述不要です。

マイグレーションを実行し、データベースにテーブルを作成します。

redmine_glossary$ bundle exec rails redmine:plugins:migrate                                     
Migrating redmine_glossary (Redmine Glossary plugin)...                                                 
== 1 CreateGlossaryTerms: migrating ===========================================                         
-- create_table(:glossary_terms)                                                                        
   -> 0.0091s                                                                                           
== 1 CreateGlossaryTerms: migrated (0.0121s) ==================================                         

redmine_glossary$

データベースのテーブルを確認します。まず、Redmineのデータベース上に存在するテーブルの一覧を表示させます。この中に、今回マイグレーションを実行して生成されるテーブル、glossary_termsがあることを確認します。

  • Redmineのデータベースのテーブル一覧を表示
    redmine_glossary$ sqlite3 ../../db/redmine.sqlite3
    SQLite version 3.8.10.2 2015-05-20 18:17:19
    Enter ".help" for usage hints.
    sqlite> .table
    ar_internal_metadata                 member_roles
    attachments                          members
    auth_sources                         messages
    boards                               news
    changes                              open_id_authentication_associations
    changeset_parents                    open_id_authentication_nonces
    changesets                           projects
    changesets_issues                    projects_trackers
    comments                             queries
    custom_field_enumerations            queries_roles
    custom_fields                        repositories
    custom_fields_projects               roles
    custom_fields_roles                  roles_managed_roles
    custom_fields_trackers               schema_migrations
    custom_values                        settings
    documents                            time_entries
    email_addresses                      tokens
    enabled_modules                      trackers
    enumerations                         user_preferences
    glossary_terms                       users
    groups_users                         versions
    import_items                         watchers
    imports                              wiki_content_versions
    issue_categories                     wiki_contents
    issue_relations                      wiki_pages
    issue_statuses                       wiki_redirects
    issues                               wikis
    journal_details                      workflows
    journals
    
  • glossary_termsテーブルのスキーマを確認
    sqlite> .schema glossary_terms
    CREATE TABLE "glossary_terms" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "description" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL);
    sqlite>
    

テーブルglossary_termsのスキーマには、モデル作成時にコマンドラインで指定したname、description、マイグレーションファイルに追記したt.timestampsによって生成されたcreated_at、updated_at、およびデフォルトで生成されるidが見られます。
NOT NULL制約を指定したnameとtimestamps(で生成されるcreated_atとupdated_at)には、NOT NULLが付いています。

railsのコマンドラインを起動し、GlossaryTermモデルのインスタンスを生成してみましょう。

redmine_glossary$ bundle exec rails console
irb(main):001:0> GlossaryTerm.new
=> #<GlossaryTerm id: nil, name: nil, description: nil, created_at: nil, updated_at: nil>

irb(main):002:0> term1 = GlossaryTerm.new(name: 'alfa', description: 'Phonetic code of "A".')
=> #<GlossaryTerm id: nil, name: "alfa", description: "Phonetic code of \"A\".", created_at: nil, updated_at: nil>
irb(main):003:0> term1.save
   (0.1ms)  begin transaction
  SQL (6.3ms)  INSERT INTO "glossary_terms" ("name", "description", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "alfa"], ["description", "Phonetic code of \"A\"."], ["created_at", "2018-05-03 14:04:16.394566"], ["updated_at", "2018-05-03 14:04:16.394566"]]
   (30.8ms)  commit transaction
=> true

モデルクラスGlossaryTermのnewメソッドを呼び出し、そのインスタンスを生成します。属性の指定はnewの引数で渡せます。
生成したインスタンスはsaveメソッドを呼ぶことでデータベースのテーブルに追加できます。

GlossaryTermクラスのインスタンスをもう一つ作成します。

irb(main):004:0> term2 = GlossaryTerm.new(name: 'bravo', description: 'Phonetic code of "B".')
=> #<GlossaryTerm id: nil, name: "bravo", description: "Phonetic code of \"B\".", created_at: nil, updated_at: nil>
irb(main):005:0> term2.save
   (0.4ms)  begin transaction
  SQL (6.4ms)  INSERT INTO "glossary_terms" ("name", "description", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "bravo"], ["description", "Phonetic code of \"B\"."], ["created_at", "2018-05-03 14:16:35.539337"], ["updated_at", "2018-05-03 14:16:35.539337"]]
   (29.5ms)  commit transaction
=> true

created_atとupdated_atの日時はどのタイミングで付くのか確認をするため、newとsaveの間に時間を置いてみました。その結果、日時はsaveメソッド実行時点になっていることが確認できました。

SQLite3にSQLのSELECT文を使ってテーブルの内容を確認します。

sqlite> select * from glossary_terms;
1|alfa|Phonetic code of "A".|2018-05-03 14:04:16.394566|2018-05-03 14:04:16.394566
2|bravo|Phonetic code of "B".|2018-05-03 14:16:35.539337|2018-05-03 14:16:35.539337

と、2件のレコードが格納されていることが分かります。

rubyの書き方でびっくりしないために(2)
  • ハッシュ記法
    マイグレーションファイルでNOT NULL制約を指定するときに次の書き方をしました。
    t.string :name, null: false
    

    ここで、null: falseは、シンボル:nullをキーに、falseを値に取るハッシュとなります。Rubyでは当初はハッシュの書き方は次のように=>演算子を使って:null => falseとしていました。しかし、簡便のため、=>ではなく、キーに指定するデータ名の末尾にコロン(:)を付け、値を指定します。キーに指定したものが文字列ならば、先頭にシンボル化のためのコロンがなくてもシンボルとして扱われます。

コントローラークラスの作成

コントローラークラスを生成します。Redmineプラグインのコントローラー雛形を生成するコマンドを実行し、redmine_glossaryプラグインの中にコントローラークラスGlossaryTermsControllerの雛形を生成します。

redmine_glossary$ bundle exec rails generate redmine_plugin_controller redmine_glossary glossary_terms index
      create  plugins/redmine_glossary/app/controllers/glossary_terms_controller.rb
      create  plugins/redmine_glossary/app/helpers/glossary_terms_helper.rb
      create  plugins/redmine_glossary/test/functional/glossary_terms_controller_test.rb
      create  plugins/redmine_glossary/app/views/glossary_terms/index.html.erb

モデルに対応するコントローラーを生成する場合は、モデルクラスの複数形を名前に指定します。
対応するアクションを指定します。今回は一覧表示だけ実装するのでindexだけを指定しています。これも後から追加できるので必要なものだけでも指定しておきます。

コントローラークラスと指定したアクションに対応するビューが生成されました。生成された雛形の中身は次です。

  • app/controllers/glossary_terms_controller.rb
    class GlossaryTermsController < ApplicationController
    
      def index
      end
    end
    
  • app/views/glossary_terms/index.html.erb
    <h2>GlossaryTermsController#index</h2>
    
  • app/helpers/glossary_terms_helper.rb
    module GlossaryTermsHelper
    end
    

コントローラーのindexが呼ばれると、index.html.erbが表示されます。


ルーティング設定

プラグインの雛形生成時に作られたroutes.rbは次の内容です。

# Plugin's routes
# See: http://guides.rubyonrails.org/routing.html

コメントのみで内容は空です。
ルーティング設定はrailsのコマンドラインで次のコマンドで確認できます。

redmine_glossary$ bundle exec rails routes
    Prefix Verb             URI Pattern           Controller#Action
    home GET                /                     welcome#index
    signin GET|POST         /login(.:format)      account#login
    :(以下略)

HTTPリクエストのURL、コマンドと対応するコントローラー名・アクション名の対応が表示されます。
ここに、今作成したコントローラーとアクションを定義します。

追加したいルーティング設定は次です。

HTTPメソッド URLパス コントローラー#アクション
GET /glossary_terms glossary_terms#index

routes.rbに次の記述をします。

Rails.application.routes.draw do
  resources :glossary_terms, only: [:index]
end

resources で、リソース名(モデルクラスの複数形:小文字スネークケース)を指定すると、リソース名へのURLアクセスを対応するコントローラーのアクションにルーティングする設定が生成されます。特定のアクションに限定する場合、only でアクションを指定します。

上述ルーティング設定を記述後、ルーティング設定の内容を確認すると次が追加されていました。

redmine_glossary$ bundle exec rails routes
    :(中略)
    glossary_terms GET      /glossary_terms(.:format)     glossary_terms#index

サーバーを起動し、Webブラウザから <サーバー名>:3000/glossary_terms へアクセスします。

glossary_terms_index-1.png


用語の一覧表示を追加

モデルクラスから用語一覧を取り出し、ビューにそれを表示させる実装を書いていきます。

まず、GlossaryTermsControllerのindexメソッドが呼ばれたら、モデルから用語一覧を取り出してコントローラーのインスタンス変数にセットします。

  • app/controllers/glossary_terms_controller.rb
      def index
        @terms = GlossaryTerm.all
      end
    
  • index.html.erbに用語を表形式(<table>)で表示するHTMLと埋め込みRubyスクリプトを記述します。
    <table>
      <thead>
        <tr>
          <th>name</th>
          <th>description</th>
        </tr>
      </thead>
      <tbody>
        <% @terms.each do |term| %>
        <tr>
          <td>
            <%= term.name %>
          </td>
          <td>
            <%= term.description %>
          </td>
        </tr>
        <% end %>
      </tbody>
    </table>
    

再度Webブラウザからglossary_termsへアクセスすると、データベースに格納された用語の内容が一覧表示されます。

glossary_terms_index-2.png

罫線がない、色が付いていない、など見栄えは悪いですがデータ内容は表示できています。


ちょっとだけ見栄えをよく

Redmineの他の画面での一覧表示に見栄えを合わせたいので、Redmineのカスケード・スタイルシート(CSS)の設定に合わせて、先ほど作成したindex.html.erbのHTML記述にCSSのセレクタに引っ掛けられるようクラスを追記します。

RedmineのCSSは、Redmineルートディレクトリから、public/stylesheets/application.cssに記載されています。
このCSSの中から一覧表示(HTMLのtable)に関わるCSSを調べます。すると、

table.list, .table-list { border: 1px solid #e4e4e4;  border-collapse: collapse; width: 100%; margin-bottom: 4px; }
table.list th, .table-list-header { background-color:#EEEEEE; padding: 4px; white-space:nowrap; font-weight:bold; }
table.list td {text-align:center; vertical-align:middle; padding-right:10px;}
table.list td.id { width: 2%; text-align: center;}
table.list td.name, table.list td.description, table.list td.subject, table.list td.comments, table.list td.roles, table.list td.attachments {text-align: left;}
  :以下略

のように記載があります。tableタグにクラスlistを指定すれば、Redmineのapplication.cssで用意されたCSSが適用されそうです。

  • index.html.rb の修正
    <h2>GlossaryTermsController#index</h2>
    
    <table class="list">
      <thead>
        <tr>
          <th>name</th>
          <th>description</th>
        </tr>
      </thead>
      <tbody>
        <% @terms.each do |term| %>
        <tr>
          <td class="name">
            <%= term.name %>
          </td>
          <td class="description">
            <%= term.description %>
          </td>
        </tr>
        <% end %>
      </tbody>
    </table>
    

画面表示は次のようになりました。

glossary_terms_index-3.png


フェーズ1の実装完了とフェーズ2へ

フェーズ1として、最小限のMVC構造を持つ用語一覧表示プラグインを実装しました。フェーズ1で実施したことを簡潔にまとめます。

  • プラグインの雛形の生成(rails generate redmine_pluginコマンド)
  • プラグイン情報の記述(init.rbの記述)
  • モデルの生成(rails generate redmine_plugin_modelコマンド)
  • マイグレーションの追記と実行(001_create_glossary_terms.rbの記述)
  • コントローラーの生成(rails generate redmine_plugin_controllerコマンド)
  • 一覧表示の実装(glossary_terms_controller.rbindex.html.erbの記述)
  • ルーティング設定の記述(routes.rbの記述)

国際化対応をやり残したので、フェーズ2を国際化対応とします。


フェーズ2)国際化対応

フェーズ1では、ビューのテキストがそのまま表示される、べた書きで記述していました。一方で、Redmineは画面の各所が国際化対応により各国語に翻訳されて表示されます。そこで、ビューを多数作りこむ前に先に国際化対応をしておきます。以後、ビューを追加するときは極力国際化対応する方針とします。

フェーズ2の概略

Redmine(Rails)では、画面に表示するテキストを国際化対応する仕組みが用意されています。
プラグインの雛形を生成したときに、config/localesディレクトリが作られ、その中にデフォルトでen.ymlが置かれ、ハッシュ構造でキーとテキストが定義されています。そして、翻訳したいロケール毎にja.ymlのようにファイルを作り、キーはen.ymlと同じくして、テキスト部分をそのロケールの言葉に書き換えます。

フェーズ2として、次を作成または修正します。

No 役割 ファイル 内容
1 翻訳ファイル(英語) en.yml 英語ロケール用のキーとテキストの定義
2 翻訳ファイル(日本語) ya.yml 日本語ロケール用のキーとテキストの定義
3 用語一覧のビュー index.html.erb 用語一覧のビューを国際化

フェーズ2では、フェーズ1の一覧表示画面から、表のタイトル、表の列名のテキストを国際化対応させ、英語および日本語のリソースを用意します。ただし、表の列名(nameとdescription)はRedmine本体にあるものを利用します。

国際化対応対象テキスト キー名 定義場所
一覧表のページタイトル label_glossary glossary plugin
表の名前列 field_name redmine本体
表の説明列 field_description redmine本体

国際化対応の仕組み

config/localesディレクトリ下にある拡張子.ymlおよび.rbファイルが読み込まれ、ロケール毎にキーと値で管理されます。
例を次に示します。

en:
  general_text_No: 'No'
  general_text_Yes: 'Yes'

ロケールen: において、キーgeneral_text_Noは、テキストNoに対応します。
Rubyのコードにおいて、I18n.translate(:general_text_No)と参照すると、ロケールenでは、Noのテキストに置き換わります。
また、日時等をロケールに応じた表記とするI18n.localizeメソッドもあります。

Railsの国際化対応の仕組み

Railsでは、ビューのヘルパーメソッドとして、I18n.translateI18n.localizeの省略表記tおよびlが提供されています。よって、次のように記述することができます。

<%=t :general_text_No %>
Redmineの国際化対応の仕組み

Redmineでは、lib/redmine/i18n.rb が用意され、Railsの国際化対応をラップした構造となっています。Railsのヘルパーメソッドの1つ"l"と重なった命名ですが、lメソッドが用意され、引数の数によって次のような仕組みになっています。

    def l(*args)
      case args.size
      when 1
        ::I18n.t(*args)
      when 2
        if args.last.is_a?(Hash)
          ::I18n.t(*args)
        elsif args.last.is_a?(String)
          ::I18n.t(args.first, :value => args.last)
        else
          ::I18n.t(args.first, :count => args.last)
        end
      else
        raise "Translation string with multiple values: #{args.first}" 
      end
    end

簡単にまとめると、次となります。

  • <%=l :general_text_No %>
    引数が1つのときは、そのままRailsのtメソッドに引数を渡す
  • <%=l :hello_message_with_name, name: "Smith" %>
    引数が2つ以上で2つ目以降がハッシュのときは、そのままRailsのtメソッドに引数を渡す
  • <%=l :hello_with_value, "Three" %>
    引数が2つで2つ目が文字列のときは、Railsのtメソッドに1つ目の引数はそのまま、2つ目の引数はキーvalueの値として渡す
  • <%=l :hello_with_count, 31 %>
    引数が2つで2つ目が数値のときは、Railsのtメソッドに1つ目の引数はそのまま、2つ目の引数はキーcountの値として渡す。
    countは、例えば英語で1と2以上で後ろの単語が単数形、複数形と変わるような場合に対応する。

このほか、redmine/i18n.rb には、format_dateformat_time、他日付・時刻関係のローカライズメソッドがいろいろ揃っています。


index.html.erb での国際化対応(1)

ハードコードされているテキストを、I18nヘルパーメソッドを使って国際化対応に置き換えます。
まずは、Redmine本体で定義されているキーfield_namefield_descriptionを指定し、ロケールに応じたテキストに置き換えます。

     <tr>
-      <th>name</th>
-      <th>description</th>
+      <th><%=l :field_name %></th>
+      <th><%=l :field_description %></th>
     </tr>

日本語環境で実行した結果が次の画面です。

index_i18n-1.png


index.html.erb での国際化対応(2)

次は、プラグインのlocalesに独自のキーとテキストを指定します。

  • config/locales/en.yml
    en:
      label_glossary_terms: "Glossary terms" 
    
  • config/locales/ja.yml
    ja:
      label_glossary_terms: "用語集" 
    

ビューを修正します。

  • index.html.erb
    -<h2>GlossaryTermsController#index</h2> 
    +<h2><%=l :label_glossary_terms %></h2> 
    

日本語ロケールで実行すると次の画面となります。

index_i18n-2.png


フェーズ2の実装完了とフェーズ3へ

フェーズ2では、国際化対応として表示画面中の表示文字列をロケールによって切り替える対応をしました。フェーズ2で実施したことを次に簡単にまとめます。

  • ビューの中で表示する文字列の部分を、埋め込みruby記述を使い、lメソッドを使ってシンボルを指定する呼び出しに置き換え
  • そのシンボルに対応する文字列をロケール別に(今回は英語と日本語の2つのロケール)定義
    ロケールの定義ファイルは、英語はen.yml、日本語はja.yml

フェーズ3では、一覧表示から用語をクリックするとその用語の詳細表示を行うようにします。


フェーズ3)一覧表示→詳細表示

一覧表示の名称列を、詳細表示へのリンクとし、詳細表示画面を作成します。
詳細表示は、コントローラー(GlossaryTermsController)のshowアクションとします。

フェーズ3の概略

フェーズ3として、次を作成または修正します。

No クラス(役割) ファイル 内容
1 ルーティング設定 routes.rb showアクションのルーティング追加
2 GlossaryTremsController glossary_terms_controller.rb showアクションの追加
3 用語詳細のビュー show.html.erb 用語詳細を表示する埋め込みruby
4 用語一覧のビュー index.html.erb 詳細へのリンクを追加
5 翻訳ファイル(英語) en.yml 詳細表示で使うテキストを追加
6 翻訳ファイル(日本語) ja.yml 詳細表示で使うテキストを追加

ルーティング設定の追加

routes.rbに、showアクションを追記します。

 Rails.application.routes.draw do
-  resources :glossary_terms, only: [:index]
+  resources :glossary_terms, only: [:index, :show]
 end

上述の追記によって、次のルーティングが追加されます。

redmine$ bundle exec rails routes
    :
    glossary_term GET     /glossary_terms/:id(.:format)      glossary_terms#show

Webブラウザから、<サーバー名>:3000/glossary_terms/24 のようにidを指定してGETリクエストをすると、GlossaryTermsControllershowメソッドにルーティングされます。


showアクションの追加

GlossaryTermsControllershowメソッドを追加し、処理を記述します。まずはメソッドを用意します。

  • app/controllers/glossary_terms_controller.rb
     class GlossaryTermsController < ApplicationController
       def index
         @glossary_terms = GlossaryTerm.all
       end
    +
    +  def show
    +  end
     end
    

showアクションでは、HTTPリクエストのURLパスにリソース名(ここではglossary_terms)とidが指定されます。このidを取り出し、データベース内のリソースに対応するテーブルから、指定されたidを持つレコードを取り出し、そのレコードからGlossaryTermインスタンスを作成してビューに渡すことがコントローラーの処理となります。ビューに渡すデータはインスタンス変数に格納します。そして、渡されたGlossaryTermインスタンスから表示を作るのがshowアクション時に実行されるビューshow.html.erbとなります。

[Webブラウザ]
    ↓  GETリクエスト(glossary_terms/24)
[GlossaryTermsController#show]
    ↓  id=24のGlossaryTermインスタンスを生成
[show.html.erb]
    ↓  id=24のGlossaryTermからHTMLを生成
[Webブラウザ]

コントローラーのアクションに対応するメソッドが呼ばれるとき、idを始め幾種類かのリクエストパラメーターがハッシュparamsに格納されています。idを取り出すには、params[:id]となります。

そこで、showメソッドの処理は次のように記述したくなります。(エラー処理は省略)

def show
  @term = GlossaryTerm.find(params[:id])
end

ところで、コントローラーにおいて指定されたidからモデルのインスタンスを取得する処理はshowアクションだけでなく、他のアクション(editなど)でも必要です。よいコードは重複を排除するので、idからモデルのインスタンスを取得する処理を別メソッドに切り出し、そのメソッドをshowアクション時に実行するようにします。Railsではベストプラクティスとして、before_actionを用いて実装します。

class GlossaryTermsController < ApplicationController

  before_action :find_term_from_id, only: [:show]
    :(中略)
  def find_term_from_id
    @term = GlossaryTerm.find(params[:id])
  end
end

before_actionで、アクションが実行される前(before)に実行するメソッド(ここではfind_term_from_id)を指定します。アクションは、onlyをキーに指定します。複数指定できるように配列でアクションを列挙します。

次にビューの実装をします。showアクションが実行されるときは、ビューはshow.html.erbが使用されるので新たにこのファイルを作成します。

  • app/views/glossary_terms/show.html.erb
    <h2><%=t :label_glossary_term %> #<%= @term.id %></h2>
    
    <h3><%= @term.name %></h3>
    
    <table>
      <tr>
        <th><%=t :field_description %></th>
        <td><%= @term.description %></td>
      </tr>
    </table>
    
  • 題名として、国際化対応テキストを使用します。キーをlabel_glossary_termとします(単数形に注意)。新たなキーを使うので、ロケールファイル(en.ymlとja.yml)にキーと翻訳テキストを追記します。
    • en.yml
       en:                                     
         label_glossary_terms: "Glossary terms"                                   
      +  label_glossary_term: "Glossary term"  
      
    • ja.yml
       ja:
         label_glossary_terms: "用語集" 
      +  label_glossary_term: "用語" 
      

これで詳細表示が実装できました。Webブラウザからidを指定したURLリクエストを行います。あらかじめデータベースに作成している用語のidをどれか指定します。
サーバー名:3000/glossary_terms/2

glossary_terms_show-1.png

ここで、idに存在しない番号を指定すると、次のようなエラー画面となってしまいます。

glossary_terms_show_error-1.png

そこで、idからモデルのインスタンスを取得する処理においてidに対応するレコードが存在しないときの例外を拾って404エラー画面を表示する処理を追加します。

   def find_term_from_id
     @term = GlossaryTerm.find(params[:id])
+  rescue ActiveRecord::RecordNotFound
+    render_404
   end

このエラー画面は次のようになります。

glossary_terms_show_error-2.png


一覧表示から詳細表示へのリンク

いよいよ、一覧表示の対象件名をクリックすると詳細表示を開くリンクを追加します。
リンクの追加には、link_toメソッドを利用します。このlink_toメソッドは、オプションの指定方法が極めて多彩なので、使い方を把握するのが大変ですがビューでは非常によく使うメソッドです。

凡その使い方は、

link_to リンク文字列, リンク先

となります。

今回は、リンク先がリソース(モデル)となっており、ルーティング設定済みで、showアクション(GET)を呼び出す場合なので、最も簡単な指定例(リンク先をリソースのオブジェクトで示す)が適用できます。

  • app/views/glossary_terms/index.html.erb
         <% @glossary_terms.each do |term| %>
         <tr>
           <td class="name">
    -       <%= term.name %>
    +       <%= link_to term.name, term %>
           </td>
    

リンク文字列には、用語(GlossaryTermオブジェクト)のname属性を指定し、リンク先には用語(GlossaryTermオブジェクト)自身を指定します。
表示される一覧画面は次のようになります。

glossary_terms_index-4.png

リンクのURLは、例えば次のようになります。

http://localhost:3000/glossary_terms/2


link_toの補足

ルーティング設定(routes.rb)にリソース指定(resources)をした場合、ルーティング設定にそのリソースに関する定義が追加されます。

Prefix           Verb      URI Pattern                       Controller#Action
glossary_terms   GET       /glossary_terms(.:format)         glossary_terms#index
glossary_term    GET       /glossary_terms/:id(.:format)     glossary_terms#show

ここで、Prefixの列に表示される名前がlink_toで使用できます。

link_to "一覧表示へ", glossary_terms_path

と記述すると、glossary_terms_pathから末尾の_pathを取り除いた名前glossary_termsをPrefixに持つURIパターンがリンクとして生成されます。

ここで、Prefixにglossary_termとあるURIパターンのリンクを生成するには、glossary_term_pathと指定したいところですが、このURLパターンにはidが含まれるため、実際のリンクを生成するときは実際のidの値が必要です。このidの値を指定する方法の一つに、リソースであるGlossaryTermインスタンス自体を指定するものがあります。

link_to "とある詳細表示へ", glossary_term_path(@term)

これをさらに省略して表記したのが

link_to "とある詳細表示へ", @term

となります。

routes.rbにリソース指定をしていない場合、この書き方はできません。初期のRedmineやプラグインに多いlink_toの記述には、次のようにリンク先のコントローラー名、アクション名を指定し、idが必要な場合はidを渡しているものをよく見かけます。

<% link_to "○○はこちら", { controller: :glossary_terms, action: :show, id: @term.id } %>

フェーズ3の実装完了とフェーズ4へ

フェーズ3では、一覧表示の名称列を、詳細表示へのリンクとし、詳細表示画面を作成しました。フェーズ3で実施したことは次です。

  • routes.rbに詳細表示のshowアクションを追記
  • GlossaryTermコントローラーにshowアクションのメソッドを記述
  • 用語詳細表示のビューshow.html.erb実装
  • 用語一覧ビューindex.html.erbに詳細表示へのリンクを追加
  • 表示文字列を国際化対応

フェーズ3までで最低限の表示ができるようになりました。フェーズ4では、新規作成および編集を実現します。


フェーズ4) 用語の作成・変更

新規の用語作成、既存の用語の変更ができる編集ページを作ります。

フェーズ4の概略

新規に用語を作成する画面は、Webアプリケーションではフォームと呼ばれ、典型的には入力項目とサブミットボタンから構成されます。また、既存の用語を変更する画面も同じくフォームとなりますが、新規の場合は入力項目が空であるのに対して既存の変更の場合は入力項目に値(文字)が入って編集可能な状態になっています。アクションとしては新規フォームの表示がnewアクション、既存フォームの表示がeditアクション、新規入力フォームをサブミットするcreateアクション、既存の変更フォームをサブミットするupdateアクションで構成されます。

フェーズ4として、次を作成または修正します。

No 役割 ファイル 内容
1 ルーティング設定 routes.rb new/create/edit/updateアクションのルーティング追加
2 GlossaryTermsController glossary_terms_controller.rb new/create/edit/updateアクションの追加
3 用語新規作成のビュー new.html.erb 用語新規作成の埋め込みruby
4 用語フォームの部分ビュー _form.html.erb フォーム部分だけ切り出した埋め込みruby
5 用語編集のビュー edit.html.erb 用語編集の埋め込みruby
6 翻訳ファイル(英語) en.yml 新規作成で使うテキストを追加
7 翻訳ファイル(日本語) ja.yml 新規作成で使うテキストを追加

用語の新規作成画面

newアクションで用語の新規作成画面(空のフォーム画面)を表示します。

  • ルーティング設定にnewアクションを追加
     Rails.application.routes.draw do
    -  resources :glossary_terms, only: [:index, :show]
    +  resources :glossary_terms, only: [:index, :show, :new, :create]
     end
    

glossary_termsに関するルーティング設定は次のようになります。

Prefix             Verb      URI Pattern                       Controller#Action
glossary_terms     GET       /glossary_terms(.:format)         glossary_terms#index
                   POST      /glossary_terms(.:format)         glossary_terms#create
new_glossary_term  GET       /glossary_terms/new(.:format)     glossary_terms#new
glossary_term      GET       /glossary_terms/:id(.:format)     glossary_terms#show

コントローラーにnewアクションのメソッドを追加します。空の用語インスタンスを生成しビューで属性をセットできるようインスタンス変数に定義します。
2行目はPrefixが一見空ですが、直前の行と同じ(glossary_terms)となります。

 def new
    @term = GlossaryTerm.new
  end

新規作成画面を作成します。新規作成の画面はnew.html.erbに記述しますが、フォーム部分(属性の入力とサブミットボタン)は後で作成する編集画面と共通するので、フォーム部分を独立したファイル_form.html.erbに記述し、新規作成画面から呼び出します。

  • new.html.erb
    <h2><%=l :label_glossary_term_new %></h2>
    
    <%= labelled_form_for :glossary_term, @term,
        url: glossary_terms_path do |f| %>
      <%= render partial: 'glossary_terms/form', locals: {form: f} %>
      <%= f.submit l(:button_create) %>
    <% end %>
    
  • ページ名を表示するH2タグに、国際化対応テキストのキーを指定しています。
  • HTMLの入力フォームの作成には、labelled_form_forを使用しています。
    • フォームの内容は、第1引数で指定したキーでparamsハッシュに格納されます。取り出す際はparams[:glossary_term]と記述します。
    • フォームの各入力フィールドには第2引数で指定したインスタンスの属性の値が入った状態で表示されます。新規作成時は引数なしのnewで生成した空のインスタンスを指定しているので、属性はnilとなっており、各フィールドは空欄となります。
    • 第3引数にはサブミット時のパスを指定します。フォームのサブミットはPOSTとなります。ルーティング設定でcreateアクションに対応するPrefixがglossary_termsなので、このPrefixに_pathを追加した文字列glossary_terms_pathを:urlをキーとしたハッシュで指定します。
  • 共通のフォーム部分(_form.html.erb)を呼び出す render partialを記述します。リソース名/form と指定すると、app/views/リソース名/_form.html.erb ファイルを呼び出します。locals: は、部分描画のビューに渡す変数のハッシュです。ここでは、ローカル変数fを渡しています。渡した変数は、部分描画のビューではfではなくformの名前で参照できます。
  • _form.html.erb
    <div class="box tabular">
      <p><%= form.text_field :name, size: 80, required: true %></p>
      <p><%= form.text_area :description, size: "80x10", required: false %></p>
    </div>
    
  • 背景を灰色とするため CSSセレクタのbox、入力フィールドのラベルと入力欄が複数並ぶときに各入力フィールドの開始位置が左右にずれないよう揃えるため CSSセレクタのtabularを指定しています。
  • 1行入力フィールドtext_fieldを用いて名称を入れます。属性名nameを指定、桁数を80文字分、必須入力記号の表示を設定しています。
    入力フィールドのラベル指定を省略しているので、field_属性名の国際化対応のキー(この例ではfield_name)が指定され対応するテキストがラベル表示されます。
  • 複数行入力フィールドtext_areaを用いて説明を入れます。属性名descriptionを指定、桁数を80文字×10行分、必須入力記号はなしを設定しています。
    これも入力フィールドのラベル指定を省略しているので、field_属性名の国際化対応のキー(この例ではfield_description)が指定され対応するテキストがラベル表示されます。

ここで作成した画面は次となります。

glossary_terms_new-1.png

参考資料

新規作成のコントローラー処理

新規画面からサブミットされると、コントローラーのcreateメソッドが呼ばれます。フォームの内容は、labelled_form_for の引数で指定したキーを使用して、params[:glossary_term]で取り出します。createメソッドでは、フォームの内容を属性として保持するGlossaryTermインスタンスを作り、データベースに永続化する処理を記述します。

 class GlossaryTermsController < ApplicationController
    :(中略)
+  def create
+    term = GlossaryTerm.new(glossary_term_params)
+    if term.save
+      flash[:notice] = l(:notice_successful_create)
+      redirect_to glossary_term_path(term.id)
+    end
+  end
    :(中略)
+  private                                 
+                                          
+  def glossary_term_params                
+    params.require(:glossary_term).permit(
+      :name, :description                 
+    )                                     
+  end                                     

フォームから渡された各フィールドの値を一括でモデルのインスタンス生成に渡す次のコードは、従来のRailsコード/Redmineコードではよく見かけますが、「マスアサインメント」と呼ばれるセキュリティ上好ましくない振る舞いを招きます。

term = GlossaryTerm.new(params[:glossary_term])

そこで、ストロングパラメーターと呼ばれる仕組みを使ってフォームから渡されたパラメータ設定可能なフィールドを許可します。
コントローラーのprivateメソッド(ここでは、glossary_term_params)で、値を設定してもよい属性名を明示的に指定します。
params.require(:glossary_term)で、フォームから渡されたパラメータに、キー:glossary_termが含まれることを確認し、このパラメータで更新してもよい属性をpermit(:name, :description)で許可しています。この許可が与えられたパラメータでGlossaryTerm.newを実行しています。

フォームの値で作成されたGlossaryTermインスタンスをデータベースに保存(saveメソッドで)し、成功すればflashメッセージを表示させ、作成した用語の詳細表示画面へ遷移します。flashメッセージの表示例は次です。

flash[:notice] = "こんにちは、flashメッセージです。"

ここまでの範囲で動作確認をしてみます。
Webブラウザから、<サーバー名>:3000/glossary_terms/new とURLを指定して開くと、用語の作成画面が表示されます。名称と説明に入力し作成ボタンを押すと、コントローラーのcreateメソッドが呼ばれ、データベースにデータが保存されます。

glossary_terms_new-2.png

データベースへの保存後、保存した用語の詳細表示画面へ遷移し、flashメッセージが表示されます。

glossary_term_new-3.png

参考資料

Railsガイド 4.5 Strong Parameters


一覧表示画面に新規作成アイコンを追加

用語集の一覧表示画面の右上に、[新しい用語]のボタンを追加し、用語の新規作成画面に遷移する機能を追加します。

  • index.html.erb
     <h2><%=l :label_glossary_terms %></h2>                                                       
    
    +<div class="contextual">                                                                     
    +  <%= link_to l(:label_glossary_term_new), new_glossary_term_path, class: 'icon icon-add' %> 
    +</div>                                                                                       
    +                                                                                             
     <table class="list">                                                                         
    
  • div でクラスcontextualを指定し、そこに追加・削除等の操作を置きます。
  • link_to でリンク名に用語の作成のテキスト、リンク先にルーティング設定に基づくパス、classキーで追加アイコンを指定しています。

画面は次のようになります。

glossary_term_index-4.png


詳細表示に編集アイコンを追加

まずルーティング設定に、編集(edit)と更新(update)を追加します。今まではリソースに対して使用するアクションを列挙していましたが、今回の追加でdestroy以外のアクションを使用することになり、destroyもすぐに追加することになります。そこで、onlyの記述を削除し、全てのアクションを有効にします。

  • routes.rb
     Rails.application.routes.draw do
    -  resources :glossary_terms, only: [:index, :show, :new, :create]
    +  resources :glossary_terms
     end
    

リソースglossary_termsに対するルーティング設定は次のようになります。

Prefix               Verb      URI Pattern                           Controller#Action
glossary_terms       GET       /glossary_terms(.:format)             glossary_terms#index
                     POST      /glossary_terms(.:format)             glossary_terms#create
new_glossary_term    GET       /glossary_terms/new(.:format)         glossary_terms#new
edit_glossary_term   GET       /glossary_terms/:id/edit(.:format)    glossary_terms#edit
glossary_term        GET       /glossary_terms/:id(.:format)         glossary_terms#show
                     PATCH     /glossary_terms/:id(.:format)         glossary_terms#update
                     PUT       /glossary_terms/:id(.:format)         glossary_terms#update
                     DELETE    /glossary_terms/:id(.:format)         glossary_terms#destroy

用語集の詳細表示画面の右上に、[編集]アイコンを追加し、用語の編集を可能にします。

  • show.html.erb
    +<div class="contextual">
    +  <%= link_to l(:button_edit), edit_glossary_term_path, class: 'icon icon-edit' %>
    +</div>
    +
     <h2><%=l :label_glossary_term %> #<%= @term.id %></h2>
    

編集アイコンを追加した画面は次になります。

glossary_terms_show-2.png


編集のコントローラーへの実装

コントローラーにeditアクションとupdateアクションに対応するメソッドを追加します。

 class GlossaryTermsController < ApplicationController             

-  before_action :find_term_from_id, only: [:show]                 
+  before_action :find_term_from_id, only: [:show, :edit, :update] 

+  def edit
+  end
+
+  def update
+    @term.attributes = glossary_term_params
+    if @term.save
+      flash[:notice] = l(:notice_successful_update)
+      redirect_to @term
+    end
+  rescue ActiveRecord::StaleObjectError
+    flash.now[:error] = l(:notice_locking_conflict)
+  end

edit および update アクションでは既存の用語を扱うので、showアクションの時と同様before_actionで対象用語のidから用語オブジェクトをインスタンス変数に取得しておきます。
editメソッドはbefore_actionでの処理以外に足すものがないので中身が空として定義します。
updateメソッドはフォームで変更された内容を受け取るときに呼ばれます。フォームから受け取ったパラメータのうち更新可能なフィールドを変更許可したものをモデルオブジェクトのattributesに代入します。saveメソッドでデータベース保存が成功すればflashメッセージで成功を表示し、詳細表示画面へリダイレクトします。redirect_toでは、ルーティング設定でリソース定義したモデルについては、モデルオブジェクトをURL替わりに指定可能です。

そこで、ストロングパラメーターと呼ばれる仕組みを使ってフォームから渡されたパラメータ設定可能なフィールドを許可します。


用語の編集画面

  • edit.html.erb
    <h2><%=l :label_glossary_term %> #<%= @term.id %></h2>
    
    <%= labelled_form_for :glossary_term, @term, url: glossary_term_path do |f| %>
      <%= render partial: 'glossary_terms/form', locals: {form: f} %>
      <%= f.submit l(:button_edit) %>
    <% end %>
    

既存の変更でフォームをサブミットするURLは次です。

glossary_term        PUT       /glossary_terms/:id(.:format)         glossary_terms#update

labelled_form_for でURLに指定するのは、このglossary_termに_pathを付加したglossary_term_pathとなります。
単数形・複数形が紛らわしいので注意してください。

formの部分描画のビュー(_form.html.erb)は新規作成で作ったものと同じものを使用します。

サブミットするボタンのラベルは、新規ではなく修正なのでnew.html.erbのものとは指定を変えています。


削除機能の追加

用語の詳細表示画面の右上、編集アイコンの右隣に削除アイコンを追加します。

  • show.html.erb
     <div class="contextual">
       <%= link_to l(:button_edit), edit_glossary_term_path, class: 'icon icon-edit' %>
    +  <%= link_to l(:button_delete), glossary_term_path, method: :delete,
    +  data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
     </div>
    

削除のルーティング設定は次なので、URLはglossary_term_path、HTTPメソッドはDELETEなので、method: deleteを指定します。

glossary_term    DELETE    /glossary_terms/:id(.:format)     glossary_terms#destroy

link_to で削除前に確認ダイアログを表示する場合、次の記述がよく行われていましたが、これはRails 4.1以降で削除されています。

<%= link_to l(:button_delete), glossary_term_path,
  confirm: l(:text_are_you_sure), method: :delete, class: 'icon icon-del' %>

用語のコントローラーにdestroyメソッドを追加します。

  • glossary_terms_controller
     class GlossaryTermsController < ApplicationController                      
    
    -  before_action :find_term_from_id, only: [:show, :edit, :update]          
    +  before_action :find_term_from_id, only: [:show, :edit, :update, :destroy]
      :(中略)
    +
    +  def destroy
    +    @term.destroy
    +    redirect_to glossary_terms_path
    +  end
    

削除対象のオブジェクトを取得するため、before_actionにdestroyを追記します。
モデルオブジェクトのdestroyメソッドを呼びデータベースから用語を削除したあとは、用語の一覧表示にリダイレクトします。


フェーズ4の実装完了とフェーズ5へ

フェーズ4では、用語の新規作成と既存の用語の編集をできるようにしました。フェーズ4で実施したことを次に簡単にまとめます。

  • ルーティング設定routes.rbに、新規作成に関するアクション(newとcreate)を追加
  • 用語コントローラーGlossaryTermsControllerに、newアクションに対応するnewメソッドを追加
  • 新規作成画面new.html.erbを作成
  • 新規作成と編集とで共通するフォーム画面を部分ビュー_form.html.erbに作成
  • 用語コントローラーGlossaryTermsControllerに、createアクションに対応するcreateメソッドを追加
  • 一覧画面index.html.erbの右上に新規作成のアイコンを追加
  • 詳細画面show.html.erbに編集アイコンを追加
  • 用語コントローラーGlossaryTermsControllerに、editアクションに対応するeditメソッドを追加
  • 編集画面edit.html.erbを作成
  • 詳細画面に削除アイコンを追加
  • 用語コントローラーGlossaryTermsControllerに、destroyアクションに対応するdestoryメソッドを追加

これで、用語リソース(GlossaryTerm)のCRUDを扱う機能が最低限実装出来ました。フェーズ5では、用語を分類するカテゴリを導入します。


フェーズ5) カテゴリの導入

用語をグルーピングする概念としてカテゴリGlossaryCategoryを導入します。

フェーズ5の概略

No クラス(役割) ファイル 内容
1 GlossaryCategory glossary_category.rb カテゴリのモデルクラス
2 GlossaryTerm glossary_term.rb GlossaryCategoryへの関連を追加
3 ルーティング設定 routes.rb GlossaryCategoryのルーティング設定を追記
4 GlossaryCategoriesController glossary_categories_controller.rb
5 カテゴリ一覧のビュー app/views/glossary_categories/index.html.erb 新規作成
6 カテゴリの新規作成ビュー app/views/glossary_categories/new.html.erb 新規作成
7 カテゴリの編集ビュー app/views/glossary_categories/edit.html.erb 新規作成
8 用語一覧のビュー app/views/glossary_terms/index.html.erb カテゴリ毎にグルーピングして一覧表示
9 用語詳細のビュー app/views/glossary_terms/show.html.erb カテゴリ追加
10 用語作成編集のフォーム app/views/glossary_terms/_form.html.erb カテゴリ追加
11 GlossaryTermsController glossary_terms_controller.rb 属性カテゴリの設定許可(ストロングパラメーター)

モデルの作成・修正

モデルの関係をクラス図で表現しました。

model_diagram-phase5.png

用語はカテゴリに属し、用語とカテゴリの関係は、1つのカテゴリが多数の用語と関連する1:多の関係となります。なお、用語が属するカテゴリは付け替えができるようライフサイクルを同一にはしない関係とします。使用する機能は、has_many と belongs_to です。

  • GlossaryCategory(カテゴリ)クラスを生成し、has_many でGlossaryTerm(用語)クラスと関連付け
    • 外部キーがデフォルトだとglossary_category_idとなるが長いのでcategory_idとする
  • 用語クラスを修正し、belongs_to でカテゴリと関連付け、およびカテゴリへの外部キーを追加するマイグレーションを生成
GlossaryCategoryモデルの生成

railsの生成コマンドで種類をredmine_plugin_modelとして属性にnameを持つモデルクラスを生成します。

redmine$ bundle exec rails generate redmine_plugin_model redmine_glossary GlossaryCategory name:string
      create  plugins/redmine_glossary/app/models/glossary_category.rb
      create  plugins/redmine_glossary/test/unit/glossary_category_test.rb
      create  plugins/redmine_glossary/db/migrate/002_create_glossary_categories.rb
redmine$
  • 注1) カラムの型は小文字で指定します(先頭大文字 Stringと指定するとマイグレーションの実行でエラー発生)

生成されたモデルクラスのコードと、マイグレーションファイルは次です。

  • glossary_category.rb
    class GlossaryCategory < ActiveRecord::Base
    end
    
  • 002_create_glossary_categories.rb
    class CreateGlossaryCategories < ActiveRecord::Migration[5.1]
      def change
        create_table :glossary_categories do |t|
          t.String :name
        end
      end
    end
    

生成されたGlossaryCategory にGlossaryTermとの関連付けを追記します。has_manyの指定は特にテーブルへのカラム追加は必要ないので、GlossaryCategoryのマイグレーションファイルは修正不要です。

  • glossary_category.rb
    class GlossaryCategory < ActiveRecord::Base
      has_many :terms, class_name: 'GlossaryTerm', foreign_key: 'category_id'
    end
    
  • カテゴリから用語にアクセスする属性名を簡潔にtermとするため、has_manyのオプションでクラス名を指定
  • 用語からカテゴリを参照する外部キーを簡潔にcategory_idとするため、has_manyのオプションで参照先のGlossaryTermで使用する外部キーを指定
  • TODO: has_manyのオプション dependent: :nullify 指定が必須か否か調べ、必須なら追記する
GlossaryTermモデルの修正

GlossaryTermに、GlossaryCategoryへの関連付けを追記します。belongs_toの指定はテーブルへの外部キーのカラムを追加する必要があるので、新たにマイグレーションファイルを作成し、カラムを追加します。

  • glossary_term.rb
     class GlossaryTerm < ActiveRecord::Base
    +  belongs_to :category, class_name: 'GlossaryCategory', foreign_key: 'category_id'
     end
    

マイグレーションファイルの名前は、変更内容が分かるように具体的に付けます。Redmineのプラグイン用のマイグレーションファイルでは次のようにファイル名を命名します。

  • カラムの追加・削除
    3桁連番_(add|remove)_カラム名_to_テーブル名.rb
    
  • テーブルの追加
    3桁連番_create_テーブル名.rb
    
  • 変更
    3桁連番_change_変更対象.rb
    

Redmineにはプラグイン用のマイグレーション生成コマンドが用意されていないので、新規に手で作るか、Railsのマイグレーション生成コマンドでいったん作成し、そのファイルの名前を変えて場所をプラグインの下に移動します。

マイグレーション生成コマンドで生成する場合は、カラムを追加する場合は、"Addカラム名Toテーブル名"と指定します。

redmine$ bundle exec rails generate migration AddCategoryToGlossaryTerms category:belongs_to
      invoke  active_record
      create    db/migrate/20180506043152_add_category_to_glossary_terms.rb
redmine$

  • 外部キーを指定するときは、型名をreferenceまはたbelongs_toを指定します。その際、外部キーが参照する先のモデルクラス名(category)を指定します。マイグレーションを実行するとカラム名に_idが付いた名前が設けられます。

Redmine本体のdb/migrate/ディレクトリ下に、ファイル名先頭が3桁連番ではなく年月日時分秒で生成されるので、次のように名前を場所を変更します。

現在プラグインのdb/migrateディレクトリ下にあるマイグレーションファイルの連番を確認します。以下の例では、001と002のマイグレーションファイルがあるので、次に作るマイグレーションファイルは003となります。

redmine$ ls plugins/redmine_glossary/db/migrate/
001_create_glossary_terms.rb  002_create_glossary_categories.rb

redmine$ mv db/migrate/20180506043152_add_category_to_glossary_terms.rb plugins/redmine_glossary/db/migrate/003_add_category_to_glossary_terms.rb
redmine$

  • 003_add_category_to_glossary_terms.rb
    class AddCategoryToGlossaryTerms < ActiveRecord::Migration[5.1]
      def change
        add_reference :glossary_terms, :category, foreign_key: true
      end
    end
    
マイグレーションの実行

プラグイン用のマイグレーションを実行します。

redmine$ bundle exec rails redmine:plugins:migrate
Migrating redmine_glossary (Redmine Glossary plugin)...
== 2 CreateGlossaryCategories: migrating ======================================
-- create_table(:glossary_categories)
   -> 0.0149s
== 2 CreateGlossaryCategories: migrated (0.0157s) =============================

== 3 AddCategoryToGlossaryTerms: migrating ====================================
-- add_reference(:glossary_terms, :category, {:foreign_key=>true})
   -> 0.0189s
== 3 AddCategoryToGlossaryTerms: migrated (0.0203s) ===========================

redmine$

2つのテーブルのスキーマを確認します。

sqlite> .schema glossary_categories
CREATE TABLE "glossary_categories" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar);
sqlite> .schema glossary_terms
CREATE TABLE "glossary_terms" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "description" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "category_id" integer);
CREATE INDEX "index_glossary_terms_on_category_id" ON "glossary_terms" ("category_id");

軽い動作確認を実施(本来はユニットテスト等を記述すべきですが)します。
  • GlossaryCategoryの作成とデータベース保存
    irb(main):002:0> cat1 = GlossaryCategory.new(name: 'Redmine')
    => #<GlossaryCategory id: nil, name: "Redmine">
    irb(main):003:0> cat1.save
       (0.2ms)  begin transaction
      SQL (13.6ms)  INSERT INTO "glossary_categories" ("name") VALUES (?)  [["name", "Redmine"]]
       (124.8ms)  commit transaction
    => true
    irb(main):004:0> cat1
    => #<GlossaryCategory id: 1, name: "Redmine">
    
  • GlossaryTermの作成とデータベース保存
    irb(main):005:0> term1 = GlossaryTerm.new(name: 'issue', category: cat1)
    => #<GlossaryTerm id: nil, name: "issue", description: nil, created_at: nil, updated_at: nil, category_id: 1>
    irb(main):006:0> term1.save
       (0.2ms)  begin transaction
      SQL (12.7ms)  INSERT INTO "glossary_terms" ("name", "created_at", "updated_at", "category_id") VALUES (?, ?, ?, ?)  [["name", "issue"], ["created_at", "2018-05-06 15:59:28.072455"], ["updated_at", "2018-05-06 15:59:28.072455"], ["category_id", 1]]
       (121.1ms)  commit transaction
    => true
    irb(main):007:0> term1
    => #<GlossaryTerm id: 13, name: "issue", description: nil, created_at: "2018-05-06 06:59:28", updated_at: "2018-05-06 06:59:28", category_id: 1>
    

ルーティング設定

新規に作成したモデル(GlossaryCategory)のリソースを追記します。

  • routes.rb
     Rails.application.routes.draw do
       resources :glossary_terms     
    +  resources :glossary_categories
     end                             
    

ルーティング設定は次のようになります。

trunk_redmine(master)$ bundle exec rails routes | grep glossary
Prefix                  Verb      URI Pattern                               Controller#Action
glossary_terms          GET       /glossary_terms(.:format)                 glossary_terms#index
                        POST      /glossary_terms(.:format)                 glossary_terms#create
new_glossary_term       GET       /glossary_terms/new(.:format)             glossary_terms#new
edit_glossary_term      GET       /glossary_terms/:id/edit(.:format)        glossary_terms#edit
glossary_term           GET       /glossary_terms/:id(.:format)             glossary_terms#show
                        PATCH     /glossary_terms/:id(.:format)             glossary_terms#update
                        PUT       /glossary_terms/:id(.:format)             glossary_terms#update
                        DELETE    /glossary_terms/:id(.:format)             glossary_terms#destroy
glossary_categories     GET       /glossary_categories(.:format)            glossary_categories#index
                        POST      /glossary_categories(.:format)            glossary_categories#create
new_glossary_category   GET       /glossary_categories/new(.:format)        glossary_categories#new
edit_glossary_category  GET       /glossary_categories/:id/edit(.:format)   glossary_categories#edit
glossary_category       GET       /glossary_categories/:id(.:format)        glossary_categories#show
                        PATCH     /glossary_categories/:id(.:format)        glossary_categories#update
                        PUT       /glossary_categories/:id(.:format)        glossary_categories#update
                        DELETE    /glossary_categories/:id(.:format)        glossary_categories#destroy

カテゴリーのコントローラーとビューの作成

やることは用語のコントローラーとビューのときとほぼ一緒です。

ビューを必要とするアクションは、index、show、new、editの4つなので、この4つのアクションを指定してコントローラーを生成します。

redmine$ bundle exec rails generate redmine_plugin_controller redmine_glossary glossary_categories index show new edit
      create  plugins/redmine_glossary/app/controllers/glossary_categories_controller.rb
      create  plugins/redmine_glossary/app/helpers/glossary_categories_helper.rb
      create  plugins/redmine_glossary/test/functional/glossary_categories_controller_test.rb
      create  plugins/redmine_glossary/app/views/glossary_categories/index.html.erb
      create  plugins/redmine_glossary/app/views/glossary_categories/show.html.erb
      create  plugins/redmine_glossary/app/views/glossary_categories/new.html.erb
      create  plugins/redmine_glossary/app/views/glossary_categories/edit.html.erb
redmine$

生成された雛形のコントローラーに、用語コントローラーとほぼ同じ実装を記述します。

  • glossary_categories_controller.rb
    class GlossaryCategoriesController < ApplicationController
    
      before_action :find_category_from_id, only: [:show, :edit, :update, :destroy]
    
      def index
        @categories = GlossaryCategory.all
      end
    
      def show
      end
    
      def new
        @category = GlossaryCategory.new
      end
    
      def edit
      end
    
      def create
        category = GlossaryCategory.new(glossary_category_params)
        if category.save
          flash[:notice] = l(:notice_successful_create)
          redirect_to category
        end
      end
    
      def update
        @category.attributes = glossary_category_params
        if @category.save
          flash[:notice] = l(:notice_successful_update)
          redirect_to @category
        end
      rescue ActiveRecord::StaleObjectError
        flash.now[:error] = l(:notice_locking_conflict)
      end
    
      def destroy
        @category.destroy
        redirect_to glossary_categories_path
      end
    
      # Find the category whose id is the :id parameter
      def find_category_from_id
        @category = GlossaryCategory.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render_404
      end
    
      private
    
      def glossary_category_params
        params.require(:glossary_category).permit(
          :name
        )
      end
    end
    

カテゴリの各ビュー(index, show, new, edit)にも用語のビューとほぼ同じ実装を記述します。

  • index.html.erb
    <h2><%=l :label_glossary_categories %></h2>
    
    <div class="contextual">
      <%= link_to l(:label_glossary_category_new), new_glossary_category_path, class: 'icon icon-add' %>
    </div>
    
    <table class="list">
      <thead>
        <tr>
          <th>#</th>
          <th><%=l :field_name %></th>
        </tr>
      </thead>
      <tbody>
        <% @categories.each do |category| %>
          <tr>
        <td class="id"><%= category.id %></td>
        <td class="name"><%= link_to category.name, category %></td>
          </tr>
        <% end %>
      </tbody>
    </table>
    
  • show.html.erb
    <div class="contextual">
      <%= link_to l(:button_edit), edit_glossary_category_path, class: 'icon icon-edit' %>
      <%= link_to l(:button_delete), glossary_category_path, method: :delete,
      data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
    </div>
    
    <h2><%=l :label_glossary_category %> #<%=@category.id %></h2>
    
    <h3><%= @category.name %></h3>
    
  • new.html.erb
    <h2><%=l :label_glossary_category_new %></h2>
    
    <%= labelled_form_for :glossary_category, @category,
    url: glossary_categories_path do |f| %>
      <%= render partial: 'glossary_categories/form', locals: {form: f} %>
      <%= f.submit l(:button_create) %>
    <% end %>
    
  • _form.html.erb
    <div class="box">
      <p><%= form.text_field :name, size: 60, required: true %></p>
    </div>
    
  • edit.html.erb
    <h2><%=l :label_glossary_category %> $<%= @category.id %></h2>
    
    <%= labelled_form_for :glossary_category, @category,
    url: glossary_category_path do |f| %>
      <%= render partial: 'glossary_categories/form', locals: {form: f} %>
      <%= f.submit l(:button_edit) %>
    <% end %>
    
国際化リソースに、ビューで使用したリソースを追加します。
  • en.yml
    -  label_glossary_term_new: "New glossary term" 
    +  label_glossary_term_new: "New glossary term" 
    +  label_glossary_categories: "Glossary categories" 
    +  label_glossary_category: "Glossary category" 
    +  label_glossary_category_new: "New glossary category" 
    

用語のコントローラーとビューの修正

用語をカテゴリと関連付けしたことにより、用語の属性にカテゴリが指定できるようになるので、その処理を追加します。

用語の一覧表示を修正

まず、一覧表示の列にカテゴリを追加します。

  • app/views/glossary_terms/index.html.erb(抜粋)
    +       <td class="roles">
    +         <%= term.category.try!(:name) %>
    +       </td>
    

用語にカテゴリ指定は必須ではないので、上述コードではcategoryがnilのことがあります。nilのときに.nameを呼ぶと実行時エラーundefined method `name' for nil:NilClassが出ます。
そこで、Railsの機能にあるtryを使って、nilでないときにメソッドを呼び、nilのときはメソッドを呼ばずにnilを返すようにします。

  • term.category.try!(:name)
    categoryがnilでなければ、nameメソッドを呼びます。categoryがnilならnameメソッドは呼び出さずにnilを返します。
  • tryの後ろに!を付けないと、nilのときだけでなく、nil以外のオブジェクトでnameメソッドがないときにエラーにならずnilを返してしまいます。

一覧表示に、id表示列も追加し、少し改善します。差分は次となります。

 <table class="list">
   <thead>
     <tr>
+      <th>#</th>
       <th><%=l :field_name %></th>
+      <th><%=l :field_category %></th>
       <th><%=l :field_description %></th>
     </tr>
   </thead>
   <tbody>
     <% @glossary_terms.each do |term| %>
       <tr>
+       <td class="id">
+         <%= term.id %>
+       </td>
        <td class="name">
          <%= link_to term.name, term %>
        </td>
+       <td class="roles">
+         <%= term.category.try!(:name) %>
+       </td>
        <td class="description">
          <%= term.description %>
        </td>

  • カテゴリ列の列名表示では、Redmine本体の標準機能でチケットの属性にカテゴリがあるのでそのキーを使っています。
  • カテゴリ列の値を表示する<td>タグでCSS用のクラスはずばりcategoryはなかったので似たようなものを探し、roleを使っています。

修正後の用語一覧表示画面を次に示します。

glossary_term_index-5.png

用語の詳細表示を修正

用語の詳細表示にカテゴリを追加します。

  • app/views/glossary_terms/show.html.erb
     <table>
       <tr>
    +    <th><%=l :field_category %></th>
    +    <td><%= @term.category.try!(:name) %>
    

修正後の用語詳細表示の画面を次に示します。

glossary_terms_show-3.png

用語の新規・編集画面のフォームを修正
  • app/views/glossary_terms/_form.html.erb
     <div class="box tabular">
       <p><%= form.text_field :name, size: 80, required: true %></p>
    +  <p><%= form.select :category_id, GlossaryCategory.pluck(:name, :id), include_blank: true %>
       <p><%= form.text_area :description, size: "80x10", required: false %></p>
    

データベースのテーブルから選択肢を拾って選択する入力(選択)フィールドを、select で出します。
最初の引数にカラム名、第2引数に選択肢となる配列またはハッシュ、第3引数はオプション指定で、include_blankは、選択肢の先頭を空白(データはnil)にするかどうかの指定です。
第2引数の選択肢は、配列の場合、[表示名, カラムに格納する値]のペアの配列となります。モデルクラスからplunkで選択肢の表示に使う属性:nameとカラムに格納する値:idを取り出し配列にします。

フォームで作成・変更する属性にカテゴリ(category_id)が増えたので、コントローラーのストロングパラメーターを更新します。

  • app/controllers/glossary_terms_controller.rb
       def glossary_term_params
         params.require(:glossary_term).permit(
    -      :name, :description
    +      :name, :description, :category_id
         )
    

新規編集画面は次となります。

glossary_term_new-5.png

既存の用語の編集画面は次となります。

glossary_term_edit-2.png

  • 外部キーのテーブルを選択肢にする入力フォームにはselectとは別にcollection_selectがあります。
    <p><%= form.collection_select :category_id, GlossaryCategory.all, :id, :name, include_blank: true %>

    しかし、Redmineのlabelled_form_forでcollection_selectを使うと、入力欄の左側にラベルが表示されない問題が生じています。

glossary_term_new-4.png

glossary_term_edit-1.png


小さな修正・改善

flashメッセージをリダイレクト後表示するなら一行で記述できる
     if category.save
-      flash[:notice] = l(:notice_successful_create)
-      redirect_to category
+      redirect_to category, notice: l(:notice_successful_create)
     end

redirect_toのオプションを指定時、ハッシュでnotice:をキーにメッセージを入れて渡すと、リダイレクト後にflashメッセージを表示します。
上のコードの修正のように書き換えが可能です。


フェーズ5の実装完了とフェーズ6へ

フェーズ5では、用語をグルーピングするカテゴリモデルを導入し、カテゴリのCRUD操作をできるようにしました。フェーズ5で実施したことを次に簡単にまとめます。

  • カテゴリモデル(GlossaryCategory)を新規作成し、用語モデル(GlossaryTerm)と1:多の関係(belongs_toとhas_many)となるよう関係を構築
  • 用語モデルの変更に対応するデータベースのスキーマ変更スクリプト作成(003_add_category_to_glossary_terms.rb)
  • ルーティング設定(routes.rb)にカテゴリを追加
  • カテゴリのコントローラー(glossary_categories_controller.rb)を作成
  • カテゴリーのビューを作成(index.html.erb, show.html.erb, new.html.erb, edit.html.erb, _form.html.erb)
  • 国際化対応に追加
  • 用語の一覧表示、詳細表示にカテゴリの列表示を追加
  • 用語の編集フォームにカテゴリの選択リストを追加

これで、2つのモデル(用語、カテゴリ)からなる用語集の最低限のCRUDができるようになりました。フェーズ6では、用語集をプロジェクト単位で扱えるようにします。


フェーズ6)プロジェクトごとに用語集を分ける

これまでの実装では、Redmineインスタンスの全体で一つの用語集を管理していました。チケットの様にプロジェクト毎に分けて別々に管理できるようにします。

フェーズ6の概略

用語およびカテゴリをプロジェクトに紐づけます。用語およびカテゴリのモデルにはプロジェクトへの関連(belongs_to)を追加します。一覧表示ではプロジェクトに紐づいた用語とカテゴリを対象にします。また、プロジェクトのメニューに用語集を追加します。プロジェクトに紐づけることでURLパスが変わるので、ルーティング設定を変更します。

No クラス(役割) ファイル 内容
1 GlossaryTerm glossary_term.rb プロジェクトに関連付け
2 GlossaryCategory glossary_category.rb
3 マイグレーション 004_add_project_to_terms_and_categories.rb テーブルにprojectへの外部キーカラムを追加
4 GlossaryTermsController glossary_terms_controller.rb projectの扱いを追加
5 GlossaryCategoriesController glossary_categories_controller.rb
6 ルーティング設定 routes.rb URLパスにプロジェクトを追加
7 プラグイン設定 init.rb プロジェクトメニューの設定追加
  • プロジェクトのモデルにhas_manyで用語やカテゴリへの関連も追加したいところですが、Redmine本体に手を入れるのは避けたいところです。また、プラグイン側にパッチを用意しRedmine本体のプロジェクトモデルにhas_manyや関連するメソッドを外部から追加する方法もありますが、それは今後必要に迫られたときに考えることにします。

モデルクラスにプロジェクトへの関連を追加

モデルのGlossaryTermGlossaryCategoryクラスにProjectクラスへの関連belongs_toを追加します。

  • glossary_term.rb
     class GlossaryTerm < ActiveRecord::Base
       belongs_to :category, class_name: 'GlossaryCategory', foreign_key: 'category_id'
    +  belongs_to :project
     end
    
  • glossary_categories.rb
     class GlossaryCategory < ActiveRecord::Base
       has_many :terms, class_name: 'GlossaryTerm', foreign_key: 'category_id'
    +  belongs_to :project
     end
    

次に、各モデルに対応するデータベースのテーブルにbelongs_toで指定したモデルのテーブルの外部キー用カラムを追加するマイグレーションスクリプトを作成します。Railsのマイグレーション生成コマンドで、マイグレーション名、カラムと型を指定します。外部キーの場合、カラム名にはモデル名(小文字)と型にbelongs_toを指定します。

redmine$ bundle exec rails generate migration AddProjectToTermsAndCategories project:belongs_to
      invoke  active_record
      create    db/migrate/20180511125114_add_project_to_terms_and_categories.rb
redmine$

生成される場所はRedmine全体のdb/migrate下なので、これをプラグイン下のdb/migrateに移動します。その際、ファイル名先頭の年月日時分秒をそのプラグインのdb/migrate下のマイグレーションファイルの連番の一番大きなものに1を足した3桁ゼロサプレスなしの名前に変更します。

redmine$ mv db/migrate/20180511125114_add_project_to_terms_and_categories.rb plugin/redmine_glossary/db/migrate/004_add_project_to_terms_and_categories.rb

生成されたマイグレーションファイルの内容は次の通り。

  • 004_add_project_to_terms_and_categories.rb
    
    class AddProjectToTermsAndCategories < ActiveRecord::Migration[5.1]
      def change
        add_reference :terms_and_categories, :project, foreign_key: true
      end
    end
    

なんかちょっと違うコードになってしまいましたので、手で修正します。

class AddProjectToTermsAndCategories < ActiveRecord::Migration[5.1]
  def change
    add_reference :glossary_terms, :project, foreign_key: true
    add_reference :glossary_categories, :project, foreign_key: true
  end
end

プラグインのマイグレーションを実行します。

$ bundle exec rails redmine:plugins:migrate
Migrating redmine_glossary (Redmine Glossary plugin)...
== 4 AddProjectToTermsAndCategories: migrating ================================
-- add_reference(:glossary_terms, :project, {:foreign_key=>true})
   -> 0.0113s
-- add_reference(:glossary_categories, :project, {:foreign_key=>true})
   -> 0.0015s
== 4 AddProjectToTermsAndCategories: migrated (0.0147s) =======================
$

マイグレーション実行後のデータベースのスキーマを確認します。
まず、glossary_termsテーブルから見ます。

$ sqlite3 db/redmine.sqlite3
SQLite version 3.8.10.2 2015-05-20 18:17:19
Enter ".help" for usage hints.
sqlite> .schema glossary_terms
CREATE TABLE "glossary_terms" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "description" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "category_id" integer, "project_id" integer);
CREATE INDEX "index_glossary_terms_on_category_id" ON "glossary_terms" ("category_id");
CREATE INDEX "index_glossary_terms_on_project_id" ON "glossary_terms" ("project_id");

  • project_idのカラムが生成されています。

続いて、glossary_categoriesテーブルを見ます。

sqlite> .schema glossary_terms
CREATE TABLE "glossary_terms" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "description" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "category_id" integer, "project_id" integer);
CREATE INDEX "index_glossary_terms_on_category_id" ON "glossary_terms" ("category_id");
CREATE INDEX "index_glossary_terms_on_project_id" ON "glossary_terms" ("project_id");
sqlite> .schema glossary_categories
CREATE TABLE "glossary_categories" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar, "project_id" integer);
CREATE INDEX "index_glossary_categories_on_project_id" ON "glossary_categories" ("project_id");

  • project_idのカラムが生成されています。

GlossaryTermControllerクラスにプロジェクトの制御を追加

Redmineのプラグインでは、プロジェクトに紐づく機能を扱う場合、コントローラーでインスタンス変数@projectに現在のプロジェクトを詰めておき、ビューで@projectを参照するのが定番なようです。そこで、before_actionでインスタンス変数@projectにプロジェクトを詰める処理を記述します。

  • glossary_terms_controller.rb
    +  before_action :find_project_from_id, only: [:index, :create, :destroy]
       :(中略)
    +  # Find the project whose id is the :project_id parameter
    +  def find_project_from_id
    +    @project = Project.find(params[:project_id])
    +  rescue ActiveRecord::RecordNotFound
    +    render_404
    +  end
    

アクションの中でプロジェクトを必要とするのは次の場合です。そこで、before_actionの対象をonlyで次の3つに絞っています。

  • 一覧表示(index)
    プロジェクトに属する用語だけ一覧するために使用
  • 新規作成(create)
    属性projectにプロジェクトを入れるために使用
  • 削除(destroy)
    削除後、一覧表示(index)に遷移するために使用

用語集をプロジェクト単位に設けるようにするので、一覧表示(indexアクション)でこれまですべての用語を返却していた部分を現在のプロジェクトに属する用語を返すように修正する必要があります。

今回は、Redmine本体のProjectモデル(クラス)に、GlossaryTermモデル(クラス)への関連has manyを付けていないので、project.glossary_termsのようには取り出せません。そこで、GlossaryTermからproject_idが現在のプロジェクトに一致するものだけを検索して取り出すことにします。Ruby on Rails 4からは、whereを使うのが推奨です。(findで:allと:conditionsを指定するのはRails 4で廃止)

  • glossary_terms_controller.rb
       def index
    -    @glossary_terms = GlossaryTerm.all
    +    @glossary_terms = GlossaryTerm.where(project_id: @project.id)
       end
    

また、新規に用語を作成するときに、フォーム上ではプロジェクトを選択しないので、コントローラーで現在のプロジェクトを用語インスタンスの属性としてセットする必要があります。

  • glossary_terms_controller.rb
       def create
         term = GlossaryTerm.new(glossary_term_params)
    +    term.project_id = @project.id
         if term.save
           redirect_to term, notice: l(:notice_successful_create)
         end
    

上述コードでは外部キーのカラム名project_idで属性を指定して代入していますが、外部キーのIDではなく、外部キーの参照先レコードのインスタンスでも指定可能です。

term.project = @project

パラメーターのハッシュをセットする場合と違って、コントローラー内で属性をセットするときはストロングパラメータの記述は不要でした。セキュリティの観点から、外部から渡される値についてストロングパラメータの指定が必要と思われます。

@projectにプロジェクトを詰める処理はRedmineで提供あり

インスタンス変数projectにプロジェクトを詰める処理は、コントローラーの基底クラス@ApplicationControllerでメソッドが定義済みです。

  • find_project_by_project_id

なので、before_actionでこのfind_project_by_project_idを呼べばOKで、各コントローラーで実装しなくて済みます。
なお、find_projectという似た名前のメソッドがありますが、これはパラメーターのキーがproject_idではなくidの場合に使用します。


ルーティング設定を変更

プロジェクトに紐づける前の用語集(GlossaryTerm)のURLパスは、/glossary_termsでした。プロジェクトに紐づけるとURLパスは、/projects/someproj/glossary_terms(someprojはプロジェクト識別子)となります。

そこで、ルーティング設定を変更します。

  • routes.rb
     Rails.application.routes.draw do
    -  resources :glossary_terms
    +  resources :projects, shallow: true do
    +      resources :glossary_terms
    +  end
       resources :glossary_categories
     end
    
  • shallowにtrueを指定すると、URIパターンにネストを要求するアクションが最小限で済みます。

この変更後、用語集プラグインに関するルーティング設定は次のようになります。

Prefix Verb                       URI Pattern                                            Controller#Action
   project_glossary_terms GET     /projects/:project_id/glossary_terms(.:format)         glossary_terms#index
                          POST    /projects/:project_id/glossary_terms(.:format)         glossary_terms#create
new_project_glossary_term GET     /projects/:project_id/glossary_terms/new(.:format)     glossary_terms#new
       edit_glossary_term GET     /glossary_terms/:id/edit(.:format)                     glossary_terms#edit
            glossary_term GET     /glossary_terms/:id(.:format)                          glossary_terms#show
                          PATCH   /glossary_terms/:id(.:format)                          glossary_terms#update
                          PUT     /glossary_terms/:id(.:format)                          glossary_terms#update
                          DELETE  /glossary_terms/:id(.:format)                          glossary_terms#destroy
   glossary_categories GET     /glossary_categories(.:format)                         glossary_categories#index
                          POST    /glossary_categories(.:format)                         glossary_categories#create
    new_glossary_category GET     /glossary_categories/new(.:format)                     glossary_categories#new
   edit_glossary_category GET     /glossary_categories/:id/edit(.:format)                glossary_categories#edit
        glossary_category GET     /glossary_categories/:id(.:format)                     glossary_categories#show
                          PATCH   /glossary_categories/:id(.:format)                     glossary_categories#update
                          PUT     /glossary_categories/:id(.:format)                     glossary_categories#update
                          DELETE  /glossary_categories/:id(.:format)                     glossary_categories#destroy

まず、routes.rbファイルで変更したglossary_termに関しては、PrefixとURIパターンが共に変更となっています。
次に、一覧表示(indexアクション)を要求する場合のルーティング設定がどのように変更されたのかを示します。

項目 変更前 変更後
Prefix glossary_terms project_glossary_terms
Verb GET
URI Pattern /glossary_terms(.:format) /projects/:project_id/glossary_terms(.:format)
Controller#Action glossary_terms#index
  • URIパターンを見ると、/projects/プロジェクトID/glossary_terms となっています。
ビューの変更
ビューの中で、ルーティング設定で生成されるリンク名を使用している個所があります。例えば、次です。
  • app/views/glossary_terms/index.html.erb
      <%= link_to l(:label_glossary_term_new), new_glossary_term_path, class: 'icon icon-add' %>
    

一覧表示の右上に[新しい用語]のリンクがあります。リンク先に指定しているのはルーティング設定で生成されるリンク名(Prefixに_pathを付加)のnew_glossary_term_pathです。しかしプロジェクトに紐づけるためにルーティング設定を変更した結果、リンク名もnew_project_glossary_pathに変更となっています。そこで、ビューも合わせて修正していきます。

  • app/views/glossary_terms/index.html.erb
     <div class="contextual">
    -  <%= link_to l(:label_glossary_term_new), new_glossary_term_path, class: 'icon icon-add' %>
    +  <%= link_to l(:label_glossary_term_new), new_project_glossary_term_path, class: 'icon icon-add' %>
    </div>
    
コントローラーの変更
次のように、モデルクラスのインスタンスを指定してredirect_toを記述している場合、Railsが内部でプロジェクトに紐づかないリンク名ができるらしく、エラーとなってしまました。
  • glossary_terms_controller.rb
        if term.save
          redirect_to term, notice: l(:notice_successful_create)
        end
    

そこで、リダイレクトにはリンク名を指定することにします。


プロジェクトメニューへの追加

都度URLを入力するのも大変なので、プロジェクトに紐づけたこのフェーズで、プロジェクトメニューに用語集を追加します。
プロジェクトメニューに用語集を追加するには、次の2つのステップが必要です。

  • プラグインを「プロジェクトモジュール」として登録
  • プロジェクトメニューに追加
プロジェクトモジュールとして登録

プロジェクトモジュールとして登録するには、init.rbに権限の設定を記述します。

  • init.rb
    Redmine::Plugin.register :redmine_glossary do
      :(中略)
    
      project_module :glossary do
        permission :all_glossary, glossary_terms: [:index]
      end
    end
    

プロジェクトモジュールに登録する名前は、通常プラグイン名と一緒にしますが、ここではプラグイン名の先頭の"redmine_"が冗長なのと実験を兼ねてプラグイン名とは別な名前の"glossary"で登録します。

project_module :glossary do

プロジェクトモジュールとしてプラグインを登録する際、1つpermission設定がないと認識されない模様です。そこで、permission定義を1つ記述します。本格的なアクセス制御は後のフェーズで行うので、ここでは最低限の記述をします。
パーミッション名、権限対象画面を指定するコントローラーとアクションのハッシュ、オプション指定を記述します。パーミッション名は、Redmineの[管理]メニュー > [ロールと権限]で登場する名前となります。権限対象画面の指定は、次のメニュー登録で登録したプロジェクトメニューをクリックしたときに表示する画面のコントローラーとアクションを記述します。

プロジェクトメニューの登録

プロジェクトのメニューにプラグインのメニューを追加するには、init.rbにメニュー登録を記述します。

  • init.rb
    Redmine::Plugin.register :redmine_glossary do
      :(中略)
      menu :project_menu, :glossary,
           { controller: 'glossary_terms', action: 'index' },
           caption: :glossary_title,
           param: :project_id
    

記載するブロックは、Redmine::Plugin.register のブロック直下か、さらにその中のproject_moduleのブロックとなります。
今回は、前者に置きました。

  • メニュー種類は、プロジェクトのメニューに置きたいので:project_menuを指定します。
  • メニュー名は、今回はモジュール名(:glossary)に合わせましたが、違っていてもよいようです。
  • 次のURL指定は、メニューが選択されたときに飛ぶパスとなり、Railsのurl_forメソッドの指定と同じ仕様とあります。コントローラーとアクションのハッシュを渡す事例がほとんどです。ルーティング設定で生成されるリンクパス(project_glossary_terms_path)を指定してもエラーとなりました。
  • オプションでは、プロジェクトメニューの場合、param: :project_idを指定します。また、メニューに表示される名称を指定するcaptionもあります。captionにシンボルを指定すると、国際化対応キーとしてロケールに応じた文字列に変換されます。
en.ymlとja.ymlに、キーglossary_titleと対応する文字列の定義を追加します。
  • en.yml
    +  glossary_title: Glossary
    
  • ya.yml
    +  glossary_title: 用語集
    

これで、プロジェクトメニューに"Glossary"が表示されます。

なお、権限設定はデフォルトでは全ロールでOFFなので、adminを除くログインユーザーでプラグインメニューを表示するには管理メニューから権限を追加する必要があります。


ルーティング設定を再変更

先のルーティング設定変更では、IDを持つリソースへのアクセスはプロジェクトをパスに含まないようにしていました。すると、作業中のプロジェクトで用語集一覧を表示し、その中の1つの用語の詳細を表示すると、詳細表示画面はプロジェクトの外に出てしまい、メニューがプロジェクトではなくなってしまいます。

Redmine本体のチケットの機能では、プロジェクトのチケット一覧から、どれか一つのチケットの詳細を表示しても、URLにはプロジェクトは含まれませんが、画面はプロジェクトメニューが表示されたままとなっています。
このやり方が解析できれば取り入れたいのですが、簡単には解析できなかったので、IDを持つリソースへのアクセスもすべてプロジェクトをパスに含むようにします。

  • routes.rb
     Rails.application.routes.draw do
    -  resources :projects, shallow: true do
    +  resources :projects do
           resources :glossary_terms
       end
    

変更後のルーティング設定(glossary_terms)は次のようになります。

Prefix Verb                         URI Pattern                                               Controller#Action
project_glossary_terms      GET     /projects/:project_id/glossary_terms(.:format)            glossary_terms#index
                            POST    /projects/:project_id/glossary_terms(.:format)            glossary_terms#create
new_project_glossary_term   GET     /projects/:project_id/glossary_terms/new(.:format)        glossary_terms#new
edit_project_glossary_term  GET     /projects/:project_id/glossary_terms/:id/edit(.:format)   glossary_terms#edit
project_glossary_term       GET     /projects/:project_id/glossary_terms/:id(.:format)        glossary_terms#show
                            PATCH   /projects/:project_id/glossary_terms/:id(.:format)        glossary_terms#update
                            PUT     /projects/:project_id/glossary_terms/:id(.:format)        glossary_terms#update
                            DELETE  /projects/:project_id/glossary_terms/:id(.:format)        glossary_terms#destroy

ルーティング設定の再変更に伴い、リンク先の指定の変更が発生します。また、GlossaryTermsControllerにおいて、IDを伴うアクションであってもプロジェクト配下となるので、before_action の find_project_from_id対象となるよう修正します。

  • glossary_terms_controller.rb(before_actionの修正)
    -  before_action :find_project_from_id, only: [:index, :create]
    +  before_action :find_project_from_id
    
  • glossary_terms_controller.rb(リンク先指定の修正)
       def index
         :(中略)
         if term.save
    -      redirect_to term, notice: l(:notice_successful_create)
    +      redirect_to [@project, term], notice: l(:notice_successful_create)
         end
       end
    
       def update
         :(中略)
         if @term.save
    -      redirect_to @term, notice: l(:notice_successful_update)
    +      redirect_to [@project, @term], notice: l(:notice_successful_update)
         end
       end
    
       def destroy
    -    project = @term.project
         @term.destroy
    -    redirect_to project.nil? ? home_path : project_glossary_terms_path(project)
    +    redirect_to project_glossary_terms_path
       end
    
  • app/views/glossary_terms/index.html.erb
            <td class="name">
    -         <%= link_to term.name, term %>
    +         <%= link_to term.name, [@project, term] %>
            </td>
    
  • app/views/glossary_terms/show.html.erb
     <div class="contextual">
    -  <%= link_to l(:button_edit), edit_glossary_term_path, class: 'icon icon-edit' %>
    -  <%= link_to l(:button_delete), glossary_term_path, method: :delete,
    +  <%= link_to l(:button_edit), edit_project_glossary_term_path, class: 'icon icon-edit' %>
    +  <%= link_to l(:button_delete), project_glossary_term_path, method: :delete,
       data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
     </div>
    
  • app/views/glossary_terms/edit.html.erb
    -<%= labelled_form_for :glossary_term, @term, url: glossary_term_path do |f| %>
    +<%= labelled_form_for :glossary_term, @term, url: project_glossary_term_path do |f| %>
    

フェーズ6の実装完了とフェーズ7へ

フェーズ6では、用語とカテゴリをプロジェクトに紐づけました。プロジェクトのメニューに用語集を追加し、プロジェクトに紐づいた用語の一覧表示をし、その先ではプロジェクトに紐づいた用語を扱います。フェーズ6で実施したことを次に簡単にまとめます。

  • モデルクラス(GlossaryTerm、GlossaryCategory)にプロジェクトへの関連(belongs_to)を追加
  • モデルの変更に伴うデータベースのスキーマの変更をするマイグレーションスクリプト(004_add_project_to_terms_and_categories.rb)を作成し実行
  • コントローラークラス(GlossaryTermsController)にプロジェクトを扱う実装を追加
  • ルーティング設定(routes.rb)を、用語がプロジェクトの配下になるようネストする記述
  • ビューとコントローラーにおいてルーティング設定を変更したことによるリンク先の変更
  • プロジェクトメニューに用語集を追加

フェーズ7では、右側にサイドバーを表示し、そこに設定、作成リンク、用語のインデックスなどを並べます。


フェーズ6a) カテゴリのプロジェクト紐づけ

フェーズ6では、用語(GlossaryTerm)をプロジェクトに紐づけました。一方、カテゴリ(GlossaryCategory)は、モデル部分はプロジェクトに紐づけるべくカラム"project_id"の追加、belongs_toでプロジェクトに紐づけはしたものの、ルーティング設定を変更しておらず、カテゴリのコントローラーとビューのプロジェクト紐づけ対応がなされていません。

そこで、カテゴリについてルーティング設定を変更し、コントローラーとビューの対応を行います。

フェーズ6aの概略

No 役割(クラス) ファイル名 内容
1 ルーティング設定 routes.rb カテゴリのリソースをプロジェクトのネストに置く
2 GlossaryTermCategory glossary_categories_controller.rb インスタンス変数プロジェクトの設定、リンク先修正
3 一覧表示 index.html.erb リンク先修正
4 詳細表示 show.html.erb
5 新規作成 new.html.erb
6 編集 edit.html.erb

ルーティング設定変更

カテゴリのリソースをプロジェクトのネストにします。

 Rails.application.routes.draw do
   resources :projects do
-      resources :glossary_terms
+    resources :glossary_terms
+    resources :glossary_categories
   end
-  resources :glossary_categories
 end

この修正でカテゴリのルーティング設定は次の通りです。

Prefix                          Verb      URI Pattern                                                    Controller#Action
project_glossary_categories     GET       /projects/:project_id/glossary_categories(.:format)            glossary_categories#index
                                POST      /projects/:project_id/glossary_categories(.:format)            glossary_categories#create
new_project_glossary_category   GET       /projects/:project_id/glossary_categories/new(.:format)        glossary_categories#new
edit_project_glossary_category  GET       /projects/:project_id/glossary_categories/:id/edit(.:format)   glossary_categories#edit
project_glossary_category       GET       /projects/:project_id/glossary_categories/:id(.:format)        glossary_categories#show
                                PATCH     /projects/:project_id/glossary_categories/:id(.:format)        glossary_categories#update
                                PUT       /projects/:project_id/glossary_categories/:id(.:format)        glossary_categories#update
                                DELETE    /projects/:project_id/glossary_categories/:id(.:format)        glossary_categories#destroy

コントローラーの修正

GlossaryCategoriesController のプロジェクト紐づけ対応を実施します。
まずは、プロジェクトIDからプロジェクト取得を行うメソッドを追加、各アクションの前に実行するようbefore_actionで設定します。

 class GlossaryCategoriesController < ApplicationController

+  before_action :find_project_from_id

+  # Find the project whose id is the :project_id parameter
+  def find_project_from_id
+    @project = Project.find(params[:project_id])
+  rescue ActiveRecord::RecordNotFound
+    render_404
+  end

一覧表示はプロジェクトに属する用語の一覧を取得する用に変更します。

   def index
-    @categories = GlossaryCategory.all
+    @categories = GlossaryCategory.where(project_id: @project_id)
   end

カテゴリの新規作成時、プロジェクトをモデルインスタンスに代入します。

   def create
     category = GlossaryCategory.new(glossary_category_params)
+    category.project = @project
     if category.save

ルーティング設定の変更に伴うリンク先パスの変更をします。

   def create
     :(中略)
-      redirect_to @category, notice: l(:notice_successful_update)
+      redirect_to [@project, @category], notice: l(:notice_successful_update)

   def update
     :(中略)
-      redirect_to @category, notice: l(:notice_successful_update)
+      redirect_to [@project, @category], notice: l(:notice_successful_update)

   def destroy
     @category.destroy
-    redirect_to glossary_categories_path
+    redirect_to project_glossary_categories_path

フェーズ18のテストで検知された問題として、destroyメソッドのredirect_toで、project_glossary_categories_pathを引数なしで指定すると、インスタンス変数@projectのidが数値としてURLパスに設定されます。動作としては問題ないのですが、通常プロジェクトIDは数値ではなく識別子をURLパスに設定するのでテストで不一致が発生します。ここは、redirect_to project_glossary_categories_path(@project)のように引数付きで指定します。


ビューの修正

ルーティング設定の変更に応じて、リンク先パスの修正をします。

  • app/views/glossary_categories/index.html.erb
     <div class="contextual">
    -  <%= link_to l(:label_glossary_category_new), new_glossary_category_path, class: 'icon icon-add' %>
    +  <%= link_to l(:label_glossary_category_new), new_project_glossary_category_path, class: 'icon icon-add' %>
     </div>
      :(中略)
           <tr>
            <td class="id"><%= category.id %></td>
    -       <td class="name"><%= link_to category.name, category %></td>
    +       <td class="name"><%= link_to category.name, [@project, category] %></td>
           </tr>
    
  • app/views/glossary_categories/show.html.erb
     <div class="contextual">
    -  <%= link_to l(:button_edit), edit_glossary_category_path, class: 'icon icon-edit' %>
    -  <%= link_to l(:button_delete), glossary_category_path, method: :delete,
    +  <%= link_to l(:button_edit), edit_project_glossary_category_path, class: 'icon icon-edit' %>
    +  <%= link_to l(:button_delete), project_glossary_category_path, method: :delete,
       data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
     </div>
    
  • app/views/glossary_categories/new.html.erb
     <%= labelled_form_for :glossary_category, @category,
    -url: glossary_categories_path do |f| %>
    +url: project_glossary_categories_path do |f| %>
    
  • app/views/glossary_categories/edit.html.erb
     <%= labelled_form_for :glossary_category, @category,
    -url: glossary_category_path do |f| %>
    +url: project_glossary_category_path do |f| %>
    

フェーズ7) 右サイドバー表示

用語集画面において、右側にサイドバーを表示し、そこに表示制御、用語の作成やカテゴリの作成へのリンク、索引などを載せます。

フェーズ7の概略

サイドバーを表示するには、ビューにおいて、content_forで:sidebarを指定して呼びます。

<% content_for :sidebar do %>
  <div></div>
<% end %>

サイドバーには、最低限の機能として、次のリンクを持たせます。

  • 新しい用語の作成
  • 新しいカテゴリの作成
  • カテゴリ一覧の表示
  • 索引(A, B, C, ... で始まる用語一覧へのリンク)

これを、各画面共通で呼び出せるようにします。

索引で指定した先頭文字を持つ用語の一覧を検索して表示します。

No 役割(クラス) ファイル名 内容
1 サイドバービュー _sidebar.html.erb サイドバーに表示する
2 用語一覧のビュー app/views/glossary_terms/index.html.erb サイドバービューを呼び出すよう修正
3 用語詳細のビュー app/views/glossary_terms/show.html.erb
4 カテゴリ一覧のビュー app/views/glossary_categories/index.html.erb
5 カテゴリ詳細のビュー app/views/glossary_categories/show.html.erb
6 GlossaryTerm glossary_term.rb 先頭文字の用語の検索(scope)追加
7 GlossaryTermsController glossary_terms_controller.rb 索引で指定した先頭文字の用語一覧を表示する追加

サイドバービューの作成(見出し)

サイドバーは、一覧表示、詳細表示、といった各画面で同じものを表示します。そこで、共通で利用できるよう部分ビューとして作成します。
まずは、見出しを並べます。<h3>がよいようです。

<% content_for :sidebar do %>
  <h3><%=l :label_view %></h3>

  <h3><%=l :label_glossary_term %></h3>

  <h3><%=l :label_glossary_category %></h3>

  <h3><%=l :label_glossary_index %></h3>
<% end %>

content_forで:sidebarを指定すると、サイドバー表示ブロックが構成できます。この中に、サイドバーに表示するパーツを配置します。
まず、用語の一覧表示(app/views/glossary_terms/index.html.erb)に、サイドバーを表示するための呼び出しを追加します。

 <div class="contextual">
   <%= link_to l(:label_glossary_term_new), new_project_glossary_term_path, class: 'icon icon-add' %>
 </div>

+<%= render partial: 'sidebar' %>
+
 <table class="list">

このサイドバー表示は次のようになります。

sidebar-2.png

サイドバービューの作成(新規・一覧リンク追加)

次のリンクを追加します。

  • 新しい用語の作成
  • 新しいカテゴリの作成
  • カテゴリ一覧の表示
   <h3><%=l :label_glossary_term %></h3>
+  <p><%= link_to l(:label_glossary_term_new), new_project_glossary_term_path,
+     class: 'icon icon-add' %></p>

   <h3><%=l :label_glossary_category %></h3>
+  <p><%= link_to l(:label_glossary_category_new),
+     new_project_glossary_category_path, class: 'icon icon-add' %></p>
+  <p><%= link_to l(:label_glossary_categories),
+     project_glossary_categories_path %></p>

既に新しい用語の作成はフェーズ4で用語一覧表示の右上に配置しているので、それと同じ記述をしています。

新しいカテゴリの作成は、表示文字列とリンク先が異なる以外は新しい用語と一緒です。表示文字列は国際化対応のキーで指定、対応する文字列をen.ymlやja.ymlに記述しておきます。リンク先は、ルーティング設定のnew_project_glossary_categoryに_pathを付けて指定します。

カテゴリ一覧へのリンクは、ルーティング設定のproject_glossary_categoriesに_pathを付けて指定します。

ここまでの記述で表示されるサイドバー画面は次です。

sidebar-3.png


サイドバービューの作成(索引追加)

続いて、索引を作ります。AからZまでを並べ、A、B、・・・ をぞれぞれAで始まる用語の一覧表示、Bで始まる用語の一覧表示、とリンクを作っていきます。
リンクは、http://localhost/projects/my-proj/glossary_terms?index=AのようにURLのパスではなくパラメーターとして渡します。

ビューでは次の様にlink_toのオプション指定します。

<%= link_to ch, project_glossary_terms_path(index: ch) %>

AからZまでの並びは、国際化ロケールのen.ymlに記述しておきます。

  index_en: |
      A B C D E F
      G H I J K L
      M N O P Q R
      S T U V W X
      Y Z

改行を含めるため、キー(index_en)の次にパイプ記号(|)を指定します。
このキーで改行込みの文字列('A'~'Z')を読み込み、各文字に検索リンクを付けて表示します。

   <h3><%=l :label_glossary_index %></h3>
+  <table>
+    <% l(:index_en).each_line do |line| %>
+      <tr>
+       <% line.split(" ").each do |ch| %>
+         <td><%= link_to ch, project_glossary_terms_path(index: ch) %></td>
+       <% end %>
+      </tr>
+  <% end %>
+  </table>

sidebar-4.png


モデルの修正(曖昧検索LIKE)

指定した先頭文字から始まる用語の一覧を検索する機能を用語モデル(GlossaryTerm)に持たせます。

SQL文では、LIKE句を使った検索で実現できますが、RailsのActiveRecordには検索条件にlikeを直接使用するメソッドが提供されていません。そこで、whereメソッドの条件でLIKEを使います。

その際、クライアントからのパラメーターをそのままSQLに放り込むのは危険なので(SQLインジェクション)、プレースホルダーを使って指定します。さらに、パラメーターの文字列の中に'%'や'_'および'\'といった文字がある場合にエラーとならないようエスケープが必要です。Rails 4.2では、モデルクラスでsanitize_sql_likeが提供されました。ただし、protectedなのでモデルクラス内でしか使えないので、先頭文字を指定しての検索はモデルクラス側に実装します。

モデルクラスにはメソッドではなく、scopeで検索を実装します。scopeは、モデルクラスで共通するクエリをメソッドのように呼び出す仕組みです。

  • glossary_term.rb
     class GlossaryTerm < ActiveRecord::Base
       belongs_to :category, class_name: 'GlossaryCategory', foreign_key: 'category_id'
       belongs_to :project
    +
    +  scope :search_by_name, -> (keyword) {
    +    where 'name like ?', "#{sanitize_sql_like(keyword)}%" 
    +  }
    +
     end
    

scopeの実装は、ラムダ式で定義し渡します。


コントローラーの修正(索引からのリンク対応)

まずは、一覧表示(indexアクション)にパラメーター付きで索引からのリンクが張られます。
パラメーターなしでindexアクションが呼ばれたときは従来通り、プロジェクトに属する用語の一覧を取得します。
パラメーターありでindexアクションが呼ばれたときは、先にモデルに追加したscope(@search_by_name)を呼び、パラメーターで指定された文字で始まる用語の一覧を取得します。

  • app/controllers/glossary_terms_controller.rb
       def index
         @glossary_terms = GlossaryTerm.where(project_id: @project.id)
    +    @glossary_terms = @glossary_terms.search_by_name(params[:index]) unless params[:index].nil?
       end
    

いったん、プロジェクトで絞り込んだあと、indexアクションに索引のパラメーターが指定されていた場合に、さらに用語の名前で絞り込みます。
whereはActiveRecord::Relationを返し、メソッドチェーンで複数の条件を絞り込んでいくことができます。SQLの実行は結果を取り出すまで保留され、最後にSQLに組み立てられます。

別な記述方法

べたにif式で書くと次のコードになります。

  def index
    if params[:index].nil?
      @glossary_terms = GlossaryTerm.where(project_id: @project.id)
    else
      @glossary_terms = GlossaryTerm.where(project_id: @project.id).search_by_name(params[:index])
    end
  end

サイドバー表示の追加

用語の詳細画面、カテゴリの一覧画面、カテゴリの詳細画面にサイドバー表示を追加します。

  • app/views/glossary_terms/show.html.erb
     </div>
    
    +<%= render partial: 'sidebar' %>
    +
    
  • app/views/glossary_categories/index.html.erb
     </div>
    
    +<%= render partial: 'glossary_terms/sidebar' %>
    +
     <table class="list">
    

カテゴリのビューは、用語のビューとは別ディレクトリにあるので、用語のビューの共通画面(_sidebar.html.erb)を参照するときはディレクトリ込みで指定しています。

  • app/views/glossary_categories/show.html.erb
     </div>
    
    +<%= render partial: 'glossary_terms/sidebar' %>
    +
     <h2><%=l :label_glossary_category %> #<%=@category.id %></h2>
    

フェーズ7の実装完了とフェーズ8へ

フェーズ7では、サイドバーの表示と、サイドバーに索引(先頭文字指定)を用意し、指定した索引を検索表示する処理を追加しました。
なお、索引については'A'~'Z'のアルファベットのみであり、日本語の「あ」~「ん」、また国際化対応上各国語の索引も用意したいところですが、それは今後の対応とします。

フェーズ8では、セキュリティ・権限の導入をします。


フェーズ8) セキュリティと権限

フェーズ8では、プラグインの権限設定をします。
用語集を参照する権限、用語集を変更する権限と2種類の権限を設け、それぞれに異なる役割(ロール)を設定できるようにします。
そして、アクションの発動、新規作成、編集などのリンクは権限がある場合に制限できるようにします。

フェーズ8の概略

No 役割(クラス) ファイル 内容
1 プラグイン情報 init.rb 権限設定
2 翻訳ファイル(英) en.yml 英語ロケール用キーと文字列
3 翻訳ファイル(日) ja.yml 日本語ロケール用キーと文字列
4 用語一覧のビュー app/views/glossary_terms/index.html.erb 権限がある場合に新規作成のリンク表示
5 用語詳細のビュー app/views/glossary_terms/show.html.erb 権限がある場合に編集・削除のリンク表示
6 サイドバービュー app/views/glossary_terms/_sidebar.html.erb 権限がある場合に新規作成のリンク表示
7 カテゴリ一覧のビュー app/views/glossary_categories/index.html.erb 権限がある場合に新規作成のリンク表示
8 カテゴリ詳細のビュー app/views/glossary_categories/show.html.erb 権限がある場合に編集・削除のリンク表示
9 GlossaryTermsController glossary_terms_controller.rb アクションを実行する前に権限チェック
10 GlossaryCategoriesController glossary_categories_controller.rb

参照する権限、変更する権限の定義

設定ファイルのproject_moduleに、参照・変更それぞれpermissionを定義します。

  • init.rb
       project_module :glossary do
    -    permission :all_glossary, glossary_terms: :index
    +    permission :view_glossary, {
    +                 glossary_terms: [:index, :show],
    +                 glossary_categories: [:index, :show]
    +               }
    +    permission :manage_glossary, {
    +                 glossary_terms: [:new, :create, :edit, :update, :destroy],
    +                 glossary_categories: [:new, :create, :edit, :update, :destroy],
    +               },
    +               require: :member
    +
       end
    

権限は、コントローラーとアクションの組み合わせで指定します。全アクションがどちらかに含まれるように記述します。用語集ではコントローラーが2つあるので、上述では2つ指定しています。
require: :memberを指定すると、プロジェクトに属するメンバーが前提となります。

権限を設定する管理者メニュー上で、プラグイン名、権限名を国際化対応するために、en.ymlとja.ymlに追記します。

  • プラグイン名
    project_moduleで使用したモジュール名(ここではglossary)の接頭辞にproject_module_を付与します。
    en.yml ja.yml
    project_module_glossary: Glossary project_module_glossary: 用語集
  • 権限名
    permissionの第1引数に指定した権限名(ここではview_glossarymanage_glossary)の接頭辞にpermission_を付与します。
    en.yml ja.yml
    permission_view_glossary: View glossary permission_view_glossary: 用語集の閲覧
    permission_manage_glossary: Manage glossary permission_manage_glossary: 用語集の管理

ここまでの設定で権限の設定画面は次の様になります。
[管理]メニュー > [ロールと権限]でロール一覧が表示されるので、一覧から[Manager]をクリックすると各プラグインの権限設定画面が表示されます。

rolesetting_glossary-1.png


用語集の変更に関わるリンクは管理権限のあるユーザーのみ

いままでは、どのロールのユーザーであっても、用語の追加・編集・削除およびカテゴリの追加・編集・削除ができてしまいました。そこで、権限がある場合(用語集の管理権限)にのみ追加・編集・削除のリンクが表示されるように制御します。

用語一覧画面右上の新規作成リンク

用語集の管理権限を持っているユーザーのみ新規作成リンクが表示されるようにします。

  • app/views/glossary_terms/index.html.erb
     <div class="contextual">
    -  <%= link_to l(:label_glossary_term_new), new_project_glossary_term_path, class: 'icon icon-add' %>
    +  <%= link_to_if_authorized l(:label_glossary_term_new),
    +  { controller: :glossary_terms, action: :new, project_id: @project},
    +  class: 'icon icon-add' %>
     </div>
    

Redmineのヘルパー関数link_to_if_authorizedを使って、権限のあるときにのみリンクが表示されるようにします。権限のある/なしは、link_to_if_authorizedのオプションで指定するコントローラーおよびアクションとログインしているユーザーとから判断されるので、link_to_if_authorizedのオプションにはRESTfulスタイルのルーティング名に基づく文字列ではなく、ハッシュでコントローラーとアクションを渡す必要があります。この部分の変更を次に示します。

- new_project_glossary_term_path
+ { controller: :glossary_terms, action: :new, project_id: @project }

また、Redmineでは、プロジェクトに紐づけられるリソースではアクションを呼び出すHTTPリクエストでオプションパラメーターとしてproject_idをキーにプロジェクトのIDを指定するのが規約です。

リンク元(このビューを表示しているコントローラー)とリンク先のコントローラーが同一の場合、コントローラーの指定を省略しても動くようです。Redmine本体にlink_to_if_authorizedを使い、コントローラーの指定を省略しているものが2つほど存在していました。また、実験した限りではコントローラーを省略しても動作しました。

サイドバー表示の用語・カテゴリの新規作成リンク

用語集の作成、編集権限を持っているユーザーのみ用語の新規作成、カテゴリの新規作成リンクが表示されるようにします。

  • app/views/glossary_terms/_sidebar.html.erb
       <h3><%=l :label_glossary_term %></h3>
    -  <p><%= link_to l(:label_glossary_term_new), new_project_glossary_term_path,
    +  <p><%= link_to_if_authorized l(:label_glossary_term_new),
    +     { controller: :glossary_terms, action: :new, project_id: @project },
          class: 'icon icon-add' %></p>
    
       <h3><%=l :label_glossary_category %></h3>
    -  <p><%= link_to l(:label_glossary_category_new),
    -     new_project_glossary_category_path, class: 'icon icon-add' %></p>
    +  <p><%= link_to_if_authorized l(:label_glossary_category_new),
    +     { controller: :glossary_categories, action: :new, project_id: @project},
    +     class: 'icon icon-add' %></p>
    
用語詳細表示の右上にある編集、削除リンク

用語集の作成、編集権限を持っているユーザーのみ、用語の編集・削除リンクが表示されるようにします。

  • app/views/glossary_terms/show.html.erb
     <div class="contextual">
    -  <%= link_to l(:button_edit), edit_project_glossary_term_path, class: 'icon icon-edit' %>
    -  <%= link_to l(:button_delete), project_glossary_term_path, method: :delete,
    -  data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
    +  <%= link_to_if_authorized l(:button_edit),
    +  { controller: :glossary_terms, action: :edit, project_id: @project },
    +  class: 'icon icon-edit' %>
    +  <%= link_to_if_authorized l(:button_delete),
    +  { controller: :glossary_terms, action: :destroy,
    +    id: @term, project_id: @project },
    +    method: :delete, data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
     </div>
    

編集の場合は、idを省略しても今表示している用語の編集画面に遷移しましたが、削除の場合はidを指定しないと削除されませんでした。
そこで、削除の場合はオプション指定のハッシュにキーidで用語を指定しています。

カテゴリ一覧画面右上の新規作成リンク

用語一覧とほぼ一緒です。

  • app/views/glossary_categories/index.html.erb
     <div class="contextual">
    -  <%= link_to l(:label_glossary_category_new), new_project_glossary_category_path, class: 'icon icon-add' %>
    +  <%= link_to_if_authorized l(:label_glossary_category_new),
    +  { controller: :glossary_categories, action: :new, project_id: @project },
    +  class: 'icon icon-add' %>
     </div>
    
カテゴリ詳細画面右上の編集・削除リンク

用語詳細とほぼ一緒です。

  • app/views/glossary_categories/show.html.erb
     <div class="contextual">
    -  <%= link_to l(:button_edit), edit_project_glossary_category_path, class: 'icon icon-edit' %>
    -  <%= link_to l(:button_delete), project_glossary_category_path, method: :delete,
    -  data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
    +  <%= link_to_if_authorized l(:button_edit),
    +  { controller: :glossary_categories, action: :edit, project_id: @project },
    +  class: 'icon icon-edit' %>
    +  <%= link_to_if_authorized l(:button_delete),
    +  { controller: :glossary_categories, action: :destroy,
    +    id: @category, project_id: @project },
    +    method: :delete, data: {confirm: l(:text_are_you_sure)}, class: 'icon icon-del' %>
     </div>
    

アクションの実行は権限のあるユーザーのみ

リンクの表示を権限のあるユーザーだけに限定しただけでは、直接URLを入力してアクセスされた場合に権限のないユーザーからのアクセスを防ぐことができません。そこでコントローラーにおいてアクションを実行する前に権限があるかのチェックをして、権限のないアクセスを防ぐ処理を入れます。

  • glossary_terms_controller.rb
     class GlossaryTermsController < ApplicationController
    
    -  before_action :find_project_from_id
    +  before_action :find_project_from_id, :authorize 
    

アクションが実行される前に、ApplicationControllerのauthorizeメソッドを呼び権限チェックを実行します。
authorizeメソッドは、インスタンス変数@projectが定義されていることが前提のため、先に@projectを設定するメソッドを呼びます。

権限のないユーザーがURLを手入力する等でアクセスした場合、権限チェックによって権限がないと次の画面が表示されます。

authorize-1.png

未ログイン状態でURLを手入力する等でアクセスした場合は、ログイン画面に飛ばされます。

カテゴリのコントローラーにも権限チェックを入れます。

  • glossary_categories_controller.rb
     class GlossaryCategoriesController < ApplicationController
    
       before_action :find_category_from_id, only: [:show, :edit, :update, :destroy]
    -  before_action :find_project_from_id
    +  before_action :find_project_from_id, :authorize
    

フェーズ8の実装完了とフェーズ9へ

参照のみと変更可と2種類の権限をinit.rbに用意し、ユーザーのロールに応じて権限を設定できるようにしました。

まず、各ビューにおいて、変更権限のないユーザーには新規作成、編集、削除のリンクを非表示にするという方法を、link_to_if_authorizedを用いて権限の制御をしました。

ただし、この方法では権限のないユーザーが直接URLを入力して新規作成や編集をすることができてしまいます。そこで、コントローラーがアクションを実行する前に権限チェックをする方法をbefore_actionでauthorizeを呼び出すことで追加しました。

フェーズ9では、用語の属性に、振り仮名、英語名、略語展開などいろいろ追加します。


フェーズ9) 用語の属性を拡充

オリジナルの用語集プラグインでは、用語の属性として、用語、英語名、ふりがな、略語の展開名称、データ型、コーディング用名称例、カテゴリ、説明があります。フェーズ8までは、そのうち用語(名前)、説明、カテゴリの3つだけを扱ってきました。
フェーズ9では、用語の属性をオリジナルに近づけます。

フェーズ9の概略

No 役割(クラス名) ファイル名 内容
1 GlossaryTerm glossary_term.rb 属性の追加
2 マイグレーション 005_add_columns_to_glossary_terms.rb 属性追加に伴うスキーマ変更
3 用語作成・編集 _form.html.erb 属性の追加
4 翻訳ファイル(英) en.yml 属性の表示名追加
5 翻訳ファイル(日) ja.yml 属性の表示名追加
6 GlossaryTermsController glossary_terms_controller.rb ストロングパラメーターに属性追加
7 用語詳細表示 show.html.erb 属性の追加

データベースのスキーマの変更

glossary_termsテーブルに次のカラムを追加します。

属性の名称 カラム名 カラムの型
英語名 name_en string
ふりがな rubi string
略語の展開名称 abbr_whole string
データ型 datatype string
コーディング用名称例 codename string

マイグレーションスクリプトは現在004まで作っているので、005で開始します。

redmine_glossary$ ls db/migrate/
001_create_glossary_terms.rb           003_add_category_to_glossary_terms.rb
002_create_glossary_categories.rb      004_add_project_to_terms_and_categories.rb

  • 005_add_columns_to_glossary_terms.rb
    class AddColumnsToGlossaryTerms < ActiveRecord::Migration[5.1]
      def change
        add_column :glossary_terms, :name_en, :string, default: ''
        add_column :glossary_terms, :rubi, :string, default: ''
        add_column :glossary_terms, :abbr_whole, :string, default: ''
        add_column :glossary_terms, :datatype, :string, default: ''
        add_column :glossary_terms, :codename, :string, default: ''
      end
    end
    

マイグレーションを実行します。

redmine$ bundle exec rails redmine:plugins:migrate
Migrating redmine_glossary (Redmine Glossary plugin)...
== 5 AddColumnsToGlossaryTerms: migrating =====================================
-- add_column(:glossary_terms, :name_en, :string, {:default=>""})
   -> 0.0048s
-- add_column(:glossary_terms, :rubi, :string, {:default=>""})
   -> 0.0007s
-- add_column(:glossary_terms, :abbr_whole, :string, {:default=>""})
   -> 0.0010s
-- add_column(:glossary_terms, :datatype, :string, {:default=>""})
   -> 0.0007s
-- add_column(:glossary_terms, :codename, :string, {:default=>""})
   -> 0.0010s
== 5 AddColumnsToGlossaryTerms: migrated (0.0118s) ============================

データベースのテーブルglossary_termsのスキーマを確認します。

sqlite> .schema glossary_terms
CREATE TABLE "glossary_terms" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "description" text, "created_at" datetime NOT NULL, "updated_at" datetime NOT NULL, "category_id" integer, "project_id" integer, "name_en" varchar DEFAULT '', "rubi" varchar DEFAULT '', "abbr_whole" varchar DEFAULT '', "datatype" varchar DEFAULT '', "codename" varchar DEFAULT '');
CREATE INDEX "index_glossary_terms_on_category_id" ON "glossary_terms" ("category_id");
CREATE INDEX "index_glossary_terms_on_project_id" ON "glossary_terms" ("project_id");
sqlite>

追加したカラムが存在することが確認できました。


用語の新規作成・編集フォームに属性追加

用語モデルに追加した属性について、用語の新規作成および編集フォームの入力・編集フィールドを追加します。

  • app/views/glossary_terms/_form.html.erb
     <div class="box tabular">
       <p><%= form.text_field :name, size: 80, required: true %></p>
    +  <p><%= form.text_field :name_en, size: 80 %></p>
    +  <p><%= form.text_field :rubi, size: 80 %></p>
    +  <p><%= form.text_field :abbr_whole, size: 80 %></p>
    +  <p><%= form.text_field :datatype, size: 80 %></p>
    +  <p><%= form.text_field :codename, size: 80 %></p>
       <p><%= form.select :category_id, GlossaryCategory.pluck(:name, :id), include_blank: true %></p>
       <p><%= form.text_area :description, size: "80x10", required: false %></p>
     </div>
    

入力・編集のテキストフィールドには、カラム名と同じ名称を指定しています。
この名称の接頭辞にfield_を付けたもの(例:name_enであれば、field_name_en)が国際化対応テキストのキーとなります。

  • en.yml
    +  field_name_en: English
    +  field_abbr_whole: Whole word for Abbreviation
    +  field_datatype: Data type for coding
    +  field_codename: Abbreviation for coding
    +  field_rubi: Ruby
    
  • ja.yml
    +  field_name_en: 英語名
    +  field_abbr_whole: 略語の展開名称
    +  field_datatype: データ型
    +  field_codename: コーディング用名称例
    +  field_rubi: ふりがな
    

属性を追加し、編集用フォームに属性の入力フィールドを追加した用語の作成画面を次に示します。

ph9_form-1.png


コントローラーの修正

新規作成、編集アクションで追加した属性をモデルに保存するため、GlossaryTermsControllerのストロングパラメーターにクライアントから渡されモデルに格納する属性名の記述を追加します。

  • glossary_terms_controller.rb
       def glossary_term_params
         params.require(:glossary_term).permit(               
    -      :name, :description, :category_id                  
    +      :name, :description, :category_id,                 
    +      :name_en, :rubi, :abbr_whole, :datatype, :codename 
         )                                                    
       end                                                    
    

用語詳細表示の修正

追加した属性の表示をする修正を入れます。

  • app/views/glossary_terms/show.html.erb
     <table>
    +  <tr>
    +    <th><%=l :field_name_en %></th>
    +    <td><%= @term.name_en %></td>
    +  </tr>                                  
    +  <tr>                                   
    +    <th><%=l :field_rubi %></th>         
    +    <td><%= @term.rubi %></td>           
    +  </tr>                                  
    +  <tr>                                   
    +    <th><%=l :field_abbr_whole %></th>   
    +    <td><%= @term.abbr_whole %></td>     
    +  </tr>                                  
    +  <tr>                                   
    +    <th><%=l :field_datatype %></th>     
    +    <td><%= @term.datatype %></td>       
    +  </tr>                                  
    +  <tr>                                   
    +    <th><%=l :field_codename %></th>     
    +    <td><%= @term.codename %></td>       
    +  </tr>                                  
       <tr>                                   
         <th><%=l :field_category %></th>     
         <td><%= @term.category.try!(:name) %>
       </tr>                                  
    

似たような記述の繰り返しで少し面倒です。オリジナルの用語集プラグインでは、属性名を引数にラベル文字列を返すヘルパーメソッドと、属性名を引数に属性の値を返すヘルパーメソッドを用意し、html.erbの中ではループで表の各行を展開していました。ヘルパーメソッドはまだ習得していないので、後のフェーズで取り組む予定です。再構築の過程であるここでは、パッと見では分かりやすいHTMLと埋め込みRubyでべた書きしています。

属性を追加した用語詳細画面表示の例を次に示します。

glossary_term_show_9-1.png

erbかヘルパーメソッドか

オリジナルの用語集プラグインでは、用語の一覧表示で用語のふりがなをHTMLのruby要素(プログラミング言語のrubyではなく)を使って表示させています。簡単にHTML(erb)で記述できるようならこのフェーズで追加したかったのですが、用語の属性ふりがなが空でなければruby要素を展開し、属性ふりがなが空ならruby要素を展開しない、といった制御構造が入り、さらにHTMLのタグがネスト(ruby要素の子要素にrp要素、rt要素を持つ)するのでHTML(erb)が見苦しくなってしまいました。

このことから、HTML(erb)では、変数の値、あるいはメソッドを呼んだ戻り値の文字列をHTMLに埋め込むこと、制御構造は集合のイテレーション程度にとどめ、それより複雑なことを実装するならヘルパーメソッドを別途作成するというのがよいと考えます。


フェーズ9の実装完了とフェーズ10へ

フェーズ9では次の実装を行いました。

  • 用語モデルに次の属性を追加
    英語名、ふりがな、略語の展開名称、データ型、コーディング用名称例
  • データベースの用語テーブルに属性に対応するカラムを追加するマイグレーションファイル作成
  • 用語の新規作成画面、編集画面へモデルに追加した属性のフィールドを追加
  • 上述で追加したフィールドのラベル名を翻訳ファイルに追加
  • 用語コントローラーへ、上述で追加したフィールドの値でモデルを更新できるようストロングパラメーターに追加
  • 用語の詳細表示へ、モデルに追加した属性を追加

フェーズ10では、一覧表示においてグループ毎に分類して表示する機能を追加します。


フェーズ10)一覧表示をグループ毎に分類

用語の一覧表示はフェーズ9時点ではID順(作成順)に並んでいます。これを、カテゴリ毎に分けて同じカテゴリに属する用語をID順に表示するようにします。

フェーズの概略

No 役割(クラス) ファイル名 内容
1 サイドバー表示設定 app/views/glossary_terms/_sidebar.html.erb グループ化設定のラジオボタン追加
2 GlossaryTermsController glossary_terms_controller.rb グループ化設定をインスタンス変数に保持
3 用語一覧表示 app/views/glossary_terms/index.html.erb グループ化設定に基づきべた一覧表示かカテゴリ毎に一覧表示
4 用語一覧表 app/views/glossary_terms/_index_terms.html.erb ファイル名を_index_in_category.html.erbから変更
5 翻訳ファイル(英語) en.yml ラジオボタンのラベル名追加
6 翻訳ファイル(日本語) ja.yml

カテゴリ毎に分類し、カテゴリ毎に一覧表示するか、元の一覧表示するかを、サイドバーにラジオボタンを追加し操作します。


グループに分類する方法を模索

用語の一覧をグループに分類する方法がないかを探していると、groupとかgroup_byといったキーワードが拾えました。

Rails の group

ActiveRecordクラスのメソッドgroupは、例えばUser.group(:prefecture)が次のSQL文に展開されます。

SELECT * FROM users GROUP BY prefecture;

グループ指定したカラムのユニークな値毎に1つのレコードが得られます。この例では、県名でグループ指定したので、県ごとに1人のユーザーが(集約されて)得られます。戻り値はActiveRecord::Relation型となります。

この振る舞いは今回の用途には合わないです。

ruby の group_by

RubyのArrayクラスのメソッドgroup_byは、各要素をイテレートし、指定した式の戻り値が同じ要素群を、式の戻り値をキーとしたハッシュに入れます。
この振る舞いが今回の用途に合いそうです。

まずは、一覧表示(indexアクション)でインスタンス変数glossary_termsをgroup_byでカテゴリをキーに、そのカテゴリに属する用語配列を値とするハッシュを作成します。

<% @glossary_terms.group_by(&:category).each do |category, terms| %>
   :
<% end %>

&:categoryは見慣れない表記ですが、Rubyはメソッドの引数に&とシンボルを結合したものを渡すと、シンボルの名前の手続きをブロックとして渡します。
この場合、group_byの引数に、category属性を取得する手続きが渡されます。

しかしながら、用語にはカテゴリに属さないものがあり、その用語のcategory属性はnilとなります。group_byでは分類に使う手続きでnilを受けると例外を吐いてしまいます。category属性がnilの時をどう扱うかが実装の課題となります。

用語をカテゴリで分類する(1)

まずは、index.html.erbで、インスタンス変数@glossary_termsを、カテゴリ属性が設定されているもの、カテゴリ属性が未設定のものと2つの用語リストに分けます。

<% categorized_terms = @glossary_terms.reject { |t| t.category_id.nil? } %>
<% uncategorized_terms = @glossary_terms.where(category_id: nil) %>

次に、カテゴリ属性が設定されているものについては、カテゴリでグループ分類(group_by)します。

<% categorized_terms.group_by(&:category).each do |category, terms| %>
  カテゴリ名の表示
 カテゴリに属する用語一覧表示
<% end %>

カテゴリ属性が設定されていないものについては、未分類として用語一覧を表示します。

<% uncategorized_terms.each do |term| %>
  カテゴリに属さない用語一覧表示
<% end %>

ここで、用語の一覧表示が2か所に記述必要となっています。同じコードを複数個所に書くのはDRY(Don't Repeat Your Self)原則に反するので、共通画面として別ファイルに定義し、renderメソッドのpartialオプションで別ファイルを部分テンプレートとして呼び出します。

用語をカテゴリで分類する(2)

用語一覧表示の共通画面を、定義します。

  • app/views/glossary_terms/_index_terms.html.erb
    <table class="list">
      <thead>
        <tr>
          <th>#</th>
          <th><%=l :field_name %></th>
          <th><%=l :field_category %></th>
          <th><%=l :field_description %></th>
        </tr>
      </thead>
      <tbody>
        <% terms.each do |term| %>
          <tr>
            <td class="id">
              <%= term.id %>
            </td>
            <td class="name">
              <%= link_to term.name, [@project, term] %>
            </td>
            <td class="roles">
              <%= term.category.try!(:name) %>
            </td>
            <td class="description">
              <%= term.description %>
            </td>
          </tr>
        <% end %>
      </tbody>
    </table>
    

用語一覧表示(index.html.erb)から、この共通定義ファイル(_index_terms.html.erb)に、用語リストを渡す必要があります。
共通定義ファイル内では、terms変数を使用していますので、termsを渡します。

renderメソッドで変数を渡す方法はいくつかあります。ここでは、localsオプションを使用します。
localsシンボルをキーに、その値にハッシュを指定、ハッシュは呼び出し先で使用する変数名をキーに、呼び出し元で渡す変数を値に指定します。

  • app/views/glossary_terms/index.html.erb(抜粋)
    <% categorized_terms.group_by(&:category).each do |category, terms| %>
      <h3><%= category.name %></h3>
      <%= render partial: 'index_terms', locals: {terms: terms} %>
    <% end %>
    

renderメソッドではpartial指定を省略することができます。そのときは、localsを省略して次の様に呼び出しできます。

  • app/views/glossary_terms/index.html.erb(抜粋)
    <%= render 'index_terms',  terms: uncategorized_terms %>
    
  • app/views/glossary_terms/index.html.erb(全体)
    <h2><%=l :label_glossary_terms %></h2>
    
    <div class="contextual">
      <%= link_to_if_authorized l(:label_glossary_term_new),
      { controller: :glossary_terms, action: :new, project_id: @project },
      class: 'icon icon-add' %>
    </div>
    
    <%= render partial: 'sidebar' %>
    
    <% categorized_terms = @glossary_terms.reject { |t| t.category_id.nil? } %>
    <% uncategorized_terms = @glossary_terms.where(category_id: nil) %>
    
    <% categorized_terms.group_by(&:category).each do |category, terms| %>
      <h3><%= category.name %></h3>
      <%= render partial: 'index_terms', locals: {terms: terms} %>
    <% end %>
    
    <h3><%=l :label_not_categorized %></h3>
    <%= render 'index_terms',  terms: uncategorized_terms %>
    

画面例を次に示します。

glossary_terms_index-10-1.png

見栄えについてはさらに工夫(カテゴリ毎の表の列幅がまちまち)ですが、機能としては達成できました。


カテゴリ毎の表示の有効・無効切替

用語一覧を表示する方法を、べたに一覧する表示と、カテゴリ毎に一覧する表示とを切り替えるための表示設定をGUIとして用意します。

オリジナルのGlossaryプラグインでは、サイドバーに用語一覧表示の方法を指定するラジオボタンがあります。ラジオボタンの選択結果はフォームとしてサーバーに送り、表示を変更するという流れになります。

ところで、Ruby on RailsはMVC構造を中核にしたフレームワークなので、クライアントからのアクションを(サーバー上で)実行するにはコントローラーが必要です。今回の表示設定は用語のビューの切り替えで、データベースに永続化すべきデータか悩ましいところです。オリジナルの用語集プラグインでは、表示設定として説明表示有無、グループ化方法、プロジェクト単位の表示かどうか、ソート順序があり、これらをユーザー毎にデータベースに永続化する実装となっています。ユーザー毎に設定を格納するので、複数のプロジェクトに用語集プラグインが使用されていると、同一ユーザーならそれらすべてに同じ設定が反映されます。

今回再構築では、表示設定のうちグループ化だけを実現しており、グループ化(べたに一覧かカテゴリ毎に一覧か)設定は永続化せずに都度設定してもほとんど支障ないと考えるので今回は一時的なデータとして使い、永続化はしないこととします。

さて、ここでMVC構造を中心とするRuby on Railsにおいて、今回の表示設定のような一時的な設定をどのように実現するのか、実現方法をいくつか検討し、用語コントローラーの一覧表示であるindexアクションにパラメーターを設け、べたに一覧表示するかカテゴリ毎に一覧表示するかをそのパラメータで指定する方法で実装します。

サイドバーに用語一覧表示の方法を指定するフォームとしてラジオボタンとサブミット用ボタンを設け、用語コントローラーのindexアクションを呼ぶ

モデルインスタンスがないフォームなので、form_tagか、またはform_withを使います。Redmine 4.0(Ruby on Rails 5.1)向けに作成するので、form_withを使うことにします。form_withは、モデルを指定する用法とモデルを指定しない用法とがあります。今回はモデルを指定しない方法で実装しています。

  • app/views/glossary_terms/_sidebar.html.erb
     <% content_for :sidebar do %>
       <h3><%=l :label_view %></h3>
    +  <%= form_with url: project_glossary_terms_path, method: :get, local: true do |form| %>
    +    <fieldset>
    +      <legend><%=l :label_grouping %></legend>
    +      <%= form.radio_button :grouping, 1, checked: @grouping == '1' ? 'checked' : nil %>
    +      <%= form.label :grouping, l(:label_categorized), vaule: 1 %>
    +      <%= form.radio_button :grouping, 0, checked: @grouping == '0' ? 'checked' : nil %>
    +      <%= form.label :grouping, l(:label_not_categorized), value: 0 %>
    +    </fieldset>
    +    <%= form.submit l(:label_view) %>
    

ルーティング設定で GlossaryTermsController#index に至るのは、Prefixが project_glossary_termsでHTTPメソッドはGETです。そこで、フォームの定義ではproject_glossary_terms_path とHTTPメソッドgetを指定します。HTTPメソッドを指定しないとデフォルトはPOSTになります。

form_withはデフォルトではリモートが有効(Ajax)になっています。そこで、従来通りの振る舞いとするため、local: trueを指定しています。(リモートではうまく表示が切り替わらなかったので、何か追加が必要な模様)。

radio_buttonは、デフォルトでは非選択なので、一度ラジオボタンを選択した場合、描画し直した場合でもそのラジオボタンが選択されているようcheckedキーで選択状態を指定しています。

ラジオボタンの選択をサイドバーに追加した画面を次に示します。

glossary_terms_index-10-2.png

コントローラーにパラメーターを保持する処理追加

フォームからパラメーターgroupingが送られてくるので、GlossaryTermsControllerのindexメソッドにパラメーターの値をインスタンス変数に格納する処理を追加します。このインスタンス変数は、ビューで参照します。

   def index
     @glossary_terms = GlossaryTerm.where(project_id: @project.id)
     @glossary_terms = @glossary_terms.search_by_name(params[:index]) unless params[:index].nil?
+    @grouping = params[:grouping]
   end
コントローラーのグループ化設定情報(インスタンス変数 @grouping)によって一覧表示方法を変更

パラメーター grouping の値が0(グループ化なし)か1(カテゴリ毎にグループ化)で表示を変更します。

  • app/views/glossary_terms/index.html.erb
    :(前略)
    <% if @grouping == '1' %>
      <% categorized_terms = @glossary_terms.reject { |t| t.category_id.nil? } %>
      <% uncategorized_terms = @glossary_terms.where(category_id: nil) %>
    
      <% categorized_terms.group_by(&:category).each do |category, terms| %>
        <h3><%= category.name %></h3>
        <%= render partial: 'index_terms', locals: {terms: terms} %>
      <% end %>
    
      <h3><%=l :label_not_categorized %></h3>
      <%= render 'index_terms',  terms: uncategorized_terms %>
    <% else %>
      <%= render 'index_terms', terms: @glossary_terms %>
    <% end %>
    
    

カテゴリ毎にグループ化する場合は、カテゴリが未設定の用語を除外した用語リストをgroup_byメソッドでカテゴリ毎に仕分け、仕訳けられた用語一覧を表示します。
グループ化しない場合は、用語一覧を表示します。


フェーズ10の実装完了とフェーズ11へ

フェーズ10では、サイドバーにラジオボタンを設置し、ラジオボタンで一覧表示の方法を選択できるようにしました。

  • サイドバーにラジオボタンを設け、サブミット時に用語コントローラーのindexにラジオボタンの選択をパラメータで渡す
  • 用語コントローラーのindexで、パラメータで渡されたラジオボタンの選択をインスタンス変数に格納
  • 用語の一覧表示ビューで、インスタンス変数に応じてべた一覧表示かカテゴリ毎に一覧表示を振り分け

フェーズ11では、用語の索引をABCだけでなく、日本語のあいうえおで引ける機能を追加します。


フェーズ11) 索引に日本語(あいうえお順)追加

フェーズ7でサイドバーに表示した索引は、ABC順のアルファベットの索引です。ABCの索引のリンクで表示される用語は、名称がアルファベットで始まるもののみです。そこで、あいうえお順のひらがなの索引を新たに設け、用語のフリガナの開始文字を検索に使って索引表示を行います。

フェーズ11の概略

No 役割(クラス) ファイル 内容
1 サイドバービュー app/views/glossary_terms/_sidebar.html.erb あいうえお順の索引表示
2 翻訳ファイル(英) en.yml フリガナ(rubi)の索引のキー追加
3 翻訳ファイル(日) ja.yml フリガナ(rubi)の索引の表示文字列追加
4 GlossaryTermsController glossary_terms_controller.rb 索引で指定した先頭文字の用語一覧を表示する追加
5 GlossaryTerm glossary_term.rb 先頭文字の用語(フリガナ)の検索(scope)追加

あいうえお順の索引表示

用語モデルには、フェーズ9でフリガナ(rubi)を追加しました。そこで、索引をこのrubiの先頭文字で用意することとします。

翻訳ファイル(日本語)に次のキーと値を追加します。

  • ja.yml
    +  index_rubi: |
    +    あ い う え お
    +    か き く け こ
    +    さ し す せ そ
    +    た ち つ て と
    +    な に ぬ ね の
    +    は ひ ふ へ ほ
    +    ま み む め も
    +    や ゆ よ
    +    ら り る れ ろ
    +    わ を ん
    +
    

サイドバー表示に、追加したキーの値を表示し、リンクを貼る記述を追加します。

  • app/views/glossary_terms/_sidebar.html.erb
       </table>
    +  <table>
    +    <% l(:index_rubi).each_line do |line| %>
    +      <tr>
    +        <% line.split(" ").each do |char| %>
    +          <td>
    +            <%= link_to char, project_glossary_terms_path(index_rubi: char) %>
    +          </td>
    +        <% end %>
    +      </tr>
    +    <% end %>
    +  </table>
     <% end %>
    

翻訳ファイルから、index_rubiをキーに値(複数行)を取り出し、値の行ごとに空白で区切った文字をリンク付きで表示します。
リンクは、索引(アルファベット)とほぼ同様ですがパラメータのキーをindexではなくindex_rubiとしています。

あいうえお順の索引を表示した画面を次に示します。

glossary_terms_index-11-1.png

英語表示対策

英語表示の際、index_rubiのキーに対する値がないとエラーメッセージが表示されてしまうので、次の記述を入れました。

  • en.yml
    +  index_rubi: |
    +
    

index_rubi: だけだとエラーのままでした。

あいうえお順の索引に対応する検索をモデルに追加

  • app/models/glossary_term.rb
    +  scope :search_by_rubi, -> (keyword) {
    +    where 'rubi like ?', "#{sanitize_sql_like(keyword)}%" 
    +  }
    +
    

モデルクラスに新たにふりがな(rubi)で検索するscopeを定義します。指定された文字を先頭に持つふりがな(rubi)を条件として指定しています。


あいうえお順の索引に対応するコントローラー

  • app/controllers/glossary_terms_controller.rb
       def index
         @glossary_terms = GlossaryTerm.where(project_id: @project.id)
    -    @glossary_terms = @glossary_terms.search_by_name(params[:index]) unless params[:index].nil?
    +    if not params[:index].nil?
    +      @glossary_terms = @glossary_terms.search_by_name(params[:index])
    +    elsif not params[:index_rubi].nil?
    +      @glossary_terms = @glossary_terms.search_by_rubi(params[:index_rubi])
    +    end
         @grouping = params[:grouping]
       end
    

indexアクションが呼ばれたら、パラメータにindexが含まれていればモデルのsearch_by_nameを呼び合致する用語リストを取得します。パラメータにindex_rubiが含まれていればモデルのsearch_by_rubiを呼び出し合致する用語リストを取得します。


フェーズ11の実装完了とフェーズ12へ

フェーズ11では、サイドバーの索引表示にABC順に加えてあいうえお順の索引表示を追加しました。
フェーズ12では、用語にファイルを添付できるようにします。


フェーズ12)ファイルの添付

用語の詳細にファイルを添付できるようにします。

フェーズ12の概略

No クラス(役割) ファイル 内容
1 GlossaryTerm glossary_term.rb ファイル添付追加
2 用語編集のビュー app/views/glossary_terms/edit.html.erb ファイル添付追加
3 用語フォームの部分ビュー app/views/glossary_terms/_form.html.erb ファイル添付追加
4 GlossaryTermsController glossary_terms_controller.rb create、updateにファイル添付追加
5 用語詳細のビュー app/views/glossary_terms/show.html.erb ファイル添付追加
6 用語新規作成のビュー app/views/glossary_terms/new.html.erb ファイル添付追加

用語モデルのファイル添付対応

用語モデルにファイル添付機能を追加します。モデルクラスに必要な記述は、acts_as_attachableの呼び出しです。

   belongs_to :project

+  acts_as_attachable view_permission: :view_glossary, edit_permission: :manage_glossary, delete_permission: :manage_glossary

ファイル添付機能の実装には、Redmineが用意するプラグイン向けライブラリ(内部プラグイン)の一つacts_as_attachableを使います。acts_asで始まる内部プラグインは、<redmineインストールディレクトリ>/lib/plugins下に多数あります。acts_as_attachableの呼び出しをモデルクラスに記述すると、モデルクラスにメソッドがいろいろ追加されます。

acts_as_attachableは、添付ファイルの取得・編集・削除の際に権限をチェックします。権限の名称は、デフォルトではモデルクラス名から次の様に生成されます。

権限種類 デフォルト生成権限名 明示的に指定するときのキー
取得(閲覧)権限 view_glossary_term view_permission
編集権限 edit_glossary_term edit_permission
削除権限 delete_glossary_term delete_permission

プラグインの設定ファイル(init.rb)に定義した権限の名称が、このacts_as_attachableが生成する権限名と一致しない場合は、acts_as_attachableの呼び出し時に引数で権限名を明示的に指定します。権限名の指定はハッシュで、上述の表の明示的に指定するときのキーに対してプラグインの設定で定義した権限の名称を値とします。


用語の編集画面にファイル添付追加

まず、用語の編集の画面にファイル添付の表示を追加します。
ファイル添付はフォームの中に入れます。また、

  • app/views/glossary_terms/_form.html.erb
     </div>                                             
    +                                                   
    +<div class="box">                                  
    +  <p>                                              
    +    <label><%=l :label_attachment_plural %></label>
    +    <%= render partial: 'attachments/form' %>      
    +  </p>
    +</div>
    

ファイル添付の表示は、Redmineに用意されている、<Redmineインストールディレクトリ>/app/views/attachments/_form.html.erbをrenderで呼び出します。これはフォーム内に含めます。また、ファイル添付のフォームはHTMLのmultipartを有効にする必要があります。

  • app/views/glossary_terms/edit.html.erb
    -<%= labelled_form_for :glossary_term, @term, url: project_glossary_term_path do |f| %>
    +<%= labelled_form_for :glossary_term, @term, url: project_glossary_term_path, html: {multipart: true} do |f| %>
    

新規作成画面にファイル添付が付加された画面を次に示します。

glossary_term_form-1.png


コントローラーにファイル添付追加

ファイル添付はフォームのサブミットでコントローラーに情報が渡ってきます。その際のアクションはupdateとcreateです。
まずupdateメソッドに追加します。
  • app/controllers/glossary_terms_controller.rb
       def update
         @term.attributes = glossary_term_params
    +    @term.save_attachments params[:attachments]
         if @term.save
    +      render_attachment_warning_if_needed @term
           redirect_to [@project, @term], notice: l(:notice_successful_update)
         end
    

ビューのフォームで添付ファイルを指定した場合、ビューから渡されるパラメーターの中にキー:attachmentsで添付ファイル情報が入っています。
acts_as_attachable呼び出しを追加したモデルクラスにはsave_attachmentsメソッドが付加されるので、コントローラーからそのメソッドを呼び出します。
また、添付ファイルの付与が成功したかどうかを判定し失敗していたら警告メッセージを出すrender_attachment_warning_if_neededを呼んでおきます。

save_attachmentsを入れる位置ですが、最初はif @term.saveの中に入れましたが、これではファイル添付されなかったので試行錯誤で上述コードの位置にしています。

createメソッドにも同様に追加します。

   def create
     term = GlossaryTerm.new(glossary_term_params)
     term.project = @project
+    term.save_attachments params[:attachments]
     if term.save
+      render_attachment_warning_if_needed term
       redirect_to [@project, term], notice: l(:notice_successful_create)
     end
   end

用語の詳細画面に添付ファイル表示追加

  • app/views/glossary_terms/show.html.erb
    +<%= render partial: 'attachments/links',
    +locals: {attachments: @term.attachments,
    +        options: {deletable: User.current.logged?}
    +}%>
    

詳細表示の画面の下に、部分ビューattachments/linksを追加します。

optionsをキーに指定した値ハッシュには、ここで指定しているdeletableの他に、author、thumbnailを指定することができます。
deletableは値が真のときに削除アイコンを表示し削除可能とします。

glossary_term_show-12-1.png

閲覧権限のみのユーザーは削除アイコンが表示されないようにする

用語集プラグインの閲覧権限のみを持つユーザーであっても、用語の詳細表示で添付ファイルの右わきに削除アイコンが表示されています。
ただし、削除アイコンをクリックしても実際には削除されず、403エラー画面となります。

しかし、それではUIとして今一つです。権限がない操作は表示されないのが望ましいUIです。そこで、削除の条件を次のように変更します。

  • app/views/glossary_terms/show.html.erb
     <%= render partial: 'attachments/links',
     locals: {attachments: @term.attachments,
    -        options: {deletable: User.current.logged?}
    +        options: {deletable: User.current.allowed_to?(:manage_glossary, @project)}
     }%>
    

すると、用語の管理権限(manage_glossary)を持つユーザーと、用語の閲覧権限(view_glossary)だけ持つユーザーとで添付ファイルの表示が次のように変わります。

管理権限のあるユーザーでの表示 閲覧権限のみのユーザーでの表示
attachment_manage_glossary-12-1.png attachment_view_glossary-12-2.png

用語の新規作成画面に添付ファイル追加

編集時と同様、フォームにHTMLのmultipartオプション指定を追加します。

  • app/views/glossary_terms/new.html.erb
     <%= labelled_form_for :glossary_term, @term,
    -    url: project_glossary_terms_path do |f| %>
    +    url: project_glossary_terms_path, html: {multipart: true} do |f| %>
       <%= render partial: 'glossary_terms/form', locals: {form: f} %>
    

フェーズ12の実装完了とフェーズ13へ

フェーズ12ではファイル添付機能を追加しました。フェーズ12で実施したことは次となります。

  • 用語モデルにファイル添付機能を追加(acts_as_attachable呼び出し)
  • 用語作成・編集の共通フォームにファイル添付の部分ビュー(attachments/form)描画を追加
  • 用語作成・編集のフォームオプションにHTMLのマルチパート指定追加
  • 用語コントローラーのcreate、updateアクションに添付ファイル保存追加
  • 用語詳細表示に添付ファイルの表示を追加

フェーズ13では用語集のデータ更新を「活動」に現れるようにします。


フェーズ13)活動へ用語集変更事象の表示

Redmineにはプロジェクトメニューに「活動」があり、チケットやWikiの更新がタイムライン的に記録されているのが確認できます。
この活動に用語集の更新も表示されるようにします。

フェーズ13の概略

Redmineの内部プラグインacts_as_activity_providerを利用して、活動(Activity)のビューに用語の作成変更イベントを時系列で表示されるようにします。また、プラグイン情報ファイル(init.rb)へ活動への登録を追加します。

No クラス(役割) ファイル 内容
1 GlossaryTerm glossary_term.rb 活動のイベント登録
2 プラグイン情報 init.rb 活動の登録
3 翻訳ファイル(英語) en.yml
4 翻訳ファイル(日本語) ja.yml

モデルクラスに活動イベント追加

モデルクラスに、活動に登録するためのイベント化(acts_as_event)と、活動情報を提供するacts_as_activity_providerを追加します。

まず、イベント化の追加から示します。

  • app/models/glossary_term.rb
    +  acts_as_event datetime: :updated_at,
    +                description: :description,
    +                author: nil,
    +                title: Proc.new {|o| "#{l(:glossary_title)} ##{o.id} - #{o.name}" },
    +                url: Proc.new {|o| { controller: 'glossary_terms',
    +                                     action: 'show',
    +                                     id: o.id,
    +                                     project_id: o.project }
    +  }
    

acts_as_eventメソッドにオプションハッシュを引数として渡して呼び出します。

  • datetimeキーは、イベント発生時刻を表すこのモデルの属性またはProcインスタンスを指定します。省略時のデフォルトは:created_onです。GlossaryTermの場合はマイグレーションファイルにtimestampsを指定しているので、created_atおよびupdated_atの2つの属性を有しています。更新情報はupdate_atなのでこれを指定します。
  • descriptionキーは、イベントの説明として活動ビューに表示するこのモデルの属性またはProcインスタンスを指定します。省略時のデフォルトは:descriptionです。GlossaryTermの場合は用語の説明を属性descriptionとして持つのでこれを指定します。省略時のデフォルトと一致しているのでこのキーを指定しなくても構いませんが設計情報をコードに明示する観点で記載しておくとよいです。
  • authorキーは、イベントの更新者として活動ビューに表示するこのモデルの属性またはProcインスタンスを指定します。省略時のデフォルトは:authorです。現時点ではGlossaryTermには更新者の情報を持たないので、nilを指定します。
    注)authorキーを省略すると、デフォルトでauthor属性がアクセスされ、undefined methodエラーとなってしまいます。
  • titleキーは、イベントの題名として活動ビューに表示するこのモデルの属性またはProcインスタンスを指定します。省略時のデフォルトは:titleです。GlossaryTermは題名に相当する用語名をname属性に持つので、IDと用語名を組み合わせて文字列を生成するProcを指定します。
  • urlキーは、イベントが参照するデータのURLを指定します。省略時のデフォルトは{controller: 'welcome'}です。GlossaryTermでは、コントローラーにglossary_terms、idはイベントのid、project_idはイベントのprojectとなります。
  • typeキーは、イベントの種類を区別する際に使用する名称を指定します。省略時のデフォルトは、イベントのモデルクラス名をスネークケース(小文字とアンダースコア)にしたものとなります。ここでは指定を省略しているので、モデルクラス名GlossaryTermのスネークケース glossary_termがイベントの種類として使用されます。

活動情報の提供は次の実装となります。

  • app/models/glossary_term.rb
    +  acts_as_activity_provider scope: joins(:project),
    +                            type: 'glossary_terms',
    +                            permission: :view_glossary,
    +                            timestamp: :updated_at
    

acts_as_activity_providerメソッドにオプションハッシュを引数として渡して呼び出します。

  • scopeキーは、参照書籍にはなく(書籍ではfind_optionsキーを使用していたが現在のRedmineバージョンでは削除されています)、見よう見まねで記述したものです。
  • typeキーは、acts_as_eventのものと一緒です。
    • ここではacts_as_eventでtypeキーを省略したデフォルトのtype名glossary_termと不一致になってしまいました。不一致による問題が何かは不明(未調査)です。
  • permissionは、閲覧ユーザーが活動上でそのモデルを表示するか否かを指定するのにつかわれます。省略時のデフォルトは:view_projectになります。
  • timestampは、モデルの並び順に使われます。省略時のデフォルトはcreated_onになります。

プラグイン情報への記載

init.rbに次のコードを追記します。

redmine::Activity.register :glossary_terms

参考書籍では、init.rbでRedmine::Plugin.registerのブロックの後に書けばよいとあります。
  • init.rb
    Redmine::Plugin.register :redmine_glossary do
      :
    end
    
    redmine::Activity.register :glossary_terms
    
一方、参考書籍のKnowledgeプラグインの最新コードでは、init.rbの先頭に次のようにto_prepareのブロック内に記載しています。
  • init.rb
    +Rails.configuration.to_prepare do
    +  Redmine::Activity.register :glossary_terms
    +end
    

どちらも動作しましたが、どう違うのかはこれから調査です。

活動に用語集のイベントが表示される画面を示します。

activity-13-1.png

右側のチェックボックスリストを見ると、翻訳ファイルのキー不足でエラーメッセージが表示されています。不足しているのは次のキーです。

label_glossary_term_plural

翻訳ファイルにlabel_glossary_term_plural追加

活動ページの右サイドバーに、活動の種類ごとに表示を有効化、無効化するチェックボックスが表示されています。
イベント(acts_as_event)のtypeに指定したシンボルに接頭辞label_と接尾辞_pluralともに付けた文字列が翻訳ファイルのキーとして使用されます。

  • config/locales/en.yml
    +  label_glossary_term_plural: Glossary
    
  • config/locales/ja.yml
    +  label_glossary_term_plural: 用語集
    

フェーズ13の実装完了とフェーズ14へ

フェーズ13として、用語集の作成変更のイベント情報をRedmineの活動に追加する実装を行いました。

  • モデルクラスGlossaryTermに、活動(Activity)に情報を提供するacts_as_activity_providerを追加
  • プラグイン情報init.rbに、Activityへ用語集プラグインを登録するコードを追加
  • 活動ページのチェックボックスに付与されるラベルの翻訳ファイルのキーと値を追加

フェーズ14では、用語の説明をWiki記法で記述・表示できるようにします。


フェーズ14)用語説明のwiki表示化

フェーズ14では、用語の説明記述をWiki記法対応し、Wiki表示できるようにします。

フェーズ14の概略

No. クラス(役割) ファイル 内容
1 用語フォームの部分ビュー app/views/glossary_terms/_form.html.erb 説明欄のWiki対応
2 用語詳細のビュー app/viwes/glossary_terms/show.html.erb
3 用語編集のビュー app/views/glossary_terms/edit.html.erb プレビュー対応
4 用語新規作成のビュー app/views/glossary_terms/new.html.erb
5 GlossaryTermsController app/controllers/glossary_terms_controller.rb previewメソッド追加

用語フォームのWiki対応

まず、フォームで説明(description)を記入するtext_areaのオプションで、classに'wiki-edit'を指定します。
wiki-editが指定されたtext_areaは表示幅を適切に広げるので、大きさの設定は行数だけ指定します。

  • app/views/glossary_terms/_form.html.erb
    -  <p><%= form.text_area :description, size: "80x10", required: false %></p>
    +  <p><%= form.text_area :description, rows: 10, class: 'wiki-edit', required: false %></p>
    

次に、Wiki編集時の編集ボタンバーをtext_areaの上に表示します。ファイル末尾に次を追加します。

  • app/views/glossary_terms/_form.html.erb
    +<%= wikitoolbar_for 'glossary_term_description' %>
    

引数では、Wiki編集ボタンバーを表示させたいtext_areaのIDを指定します。このIDは、edit.html.erbのlabelled_form_forの第1引数に指定したシンボル(このプラグインでは:glossary_term)と、text_areaの第1引数に指定したシンボル(このプラグインでは:description)をアンダースコアで結合した文字列(このプラグインでは'glossary_term_description')を指定します。

次のようにテキスト編集領域の上にWiki編集ボタンバーが表示されます。

glossary_terms_form-14-1.png


用語の詳細表示のWiki対応

CSSクラス"wiki"を指定したdiv要素の中に、textilizableでWiki記法のテキストを指定します。

  • app/views/glossary_terms/show.html.erb
       <tr>
         <th><%=l :field_description %></th>
    -    <td><%= @term.description %></td>
    +    <td><div class="wiki"><%= textilizable @term :description %></div></td>
       </tr>
    

表示例を次に示します。

glossary_term_show-14-1.png

textilizableの使い方メモ

textilizableは末尾のハッシュ形式で指定するオプションを除く引数の数が1つと2つのバリエーションがあります。(分かりにくい)
1つのときは、Wiki記法を含む文字列が渡されるもとのして処理され、2つのときは1つ目が属性にWiki記法を含む文字列を持つインスタンスを、2つ目が属性の名称が渡されるものとして処理されます。

添付ファイルを持つデータ(acts_as_attachableなモデルクラス)でWiki記法に添付ファイルを表示する記法がある場合、後者の指定をする必要があります。


プレビュー対応

Redmine本体のWikiやチケット、フォーラムなどでWiki記法の記述をする箇所では、プレビューが可能になっています。そこで、用語の説明記述もプレビュー対応します。

ルーティング設定

プレビューは標準のアクションにはないので、ルーティング設定で指定します。

用語の作成・編集画面からプレビューのリンクを用語コントローラーのpreviewアクションに振り分けるルーティング設定を記述します。リソースで指定したルーティングにアクションを追加するときは、メンバールーティングまたはコレクションルーティングの設定を加えます。
また、プレビューのリンクのHTTPメソッドは、 編集(editアクション)ならPATCH、新規(newアクション)ならPOSTメソッドとなるので、次の記述を追加します。

  • routes.rb
       resources :projects do
    -    resources :glossary_terms
    +    resources :glossary_terms do
    +      member do
    +        patch 'preview'
    +      end
    +      collection do
    +        post 'preview'
    +      end
    +    end
         resources :glossary_categories
       end
    

用語の編集画面からのプレビュー時は、用語インスタンスのIDがあるのでメンバールーティングでpreviewを指定、用語の新規作成画面からのプレビュー時は用語インスタンスのIDがないのでコレクションルーティングでpreviewを指定してみました。

ルーティングの設定結果を見てみます。

$ bundle exec rails routes
Prefix Verb                     URI    Pattern                                                      Controller#Action
  :
preview_project_glossary_term   POST   /projects/:project_id/glossary_terms/:id/preview(.:format)   glossary_terms#preview
preview_project_glossary_terms  POST  /projects/:project_id/glossary_terms/preview(.:format)       glossary_terms#preview
  :
  • コレクションルーティングは、本来検索結果など複数のリソースを結果とするアクションに指定するので、この使い方はきれいな方法とは言えないかもしれません。
編集画面のプレビュー

要修正:preview_linkはRedmine 4.0で廃止となりました。Wikiのテキストエリアを使用すると、タブ切替によるプレビューが標準で可能となるので、本節で記載したプレビュー処理は不要となります。

次に、用語の編集画面の下部にプレビューのリンクを設けます。

  • app/views/glossary_terms/edit.html.erb
    -<%= labelled_form_for :glossary_term, @term, url: project_glossary_term_path, html: {multipart: true} do |f| %>
    +<%= labelled_form_for :glossary_term, @term, url: project_glossary_term_path, html: {multipart: true, id: 'term-form'}
    do |f| %>
       <%= render partial: 'glossary_terms/form', locals: {form: f} %>
       <%= f.submit l(:button_edit) %>
    +  <%= preview_link preview_project_glossary_term_path(@project, @term), 'term-form' %>
     <% end %>
    
    +<div id="preview" class="wiki" ></div>
    

まずフォームの定義(labelled_form_for)のオプション引数のハッシュの中のhtmlをキーとするHTMLオプションに、id指定を追加します。
次に、preview_linkをフォームの中(サブミットの後ろ)に記述します。引数には、previewアクションのURLとフォームに追加したid属性の値を指定します。編集画面からのプレビューではURLにプロジェクトIDと用語IDが含まれるので、URLの引数にはプロジェクトと用語のインスタンス変数を指定しています。
プレビュー結果を表示する場所として、空のdiv要素を末尾に追加します。id属性には、preview_linkのターゲット指定の省略時のデフォルト値previewを指定、class属性にはwikiを指定します。プレビューリンクをクリックするとJavaScriptのコードが実行され、このdiv要素にプレビュー結果が挿入されます。

プレビューリンクは次の表示となります。

glossary_term_edit-14-1.png

プレビュー表示例は次となります。

glossary_term_edit-14-2.png

新規作成画面のプレビュー

同様に、用語の新規作成画面にも下部にプレビューのリンクを設けます。

  • app/views/glossary_terms/new.html.erb
     <%= labelled_form_for :glossary_term, @term,
    -    url: project_glossary_terms_path, html: {multipart: true} do |f| %>
    +    url: project_glossary_terms_path, html: {multipart: true, id: 'term-form'} do |f| %>
       <%= render partial: 'glossary_terms/form', locals: {form: f} %>
       <%= f.submit l(:button_create) %>
    +  <%= preview_link preview_project_glossary_terms_path(@project), 'term-form' %>
     <% end %>
    
    +<div id="preview" class="wiki"></div>
    

こちらもフォームの定義(labelled_form_for)のオプション引数のハッシュの中のhtmlをキーとするHTMLオプションに、id指定を追加します。
次に、preview_linkをフォームの中(サブミットの後ろ)に記述します。引数には、previewアクションのURLとフォームに追加したid属性の値を指定します。新規作成画面からのプレビューではURLにプロジェクトIDを含みますが用語IDは含まないので、URLの引数にはプロジェクトのインスタンス変数を指定しています。
プレビュー結果を表示する場所として、空のdiv要素を末尾に追加します。id属性には、preview_linkのターゲット指定の省略時のデフォルト値previewを指定、class属性にはwikiを指定します。プレビューリンクをクリックするとJavaScriptのコードが実行され、このdiv要素にプレビュー結果が挿入されます。

コントローラーにpreviewアクション追加

用語コントローラーにpreviwアクションを追加します。

  • app/controllers/glossary_terms_controller.rb (その1)
    -  before_action :find_project_by_project_id, :authorize
    -
    +  before_action :find_project_by_project_id, :authorize, except: [:preview]
    +  before_action :find_attachments, only: [:preview]
    

まず、権限チェックの対象からpreviewアクションを外します。(外さないとプレビューが権限エラーとなってしまう)
次にプレビュー時(部分ビュー common/preview)に添付ファイルを扱うため、インスタンス変数attachmentsに値をセットする処理をpreviewメソッド呼び出し前に入れておきます。

  • app/controllers/glossary_terms_controller.rb (その2)
    +  def preview
    +    term = GlossaryTerm.find_by_id(params[:id])
    +    if term
    +      @attachments += term.attachments
    +      @previewed = term
    +    end
    +    @text = params[:glossary_term][:description]
    +    render partial: 'common/preview'
    +  end
    

プレビューメソッドを定義します。
フォームから送られたパラメーターの中にidがあればfind_by_idで用語インスタンスを取得します。編集画面のプレビュー時はidがありますが、新規作成画面のプレビュー時はidはありません。findを使うとidがないときや用語インスタンスが見つからないときに例外を吐きますが、find_by_idであればnilを戻します。
編集時(上述でidがある場合)は、後の部分ビューcommon/previewに渡すインスタンス変数attachmentsおよびpreviewedを設定します。
プレビューするWiki記法のテキストはフォームのパラメーターからインスタンス変数textに設定します。
部分ビューcommon/previewの中では、インスタンス変数attachments、previewed、textを参照します。


参考資料


フェーズ14の完了とフェーズ15へ

フェーズ14では、用語の説明記述をWiki記法対応し、説明記述のプレビュー表示をできるようにしました。
フェーズ15では、プラグイン独自のCSSファイルを用意し、見栄えを改善します。


フェーズ15)CSSで見栄え向上

最低限の機能はそろいましたが、見栄えについてはまだまだです。

フェーズ15の概略

プラグインの表示を制御するカスケードスタイルシートを作成します。
プラグインのカスケードスタイルシートは、プラグインディレクトリ下のassets/stylesheetsディレクトリ下に置きます。

No クラス(役割) ファイル 内容
1 スタイルシート assets/stylesheets/glossary.css 見栄え
2 用語詳細のビュー app/views/glossary_terms/show.html.erb 見栄え

スタイルシートの定義

用語詳細画面では、現在ラベル文字列が中寄せとなっています(次の画面ーRedmineのバグのため表示されません)。

glossary_term_show_9-1.png

これを右寄せさせます。

  • assets/stylesheets/glossary.css
    table.term th { text-align: right; vertical-align: top }
    

セレクターに、クラス属性にtermを持つtable要素の子要素のth要素を設定し、右寄せ(text-alignをrightに指定)しています。


用語詳細画面にスタイルシート参照を追加

用語詳細画面に、スタイルシートの参照を追記します。

  • app/views/glossary_terms/show.html.erb
    +<% content_for :header_tags do %>
    +  <%= stylesheet_link_tag 'glossary', plugin: 'redmine_glossary' %>
    + <% end %>
    

stylesheet_link_tag でスタイルシート名(拡張子.cssの指定は不要)とプラグイン名を指定します。

同じく用語詳細画面のtable要素にクラス属性を追記します。

  • app/views/glossary_terms/show.html.erb
    -<table>
    +<table class="term">
    

画面例を次に示します。

glossary_term_show-15-1.png


フェーズ15の完了とフェーズ16へ

フェーズ15では、カスケードスタイルシートによる見栄えの制御を行いました。

  • プラグインディレクトリの下にassets/stylesheetsディレクトリを作成し、その中にカスケードスタイルシート・ファイル(ここではglossary.css)を新規作成
  • 詳細表示のビューshow.html.erbに、スタイルシートを参照する記述を追加

フェーズ16では、Wiki記法のマクロを定義します。


フェーズ16)マクロの定義

Wiki記法のマクロを定義します。ここでは、用語の番号および用語名を指定して用語集の用語へのリンクを生成するマクロ{{termno}}{{term}}を定義します。
フェーズ16で実現するマクロの仕様は次の通りです。

マクロ 内容
{{termno(1)}} 指定したIDの用語集へのリンク
{{term(近地点)}} 指定した名前の用語集へのリンク

フェーズ16の概略

フェーズ16として以下を作成します。

No クラス(役割) ファイル 内容
1 プラグイン設定 init.rb マクロ定義

マクロの定義は、init.rbに記述するか、別ファイルに定義してinit.rbからそのファイルを指定します。
今回は、まずinit.rbに記載した例を示し、次に別ファイルに定義した例を示します。


用語のIDを指定して用語へのリンクを生成する{{termno}}マクロ

  • init.rb
    Redmine::WikiFormatting::Macros.register do
      desc "create macro which links to glossary term." 
      macro :termno do |obj, args|
        term_id = args.first
        term = GlossaryTerm.find(term_id)
        link_to term.name, project_glossary_term_path(@project, term)
      end
    end
    
  • Redmine::WikiFormatting::Macrosのregisterメソッドを呼び、マクロ記述を登録します。
  • マクロ記述は、descでマクロの説明文を指定し、macroでマクロ名と実装を記述します。マクロ名に指定した:termnoがWiki記法で{{termno(..)}}となります。
  • マクロ定義の実装に渡されるパラメーターは2つあります。
    • 一つ目のobjはマクロを描画するコンテンツ(モデルインスタンス)で、WikiContentやIssueなどのモデルクラスのインスタンスです。
    • 二つ目のargsは、Wiki記法でマクロ名の直後に指定した引数の配列です。
  • 今回は、マクロにはIDが引数として渡されるので、argsの最初の要素をfirstで取得し、それをIDとして用語モデルクラスGlossaryTermにfindを呼び用語インスタンスを取得します。
  • 取得したら、その用語の詳細表示(showアクション)へリンクする記述をします。

マクロの記述と表示結果を次に示します。引数に存在しないIDや数値以外を指定した場合と引数を省略した場合の例も並べています。

macro_termno-1.png

init.rbはサーバーを起動するときに読み込まれるので、マクロ実装をinit.rbに記載した場合、開発中にマクロ定義を修正する度サーバーを再起動しなくてはなりません。


{{termno}}マクロをinit.rbとは別ファイルに定義

プラグインディレクトリ下にlibディレクトリを作成し、その中にマクロ定義を記述したソースファイルを配置します。

redmine_glossary
  +-- app
  +-- assets
  +-- config
  +-- db
  +-- lib
  |     +-- glossary_macros.rb
  +-- test
  +-- init.rb

init.rb からマクロ定義をglossary_macros.rbに移し、init.rbからglossary_macros.rbを呼び出します。

  • init.rb に追加
    +Rails.configuration.to_prepare do
    +  require_dependency "glossary_macros" 
    +end
    

Rails.configuration.to_prepare は、Rails初期化時に1回実行されますが、developmentモードのときはリクエストの度に実行されるようです。
require_dependency はRuby on Railsのメソッドで、指定したファイルを読み込みます。ruby標準のrequireと似ていますが、developmentモードで実行しているときは読み込みファイルが変更されたときは再読み込みする点が異なります。

require_dependencyをトップレベルに指定してもよいかと思って試してみたところ、起動後、glossary_macros.rbを修正した後はマクロが展開されなくなってしまったので(developmentモード)、require_dependencyだけではうまくいかないようです。

  • glossary_macros.rb
    module GlossaryMacros
    
      Redmine::WikiFormatting::Macros.register do
        desc "create macro which links to glossary term by id." 
        macro :termno do |obj, args|
          term_id = args.first
          term = GlossaryTerm.find(term_id)
          link_to term.name, project_glossary_term_path(@project, term)
        end
      end
    
    end
    

require_dependencyで呼ばれるファイルの先頭にはmodule定義が必要なようです。
モジュール名は、libディレクトリから下のファイルへのパスをキャメルケースで記述します。ディレクトリが入るときは、::で区切ります。
例)

lib/glossary_macros.rb -> GlossaryMacros
lib/redmine_glossary/macros.rb -> RedmineGlossary::Macros


{{term}}マクロを追加

glossary_macros.rb に、{{term}}マクロの定義を追加します。
引数は1つで用語名を記述します。

  • lib/glossary_macros.rb
    +    desc "create macro which links to glossary term by name." 
    +    macro :term do |obj, args|
    +      term = GlossaryTerm.find_by(name: args.first)
    +      link_to term.name, project_glossary_term_path(@project, term)
    +    end
    

GlossaryTerm モデルクラスのfind_byで属性nameの検索をします。見つかったGlossaryTermインスタンスの
詳細表示へのリンクを生成します。

マクロtermの使用例を次に示します。

macro_term-1.png


エラー処理の追加

マクロの使用において、パラメーターの指定を間違えた場合に、分かりやすいエラーメッセージを表示します。

まず、termnoマクロのエラー処理を追加します。

  • lib/glossary_macros.rb
         macro :termno do |obj, args|
    -      term_id = args.first
    -      term = GlossaryTerm.find(term_id)
    -      link_to term.name, project_glossary_term_path(@project, term)
    +      begin
    +        raise 'no parameters' if args.count.zero?
    +        raise 'too many parameters' if args.count > 1
    +        term_id = args.first
    +        term = GlossaryTerm.find(term_id)
    +        link_to term.name, project_glossary_term_path(@project, term)
    +      rescue => err_msg
    +        raise <<-TEXT.html_safe
    +Parameter error: #{err_msg}<br>
    +Usage: {{termno(glossary_term_id)}}
    +TEXT
    +      end
         end
    
  • begin~rescue~endの構文で、例外の発生と対処を記述
  • マクロの引数が使用方法と違う場合(引数なし、および引数が2個以上)に、raiseで例外を上げ、rescueで例外表示
  • rescueではヒアドキュメントでエラーメッセージを作成しraise
  • findは条件に合致するレコードが存在しない場合は例外を出すので、rescueで拾える

次に、termマクロのエラー処理を追加します。

  • lib/glossary_macros.rb
         macro :term do |obj, args|
    -      term = GlossaryTerm.find_by(name: args.first)
    -      link_to term.name, project_glossary_term_path(@project, term)
    +      begin
    +        raise 'no parameters' if args.count.zero?
    +        raise 'too many parameters' if args.count > 1
    +        term = GlossaryTerm.find_by!(name: args.first)
    +        link_to term.name, project_glossary_term_path(@project, term)
    +      rescue => err_msg
    +        raise <<-TEXT.html_safe
    +Parameter error: #{err_msg}<br>
    +Usage: {{term(name)}}
    +TEXT
    +      end
         end
    

ほぼ先のtermnoマクロのエラー処理と同様です。
find_byは、条件に合致するレコードが存在しない場合nilが返却されるので、rescueでレコードが存在しない場合を拾えません。
そこで、find_by!を使うことでレコードが存在しない場合例外を出す振る舞いとします。

次にエラーメッセージを含むマクロの画面を示します。

macro-termno_term-1.png


フェーズ16の実装完了とフェーズ17へ

フェーズ16では、用語詳細表示へのリンクを生成するマクロを2種類定義しました。
フェーズ17では、テストを導入します。


フェーズ17) ユニットテスト

Redmineプラグインのテストでは、RubyおよびRuby on Railsに標準で搭載されるMinitestと呼ぶテスト機構を使うもののほか、別途gemを追加してRSpecと呼ぶテスト機構を使うものが定番のようです。RSpecの方が高度なようですが、覚えることも多くなるので、ここでは標準で使用可能なMinitestでテストを実施します。

プラグインの雛形を生成した際に、Minitest機構で使用するテストディレクトリとテストコードの雛形も生成されます。
Minitestでは、テストの種類として次の3種類が用意され、それぞれtestディレクトリ下に括弧内にある名前のディレクトリとして用意されます。

  • ユニットテスト(unit)
  • 機能テスト(functional)
  • 統合テスト(integration)

ユニットテストは主にモデルを対象としたテストです。
機能テストは主にコントローラーのアクションを対象としたテストです。
統合テストは複数のコンポーネントをまたいだテストです。

テストで使用するデータはフィクスチャ(fixture)と呼ぶ外部ファイルに記載します。

Ruby on Rails 5以降では、テストディレクトリがmodels、controllers、helpers、integration、systemと分類されています。ただし、従来のunit、functional、integrationでも問題ありません。Redmine 4.0では テストディレクトリを移動されるチケット が発行されています。

フェーズ17では、まずユニットテストを実施します。

フェーズ17の概略

フェーズ17では、モデルを対象とするユニットテストを実施します。
テストデータは、テストコード中に記載する他、外部ファイル(YAML形式)で提供することができます。後の機能テストや統合テストで同じデータを使うことを考慮すると外部データの方が冗長性がなくて済みます。

No クラス(役割) ファイル 説明
1 GlossaryCategoryTest test/unit/glossary_category_test.rb カテゴリモデルのユニットテスト
2 GlossaryCategoryのテストデータ test/fixtures/glossary_categories.yml
3 GlossaryTermTest test/unit/glossary_term_test.rb 用語モデルのユニットテスト
4 GlossaryTermのテストデータ test/fixtures/glossary_terms.yml

プラグインのテストデータを読み込む準備

Redmineプラグインのテストは、testモード(RAILS_ENV=test)で実行し、testモード専用のデータベースを使用します。テストを実行するとデータベースが刷新されるので、本番/開発モードとは別なデータベースを使います。SQLiteの場合はファイルを別にします。

  • Redmie本体/config/database.yml
    # SQLite3 configuration example
    production:
      adapter: sqlite3
      database: db/redmine.sqlite3
    
    development:
      adapter: sqlite3
      database: db/redmine.sqlite3
    
    test:
      adapter: sqlite3
      database: db/redmine_test.sqlite3
    

Redmineプラグインのテストに関わるディレクトリ構造は次の通りです。

plugin/redmine_glossary/
    +-- test/
          +-- fixtures/
          +-- functional/
          +-- integration/
          +-- unit/
  • fixturesディレクトリはテストで使うデータを定義するファイルを格納するためのものです。テストで使うデータはYAML形式ファイルで記述します。
  • functionalディレクトリは機能テストで使うコードを格納するためのものです。
  • integrationディレクトリは統合テストで使うコードを格納するためのものです。
  • unitディレクトリはユニットテストで使うコードを格納するためのものです。

プラグインの雛形を生成するときに、テストデータを置くためのtest/fixturesディレクトリが作られています。ここにプラグインのテストで使用するデータを置きますが、テスト実行時にはこのディレクトリにあるデータは読み込まれません。テスト実行前にプラグインのtest/fixturesディレクトリにあるデータファイルを、Redmine本体のテストデータと同じディレクトリにコピーすれば読み込まれますが、かなりの手間です。そこで、プラグインのtest/test_helper.rbにプラグインのtest/fixturesディレクトリから読み込むメソッドを定義します。

テストモードでテストを実行すると、データベースに対する次のタスクが裏で実行されます。

  • db:drop
  • db:create
  • db:migrate
  • redmine:plugins:migrate
  • redmine:load_default_data

プラグインのテスト実行環境の確認

用語集のモデルクラスは、GlossaryTermがGlossaryCategoryに所属している、つまりGlossaryTermはGlossaryCategoryに依存しているので、依存関係のないGlossaryCategoryのテストから着手します。

プラグインの雛形を生成する際に、次のテストコードの雛形が生成されているので、これを出発点にします。

  • test/unit/glossary_category_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryCategoryTest < ActiveSupport::TestCase
    
      # Replace this with your real tests.
      def test_truth
        assert true
      end
    end
    
  • モデルクラスのテストは、プラグインディレクトリのtest/unit/ディレクトリに置きます。
  • クラス名は、モデル名(GlossaryCategory)の後ろにTestを付けたGlossaryCategoryTestとなります。ファイル名はクラス名を小文字スネークケースにしたglossary_category_test.rbとなります。
  • ユニットテスト(モデルのテスト)は、ActiveSupport::TestCaseを継承します。
  • テスト用のメソッドは、名前がtestで始まる必要があります。
  • テストの成否判定はassert系のメソッドを使用します。
ユニットテストを実行してみる
redmine$ bundle exec rails redmine:plugins:test:units NAME=redmine_glossary RAILS_ENV=test
Run options: --seed 57547

# Running:

..

Finished in 0.010143s, 197.1754 runs/s, 197.1754 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips
  • railsコマンドで、redmine:plugin:test:unitsを実行します。デフォルトではすべてのプラグインのテストを実行します。
  • NAMEにテストを実行するプラグインを指定します。
  • RAILS_ENVにtestを指定します。testモードでのデータベースに、フィクスチャとして用意したデータが読み込まれます。
  • 雛形として生成されたテストコードが実行されています(各モデルにつき1個のテストメソッドがあり、各テストメソッドに1つのassertがあります)。
テストを失敗させる

テスト実行環境が正しく動いていることを確認するため、テストを失敗させるコードを書いてテストを実行します。

  • test/unit/glossary_category_test.rb
       def test_truth
    -    assert true
    +    assert false
       end
    

テストを実行します。

redmine$ bundle exec rails redmine:plugins:test:units NAME=redmine_glossary RAILS_ENV=test
Run options: --seed 21986

# Running:

F

Failure:
GlossaryCategoryTest#test_truth [/work/redmine/plugins/redmine_glossary/test/unit/glossary_category_test.rb:7]:
Expected false to be truthy.

bin/rails test plugins/redmine_glossary/test/unit/glossary_category_test.rb:6

.

Finished in 0.007657s, 261.1838 runs/s, 261.1838 assertions/s.
2 runs, 2 assertions, 1 failures, 0 errors, 0 skips

テストが1件失敗したことから、テスト実行環境は正しく実行されていることが確認できました。


プラグインのテストデータの作成(カテゴリモデル)

カテゴリモデルのテスト用フィクスチャを作成します。

  • test/fixtures/glossary_categories.yml
    color:
      id: 1
      name: Color
      project_id: 1
    
    shape:
      id: 2
      name: Shape
      project_id: 1
    

プラグインのユニットテストの作成(カテゴリモデル)

モデルのテストでは、主に次を実施します。

  • validationのテスト
  • データベースの制約(整合性)テスト
  • publicなメソッドのテスト
validの確認、最初の一歩

まず、正常なデータを読み込み、validであることを確認するテストを記述します。

  • test/unit/glossary_category_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryCategoryTest < ActiveSupport::TestCase
      plugin_fixtures :glossary_categories
    
      def test_valid
        assert GlossaryCategory.find(1).valid?
      end
    end
    
  • プラグインのtest/fixtures/glossary_categories.yml フィクスチャを読み込むため、plugin_fixturesメソッドでファイル名(拡張子.ymlを除いたもの)を指定
  • フィクスチャのid=1をモデルインスタンスとしてfindで読み込み、validを確認し結果をassertでテスト判定

これを実行し、エラー無くテストが成功することを確認します。

redmine$ bundle exec rails redmine:plugins:test:units NAME=redmine_glossary RAILS_ENV=test
Run options: --seed 33795

# Running:

..

Finished in 0.011835s, 168.9836 runs/s, 168.9836 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips
フィクスチャの名前でテストデータを参照する

最初の一歩のコードでは、idを指定してテストデータを参照しました。
フィクスチャでは、レコード(インスタンス)1つ1つに名前を付けて定義をしています。この名前を使ってテストデータを参照します。

  • test/unit/glossary_category_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryCategoryTest < ActiveSupport::TestCase
      fixtures :glossary_categories
      plugin_fixtures :glossary_categories
    
      def test_valid
        category = glossary_categories('color')
        assert category.valid?
      end
    end
    
  • まずfixturesメソッドでフィクスチャ名を指定します。本来、Redmine本体のディレクトリ直下にあるtest/fixtures/下のフィクスチャファイルはこれでテストデータとして読み込まれるのですが、プラグインの下にあるtest/fixturesはこれでは読み込まれないので先に述べたようにplugin_fixturesでテストデータを読み込みます。ただし、フィクスチャの名前でデータを参照するには、plugin_fixturesメソッドで指定しただけではだめで、fixturesメソッドでも指定する必要があります。
  • テストデータを参照するときは、fixturesメソッドで指定したフィクスチャ名のメソッドで名前を指定します。
テスト実施前に共通で実行するコードをsetupメソッドに定義する

テストメソッド実行前に必ず実行するコードをsetupメソッドに定義すると各テストコードがシンプルに、冗長なコードを削減できます。

  • test/unit/glossary_category_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryCategoryTest < ActiveSupport::TestCase
      fixtures :glossary_categories
      plugin_fixtures :glossary_categories
    
      def setup
        @category = glossary_categories('color')
      end
    
      def test_valid
        assert @category.valid?
      end
    end
    
  • setupメソッドで、フィクスチャ名colorのカテゴリインスタンスを取得、インスタンス変数に格納(他のメソッドで参照するため)
テストの追加

カテゴリモデルにはvalidationをかけていないので、データベース制約のテストを追加します。と思いきや、スキーマを見ると

カラム名 制約
id INTEGER PRIMARY KEY, AUTO INCREMENT, NOT NULL
name VARCHAR
project_id INTEGER

となっており、カラムid以外に制約がありません。idは自動で発行されるのでテストの効果はあまりないので通常テストはしません。
ということで、テストをするには、モデルクラスにvalidationを追記するかスキーマに制約を追加することから始めなくてはなりません。

ここでカテゴリモデルのテストはいったん打ち切ります。


プラグインのテストデータの作成(用語モデル)

  • test/fixtures/glossary_terms.yml
    red:
      id: 1
      project_id: 1
      category_id: 1
      name: red
    
    green:
      id: 2
      project_id: 1
      category_id: 1
      name: green
    
    blue:
      id: 3
      project_id: 2
      category_id: 1
      name: blue
    
    clear:
      id: 4
      project_id: 1
      name: clear
    
    

プラグインのユニットテストの作成(用語モデル)

まずは、1つ目のフィクスチャのデータを読み込み、valid判定します。

require File.expand_path('../../test_helper', __FILE__)

class GlossaryTermTest < ActiveSupport::TestCase
  fixtures :glossary_terms
  plugin_fixtures :glossary_terms

  def setup
    @term = glossary_terms('red')
  end

  def test_valid
    assert @term.valid?
  end
end

glossary_termsテーブルのスキーマは次です。

カラム名 制約
id INTEGER PRIMARY KEY, AUTO INCREMENT, NOT NULL
name VARCHAR NOT NULL
description TEXT
category_id INTEGER
project_id INTEGER
name_en VARCHAR DEFAULT ''
rubi VARCHAR DEFAULT ''
abbr_whole VARCHAR DEFAULT ''
datatype VARCHAR DEFAULT ''
codename VARCHAR DEFAULT ''
created_at DATETIME NOT NULL
update_at DATETIME NOT NULL

フィクスチャを読み込みデータベースに格納する際に、created_atとupdate_atはフィクスチャに定義されていない場合は、自動で設定されます。
それ以外でカラムの定義を記述していないと、NULLがカラムに格納されます。

name_enなどのように、DEFAULTで空文字列を設定しておきながら、NOT NULL制約を付けていないカラムは、フィクスチャの読み込みでデータベースにNULLが入ってしまいます。NULLの必要性がないならば、設計上DEFAULTを指定した場合はNOT NULL制約をかけた方がよいでしょう。

続いて、データベース制約のテストを追加します。

+  def test_invalid_without_name
+    @term.name = nil
+    assert_raises ActiveRecord::NotNullViolation do
+      @term.save
+    end
+  end

カラムnameはNOT NULLなので、モデルインスタンスのnameにnilを入れてsaveメソッドを呼びます。制約違反なので、例外が発生すればテストに合格です。例外発生を判定するにはassert_raisesメソッドを使います。期待する例外クラスを引数に指定し、例外発生コードをブロックで渡します。
------------------------------------------------------------------------------------------------------------------

参考資料

Redmine3でのテストあれこれ〜minitest

あなたのコードにハナマルを。ボッチ開発でも出来るプラグインテスト初めの一歩

Rails 5.0の変更点(仮)
assert_templateが非推奨になった件について記載あり(理由も記載あり)

Testing Rails Applications - RAILS GUIDES


フェーズ17の実装完了とフェーズ18へ

フェーズ17として、モデルのテストであるユニットテストの最小限の実装をしました。フェーズ17で実施したことを簡潔にまとめます。

  • テストで使用するプラグインのフィクスチャ(テストデータ)ファイルを読み込む処理をプラグインのtest/test_helper.rbに実装
  • カテゴリクラスのフィクスチャを作成(test/fixtures/glossary_categories.yml)
  • カテゴリクラスのテストコードを作成(test/unit/glossary_category_test.rb)
  • 用語クラスのフィクスチャを作成(test/fixtures/glossary_terms.yml)
  • 用語クラスのテストコードを作成(test/unit/glossary_term_test.rb)

フェーズ18では、コントローラーのアクションをテストする機能テストを実施します。


フェーズ18)機能テスト

フェーズ18では、コントローラーのアクションをテストする機能テストを実施します。

フェーズ18の概略

No クラス(役割) ファイル 説明
1 GlossaryCategoriesControllerTest test/functional/glossary_categories_controller_test.rb カテゴリコントローラーの機能テスト
2 GlossaryTermsControllerTest test/functional/glossary_terms_controller_test.rb 用語コントローラーの機能テスト

Redmine本体のテストデータ

コントローラーの機能テストでは、コントローラーが扱うモデルのテストデータの他、ユーザーやプロジェクトなどRedmine本体のモデルのデータも必要となります。
Redmine本体のモデルのテストデータは、Redmine本体のtest/fixturesディレクトリにあるフィクスチャを読み込むことで利用可能となります。プラグインのテストでは、これらのデータを参照することで、プラグイン側でプロジェクト、ユーザー、ロールなどを一からテストデータとして用意せずに済みます。

テスト用プロジェクト

Redmine本体のtest/fixtures/projects.yml に定義されています。

ID 識別子 名前 公開 親プロジェクト メンバー
1 ecookbook eCookbook 公開 - jsmith(管理者), dlopper(開発者), dlopper2(開発者)
2 onlinestore OnlineStore 非公開 - jsmith(開発者), miscuser8(開発者), B Team(開発者)
3 subproject1 eCookbook Subproject 1 公開 1
4 supbroject2 eCookbook Subproject 2 公開 1
5 private-child Private child of eCookbook 非公開 1 admin(管理者), jsmith(管理者), miscuser8(管理者), A Team(開発者)
6 project6 Child of private child 公開 5
テスト用ユーザー

Redmine本体のtest/fixtures/users.yml に定義されています。

ID login status admin
1 admin 有効
2 jsmith 有効
3 dlopper 有効
4 rhill 有効
5 dlopper2 ロック
6 - 匿名 匿名ユーザー
7 someone 有効
8 miscuser8 有効
9 miscuser9 有効
10 (A Team) 有効
11 (B Team) 有効
12 Non member users 有効
13 Anonymous users 有効

ID:10~13はグループがデータとして設定されます(省略)。

テストに使用するプロジェクト・ユーザー・ロールなど
  • 公開プロジェクトのeCookbook(ID:1)
    ユーザーID:2 のjsmith が、eCookbookの管理者ロールでメンバーとなっています。

カテゴリコントローラーの機能テストの記述と実行

まずは、カテゴリの一覧表示(GlossaryCategoriesControllerindexアクション呼び出し)のテストを作っていくこととします。
次に、カテゴリの編集フォームの表示(GlossaryCategoriesControllereditアクションを呼び出し)と編集フォームのサブミット(updateアクション)のテストを作ります。
続いて、新規フォームの表示(newアクション)と新規フォームのサブミット(createアクション)のテストを作り、削除(dropアクション)のテストを作ります。

一覧表示(indexアクション)のテスト

第1歩として、WebからのHTTPリクエストを模擬し、indexアクションが成功を返すことのテストを記述します。

アクションを呼び出し、正しく振舞うためには、呼び出し時に必要な権限、プロジェクト、ユーザー、ログイン状態が整っている必要があります。
用語集プラグイン(Redmine Glossaryプラグイン)のRedmine 4.0移行対応において、条件を整えるまでの試行錯誤を次の日記に書いています。若干コードは異なりますが、ほぼ本再構築作業と同じ道筋となります。

Redmine 4.0(Rails 5.2)のプラグインをテストするなど(コントローラーのテスト編の続き)

以下がindexアクションを呼び出し成功を返すことのテストコードです。

  • test/functional/glossary_categories_controller_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryCategoriesControllerTest < ActionController::TestCase
      fixtures :projects, :users, :roles, :members, :member_roles
    
      def setup
        @project = projects('projects_001')
        @project.enabled_module_names = [:glossary]
        roles('roles_001').add_permission! :view_glossary, :manage_glossary
      end
    
      def test_index
        @request.session[:user_id] = users('users_002').id
        get :index, params: {project_id: 1}
        assert_response :success
      end
    end
    
  • テストで使用するプロジェクトは、Redmine本体のフィクスチャで定義しているeCookbookを取得します。
      fixtures :projects
        :
      @project = projects('projects_001')
    
  • そのプロジェクトに利用可能モジュールを追加します。
    @project.enabled_module_names = [:glossary]
  • プロジェクトのロール(ここでは管理者ロール)に用語集の権限を付加します。
    roles('roles_001').add_permission! :view_glossary, :manage_glossary
  • indexアクション呼び出し時のリクエストパラメーターに、ログインしているユーザー(カレントユーザー)を設定します。
    @request.session[:user_id] = users('users_002').id
  • indexアクションをプロジェクトIDを指定して呼び出します。
    get :index, params: {project_id: 1}
  • indexアクションの応答が成功かどうかを判定します。
    assert_response :success

機能テストを実行します。

redmine$ bundle exec rails redmine:plugins:test:functionals NAME=redmine_glossary RAILS_ENV=test
Run options: --seed 8912

# Running:

..

Finished in 0.392184s, 5.0996 runs/s, 5.0996 assertions/s.
2 runs, 2 assertions, 0 failures, 0 errors, 0 skips
編集フォームの表示(editアクション)のテスト

editアクションは、編集フォームを表示します。

  def test_edit
    @request.session[:user_id] = users('users_002').id
    get :edit, params: {id: 1, project_id: 1}
    assert_response :success
    assert_select 'form', true
  end
  • editアクションを、カテゴリのID=1とプロジェクトのID=1をパラメーターとして呼び出します。
  • 成功を返すこと、結果のHTMLにform要素が含まれていることをテスト結果として判定しています。
編集フォームのサブミット(updateアクション)のテスト
  def test_update
    @request.session[:user_id] = users('users_002').id
    patch :update, params: {id: 1, project_id: 1, glossary_category: {name: 'Co\
lour'}}
    category = GlossaryCategory.find(1)
    assert_redirected_to project_glossary_category_path(@project, category)
    assert_equal 'Colour', category.name
  end
  • updateアクションを、フォームのサブミットのパラメータにカテゴリのID=1、プロジェクトのID=1、フォーム内容としてnameをColourにする値を入れて呼び出します。
  • updateアクションはshowアクションにリダイレクトするので、名前付きルーティング設定でリダイレクト先が正しいか判定しています。
  • updateアクションの中でデータベースに変更が反映されるので、次にカテゴリのID=1で再度検索し取得し、そのnameがフォームで渡したものと一致するか判定しています。
新規フォームの表示(newアクション)のテスト

editアクションとほぼ同じテストコードとなります。違いは、呼び出すアクションがnewとなる点です。

  def test_new
    @request.session[:user_id] = users('users_002').id
    get :new, params: {id: 1, project_id: 1}
    assert_response :success
    assert_select 'form', true
  end
新規フォームのサブミット(createアクション)のテスト

新規フォームのサブミットなので、HTTPのリクエストパラメーターには、id指定は不要で、必須カラムに値を詰めておきます。

  def test_create
    @request.session[:user_id] = users('users_002').id
    post :create, params: {
      project_id: 1, glossary_category: {name: 'Material'}
    }
    category = GlossaryCategory.find_by(name: 'Material')
    assert_not_nil category
    assert_redirected_to project_glossary_category_path(@project, category)
  end
削除(destroyアクション)のテスト

id指定でカテゴリを削除します。

  def test_destroy
    @request.session[:user_id] = users('users_002').id
    delete :destroy, params: { id: 1, project_id: 1 }
    assert_raise(ActiveRecord::RecordNotFound) { GlossaryCategory.find(1) }
    assert_redirected_to project_glossary_categories_path(@project)
  end

まず、HTTPメソッドのDELETEで、destroyアクションを呼び出し、リクエストパラメータ-には削除するカテゴリのidとプロジェクトidを指定します。
削除されたカテゴリのidはデータベースには存在しないので、assert_raiseで例外の型を指定、ブロックで例外の発生するコードを記述します。


用語コントローラーの機能テストの記述と実行

用語の一覧表示(GlossaryTermsControllerindexアクション呼び出し)のテストを記述し、次に、編集フォームの表示(同editアクションを呼び出し)と編集フォームのサブミット(updateアクション)のテストを記述します。
続いて、新規フォームの表示(newアクション)と新規フォームのサブミット(createアクション)のテストを作り、削除(dropアクション)のテストを作ります。

一覧表示(indexアクション)のテスト

カテゴリコントローラーと同様に記述します。

  • test/functional/glossary_terms_controller_test.rb
    require File.expand_path('../../test_helper', __FILE__)
    
    class GlossaryTermsControllerTest < ActionController::TestCase
      fixtures :projects, :users, :roles, :members, :member_roles
    
      def setup
        @project = projects('projects_001')
        @project.enabled_module_names = [:glossary]
        roles('roles_001').add_permission! :view_glossary, :manage_glossary
      end
    
      def test_index
        @request.session[:user_id] = users('users_002').id
        get :index, params: {project_id: 1}
        assert_response :success
      end
    end
    
編集フォームの表示(editアクション)のテスト
+   plugin_fixtures :glossary_terms
    :
+   def test_edit
+     @request.session[:user_id] = users('users_002').id
+     get :edit, params: {id: 1, project_id: 1}
+     assert_response :success
+     assert_select 'form', true
+   end
編集フォームのサブミット(updateアクション)のテスト
  def test_update
    @request.session[:user_id] = users('users_002').id
    patch :update, params: {id: 1, project_id: 1, glossary_term: {
      name: 'rosso'
    }}
    term = GlossaryTerm.find(1)
    assert_redirected_to project_glossary_term_path(@project, term)
    assert_equal 'rosso', term.name
  end
新規フォームの表示(newアクション)のテスト
  def test_new
    @request.session[:user_id] = users('users_002').id
    get :new, params: {project_id: 1}
    assert_response :success
    assert_select 'form', true
  end
新規フォームのサブミット(createアクション)のテスト
  def test_create
    @request.session[:user_id] = users('users_002').id
    post :create, params: { project_id: 1, glossary_term: {
      name: 'white', category_id: 1
    }}
    term = GlossaryTerm.find_by(name: 'white')
    assert_not_nil term
    assert_redirected_to project_glossary_term_path(@project, term)
  end
削除(destroyアクション)のテスト
  def test_destroy
    @request.session[:user_id] = users('users_002').id
    delete :destroy, params: {id: 1, project_id: 1}
    assert_raise(ActiveRecord::RecordNotFound) { GlossaryTerm.find(1) }
    assert_redirected_to project_glossary_terms_path(@project)
  end

フェーズ18の完了とフェーズ19へ

フェーズ18では、機能テストとしてコントローラーのアクションに対するテストを実施しました。
フェーズ19では、統合テストを実施します。


フェーズ19)統合テスト

T.B.D.


フェーズ20)機能向上いろいろ

細かな機能追加を実施していきます。

  1. カテゴリ一覧表示で並び替え操作を可能にする #

カテゴリの並び替え

一覧表示の順番を変更できるようにします。Redmine標準搭載の機能を使用します。Redmineでは、[管理]メニューから[トラッカー]で一覧表示をすると、トラッカーの各行の右端に両端矢印のアイコンが表示されますが、それをドラッグ&ドロップで上下に移動させることができます。

tracker_list-1.png

「バグ」、「機能」、「サポート」とトラッカーが並んでいます。ここで、「サポート」の右側にある両端矢印アイコン付近をドラッグし、「バグ」のちょっと上にドロップしようとしているのが次の画面です。

tracker_list-2.png

このように並び替え操作ができる機能を、カテゴリに導入します。

カテゴリのモデルにacts_as_positioned設定
  • app/models/glossary_category.rb
     class GlossaryCategory < ActiveRecord::Base
       has_many :terms, class_name: 'GlossaryTerm', foreign_key: 'category_id'
       belongs_to :project
    +
    +  acts_as_positioned
    +
    +  scope :sorted, lambda { order(:position) }
    +
     end
    

Redmine標準搭載のacts_as_positioned を使用します。
sorted で属性position順に並べます。

属性positionはデータベースのglossary_categoriesにカラムとして追加する必要があります。

マイグレーションコード追加
  • db/migrate/006_add_position_to_glossary_categories.rb
    class AddPositionToGlossaryCategories < ActiveRecord::Migration[5.2]
    
      def change
        add_column :glossary_categories, :position, :integer, default: nil
      end
    end
    

glossary_categoriesテーブルにpositionカラムを追加します。
Redmineのtrackersテーブルを参考にして、DEFAULT NULLが指定されるようにしています。

カテゴリの一覧表示
  • app/views/glossary_categories/index.html.erb
    -<table class="list">
    +<table class="list table-sortable">
       <thead>
         <tr>
           <th>#</th>
           <th><%=l :field_name %></th>
    +      <th/>
         </tr>
       </thead>
       <tbody>
         <% @categories.each do |category| %>
           <tr>
            <td class="id"><%= category.id %></td>
            <td class="name"><%= link_to category.name, [@project, category] %></td>
    +       <td class="buttons">
    +         <%= reorder_handle(category, url: project_glossary_category_path(@project, category)) %>
    +       </td>
           </tr>
         <% end %>
       </tbody>
     </table>
    +
    +<%= javascript_tag do %>
    +  $(function() { $("table.table-sortable tbody").positionedItems(); });
    +<% end %>
    
  • <table>のクラス指定にtable-sortableを追加します。あとでJavaScriptコードから触るためです。
  • <table>に列を1つ追加します。列ヘッダーは空で、列本体はreorder_handleメソッド呼び出しを記述します。
    引数の1つ目は対象オブジェクトとなるカテゴリで、引数の2つ目は並び替え操作でPOSTメソッドを呼ぶURLを指定します。URLを指定しないと、1つ目の引数のリソースからURLを生成しますが、今回のようにネストしたリソースでは外側の情報がないので正しく動作しません。
  • <javascript_tag>で、テーブルの並び替え操作をしたときの処理を呼ぶJavaScriptコードを記述します。
カテゴリのコントローラー

カテゴリコントローラーを並び替え操作対応します。

  • app/controllers/glossary_categories_controller.rb

まず、一覧表示では並び順であるカテゴリの属性position順に並べた一覧を取得します。

   def index
-    @categories = GlossaryCategory.where(project_id: @project.id)
+    @categories = GlossaryCategory.where(project_id: @project.id).sorted
   end

並び替え操作をすると、コントローラーのupdateアクションが呼ばれます。並び替え操作によるupdateアクション呼び出し時は、通常のupdateアクション呼び出しと異なるのでフォーマットにより処理を振り分けます。

   def update
     @category.attributes = glossary_category_params
     if @category.save
-      redirect_to [@project, @category], notice: l(:notice_successful_update)
+      respond_to do |format|
+        format.html {
+          redirect_to [@project, @category],
+            notice: l(:notice_successful_update)
+        }
+        format.js {
+          head 200
+        }
+      end
     end
  • updateアクションでは、リクエストの形式がHTMLの場合とJavaScriptの場合をrespond toで分けています。
    JavaScriptのときは、クライアントの表示はカテゴリ一覧のままでよいのでredirect_toはせずに単に応答ヘッダーのみを返すようにしています。
   def glossary_category_params
     params.require(:glossary_category).permit(
-      :name
+      :name, :position
     )
   end

ストロングパラメータでの許可属性に、並び替え操作で変更するpositionを追加します。

動作例

右端の上下矢印アイコンをドラッグして順番を変えている画面を次に示します。id=1のカテゴリがドラッグ操作で移動する途中を示しています。

category_reorder-1.png


カテゴリ一覧で各カテゴリの右端に編集・削除アイコン

フェーズ8の用語詳細表示の右上にある編集、削除リンク に記載のコードで編集・削除アイコンを追加します。

  • app/views/glossary_categories/index.html.erb
            <td class="buttons">
              <%= reorder_handle(category, url: project_glossary_category_path(@project, category)) %>
    +         <%= link_to_if_authorized l(:button_edit), {
    +           controller: :glossary_categories, action: :edit, id: category,
    +           project_id: @project
    +         }, class: 'icon icon-edit' %>
    +         <%= link_to_if_authorized l(:button_delete), {
    +           controller: :glossary_categories, action: :destroy, id: category,
    +           project_id: @project
    +         }, method: :delete, data: {confirm: l(:text_are_you_sure)},
    +         class: 'icon icon-del' %>
            </td>
           </tr>
    

カテゴリ一覧表示画面は次の様になります。

category_index_with_icons-1.png



フェーズ21)再構築前のデータベースマイグレーション

これまでの再構築では、データベースをゼロから作ってきました。ですから、再構築前のデータベースを引き継ぐこと(マイグレーション)はできません。再構築前のプラグインを使用している場合、再構築後のプラグインに入れ替えた後にデータを一から登録し直す必要があります。ちょっとひどいですね。

そこで、再構築前のデータベースを引き継げるようにします。(参照:[#96])

再構築前のデータベース

再構築前のデータベースは、3つのテーブルから構成されています。
  • terms
  • term_categories
  • glossary_style

再構築後のデータベース

テーブル名と一部カラム名を変更し、また未使用と思われるカラムを削除し、次の3つのテーブルを作成します。

  • glossary_terms
  • glossary_categories
  • glossary_view_settings

テーブルの変更内容

terms から glossary_terms への変更

termsテーブルについては次の表に示すようにテーブル名とカラム名の変更をします。

旧テーブルterms 新テーブルglossary_terms
カラム名 カラム型 カラム名 カラム型 備考
id int(11) auto_increment 同左
project_id int(11) 同左
category_id int(11) 同左
author_id int(11) 同左 モデルクラスに要追加
updater_id int(11) 同左 モデルクラスに要追加
name varchar(255) 同左
name_en varchar(255) 同左
datatype varchar(255) 同左
codename varchar(255) 同左
description text 同左
created_on datetime created_at datetime
updated_on datetime updated_at datetime
rubi varchar(255) 同左
abbr_whole varchar(255) 同左
tech_en varchar(255) 削除(未使用)
name_cn varchar(255) 削除(未使用)
name_fr varchar(255) 削除(未使用)
  • author_id と updater_idは、用語を作成および編集したユーザーのidを保持します。
  • created_on と updated_onは、ActiveRecordのtimestamps対応のため名前を変更します。
  • tech_en, name_cn, name_frは使用箇所が見当たらないので削除します。
term_categories から glossary_categories への変更
旧テーブル term_categories 新テーブルglossary_categories
カラム名 カラム型 カラム名 カラム型
id int(11) auto_increment 同左
project_id int(11) 同左
name varchar(255) 同左
position int(11) 同左
  • テーブル名のみの変更です。
glossary_style から glossary_view_settings への変更
旧テーブル glossary_style 新テーブル glossary_view_settings
カラム名 カラム型 カラム名 カラム型 備考
id int(11) auto_increment 同左
show_desc tinyint(1)
groupby int(11)
project_scope int(11)
sort_item_0 varchar(255)
sort_item_1 varchar(255)
sort_item_2 varchar(255)
user_id int(11)
  • テーブル名のみの変更です。

マイグレーションの作成

plugin/redmine_glossary/db/migrateディレクトリの下にある既存のマイグレーションファイルをすべて削除し、再構築前のマイグレーションファイルをいったん持ち込み、そこからフェーズ20相当のスキーマになるよう変更ファイルを作成します。

  • 再構築前のマイグレーションファイル一覧
001_create_terms.rb
002_create_glossary_styles.rb
003_terms_add_columns.rb
  • 再構築前後のテーブル名とマイグレーションファイル対応一覧
再構築前のテーブル 再構築後のテーブル マイグレーションファイル名
terms glossary_terms 004_rename_glossary_terms_from_terms.rb
term_categories glossary_categories 005_rename_glossary_categories_from_term_category.rb
glossary_styles glossary_view_settings 006_rename_glossary_view_settings_from_glossary_styles.rb

テーブル名を変更するので、マイグレーションファイル名には

terms → glossary_terms へのマイグレーション
  • 004_rename_glossary_terms_from_terms.rb
    class RenameGlossaryTermsFromTerms < ActiveRecord::Migration[5.2]
      def change
        remove_column :terms, :tech_en, :string
        remove_column :terms, :name_cn, :string
        remove_column :terms, :name_fr, :string
        rename_column :terms, :created_on, :created_at
        rename_column :terms, :updated_on, :updated_at
        rename_table :terms, :glossary_terms
      end
    end
    
term_categories → glossary_categories へのマイグレーション
  • 005_rename_glossary_categories_from_term_category.rb
    class RenameGlossaryCategoriesFromTermCategories < ActiveRecord::Migration[5.2]
      def change
        rename_table :term_categories, :glossary_categories
      end
    end
    
glossary_style → glossary_view_settings へのマイグレーション
class RenameGlossaryViewSettingsFromGlossaryStyle < ActiveRecord::Migration[5.2]
  def change
    rename_table :glossary_style, glossary_view_settings
  end
end

マイグレーションの実行

クリーン状態からのマイグレーション実行

プラグイン開発環境のRedmineデータベースを削除し、クリーン状態からRedmineのデータベーススキーマ生成とプラグインのスキーマ生成をします。

D:\work\redmine> del db\redmine.sqlite3

D:\work\redmine> bundle exec rails db:migrate
  :
D:\work\redmine> bundle exec rails redmine:plugins:migrate
  :
redmine 3.4のデータベースからマイグレーション実行
D:\work\redmine> bundle exec rails db:migrate
  :
D:\work\redmine> bundle exec rails redmine:plugins:migrate
  :

残件

glossary_view_settings については、本フェーズではモデルクラスを用意していない。
今後、表示設定を永続化するタイミングでモデルクラスの生成とカラムの見直しを実施する。


フェーズ22)用語集を検索可能にする

従来の用語集プラグインは、検索専用の表示・入力ビューを独自に設けていました。再構築では、Redmine汎用の検索機能に用語集の検索を追加することとし、プラグイン側で独自の検索入力・表示は持たないことにします。

フェーズ22の概略

Redmineの標準プラグイン(lib/plugins)に収容されている acts_as_searchable を利用して検索対象に用語集を追加します。

フェーズ21として、次を作成または修正します。

No クラス(役割) ファイル 内容
1 プラグイン設定 init.rb 検索対象に用語集を追加
2 GlossaryTerm glossary_term.rb 検索可能に

プラグイン設定で検索対象に用語集を追加

設定ファイル init.rb に、Redmine::Search.available_search_types へ用語モデルを追加する記述を1行追加します。

  • init.rb
     Rails.configuration.to_prepare do
       require_dependency "glossary_macros" 
       Redmine::Activity.register :glossary_terms
    +  Redmine::Search.available_search_types << 'glossary_terms'
     end
    
     Redmine::Plugin.register :redmine_glossary do
    

モデルは、小文字複数形(データベースのテーブル名と同じ)で記述します。
記述は、Redmine::Plugin.registerのブロックの外側になります。


プラグインの権限名をモデル名を含むよう修正

Redmineの検索機能は、プロジェクトを範囲とした検索を実行する際に、検索対象とする部位のチェックボックス(チケット、ニュース、等)を表示するか否かを判定する方法として、モデル名(小文字スネークケース形式)の頭にview_を付与した権限名が検索を実行したユーザーに付与されるかどうか調べています。
再構築では、モデル名とは異なる権限名を使用してきたので、このままでは検索対象としてチェックボックスに表示されません。

そこで、プラグインの権限名をモデル名にちなむように修正します。

変更前 変更後
view_glossary view_glossary_terms
manage_glossary manage_glossary_terms
  • init.rb
       project_module :glossary do
    -    permission :view_glossary, {
    +    permission :view_glossary_terms, {
                      glossary_terms: [:index, :show],
                      glossary_categories: [:index, :show]
                    }
    -    permission :manage_glossary, {
    +    permission :manage_glossary_terms, {
    :
    

権限を使用している個所も合わせて修正します。

  • app/models/glossary_term.rb
    -  acts_as_attachable view_permission: :view_glossary, edit_permission: :manage_glossary, delete_permission: :manage_glossary
    +  acts_as_attachable view_permission: :view_glossary_terms, edit_permission: :manage_glossary_terms,
    +                     delete_permission: :manage_glossary_terms
        :
       acts_as_activity_provider scope: joins(:project),
                                 type: 'glossary_terms',
    -                            permission: :view_glossary,
    +                            permission: :view_glossary_terms,
                                 timestamp: :updated_at
    
  • app/views/glossary_terms/show.html.erb
     <%= render partial: 'attachments/links',
     locals: {attachments: @term.attachments,
    -        options: {deletable: User.current.allowed_to?(:manage_glossary, @project)}
    +        options: {deletable: User.current.allowed_to?(:manage_glossary_terms, @project)}
     }%>
    
  • config/locales/en.yml
    -  permission_view_glossary: View glossary
    -  permission_manage_glossary: Manage glossary
    +  permission_view_glossary_terms: View glossary
    +  permission_manage_glossary_terms: Manage glossary
    
  • config/locales/ja.yml
    -  permission_view_glossary: 用語集の閲覧
    -  permission_manage_glossary: 用語集の管理
    +  permission_view_glossary_terms: 用語集の閲覧
    +  permission_manage_glossary_terms: 用語集の管理
    

モデルクラスを検索対象にする

モデルクラスに、acts_as_eventおよびacts_as_searchableの記述を追記します。既にacts_as_eventの記述をしているので、ここではacts_as_searchableを追記します。

  • glossary_term.rb
    +  acts_as_searchable columns: [ "#{table_name}.name", "#{table_name}.description" ],
    +                     preload: [:project ],
    +                     date_column: "#{table_name}.created_at",
    +                     scope: joins(:project),
    +                     permission: :view_glossary_terms
    
  • columnsは、検索対象とするテーブルのカラム名を列挙します。テーブル名+カラム名としているのは、対象カラムを検索するSQL文でカラム名のみ指定した場合、JOINする他のテーブルに同じカラム名があった場合にカラム名が衝突してエラーになることを避けるためです。検索対象のカラムを増やす場合は、ここにカラムを追記していきます。
  • preloadは?(後日調査)
    APIのコメントには、"associations to preload when loading results for display"とある
  • date_columnは、検索結果を並べるときに使用する日時カラムを指定します。デフォルトはcreated_onなので異なるときに記述します。
  • scopeは、検索SQLを実行するときに追加する内容を指定します。ここではprojectをJOINします。
  • permissionは、検索結果を見てもよいかチェックする権限名を指定します。

フェーズ22の完了とフェーズ23へ

フェーズ22では、Redmineの総合検索機能から用語集の検索を可能としました。フェーズ22では次を実施しました。

  • 用語モデルに検索機能を追加(acts_as_searchable呼び出し)
  • プラグイン情報ファイル(init.rb)に、用語集プラグインを検索機能(Search)へ登録するコードを記述
  • Redmineの検索機能で使用する権限の名称生成ルールに合うよう用語集プラグインの権限名を修正(init.rbおよびglossary_term.rb、glossary_terms/show.html.erb、config/locales/en.ymlとja.yml)

フェーズ23)CSVファイルのインポート

CSVファイルに記載された用語集データを取り込む機能を実装します。

フェーズ23の概略

CSVファイルから用語集データを取り込むには、まずCSVファイルの選択を行う画面を作成します。Webアプリケーションなのでフォームを使用します。
用語集一覧画面の右側メニューにインポートするCSVファイル選択フォームを作成し、サブミットすると選択したファイルを読み込み、モデルを作成しデータベースへ登録します。

フェーズ23として、次を作成または修正します。

No 役割 ファイル 内容
1 用語一覧のサイドバービュー _sidebar.html.erb CSVファイル選択フォームを追加
2 用語集コントローラー glossary_terms_controller.erb importアクションを追加
3 ルーティング設定 routes.rb 2.のアクションに対応するルーティングを追加
4 プラグイン情報 init.rb 2.のアクションの権限設定追加
5 用語集モデル glossary_term.rb CSVファイルを読み込み、モデルを生成してデータベースへ登録する処理を追加
6 翻訳ファイル(英語) en.yml CSVインポートに関する国際化リソース文字列を追加
7 翻訳ファイル(日本語) ja.yml

用語集一覧のサイドバーにCSVファイル選択フォームを追加

まず、サイドバービューにCSVファイル選択フォームを追加します。折り畳み形式でフォームを用意します。

折り畳んだCSVファイル選択フォーム

展開すると次の表示となります。

展開したCSVファイル選択フォーム

ビューのコードの実装は次のとおりです。

  • app/views/glossary_terms/_sidebar.html.erb
          { controller: :glossary_terms, action: :new, project_id: @project },                    
          class: 'icon icon-add' %></p>                                                           
    
    +  <fieldset class="collapsible collapsed">
    +    <legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
    +      <%=l :label_glossary_term_import_csv %>
    +    </legend>
    +    <div style="display: none;">
    +      <%= form_with url: import_project_glossary_terms_path, method: :post, local: true do |form| %>
    +        <%= form.file_field :file %>
    +        <%= form.submit l(:label_import) %>
    +      <% end %>
    +    </div>
    +  </fieldset>
    +
       <h3><%=l :label_glossary_category %></h3>                                                  
       <p><%= link_to_if_authorized l(:label_glossary_category_new),                              
    

Redmineでチケットのフィルターなどの設定で使われている記述です。
フォームを囲むfieldsetに、折り畳み中・展開中のクラス定義 collapsible、collapsedを指定します。
legendにはキャプション(折り畳み中に表示する名称)を指定します。ロケール定義なので、en.ymlとja.ymlに文字列を定義します。

  • config/locales/en.yml
    +  label_glossary_term_import_csv: "Import from CSV" 
    
  • config/locales/ja.yml
    +  label_glossary_term_import_csv: "CSVからインポート" 
    

折り畳み中に隠すフォームはdivで囲い、初期状態をstyleで非表示にしておきます。
form_with でサブミット時に呼び出すアクションはURLパスで指定し、glossary_termコントローラーのimportアクションに対応するパスを指定します。
このアクションは標準の7アクション以外なので、routes.rbに設定を追記します。

  • config/ruotes.rb
       resources :projects do
         resources :glossary_terms do
           member do
             patch 'preview'
           end
           collection do
             post 'preview'
    +        post 'import'
           end
         end
         resources :glossary_categories
       end
    

追記したimportアクションのURLパスは、bundle exec rails routesコマンドで確認します。

   import_project_glossary_terms  POST  /projects/:project_id/glossary_terms/import(.:format)  glossary_terms#import

この最初の項 import_project_glossary_terms に接尾辞 _pathを付けた import_project_glossary_terms_pathがURLパスになります。

サブミットでファイルをサーバーに送るのでmethodはpostを指定します。
form_withはデフォルトではAjaxとなるので、local: trueを指定して従来の(非Ajax)呼び出しとします。
ファイル選択をするフォームでは、file_fieldを指定します。
サブミットのボタンに表示する文字列をロケール定義で指定します。

  • config/locales/en.yml
    +  label_import: Import
    
  • config/locales/ja.yml
    +  label_import: インポート
    

GlossaryTermsコントローラーにimportアクションを追加

  • app/controllers/glossary_terms_controller.rb
    +  def import                                         
    +    GlossaryTerm.import(params[:file], @project)     
    +    redirect_to project_glossary_terms_path(@project)
    +  end                                                
    +                                                     
    

コントローラーのimportアクションは、アップロードされたファイルをモデル(GlossaryTerm)に渡し、モデルで実際にCSVファイルから読み込みを行います。
モデルでは、現在のプロジェクトが拾えないので引数で渡しておきます。
読み込み後は、用語の一覧ページ(index)へリダイレクトします。

なお、コントローラーに新たに追加したアクションは、権限の設定をしておかないと403エラーとなります。プラグインの設定ファイルに追記します。

  • init.rb
         permission :manage_glossary_terms, {
    -                 glossary_terms: [:new, :create, :edit, :update, :destroy],
    +                 glossary_terms: [:new, :create, :edit, :update, :destroy, :import],
                      glossary_categories: [:new, :create, :edit, :update, :destroy],
                    },
    

GlossaryTermsコントローラーにimportアクションを追加

まず、importメソッドを定義します。ファイルとプロジェクトを引数で受け取ります。

  • app/models/glossary_term.rb
      def self.import(file, project)
      end
    

次に、引数で受け取ったファイルのパスから、CSVファイルを行単位に読み込む処理を記述します。今回はWindows上で作成したCSVファイルの文字コード(コードページ932)を決め打ちで読み込んでいます。ヘッダーを有り(headers: true)にすると、1行目の各列にある文字列をハッシュのキーとして扱い、2行目以降を順次読み込むときに、2行目の各列がハッシュのキーに対応するバリューとして扱います。

  • app/models/glossary_term.rb
      def self.import(file, project)
        CSV.foreach(file.path, headers: true, encoding: "CP932:UTF-8" ) do |row|
        end
      end
    

Railsのモデルのattributesに、属性(attribute)の名前と値のハッシュを設定すると、属性が更新できます。そこで、CSVファイルから読み込んだ各行のハッシュから、直接属性を更新するものを抜粋し、attributesに設定します。直接更新しないものは、例えばcategory_idのように別なモデルへの関連(外部キー)です。
csv_attributesメソッドは、直接属性を更新するハッシュのキー名を配列として定義します。

  • app/models/glossary_term.rb
      def self.csv_attributes
        ["name", "name_en", "datatype", "codename", "description", "rubi", "abbr_whole"]
      end
    
      def self.import(file, project)
        CSV.foreach(file.path, headers: true, encoding: "CP932:UTF-8" ) do |row|
          term = new
          term.attributes = row.to_hash.slice(*csv_attributes)
        end
      end
    

続いて、直接更新しなかった属性のうちprojectを設定します。

  • app/models/glossary_term.rb
      def self.import(file, project)
        CSV.foreach(file.path, headers: true, encoding: "CP932:UTF-8" ) do |row|
          term = new
          term.attributes = row.to_hash.slice(*csv_attributes)
          term.project = project
        end
      end
    

続いて、直接更新しなかった属性のうちcategoryを設定します。categoryはCSVファイルに定義されている文字列から既存のGlossaryCategoryモデルを検索し、見つかればそれを設定します。存在しなければ新規にGlossaryCategoryを生成し、それを設定します。最後にデータベースに保存してメソッドを終了します。

  • app/models/glossary_term.rb
      def self.import(file, project)
        CSV.foreach(file.path, headers: true, encoding: "CP932:UTF-8" ) do |row|
          term = new
          term.attributes = row.to_hash.slice(*csv_attributes)
          term.project = project
          term.category = GlossaryCategory.find_by(name: row["category"]) ||
              GlossaryCategory.create(name: row["category"], project: project)
          term.save!
        end
      end
    

上記コードは、category列が空のときにcreateでエラーになってしまうので、category列の値が空かどうかチェックしてから生成するようにします。

  • app/models/glossary_term.rb
      def self.import(file, project)
        CSV.foreach(file.path, headers: true, encoding: "CP932:UTF-8" ) do |row|
          term = new
          term.attributes = row.to_hash.slice(*csv_attributes)
          term.project = project
          unless row["category"].blank?
            term.category = GlossaryCategory.find_by(name: row["category"]) ||
                GlossaryCategory.create(name: row["category"], project: project)
          end
          term.save!
        end
      end
    

フェーズ23の実装完了とフェーズ24へ

フェーズ23では、CSVファイルを選択し、読み込んでデータベースに登録する処理を実装しました。フェーズ23で実施したことは次のとおりです。

  • 用語一覧ビューの右サイドバー(_sidebar.html.erb)に、読み込むCSVファイル選択フォームを畳み込み方式で追加
  • 用語コントローラーにimportメソッドを追加、importメソッドは管理者権限で実行可能となるよう定義を追加
  • 用語モデルにCSVファイルを読み込んでデータベースに登録する処理を追加

フェーズ24では、CSVファイルへ出力する機能を実装します。



フェーズ24) CSVファイルのエクスポート

用語集に登録されたデータをCSVファイルへ出力する機能を実装します。

フェーズ24の概略

用語集一覧画面の下部にCSV出力のリンクを設け、用語集一覧のCSV出力処理はビューにcsv形式のテンプレートを新たに用意して記述します。

フェーズ24として、次を作成または修正します。

No 役割 ファイル 内容
1 用語一覧ビュー index.html.erb CSV出力リンクを追加
2 CSVファイル出力テンプレート index.csv.ruby CSV出力処理

用語集一覧画面の下部にCSV出力リンクを追加

Redmine標準のチケット一覧の下部には、次の図に示すように「他の形式にエクスポート」があります。

チケット一覧下部にある他の形式にエクスポート

これに倣ってCSVへのエクスポートのリンクを生成します。

  • app/views/glossary_terms/index.html.erb
    <% other_formats_links do |f| %>
      <%= f.link_to_with_query_parameters 'CSV' %>
    <% end %>
    

このコードで次の図に示すリンクが生成されます。

用語集一覧下部に設けた他の形式にエクスポート

CSVファイル出力テンプレートの新規作成

GlossaryTermコントローラーのindexアクションで、MIMEタイプcsvがリクエストされたときのビューテンプレートを用意します。
csvの場合、画面を表示するのではなくCSVファイルをダウンロードさせるので、テンプレートはerbではなくrubyのコードを記述します。
rubyのコードは、index.csv.ruby のファイル名に記述します(なお、拡張子が.rbではテンプレートとして扱われませんでした)。

  • index.csv.ruby
    require 'csv'
    
    CSV.generate do |csv|
      column_names =   ["name", "name_en", "category", "datatype", "codename", "description", "rubi", "abbr_whole"]
      csv << column_names
      @glossary_terms.each do |term|
        column_values = [
            term.name,
            term.name_en,
            term.category.try!(:name),
            term.datatype,
            term.codename,
            term.description,
            term.rubi,
            term.abbr_whole
        ]
        csv << column_values
      end
    end
    
関連を辿る場合

用語(GlossaryTerm)は外部キーでカテゴリ(GlossaryCategory)へ関連を有しています。この関連はnot nullではないので、nilである可能性があります。
term.category.name とメソッドチェーンを使うとき、term.categoryがnilの場合、nameメソッドがnilClassへの呼び出しとなるため、undefined methodエラーとなります。そこで、term.categoryがnilでない場合に限りnameメソッドを呼ぶようにする必要があります。

  • 三項演算子による方法
    term.category.nil? ? nil : term.category.name
    
  • Rails(ActiveSupport)のtry!メソッドを使う方法
    term.category.try!(:name)
    
    • try!は、レシーバーがnilのときはnilを返し、nilでないときは第1引数のシンボルと同じ名前のメソッドを呼びます。
  • Rubyのぼっち演算子を使う方法(Ruby 2.3で導入された演算子)
    term.category&.name
    
    • ぼっち演算子はレシーバーがnilのときはnilを返し、nilでないときはそのままメソッドを呼びます。
文字コードをCP932、改行コードをCRLFにする

出力したCSVファイルは、Windows上でExcelで扱うことが多いので、今回は出力するCSVファイルの形式として、文字コードはCP932(Windows_31Jのエイリアス)、改行コードはCRLFにします。

- CSV.generate do |csv|
+ CSV.generate(row_sep: "\r\n", encoding: "CP932") do |csv|

フェーズ24の実装完了とフェーズ25へ

フェーズ24では、CSVファイル出力でそのプロジェクトに登録されている用語の一覧をCSVファイルに出力する処理を実装しました。フェーズ24で実施したことは次のとおりです。

  • 用語一覧ビュー(index.html.erb)の下部に、他の形式にエクスポートするリンクを設置
  • 用語一覧のCSV型のビュー(index.csv.ruby)を新規作成し、CSVファイルへ出力するテキストを生成する処理を記述

積み残した課題もあります。

  • 出力するCSVファイルは、改行コードをCRLF(Windowsの標準改行コード)、文字コードをCP932(日本語Windows上で主に使われるShift JIS派生文字コード)に決め打ちしています。Redmineのチケット一覧をCSVに出力するときは、ユーザーが文字コードを選択できるようになっているので、それと同様に文字コードを選択できるようにすべきです。


ほぼ5年前に更新