Redmineで複数人の工数を同時に入力する【view customize plugin】

Redmineで複数人の工数を同時に入力する【view customize plugin】

前々から効率化したかったこの作業。

Redmineは工数入力のAPIが用意されているものの、工数が計上されるのはAPIの実行ユーザーになってしまいます。

つまりプログラムからAPI経由で工数を任意のユーザーに計上するのは無理・・・?

と思っていましたが、以前の記事で解説した通り、実行ユーザーの指定が可能でした!

というわけでやってみます。

実行ユーザーの指定方法は▼▼コチラから▼▼

やりたいこと

例えば

A,B,Cの3名が1時間打ち合わせした場合、

A,B,Cそれぞれが工数入力する必要がある。

これでは非効率なので、任意のユーザー(リーダーなり、打ち合わせの主催者なり)が

1時間の工数を入力すれば、参加者それぞれに計上される

・・・のようにしたい。

実装内容

例の如く view customize plugin で実現します。

view customize pluginのインストール方法はコチラ▼を参考。

実装内容は下記の通り。

カスタマイズする画面は、工数入力画面を想定。

下図赤枠辺りにプロジェクトの参加者を複数選択可能なリストに表示する。

選択された参加者に対して同内容の工数情報を登録する。

実装結果

実装した結果は下図のような動きになります。

該当するチケットが属するプロジェクトのメンバーのリストが表示され、

同時に登録したいユーザーを選択する、という形です。

コードの解説

次に実装したコード・・・の前に使用するAPIをメモ。

使用するAPIその1:Time Entries(POST)

工数情報に関するAPI。

今回は作成(POST)のみ使用します。

APIのパラメータは、こんな感じ(引用)▼ですね

Parameters:

  • time_entry (required): a hash of the time entry attributes, including:
    • issue_id or project_id (only one is required): the issue id or project id to log time on
    • spent_on: the date the time was spent (default to the current date)
    • hours (required): the number of spent hours
    • activity_id: the id of the time activity. This parameter is required unless a default activity is defined in Redmine.
    • comments: short description for the entry (255 characters max)

必須項目が幾つかあります。(太字 + 青下線)

画面から入力する場合の必須項目と若干異なります(チケット番号が任意だったり)。

使用するAPIその2:Project Memberships(GET)

プロジェクトのメンバー情報に関するAPI。

今回は取得(GET)のみ使用します。

使用例は以下の通り▼

Examples:

GET /projects/1/memberships.xml
GET /projects/redmine/memberships.xml

全プロジェクトのメンバー情報も取得できるし、

プロジェクトID(または識別子)を渡せば指定のプロジェクトのメンバー情報だけ取得、という事も可能です。

使用するAPIその3:User(GET)

ユーザー情報に関するAPI。

今回は取得(GET)のみ使用します。

使用例は以下の通り▼

Examples:

GET /users/current.xml

Returns the details about the current user.

GET /users/3.xml?include=memberships,groups

Returns the details about user ID 3, and additional detail about the user’s project memberships.

ユーザーIDをエンドポイントに指定することで、対象のユーザー情報のみ取得できるとの事。

実装したコード

言わずもがな冒頭のAPI_KEYは各環境のAPIキーを指定します。

const API_KEY = "redmine-api-hogehoge";

const makeUserList = function() {
    // 作成済み要素を削除
    $('#time_entry_related_user').remove();
    // チケット番号取得
    const issueId = $('#time_entry_issue_id').val()

    //対象チケットが属するプロジェクトのメンバー情報を取得
    getIssue(issueId).then(function(issueInfo) {
        console.log(issueInfo);
        if (!issueInfo) {
            return Promise.reject();
        }
        return getProjectMember(issueInfo.issue.project.id);
    }).then(function(resp) {
        const userPromise = [];
        resp.memberships.forEach(function(member) {
            userPromise.push(getUser(member.user.id));
        });
        return Promise.all(userPromise);
    }).then(function(resp) {
        // プロジェクトのメンバー情報を全て取得後、select要素を生成する。
        // ログインユーザーの取得(DOM要素から無理やり)
        const loginUser = $('#loggedas .user.active').text();
        let users = "";
        resp.forEach(function(userInfo) {
            if (loginUser === userInfo.user.login) {
                return;
            }
            users += '<option value="' + userInfo.user.login + '">'
                    +   userInfo.user.lastname + userInfo.user.firstname
                    + '</option>';
        });
        const source = ""
        + '

'
        +   '<label for="time_entry_user">関連ユーザー</label>'
        +   '<select name="time_entry[user]" id="time_entry_user" multiple>'
        +     '<option value="">--- 選んでください ---</option>'
        +       users
        +   '</select>'
        +  '<input id="button-time-entry-user" type="button" name="button" value="工数登録" style="vertical-align:top; margin: 0px 4px">'
        + '

'
        $(".box.tabular").append(source);
    }).catch(function(e) {
        console.log(e);
    });
}

/**
 * Redmine API 実行用関数
 * @param _param 
 */
const executeApi = function(_param) {
    // API_KEYは共通なので、実行用関数で設定
    if (_param.headers) {
        _param.headers['X-Redmine-API-Key'] = API_KEY;
    } else {
        _param['headers'] = {
            'X-Redmine-API-Key': API_KEY
        }
    }
    // パラメータ設定
    const param = {
        type: _param.type,
        url: _param.url,
        headers: _param.headers,
        dataType: 'text',
        contentType: 'application/json',
    }
    // dataが設定されている場合、パラメータに追加
    if (_param.data) {
        param['data'] = JSON.stringify(_param.data)
    }
    return new Promise(function(resolve, reject) {
        $.ajax(param).done(function(resp) {
            resolve(JSON.parse(resp));
        }).fail(function(jqXHR, textStatus, errorThrown){
            console.log(jqXHR);
            console.log(textStatus);
            console.log(errorThrown);
            reject(jqXHR);
        });
    });
};

/**
 * 工数入力用関数
 * @param userId 
 */
const postTimeEntry = function(userId) {
    const param = {
        type: 'POST',
        url: '/time_entries.json',
        headers: {
            'X-Redmine-Switch-User': userId,
        },
        data: {
            "time_entry": {
                "issue_id": $('#time_entry_issue_id').val(),
                "spent_on": $('#time_entry_spent_on').val(),
                "hours":$('#time_entry_hours').val(),
                "comments":$('#time_entry_comments').val(),
                "activity_id":$('#time_entry_activity_id').val()
            }
        },
    }
    return executeApi(param);
}

/**
 * プロジェクトメンバー取得用関数
 */
const getProjectMember = function(projectId) {
    const param = {
        type: 'GET',
        url: '/projects/' + projectId + '/memberships.json',
    }
    return executeApi(param);
}

/**
 * ユーザー情報取得用関数
 * @param userId 
 */
const getUser = function(userId) {
    const param = {
        type: 'GET',
        url: '/users/' + userId + '.json',
    }
    return executeApi(param);
}

const getIssue = function(issueId) {
    const param = {
        type: 'GET',
        url: '/issues/' + issueId + '.json'
    }
    return executeApi(param);
    
}

/**
 * 工数登録ボタンクリックイベント
 */
$(document).on('click', '#button-time-entry-user', function(e) {
    const self = this;
    console.log(e);
    const selected = $('#time_entry_user').val();
    const promises = [];
    // //未選択の場合、スキップ
    if (!selected) {
        return;
    }
    selected.forEach(function(selectedUser) {
        // 未選択の場合、スキップ
        if (!selectedUser) {
            return;
        }
        promises.push(postTimeEntry(selectedUser));
    });
    // 工数登録処理実行対象外の場合、終了
    if (promises.length === 0) {
        return;
    }
    Promise.all(promises).then(function(responses) {
        alert("関連ユーザーの工数を登録しました。")
        console.log(responses);
        $(self).prop("disabled", true);
    }).catch(function(e) {
        let error;
        if (e.responseText) {
            error = JSON.parse(e.responseText);
        }
        alert(
            "関連ユーザーの工数登録に失敗しました。\n" +
            error.errors.join("\n")
        );
    });
});

/**
 * チケットIDの変更イベント
 */
$(document).on('change', '#time_entry_issue_id', function(e) {
    makeUserList();    
});

$(function() {
    makeUserList();
});

納得していないところ

ボタンクリックが2度手間

「工数登録」ボタンをクリック後、通常の「作成」ボタンを押すので、やはり面倒。

「作成」ボタンの処理に、今回の追加処理を割り込ませても良いのですが、

バリデーションエラーの場合に、こんな問題が▼

「作成」ボタンをクリックする。

関連ユーザーの工数登録処理が実行される。

バリデーションエラーの為、本来の登録処理がキャンセルされる。

関連ユーザーの工数登録処理だけ実行されてしまう。

「作成」ボタンのsubmitの成功/失敗が拾えないので、「submit処理が失敗したら、関連ユーザーの工数登録処理もキャンセルする」ができないのです・・・。

JavaScriptでの実装の限界ですね。

その為、トリガーを別とし、完全に処理を分けています。

おわりに

こんなプラグインがあったら良いなと思うので、作ってみようかな。

Redmineカテゴリの最新記事