Redmineにテンプレートを使って関連チケットを作成できるリンクを追加する

# 変更履歴

* 2020/08/13 プロジェクト指定の説明とコードにミスがあったので修正しました。

# はじめに

Redmineに関連チケット作成ボタンを追加するために、[こちらの方法](https://qiita.com/sioaji/items/c6fe349e1e637303db4e "Redmineに関連チケット作成ボタンを追加する")（感謝！）を見習っていたんですが、

* [Issue Template プラグイン](https://github.com/akiko-pusu/redmine_issue_templates)を使って、非管理者でもテンプレートの管理ができるように
* チケットを登録する前に編集可能に

といったことがどうしてもやりたくなりました。元の方法では、

1. 一旦チケットを作成（内容は[ViewCustmizeプラグイン](https://github.com/onozaty/redmine-view-customize)のコード内で決め打ち）
2. リンク元のチケットと1.で作成したチケットを関連付け
3. 作成したチケットに移動

という処理になっています。これは、

**チケットを関連付けするためには、両方のチケットのID（つまり両方登録済みであること）が必要**

というRedmineのテーブル設計上の制約を満たすための苦肉の策なんだと思われます。その点を踏まえた上で、冒頭の要求を満たすため、ここで紹介する方法では、

1. 関連するチケットを自動的に登録するための項目(a)をカスタムフィールドとして追加する
2. 関連するチケットの作成リンクからは、フィールド(a)に元のチケットIDを埋め込んだ、標準のチケット新規作成画面を開く
3. チケットが登録された直後に(a)の値を使ってチケットの関連付けを自動的に行う

という処理を実現します。

# 利用シーン

社内の勤怠管理/タイムカードのチケットから、休暇や残業のチケットを作成したり、経費申請のチケットをカテゴリ（旅費・交通費）指定して作成した上で関連付け、

<img width="965" alt="スクリーンショット 2020-08-10 19.52.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154669/98673b84-a7fe-e567-4597-19a0af381bdb.png">

タイムカードの一覧から関連するチケットを確認したり、

<img width="1063" alt="スクリーンショット 2020-08-10 20.06.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154669/dfae65fd-de6b-4fd1-103d-34dbaaec042b.png">

カスタムクエリを使って必要な項目をピックアップした一覧をPDF化し、それを出勤簿として処理したり、ということをやっています。

# 具体的な手順

## カスタムフィールドを作成する

まず、Redmineの管理画面から、関連チケット自動登録用のカスタムフィールドを登録して、機能を利用したいプロジェクト/トラッカーで有効にします。

![01_newCustomField.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154669/ff59f0f2-5015-050c-6af3-174b776fa8e8.png)

登録が終わったら、このカスタムフィールドの ID を確認しておきます（このページのサンプルでは'1'とします）。

## JavaScriptの登録

ViewCustomizePlugin に、以下のスクリプトを登録します。


|項目|値|
|:-:|:-:|
|パスのパターン |/issues/[0-9]+  |
|挿入位置：    |チケット入力欄の下 |
|種別         |JavaScript      |

として、以下のコードの次の箇所を自分のサイトに合わせて登録します。

* relation_field_id の値を上の手順で登録したカスタムフィールドのIDに書き換えます。
* relation_links には、追加するリンクのオプションをハッシュ形式の配列で指定します。

リンクには以下のオプションを指定可能です

| キー | 内容 |
|:-:|:-:|
| title   | リンクの見出し      |
| project | プロジェクト識別子（同じプロジェクトなら省略可） |
| params  | URLに埋め込むデフォルトパラメータのハッシュ |

最後の'params'にはRedmineでURLからチケットを作成するときに指定可能なオプションをハッシュ形式で指定します。例えば、

| キー | 内容 |
|:-:|:-:|
| tracker_id       | トラッカーのID |
| assigned_to_id   | 担当者のID |
| category_id      | カテゴリーのID |
| watcher_user_ids | ウォッチャーのIDをリスト形式で指定 |

などが指定可能です。題名や内容欄も固定してしまいたい場合にはこちらのオプションで指定してもよいでしょう。

具体的なサンプルをコードの方にも記述してあります。

```javascript
$(function() {
  // 関連付け用カスタムフィールドのID
  const relation_field_id = '1';
  // ルートディレクトリ以外に設置している場合に指定
  const BasePath = '';

  // リンクの定義（サンプル）
  let relation_links = [
    // デフォルトのチケット
    { title: "関連チケットの作成1" },
    // プロジェクトを指定
    { title: "関連チケットの作成2", project: "project02" },
    // 担当者をID '1', ウォッチャーをID '2'と'3'のユーザに指定
    { title: "関連チケットの作成3", params { assigned_to_id: 1, watcher_user_ids: [ '2', '3' ]  },
  ];
  // 特定のトラッカーのみに追加
  tracker_id = $('#issue_tracker_id').val();
  if (tracker_id == '1') {
    relation_links.push({ title: "トラッカー2", params: { tracker_id: 2 } });
  }
  
  ////////// (DO NOT EDIT BELOW THIS LINE / 以下修正不要)  //////////
  // チケットIDの取得とチェック
  let IssueId =  ViewCustomize.context.issue.id;
  if (!IssueId) return;

  // カスタムフィールドの存在確認
  const custom_field = $('#issue_custom_field_values_' + relation_field_id);
  if (!custom_field[0]) return;

  ////////// リンクの表示 //////////
  // 見出しを表示する
  if (!$('#view_customize_create_relation')[0]) {
    const b_title = $('<div id="view_customize_create_relation" class="clear"><p><strong>関連するチケットの新規登録</strong></p></div>');
    $('#relations').append(b_title);
  }

  // リンクを作成する
  relation_links.forEach(function (opts, index) {
    if (!opts.params) opts.params = {};
    opts.params['custom_field_values_' + relation_field_id] = IssueId;
    addRelationLink(opts, index);
  });

  // 標準機能と被るだけなので登録後の画面では非表示に
  $('.cf_' + relation_field_id).hide();
  custom_field.parent().hide();

  ////////// チケットの自動関連付け //////////
  let relate_to = custom_field.val();
  if (!relate_to) return;
  if (isRelated(relate_to)) {
    // 次の保存時にこっそり消す(isRelated()が呼ばれなくなる)
    custom_field.val('');
    return;
  }
  createRelation(relate_to);

  //// 関数
  // 関連付け済み判定
  function isRelated(relate_to) {
    let related = false;
    let rids = $('#relations table input[name="ids\\[\\]"]').map(function(){return $(this).val();}).get();
    rids.some(function(val,idx) { 
      if (val == relate_to) {
        related = true;
        return;
      }
    });
    return related;
  }

  // チケットの関連付け
  function createRelation(relate_to) {
    let dfd = $.Deferred();

    let json_data = JSON.stringify(
      {
        relation: {
          "issue_id" : IssueId,
          "issue_to_id" : relate_to,
          "relation_type" : "relates"
        }
      }
    );

    $.ajax({
      type: 'POST',
      url: BasePath + '/issues/' + IssueId + '/relations.json',
      headers: {
        'X-Redmine-API-Key': ViewCustomize.context.user.apiKey
      },
      dataType: 'text',
      contentType: 'application/json',
      data: json_data
    })
    .done(function() {
      // 成功したらリロード（関連するチケット一覧を更新）
      location.reload();
      dfd.resolve();
    })
    .fail(function() {
      alert('チケットの関連付けに失敗した可能性があります');
      dfd.reject();
    });

    return dfd.promise();
  }  

  // リンクを追加
  function addRelationLink(opts, idx) {
    // 他の操作による二重表示を回避する
    const link_id = 'view_customize_relation_link_' + idx;
    if ($('#' + link_id)[0]) return;

    let project_id = opts.project || ViewCustomize.context.project.identifier;
    let url = BasePath + '/projects/' + project_id + '/issues/new?';

    // パラメータをクエリに変換
    if (opts.params) {
      for (let [key, value] of Object.entries(opts.params)) {
        if (key.match(/^custom_field_values_\d+$/)) {
          // カスタムフィールド
          var cfid = key.match(/(\d+)$/)[1];
          url += '&issue[custom_field_values][' + cfid + ']=' + encodeURIComponent(value);;
        } else if (Array.isArray(value)) {
          // 複数指定
          value.forEach(function(val) {
            url += '&issue[' + key + '][]=' + val;
          });
        } else {
          url += '&issue[' + key + ']=' + encodeURIComponent(value);
        }
      }
    }

    let link = $('<a title="' + opts.title + '" class="icon icon-add" id="' + link_id + '" href="' + url + '">' + opts.title + '</a>');

    $('#view_customize_create_relation').append($('<div>', { style: 'width:33%;height:2em;float:left' }).append(link));
 }  
});
```

## Tips

この方法+[Issue Template プラグイン](https://github.com/akiko-pusu/redmine_issue_templates)でそのまま利用可能になるのは、各プロジェクト/トラッカーのデフォルトテンプレートになります。URLによるチケット作成時に、任意のテンプレートが指定できると嬉しいんですが、[GithubのIssue](https://github.com/akiko-pusu/redmine_issue_templates/issues/216)に上がってはいるものの、オープンなまま残ってしまっているようです。ソースも読んでみたんですが、あーこれは大変そうだなぁと。

この問題はいまのところは[ViewCustmizeプラグイン](https://github.com/onozaty/redmine-view-customize)で、チケットのカテゴリー別にswitch文でテンプレートを適用する、という方法で回避しています。

|項目|値|
|:-:|:-:|
|パスのパターン |/projects/[プロジェクト識別子]/issues/new  |
|挿入位置：    |チケット入力欄の下 |
|種別         |JavaScript      |

```javascript
$(function () {
  if (!templateNS) return;

  let observer = new MutationObserver(function() {
    // テンプレートプラグインの準備完了を待つ
    if ($('#template_area').is(':visible')) {
      // カテゴリーごとにテンプレートのIDを指定する例
      let categoryId = $('#issue_category_id').val();
      if (categoryId) {
        switch (categoryId) {
          case '##':
            $('#issue_template').val(##);
            break;
        }
      }
      templateNS.load_template(false);
      observer.disconnect();
    }
  });

  observer.observe(
    document.getElementById('template_area'), 
    { attributes: true }
  );
});
```

## 参照元

[Redmineに関連チケット作成ボタンを追加する](https://qiita.com/sioaji/items/c6fe349e1e637303db4e "Redmineに関連チケット作成ボタンを追加する")