Redmine上で更新されたチケットをステータス別に一覧表示するElectronアプリの作り方

https://github.com/emsk/redmine-now

「Redmine Now」は、Redmine 上で更新されたチケットをほぼリアルタイムに検知し、ステータス別に一覧表示してくれるデスクトップアプリです。

* 自分たちの開発チームが今日一日、または今週一週間の間にどのチケットを対応したのか
* それらのチケットは今どのステータスになっているのか

といったことがひと目でわかります。
macOS と Windows に対応しています。

![Screenshot](https://raw.githubusercontent.com/emsk/redmine-now/v0.2.0/examples/redmine-now.png)

# 使い方

※v0.2.0 のリリースに併せて、説明を更新しました

1. REST API 機能を有効にする
まずは Redmine の管理画面にて REST API の機能を有効にしておきます。
※システム管理者権限が必要です

2. 設定する
「Redmine Now」の起動後、画面上部のフォームに Redmine の URL、API アクセスキー等を設定します。
Project ID の入力は任意です。
Base time に設定した時刻以降に更新されたチケットのみが一覧に表示されます。
※アプリ終了時に、Base time 以外の設定は自動的に保存されます

3. 最新状況を確認する
一覧は自動で更新されますので、最新のチケット状況を常に把握しておくことができます。

# 作り方

※下記で引用しているソースコードについては 2017/1/2 の v0.1.0 リリース時のものであり、v0.2.0 では変更されていますが、大幅には変わっていません

## アプリの内部実装

「Redmine Now」は、「[Redmine Notifier](http://qiita.com/emsk/items/60b8cf8d05cc01ebd1f3)」というチケットの更新をデスクトップ通知するアプリのソースをベースに、Electron を利用して開発されています。

`app` ディレクトリ以下のファイルが、アプリの機能に関わるコードになっています。

### app/main.js

`main.js` には main プロセスのコードを書きます。

```Javascript:app/main.js
'use strict';

(() => {
  const electron = require('electron');
  const app = electron.app;
  const BrowserWindow = electron.BrowserWindow;
  let win = null;

  app.on('window-all-closed', () => {
    app.quit();
  });

  // Avoid the slow performance issue when renderer window is hidden
  app.commandLine.appendSwitch('disable-renderer-backgrounding');

  app.on('ready', () => {
    win = new BrowserWindow({
      width: 850,
      height: 600
    });

    win.loadURL(`file://${__dirname}/index.html`);

    win.on('closed', () => {
      win = null;
    });
  });
})();
```

### app/index.html

`index.html` にて画面の要素を実装し、`stylesheets/index.css` や `index.js` を読み込みます。
Electron では HTML5/CSS3/JavaScript(ES2015) が使えるため、比較的手間をかけずに実装ができるのではないでしょうか。

```HTML:app/index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Redmine Now</title>
    <link rel="stylesheet" href="stylesheets/index.css">
  </head>

  <body>
    <div id="main">
      <table id="setting">
        <tr>
          <th>Redmine URL</th>
          <td class="setting-redmine"><input type="text" id="url" required></td>
          <td rowspan="2">
            <button id="fetch-button" tabindex="-1">Fetch</button>
            <button id="show-hide-button" tabindex="-1">Show/Hide settings</button>
          </td>
        </tr>
        <tr>
          <th>API key</th>
          <td class="setting-redmine"><input type="text" id="api-key" required></td>
        </tr>
        <tr>
          <th>Project ID</th>
          <td colspan="2" class="setting-number"><input type="number" id="project-id" min="1" pattern="^\d+$"></td>
        </tr>
      </table>

      <div id="headers"></div>
      <div id="container"></div>
    </div>

    <script src="index.js"></script>
  </body>
</html>
```

### app/index.js

`index.js` にて renderer プロセスのコードを書いていきます。

`window` の `load` イベントにて、各種初期化処理を行います。

```Javascript:app/index.js
window.addEventListener('load', () => {
  const redmineNow = new RedmineNow();
  redmineNow.initMenu()
    .initEventListener()
    .displaySettings()
    .fetchIssueStatus()
    .updateLastExecutionTime();
});
```

アプリのメニューは下記のような簡潔なコードで実現できます。

```Javascript:app/index.js
initMenu() {
  const appMenu = Menu.buildFromTemplate([
    {
      label: 'Edit',
      submenu: [
        { role: 'undo' },
        { role: 'redo' },
        { role: 'cut' },
        { role: 'copy' },
        { role: 'paste' },
        { role: 'selectall' },
        { type: 'separator' },
        { role: 'quit' }
      ]
    }
  ]);
  Menu.setApplicationMenu(appMenu);

  return this;
}
```

ボタンをクリックした際などのイベントハンドラを実装します。

```Javascript:app/index.js
initEventListener() {
  document.getElementById('fetch-button').addEventListener('click', () => {
    this.fetch();
  });

  document.getElementById('show-hide-button').addEventListener('click', () => {
    this.toggleSettings();
  });

  remote.getCurrentWindow().on('close', () => {
    this.updateSettings();
  });

  return this;
}
```

更新されたチケット情報やステータスマスタの取得は、[Redmine Issues API](http://www.redmine.org/projects/redmine/wiki/Rest_Issues) を通じて行います。

```Javascript:app/index.js
fetch() {
  if (this._issueStatuses.length === 0) {
    this.fetchIssueStatus();
  }

  const xhr = new XMLHttpRequest();

  xhr.onreadystatechange = () => {
    if (xhr.readyState === 4) {
      this.handleResponseFetch(xhr.status, xhr.responseText);
    }
  };

  const url = document.getElementById('url').value;
  const apiKey = document.getElementById('api-key').value;
  xhr.open('GET', `${url}/issues.json${this.getRequestParams()}`);
  xhr.setRequestHeader('X-Redmine-API-Key', apiKey);
  xhr.send();

  return this;
}

handleResponseFetch(status, responseText) {
  if (status === 200) {
    this.show(JSON.parse(responseText).issues)
      .updateLastExecutionTime();
  }

  return this;
}

getRequestParams() {
  const lastExecutionTime = localStorage.getItem('lastExecutionTime');
  const params = [
    `updated_on=%3E%3D${lastExecutionTime}`,
    'status_id=*',
    'sort=updated_on:desc'
  ];

  const projectId = document.getElementById('project-id').value;
  if (projectId !== '') {
    params.unshift(`project_id=${projectId}`);
  }

  return `?${params.join('&')}`;
}
```

チケット情報を取得したら、画面に反映するようにします。

```Javascript:app/index.js
show(issues) {
  const issueCount = issues.length;

  if (issueCount === 0) return this;

  const url = document.getElementById('url').value;

  issues.forEach((issue) => {
    const boxId = `issue-${issue.id}`;
    const currentBox = document.getElementById(boxId);
    if (currentBox) {
      currentBox.parentNode.removeChild(currentBox);
    }

    const box = document.createElement('div');
    box.id = boxId;
    box.className = 'issue';
    box.innerText = `#${issue.id} ${issue.subject}`;
    box.addEventListener('click', () => {
      shell.openExternal(`${url}/issues/${issue.id}`);
    });

    const column = document.getElementById(`column-status-${issue.status.id}`);
    column.insertBefore(box, column.firstChild);
  });

  return this;
}
```


各種設定や最終取得時刻は `localStorage` に保存するようにします。

```Javascript:app/index.js
updateLastExecutionTime() {
  const lastExecutionTime = (new Date()).toISOString().replace(/\.\d+Z$/, 'Z');
  localStorage.setItem('lastExecutionTime', lastExecutionTime);

  return this;
}

updateSettings() {
  localStorage.setItem('url', document.getElementById('url').value);
  localStorage.setItem('apiKey', document.getElementById('api-key').value);
  localStorage.setItem('projectId', document.getElementById('project-id').value);

  return this;
}
```

API キー等の設定情報が他者に見られては困る場合のため、簡易的に設定を隠す「Show/Hide settings」ボタンも設けています。

```Javascript:app/index.js
toggleSettings() {
  const elements = Array.prototype.slice.call(document.getElementsByTagName('input'));
  elements.forEach((element) => {
    element.classList.toggle('mask');
  });

  return this;
}
```

## インストーラ作成のための設定

「Redmine Now」のインストーラの作成には [electron-builder](https://github.com/electron-userland/electron-builder) を利用しています。
macOS と Windows 用のインストーラ作成処理を `electron-builder` が担ってくれるため、非常に便利です。

2017/1/2 現在、結構古めのバージョン7を利用しています。
※`electron-builder` はバージョンアップが頻繁にあるため、ついていくのが大変なのです…
バージョン7では[2つの package.json が必要](https://github.com/electron-userland/electron-builder/wiki/Two-package.json-Structure)なため、作成しておきます。

ルートディレクトリの `package.json` には、開発用の各種 npm-scripts やインストーラ作成時のオプションを記載します。
各種タスクは `bin` ディレクトリ以下のシェルスクリプトに分離しておくことで、`package.json` 内の記述を簡潔にしています。

```package.json
{
  "name": "RedmineNow",
  "version": "0.1.0",
  "repository": "emsk/redmine-now",
  "scripts": {
    "start": "electron ./app/main.js",
    "prepare": "./bin/prepare.sh",
    "lint": "npm run lint:js",
    "lint:js": "./bin/lint-js.sh",
    "build": "npm run build:mac && npm run build:win",
    "build:mac": "./bin/build-mac.sh",
    "build:win": "./bin/build-win.sh",
    "pack": "npm run pack:mac && npm run pack:win",
    "pack:mac": "./bin/pack-mac.sh",
    "pack:win": "./bin/pack-win.sh",
    "test": "./bin/test.sh",
    "release": "npm run prepare && npm run pack"
  },
  "dependencies": {
  },
  "devDependencies": {
    "chai": "^3.5.0",
    "chai-as-promised": "^6.0.0",
    "electron-builder": "~7.0.0",
    "electron-prebuilt": "1.4.1",
    "eslint": "~3.12.2",
    "mocha": "^3.2.0",
    "spectron": "~3.4.0"
  },
  "build": {
    "appId": "redmine-now",
    "category": "public.app-category.productivity",
    "files": ["**/*", "!**/npm-debug\\.log", "!etc"],
    "copyright": "Copyright (c) 2016-2017 emsk",
    "mac": {
      "target": "dmg"
    },
    "dmg": {
      "icon-size": 120,
      "contents": [
        { "x": 450, "y": 210, "type": "link", "path": "/Applications" },
        { "x": 180, "y": 210, "type": "file" }
      ]
    }
  }
}
```

`app` ディレクトリの `package.json` には、アプリのプロパティなどに表示するための情報を記載します。

```app/package.json
{
  "name": "RedmineNow",
  "productName": "Redmine Now",
  "version": "0.1.0",
  "description": "Redmine Now",
  "main": "main.js",
  "repository": "emsk/redmine-now",
  "author": "emsk",
  "license": "MIT",
  "dependencies": {
  }
}
```

下記コマンドでインストーラを作成することができます。

```Bash
npm run pack
```

## テスト

テストは、[spectron](https://github.com/electron/spectron)、[mocha](https://github.com/mochajs/mocha)、[chai](https://github.com/chaijs/chai)、[chai-as-promised](https://github.com/domenic/chai-as-promised) を利用して一部自動化しています。

`dist` 以下に作成された `Redmine Now.app` に対してテストを実行します。

```JavaScript:test/test.js
'use strict';

describe('application launch', function() {
  const Application = require('spectron').Application;
  const chai = require('chai');
  const chaiAsPromised = require('chai-as-promised');
  const app = new Application({
    path: `${__dirname}/../dist/mac/Redmine Now.app/Contents/MacOS/Redmine Now`
  });

  chai.should();
  chai.use(chaiAsPromised);
  chaiAsPromised.transferPromiseness = app.transferPromiseness;

  beforeEach(() => {
    return app.start();
  });

  afterEach(() => {
    if (app && app.isRunning()) {
      return app.stop();
    }
  });

  it('opens a window', () => {
    return app.client.waitUntilWindowLoaded()
      .getWindowCount().should.eventually.equal(1)
      .browserWindow.getBounds().should.eventually.have.property('width').equal(850)
      .browserWindow.getBounds().should.eventually.have.property('height').equal(600)
      .browserWindow.isVisible().should.eventually.be.true
      .browserWindow.isResizable().should.eventually.be.true
      .browserWindow.isFocused().should.eventually.be.true
      .browserWindow.isMaximized().should.eventually.be.false
      .browserWindow.isMinimized().should.eventually.be.false
      .browserWindow.isFullScreen().should.eventually.be.false
      .browserWindow.isMovable().should.eventually.be.true
      .browserWindow.isMaximizable().should.eventually.be.true
      .browserWindow.isMinimizable().should.eventually.be.true
      .browserWindow.isFullScreenable().should.eventually.be.true
      .browserWindow.isClosable().should.eventually.be.true
      .browserWindow.isAlwaysOnTop().should.eventually.be.false
      .browserWindow.isKiosk().should.eventually.be.false
      .browserWindow.isDocumentEdited().should.eventually.be.false
      .browserWindow.isMenuBarAutoHide().should.eventually.be.false
      .browserWindow.isMenuBarVisible().should.eventually.be.true
      .browserWindow.isVisibleOnAllWorkspaces().should.eventually.be.false
      .browserWindow.isDevToolsOpened().should.eventually.be.false
      .browserWindow.isDevToolsFocused().should.eventually.be.false
      .getUrl().should.eventually.match(/^file:\/\/.+\/index.html$/)
      .getTitle().should.eventually.equal('Redmine Now');
  });
});

```

下記コマンドでテストを実行することができます。

```Bash
npm test
```

## まとめ

以上のように、Electron を利用すると Web の技術でデスクトップアプリが作れてしまいます。
「Redmine Now」のように比較的シンプルなアプリであれば、あまり工数をかけずにサクッと作ることができますので、Web プログラマにとってはありがたい限りです。

「Redmine Now」は全体的にデザイン（仕様や見た目など）が粗削りなため、今後ブラッシュアップしていきたいです。
また、テストもさらに自動化を進められればと思います。

何かご要望がありましたら、お気軽にコメントなどでご連絡ください！