Redmine Glossaryプラグイン再構築¶
- 目次
- Redmine Glossaryプラグイン再構築
- はじめに
- 再構築の計画
- 再構築の実施
- フェーズ1)最小限の用語モデルと一覧表示
- フェーズ2)国際化対応
- フェーズ3)一覧表示→詳細表示
- フェーズ4) 用語の作成・変更
- フェーズ5) カテゴリの導入
- フェーズ6)プロジェクトごとに用語集を分ける
- フェーズ6a) カテゴリのプロジェクト紐づけ
- フェーズ7) 右サイドバー表示
- フェーズ8) セキュリティと権限
- フェーズ9) 用語の属性を拡充
- フェーズ10)一覧表示をグループ毎に分類
- フェーズ11) 索引に日本語(あいうえお順)追加
- フェーズ12)ファイルの添付
- フェーズ13)活動へ用語集変更事象の表示
- フェーズ14)用語説明のwiki表示化
- フェーズ15)CSSで見栄え向上
- フェーズ16)マクロの定義
- フェーズ17) ユニットテスト
- フェーズ18)機能テスト
- フェーズ19)統合テスト
- フェーズ20)機能向上いろいろ
- フェーズ21)再構築前のデータベースマイグレーション
- フェーズ22)用語集を検索可能にする
- フェーズ23)CSVファイルのインポート
- フェーズ24) CSVファイルのエクスポート
はじめに¶
Glossasry(用語集)プラグインは、Redmineのプラグインとしてはかなり初期の頃(Redmine 1.0.x、2010年)から公開されている、用語集(データ辞書)を作成・管理するプラグインです。用語集の作成は、Redmine標準のWiki機能を使って頑張ればプラグインを導入しなくてもできなくはないですが、このプラグインは、日本語・英語・略語展開・ルビ・説明・コーディング時名称といった枠で統一して管理でき、インデックスが自動生成され、用語の一覧詳細管理が容易にできるので、大変便利です。詳しい機能は次のプラグイン説明ページに記載されています。
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プラグインの再構築を行うにあたり、プラグイン作成技術をステップ・バイ・ステップで段階的に獲得しながら、機能の実装を進める計画とします。
- 用語モデルと一覧表示(プロジェクトの縛りなし)
- 国際化対応
- 一覧表示→詳細表示
- 用語の作成・変更
- カテゴリの導入
- プロジェクトの縛りを入れる
- 右サイドバー表示(設置、索引、新規作成リンクなど)
- セキュリティ、権限の導入
- 用語の属性を充実
- 用語をグループごとに分類表示
- 索引に日本語(あいうえお順)追加
- ファイル添付
- 活動への用語集変更イベント表示
- 用語説明のwiki表示化
- CSSで見栄え制御
- マクロ
- テスト(モデル)
- テスト(コントローラー)
- テスト(統合)
- 機能向上いろいろ
- 再構築前のデータベースマイグレーション
- 用語集を検索可能にする
- CSVファイルのインポート
- CSVファイルのエクスポート
- カテゴリ一覧に用語割当て数を表示
- バリデーション
- セキュリティ、権限の厳格化
プラグイン開発環境の準備は、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の内容が表示されていることを確認します。
rubyの書き方でびっくりしないために¶
init.rbも拡張子から類推されるようにRubyのコードです。
このファイルにあるrubyのコードは、なかなかに面喰う書き方です。また、rubyに慣れていないと他の言語の経験から類推できないこともあります。
まず、次の記述から見ていきましょう。
Redmine::Plugin.register :redmine_glossary do
:
end
Redmine
はモジュールです。Plugin
は、Redmine
モジュールの中に定義されるクラスです。よって、モジュール外からPlugin
クラスを参照するには、定数参照の演算子ダブルコロン(::
)を用いて、Redmine::Plugin
と参照します。Rubyでは、モジュールやクラス定義も定数と扱われるので、定数参照演算子を使用しています。register
はPlugin
クラスのメソッドです。メソッド呼び出しの演算子ピリオド(.
)を用います。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_at
とupdated_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 へアクセスします。
用語の一覧表示を追加¶
モデルクラスから用語一覧を取り出し、ビューにそれを表示させる実装を書いていきます。
まず、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へアクセスすると、データベースに格納された用語の内容が一覧表示されます。
罫線がない、色が付いていない、など見栄えは悪いですがデータ内容は表示できています。
ちょっとだけ見栄えをよく¶
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>
画面表示は次のようになりました。
フェーズ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.rb
とindex.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.translate
とI18n.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_date
、format_time
、他日付・時刻関係のローカライズメソッドがいろいろ揃っています。
index.html.erb での国際化対応(1)¶
ハードコードされているテキストを、I18nヘルパーメソッドを使って国際化対応に置き換えます。
まずは、Redmine本体で定義されているキーfield_name
とfield_description
を指定し、ロケールに応じたテキストに置き換えます。
<tr>
- <th>name</th>
- <th>description</th>
+ <th><%=l :field_name %></th>
+ <th><%=l :field_description %></th>
</tr>
日本語環境で実行した結果が次の画面です。
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>
日本語ロケールで実行すると次の画面となります。
フェーズ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リクエストをすると、GlossaryTermsController
のshow
メソッドにルーティングされます。
showアクションの追加¶
GlossaryTermsController
の show
メソッドを追加し、処理を記述します。まずはメソッドを用意します。
- 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: "用語"
- en.yml
これで詳細表示が実装できました。Webブラウザからidを指定したURLリクエストを行います。あらかじめデータベースに作成している用語のidをどれか指定します。サーバー名:3000/glossary_terms/2
ここで、idに存在しない番号を指定すると、次のようなエラー画面となってしまいます。
そこで、idからモデルのインスタンスを取得する処理においてidに対応するレコードが存在しないときの例外を拾って404エラー画面を表示する処理を追加します。
def find_term_from_id
@term = GlossaryTerm.find(params[:id])
+ rescue ActiveRecord::RecordNotFound
+ render_404
end
このエラー画面は次のようになります。
一覧表示から詳細表示へのリンク¶
いよいよ、一覧表示の対象件名をクリックすると詳細表示を開くリンクを追加します。
リンクの追加には、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オブジェクト)自身を指定します。
表示される一覧画面は次のようになります。
リンクの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をキーとしたハッシュで指定します。
- フォームの内容は、第1引数で指定したキーでparamsハッシュに格納されます。取り出す際は
- 共通のフォーム部分(
_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)が指定され対応するテキストがラベル表示されます。
ここで作成した画面は次となります。
参考資料¶
新規作成のコントローラー処理¶
新規画面からサブミットされると、コントローラーの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メソッドが呼ばれ、データベースにデータが保存されます。
データベースへの保存後、保存した用語の詳細表示画面へ遷移し、flashメッセージが表示されます。
参考資料¶
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キーで追加アイコンを指定しています。
画面は次のようになります。
詳細表示に編集アイコンを追加¶
まずルーティング設定に、編集(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>
編集アイコンを追加した画面は次になります。
編集のコントローラーへの実装¶
コントローラーに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 | 属性カテゴリの設定許可(ストロングパラメーター) |
モデルの作成・修正¶
モデルの関係をクラス図で表現しました。
用語はカテゴリに属し、用語とカテゴリの関係は、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を使っています。
修正後の用語一覧表示画面を次に示します。
用語の詳細表示を修正¶
用語の詳細表示にカテゴリを追加します。
- app/views/glossary_terms/show.html.erb
<table> <tr> + <th><%=l :field_category %></th> + <td><%= @term.category.try!(:name) %>
修正後の用語詳細表示の画面を次に示します。
用語の新規・編集画面のフォームを修正¶
- 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 )
新規編集画面は次となります。
既存の用語の編集画面は次となります。
- 外部キーのテーブルを選択肢にする入力フォームにはselectとは別にcollection_selectがあります。
<p><%= form.collection_select :category_id, GlossaryCategory.all, :id, :name, include_blank: true %>
しかし、Redmineのlabelled_form_forでcollection_selectを使うと、入力欄の左側にラベルが表示されない問題が生じています。
小さな修正・改善¶
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や関連するメソッドを外部から追加する方法もありますが、それは今後必要に迫られたときに考えることにします。
モデルクラスにプロジェクトへの関連を追加¶
モデルのGlossaryTerm
とGlossaryCategory
クラスに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
+ 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">
このサイドバー表示は次のようになります。
サイドバービューの作成(新規・一覧リンク追加)¶
次のリンクを追加します。
- 新しい用語の作成
- 新しいカテゴリの作成
- カテゴリ一覧の表示
<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を付けて指定します。
ここまでの記述で表示されるサイドバー画面は次です。
サイドバービューの作成(索引追加)¶
続いて、索引を作ります。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>
モデルの修正(曖昧検索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_glossary
とmanage_glossary
)の接頭辞にpermission_
を付与します。en.yml ja.yml permission_view_glossary: View glossary
permission_view_glossary: 用語集の閲覧
permission_manage_glossary: Manage glossary
permission_manage_glossary: 用語集の管理
ここまでの設定で権限の設定画面は次の様になります。
[管理]メニュー > [ロールと権限]でロール一覧が表示されるので、一覧から[Manager]をクリックすると各プラグインの権限設定画面が表示されます。
用語集の変更に関わるリンクは管理権限のあるユーザーのみ¶
いままでは、どのロールのユーザーであっても、用語の追加・編集・削除およびカテゴリの追加・編集・削除ができてしまいました。そこで、権限がある場合(用語集の管理権限)にのみ追加・編集・削除のリンクが表示されるように制御します。
用語一覧画面右上の新規作成リンク¶
用語集の管理権限を持っているユーザーのみ新規作成リンクが表示されるようにします。
- 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を手入力する等でアクセスした場合、権限チェックによって権限がないと次の画面が表示されます。
未ログイン状態で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: ふりがな
属性を追加し、編集用フォームに属性の入力フィールドを追加した用語の作成画面を次に示します。
コントローラーの修正¶
新規作成、編集アクションで追加した属性をモデルに保存するため、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でべた書きしています。
属性を追加した用語詳細画面表示の例を次に示します。
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 %>
画面例を次に示します。
見栄えについてはさらに工夫(カテゴリ毎の表の列幅がまちまち)ですが、機能としては達成できました。
カテゴリ毎の表示の有効・無効切替¶
用語一覧を表示する方法を、べたに一覧する表示と、カテゴリ毎に一覧する表示とを切り替えるための表示設定を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キーで選択状態を指定しています。
ラジオボタンの選択をサイドバーに追加した画面を次に示します。
コントローラーにパラメーターを保持する処理追加¶
フォームからパラメーター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
としています。
あいうえお順の索引を表示した画面を次に示します。
英語表示対策¶
英語表示の際、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| %>
新規作成画面にファイル添付が付加された画面を次に示します。
コントローラーにファイル添付追加¶
ファイル添付はフォームのサブミットでコントローラーに情報が渡ってきます。その際のアクションは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は値が真のときに削除アイコンを表示し削除可能とします。
閲覧権限のみのユーザーは削除アイコンが表示されないようにする¶
用語集プラグインの閲覧権限のみを持つユーザーであっても、用語の詳細表示で添付ファイルの右わきに削除アイコンが表示されています。
ただし、削除アイコンをクリックしても実際には削除されず、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)だけ持つユーザーとで添付ファイルの表示が次のように変わります。
管理権限のあるユーザーでの表示 | 閲覧権限のみのユーザーでの表示 |
---|---|
用語の新規作成画面に添付ファイル追加¶
編集時と同様、フォームに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
- init.rb
+Rails.configuration.to_prepare do + Redmine::Activity.register :glossary_terms +end
どちらも動作しましたが、どう違うのかはこれから調査です。
活動に用語集のイベントが表示される画面を示します。
右側のチェックボックスリストを見ると、翻訳ファイルのキー不足でエラーメッセージが表示されています。不足しているのは次のキーです。
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編集ボタンバーが表示されます。
用語の詳細表示の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>
表示例を次に示します。
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要素にプレビュー結果が挿入されます。
プレビューリンクは次の表示となります。
プレビュー表示例は次となります。
新規作成画面のプレビュー¶
同様に、用語の新規作成画面にも下部にプレビューのリンクを設けます。
- 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のバグのため表示されません)。
これを右寄せさせます。
- 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">
画面例を次に示します。
フェーズ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や数値以外を指定した場合と引数を省略した場合の例も並べています。
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の使用例を次に示します。
エラー処理の追加¶
マクロの使用において、パラメーターの指定を間違えた場合に、分かりやすいエラーメッセージを表示します。
まず、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!を使うことでレコードが存在しない場合例外を出す振る舞いとします。
次にエラーメッセージを含むマクロの画面を示します。
フェーズ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メソッドを使います。期待する例外クラスを引数に指定し、例外発生コードをブロックで渡します。
------------------------------------------------------------------------------------------------------------------
参考資料¶
あなたのコードにハナマルを。ボッチ開発でも出来るプラグインテスト初めの一歩
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の管理者ロールでメンバーとなっています。
カテゴリコントローラーの機能テストの記述と実行¶
まずは、カテゴリの一覧表示(GlossaryCategoriesController
のindex
アクション呼び出し)のテストを作っていくこととします。
次に、カテゴリの編集フォームの表示(GlossaryCategoriesController
のedit
アクションを呼び出し)と編集フォームのサブミット(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で例外の型を指定、ブロックで例外の発生するコードを記述します。
用語コントローラーの機能テストの記述と実行¶
用語の一覧表示(GlossaryTermsController
のindex
アクション呼び出し)のテストを記述し、次に、編集フォームの表示(同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)機能向上いろいろ¶
細かな機能追加を実施していきます。
- カテゴリ一覧表示で並び替え操作を可能にする #
カテゴリの並び替え¶
一覧表示の順番を変更できるようにします。Redmine標準搭載の機能を使用します。Redmineでは、[管理]メニューから[トラッカー]で一覧表示をすると、トラッカーの各行の右端に両端矢印のアイコンが表示されますが、それをドラッグ&ドロップで上下に移動させることができます。
「バグ」、「機能」、「サポート」とトラッカーが並んでいます。ここで、「サポート」の右側にある両端矢印アイコン付近をドラッグし、「バグ」のちょっと上にドロップしようとしているのが次の画面です。
このように並び替え操作ができる機能を、カテゴリに導入します。
カテゴリのモデルに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のカテゴリがドラッグ操作で移動する途中を示しています。
カテゴリ一覧で各カテゴリの右端に編集・削除アイコン¶
フェーズ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>
カテゴリ一覧表示画面は次の様になります。
フェーズ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ファイル選択フォームを追加します。折り畳み形式でフォームを用意します。
展開すると次の表示となります。
ビューのコードの実装は次のとおりです。
- 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に出力するときは、ユーザーが文字コードを選択できるようになっているので、それと同様に文字コードを選択できるようにすべきです。