Garoon スケジュール・ポータルのカスタマイズをはじめよう〜会議効率化カスタマイズ〜

目次

はじめに

この記事は、「API ドキュメントを読んだけど、使い方がわからない」「Garoon カスタマイズに興味はあるけど、何から始めてよいかわからない」といった人に向けた内容です。
Garoon スケジュールやポータルのカスタマイズを通じて、Garoon JavaScript API と Garoon REST API の使い方を知り、Garoon カスタマイズの基本を学んでみましょう。

この記事の内容は、Cybozu Days 2019 の Garoon 中級ハンズオン〜API を使って作る、カスタマイズポータル作成ハンズオン〜で行ったものです。

この記事で学べること

  • Garoon JavaScript API(イベント)の使い方
  • Garoon REST API の使い方
    • 予定の更新
    • 条件に一致する予定の取得
  • 予定のカスタム項目(schedule datastore)
  • Garoon スケジュールおよびポータルのカスタマイズを適用する方法

環境準備

  • Garoon クラウド版、またはパッケージ版 ver. 5.9 以降
  • テキストエディタ

注意事項

次の画面ではカスタマイズを利用できません。

  • モバイル表示
  • モバイル用アプリ(KUNAI および Garoon モバイル)

Garoon JavaScript API と Garoon REST API

ここでは Garoon の代表的なアプリケーションのワークフロー、スケジュール、ポータルを例に、Garoon JavaScript API と Garoon REST API を説明します。

JavaScript とは

JavaScript はブラウザーで利用できるプログラミング言語です。
Web ページはコンテンツの内容を作る HTML と見た目を整える CSS を使って作られていますが、一度読み込むとページの内容は基本的に変化しません。
しかし、JavaScript を利用すると、リアルタイムにページの内容を書き換えできるので、Web ページに動きをつけることができます。

Garoon JavaScript API とは

Garoon JavaScript API は、画面をカスタマイズするためのインターフェースで、開いている Garoon の画面に表示されている内容や HTML の要素を取得、操作できます。
Garoon JavaScript API を使うと、Garoon の画面の見た目を変えたり、入力された値が正しいかをチェックするなどの動きを変えたりできます。

JavaScript には、「◯◯が実行されたときに◯◯する」といった処理ができる「イベント」というしくみがあります。
Garoon の場合、「予定の詳細画面が表示されたとき」「ワークフローが承認されたとき」といったイベントが存在します。
このイベントを利用すると、「グループ週画面が表示されたとき、週画面に付加情報を表示する」といったカスタマイズができます。

Garoon REST API とは

Garoon REST API は、データ連携のためのインターフェースで、Garoon で管理しているデータを外部システムに渡したり、外部システムから Garoon にデータを登録、更新、削除できます。

また、Garoon REST API はスケジュールとワークフローといった、Garoon の異なるアプリケーション間でデータをやりとりしたい場合や、開いている Garoon の画面上にない内容を取得、操作したい場合にも利用します。

ハンズオン

それでは、スケジュールとポータルをそれぞれカスタマイズしていきましょう。

完成イメージ

会議の効率化(会議が短縮された時間)を数値として把握し、ポータルで全社の成果をグラフで見える化するカスタマイズです。

スケジュールのカスタマイズ

  • 予定の詳細画面の「会議を終了する」ボタンをクリックすると、予定の終了日時が更新されます。
    予定メニュー (External link) 」が「打合」の予定が対象です。
  • 「会議を終了するボタン」で会議を終了した予定の場合、改善成果(短縮された会議時間)を表示します。
ポータルのカスタマイズ

  • ポータルに、直近 1 週間の改善成果をグラフ表示します。
    • 短縮された会議時間の予定ランキング
    • 1 週間でどの程度会議を効率できたかの比較

スケジュールのカスタマイズ

Step 1: カスタマイズファイルの作成
  1. テキストエディタを開いて、次のコードの内容をコピー&ペーストします。

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    
    /*
    * Garoon meeting efficiency customize(schedule)
    * Copyright (c) 2020 Cybozu
    *
    * Licensed under the MIT License
    * https://opensource.org/license/mit/
    */
    
    (function($) {
      'use strict';
    
      // カスタマイズ対象の予定メニュー
      const MEETING = '打合';
      const SCHEDULE_DATASTORE_KEY = 'jp.co.cybozu.schedule.sample.efficiency';
    
      garoon.events.on('schedule.event.detail.show', (event) => {
        const scheduleEvent = event.event;
        const start = scheduleEvent.start.dateTime;
        const end = scheduleEvent.end.dateTime;
        const scheduleStartTime = new Date(start);
        const scheduleEndTime = new Date(end);
        const now = new Date();
        const datastore = garoon.schedule.event.datastore.get(SCHEDULE_DATASTORE_KEY);
        const isClicked = datastore.value.isClicked;
    
        /**
         * スケジュールの終了時刻を更新する関数
        * ※更新時に、予定終了時刻・削減時間・効率(%)をdatastoreに格納する
        */
        const finishMeeting = function(ev) {
          // 予定の詳細画面を開いてから「会議終了」ボタンが押されるまでに時間差があることを考慮し、
          // 「会議終了」ボタンを押したときの時刻を終了時刻に設定する
          const date = new Date();
          const actualEndTime = new Date(date.setMinutes(Math.ceil(date.getMinutes() / 5) * 5, 0));
          const scheduledTime = scheduleEndTime - scheduleStartTime;
          const actualSpentTime = actualEndTime - scheduleStartTime;
          // datastoreに格納する値
          const value = {
            value: {
              isClicked: true,
              end: end,
              reductionTime: Math.ceil((scheduleEndTime - actualEndTime) / (1000 * 60)),
              efficiency: Math.ceil((scheduledTime / actualSpentTime) * 100)
            }
          };
          // スケジュール更新内容
          const updateEvent = {
            end: {dateTime: actualEndTime}
          };
          garoon.api('/api/v1/schedule/events/' + ev.id + '/datastore/' + SCHEDULE_DATASTORE_KEY, 'PUT', value).then((resp) => {
            return garoon.api('/api/v1/schedule/events/' + ev.id, 'PATCH', updateEvent);
          }).then((resp) => {
            location.reload();
          }).catch((err) => {
            window.alert('Error!!');
            console.error(err);
          });
        };
    
        /**
         * 効率化達成結果を表示する関数
        */
        const showResult = function() {
          const resultReportSpace = garoon.schedule.event.getHeaderSpaceElement();
          const $resultReport = $('<STRONG>', {
            id: 'items',
            class: 'sat_color1_grn_kit',
            type: 'text',
            text:
              '会議効率化達成!(' +
              datastore.value.efficiency +
              '%)  これによって会議が' +
              datastore.value.reductionTime +
              '分削減されました!'
          });
          $resultReport.prependTo(resultReportSpace);
        };
    
        /**
         * 会議終了ボタンを設置する関数
        */
        const setButton = function(ev) {
          const endButtonSpace = garoon.schedule.event.getHeaderSpaceElement();
          const $endButton = $('<button>', {
            id: 'endBtn',
            class: 'button_main_sub_grn_kit',
            type: 'button',
            text: '会議を終了する'
          }).click(() => {
            // クリックした時に行いたい処理を登録
            finishMeeting(ev);
          });
          const $brElements = $('<br><br>');
          $brElements.prependTo(endButtonSpace);
          $endButton.prependTo(endButtonSpace);
        };
    
        // 予定メニューが MEETING に設定した値の予定のみ、カスタマイズを実行する
        if (scheduleEvent.eventMenu === MEETING) {
        // 効率化達成結果がカスタマイズ項目に登録されていたら、結果を画面に表示する
          if (isClicked) {
            showResult();
            return;
          }
          // 現在時刻が打合日時の間の場合
          if (scheduleStartTime <= now && now <= scheduleEndTime) {
            setButton(scheduleEvent);
          }
        }
      });
    
      /**
       * スケジュール作成(再利用)時にdatastoreを初期化する
      */
      garoon.events.on('schedule.event.create.submit', (event) => {
        const initialValue = {
          value: {
            isClicked: false,
            end: '',
            reductionTime: '',
            efficiency: ''
          }
        };
        garoon.schedule.event.datastore.set(SCHEDULE_DATASTORE_KEY, initialValue);
        return event;
      });
    
    })(jQuery.noConflict(true));
  2. ファイルの拡張子「.js」、文字コードは「UTF-8(BOM なし)」で、ファイルに名前を付けて保存します。
    この記事では、ファイル名を schedule-customize.js としています。

Step 2: カスタマイズの適用

カスタマイズグループを作成し、スケジュールのカスタマイズファイルを適用します。
手順の詳細は「 スケジュールのカスタマイズ設定 (External link) 」を参照してください。

  1. Garoon メニュー右の歯車アイコンをクリックし、[Garoon システム管理]を選択します。
  2. [各アプリケーションの管理]タブを選択し、[スケジュール]を選択します。
  3. [JavaScript/CSS によるカスタマイズ]を選択します。
  4. [カスタマイズグループを追加する]をクリックします。
  5. 次の内容を入力します。入力が終わったら、[追加する]ボタンをクリックします。
    項目 設定する値
    カスタマイズ 「適用する」を選択します。
    カスタマイズグループ名 任意の値を入力します。この記事では「会議効率化カスタマイズ」としています。
    適用対象 カスタマイズを適用するユーザーやグループを選択します。
    JavaScript カスタマイズ 以下の順で、URL およびファイルを指定します。
    • https://js.cybozu.com/jquery/3.1.1/jquery.min.js
    • schedule-customize.js(カスタマイズファイル)
    CSS カスタマイズ grn_kit.css を指定します

    grn_kit.css(Garoon html/css/image-Kit for Customize)の入手方法
    1. https://github.com/garoon/css-for-customize (External link) にアクセスします。
    2. [Clone or download] ボタンをクリックして、「Download ZIP」を選択します。
    3. ダウンロードした zip ファイルを解凍します。
    4. 解凍したファイルの「css」フォルダー以下の「grn_kit.css」を利用します。
コードの解説
イベントを使って、予定の詳細画面を開いたときに実行する処理を定義する

16 行目〜110 行目では、イベントを使って、予定の詳細画面を開いたときに実行する処理を記述しています。

16
garoon.events.on('schedule.event.detail.show', (event) => {

110
});

Garoon のイベントは、garoon.events.on(処理を行うタイミングのイベントタイプ, 実行する処理の関数); という形で指定します。
今回、処理を行うタイミングは「予定の詳細画面を開いたとき」なので「schedule.event.detail.show」というイベントタイプを指定します。
2 番目の引数には、実行する処理を関数の形で指定します(function(event) { ... } の部分)

「会議を終了する」ボタンをクリックしたときに、Garoon REST API を実行する

30 行目〜58 行目の finishMeeting 関数は、「会議を終了する」ボタンをクリックしたときに呼び出されます。

30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
const finishMeeting = function(ev) {
  // 予定の詳細画面を開いてから「会議終了」ボタンが押されるまでに時間差があることを考慮し、
  // 「会議終了」ボタンを押したときの時刻を終了時刻に設定する
  const date = new Date();
  const actualEndTime = new Date(date.setMinutes(Math.ceil(date.getMinutes() / 5) * 5, 0));
  const scheduledTime = scheduleEndTime - scheduleStartTime;
  const actualSpentTime = actualEndTime - scheduleStartTime;
  // datastoreに格納する値
  const value = {
    value: {
      isClicked: true,
      end: end,
      reductionTime: Math.ceil((scheduleEndTime - actualEndTime) / (1000 * 60)),
      efficiency: Math.ceil((scheduledTime / actualSpentTime) * 100)
    }
  };
  // スケジュール更新内容
  const updateEvent = {
    end: {dateTime: actualEndTime}
  };
  garoon.api('/api/v1/schedule/events/' + ev.id + '/datastore/' + SCHEDULE_DATASTORE_KEY, 'PUT', value).then((resp) => {
    return garoon.api('/api/v1/schedule/events/' + ev.id, 'PATCH', updateEvent);
  }).then((resp) => {
    location.reload();
  }).catch((err) => {
    window.alert('Error!!');
    console.error(err);
  });
};

finishMeeting 関数では、2 つの Garoon REST API を実行しています。

  • 予定のカスタム項目(Schedule datastore)を更新する
    予定のカスタム項目(Schedule datastore)は、スケジュールのカスタマイズにおいて複数のデータを保持できる機能です。
    このカスタマイズでは、「ボタンをクリックしたか」「ボタンをクリックしたときの時刻」「削減できた時間」「効率(%)」を、カスタム項目(Schedule datastore)として保存しています。
  • 予定を更新する

Garoon 上で Garoon REST API を実行するときは、 Garoon REST APIリクエストを送信する API を利用できます(50 行目、51 行目)。
この API は、garoon.api(REST API の URL パス, メソッド, APIに渡すデータ) という形で指定します。
これは Promise を使う方法です。Promise については、 kintone における Promise の書き方の基本 を参照してください。
たとえば、 予定を更新する API を実行するときに指定する内容は、次のようになります。

  • REST API の URL パス: /api/v1/schedule/events/更新する予定の ID
  • メソッド:PATCH
  • API に渡すデータ:更新したい内容

REST API が正常に実行できた後の処理は、.then の引数で指定した関数に記述します(52 行目〜52 行目)の .then(function(resp) { ... }) の部分)。
今回は、画面を再読み込みしています。

動作確認
  1. 予定を登録します。
    • 予定メニュー:「打合」を選択します。
    • 日時:現在時刻が開始日時と終了日時の間になるように設定します。
      例:現在時刻が 10:30 なら、日時を 10:00 〜 11:00 に設定する。
  2. 登録した予定の「会議を終了する」ボタンをクリックして、会議を終了します。
  3. 予定の終了日時が更新され、短縮時間や効率の情報が表示されたことを確認します。

ポータルのカスタマイズ

Step1: カスタマイズファイルの作成
  1. テキストエディタを開いて、次のコードの内容をコピー&ペーストします。

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    
    /*
    * Garoon meeting efficiency customize(portlet)
    * Copyright (c) 2020 Cybozu
    *
    * Licensed under the MIT License
    * https://opensource.org/license/mit/
    */
    
    (function($) {
      'use strict';
    
      // 抽出対象とする予定メニュー
      const SCHEDULE_MENU = '打合';
      // グラフ表示する順位数
      const TARGET_RANK = 6;
      // グラフの幅
      const WIDTH = 700;
      // グラフの高さ
      const HEIGHT = 550;
      const colors = {
        pink: 'rgba(255, 99, 132, 0.2)',
        blue: 'rgba(54, 162, 235, 0.2)',
        yellow: 'rgba(255, 206, 86, 0.2)',
        green: 'rgba(75, 192, 192, 0.2)',
        purple: 'rgba(153, 102, 255, 0.2)',
        orange: 'rgba(255, 159, 64, 0.2)',
        gray: 'rgba(120, 126, 126, 0.4)',
        red: 'rgba(255, 2, 13, 0.8)'
      };
      const borderColors = {
        pink: 'rgba(255,99,132,1)',
        blue: 'rgba(54, 162, 235, 1)',
        yellow: 'rgba(255, 206, 86, 1)',
        green: 'rgba(75, 192, 192, 1)',
        purple: 'rgba(153, 102, 255, 1)',
        orange: 'rgba(255, 159, 64, 1)'
      };
      const SCHEDULE_DATASTORE_KEY = 'jp.co.cybozu.schedule.sample.efficiency';
    
    
      /**
      * ログインユーザの一週間の予定を取得する関数
      * @returns {object} スケジュールデータの取得結果
      */
      const getSchedules = function() {
        const m = moment();
        const today = m.format('YYYY-MM-DD');
        const startDate = m.add(-6, 'day').format('YYYY-MM-DD');
        const path = '/api/v1/schedule/events';
        const params = {
          orderBy: 'start asc',
          rangeStart: startDate + 'T00:00:00+09:00',
          rangeEnd: today + 'T23:59:59+09:00'
        };
        return garoon.api(path, 'GET', params).then((resp) => {
          return resp.data.events;
        });
      };
    
      /**
      * スケジュールに紐づくdatastoreを取得する関数
      * @param {number} eventId 加工対象のスケジュールID
      * @returns {object} datastore取得結果
      */
      const getDatastore = function(eventId) {
        const url = '/api/v1/schedule/events/' + eventId + '/datastore/' + SCHEDULE_DATASTORE_KEY;
        return garoon.api(url, 'GET', {}).then((resp) => {
          return {
            id: eventId,
            datastore: resp.data
          };
        });
      };
    
      /**
      * 複数のスケジュールデータを水平棒グラフ用の配列に加工する関数
      * @param {object} events 加工対象のスケジュールデータ
      * @returns {array} 削減時間ランキング上位のデータ
      */
      const makeHorizonBarData = function(events) {
        const horizonBarDataArray = [];
        events.forEach((event) => {
          const horizonBarDataObj = {};
          if (typeof event.datastore === 'undefined' || !event.datastore.isClicked) {
            return;
          }
          horizonBarDataObj.subject = event.subject;
          horizonBarDataObj.reductionTime = event.datastore.reductionTime;
          horizonBarDataArray.push(horizonBarDataObj);
        });
    
        // 「削減時間」を降順にソート
        horizonBarDataArray.sort((a, b) => {
          if (a.reductionTime < b.reductionTime) {
            return 1;
          }
          return -1;
        });
    
        // グラフ表示する配列のみを返す
        return horizonBarDataArray.slice(0, TARGET_RANK);
      };
    
      /**
      *  水平棒グラフを作成する関数
      * @param {array} horizonBarData グラフ表示用に加工されたデータ
      */
      const createHorizonBarGraph = function(horizonBarData) {
        const subjectArray = [];
        const reductionTimeArray = [];
        const ctx = document.getElementById('horizonBarChart').getContext('2d');
        let i;
        // グラフサイズを指定
        ctx.canvas.width = WIDTH;
        ctx.canvas.height = HEIGHT;
    
        // 配列[horizonBarData]をプロパティ別に分割する(Chart.js対応)
        for (i = 0; i < horizonBarData.length; i++) {
          subjectArray.push(horizonBarData[i].subject);
          reductionTimeArray.push(horizonBarData[i].reductionTime);
        }
    
        new Chart(ctx, {
          type: 'horizontalBar',
          data: {
            labels: subjectArray,
            datasets: [
              {
                label: '削減時間(分)',
                data: reductionTimeArray,
                backgroundColor: [
                  colors.pink,
                  colors.blue,
                  colors.yellow,
                  colors.green,
                  colors.purple,
                  colors.orange
                ],
                borderColor: [
                  borderColors.pink,
                  borderColors.blue,
                  borderColors.yellow,
                  borderColors.green,
                  borderColors.purple,
                  borderColors.orange,
                ],
                borderWidth: 1
              }
            ]
          },
          options: {
            title: {
              display: true,
              position: 'top',
              fontSize: 25,
              fontColor: 'black',
              fontStyle: 'bold',
              padding: 10,
              text: '今週のランキング'
            },
            responsive: false,
            maintainAspectRatio: false,
            scales: {
              // X軸のオプション
              xAxes: [
                {
                  ticks: {
                    fontColor: 'black',
                    beginAtZero: true,
                    max: 40,
                    stepSize: 5
                  }
                }
              ]
            }
          }
        });
      };
    
      /**
      * スケジュールデータを2軸棒×折れ線グラフ用の配列に加工する関数
      * @param {object} events 加工対象のスケジュールデータ
      * @returns {array} 加工済データ
      */
      const makeMixedChartData = function(events) {
        const mixedChartArray = [];
        const mixedChartObj = {};
    
        // 現在日時から一週間分の配列[mixedChartArray]を作成する(初期化)
        for (let j = 0; j < 7; j++) {
          const labelDate = moment().add(-j, 'day').format('MM-DD');
          mixedChartObj.date = labelDate;
          mixedChartObj.scheduleMeetingTime = 0;
          mixedChartObj.actualMeetingTime = 0;
          mixedChartArray.push(mixedChartObj);
        }
    
        events.forEach((event) => {
          const start = event.start.dateTime;
          const end = event.end.dateTime;
          const scheduleStartTime = new Date(start);
          const actualEndTime = new Date(end).setSeconds(0);
          const datastore = event.datastore;
          const keyDate = moment(start).format('MM-DD');
          const actualSpentTime = (actualEndTime - scheduleStartTime) / (1000 * 60);
          let scheduledTime = 0;
    
          // 打合日時をキーとし、各プロパティに値を合算する
          if (typeof event.datastore === 'undefined' || !event.datastore.isClicked) {
            scheduledTime = actualSpentTime;
          } else {
            const scheduleEndTime = new Date(datastore.end || actualEndTime);
            scheduledTime = (scheduleEndTime - scheduleStartTime) / (1000 * 60);
          }
          mixedChartArray.forEach((obj) => {
            if (obj.date === keyDate) {
              obj.scheduleMeetingTime += scheduledTime;
              obj.actualMeetingTime += actualSpentTime;
            }
          });
        });
        return mixedChartArray;
      };
    
      /**
      * 2軸棒✕折れ線グラフを作成する関数
      * @param {array} mixedChartData グラフ表示用に加工されたデータ
      */
      const createMixedChart = function(mixedChartData) {
        const dateLabelArray = [];
        const scheduleMeetingTimeArray = [];
        const actualMeetingTimeArray = [];
        const efficiencyArray = [];
        const ctx = document.getElementById('mixedChart').getContext('2d');
        // グラフサイズを指定
        ctx.canvas.width = WIDTH;
        ctx.canvas.height = HEIGHT;
    
        // 配列[mixedChartData]をプロパティ別に分割する(Chart.js対応)
        mixedChartData.forEach((data) => {
          const efficiency = Math.ceil((data.scheduleMeetingTime / data.actualMeetingTime) * 100);
          dateLabelArray.unshift(data.date);
          scheduleMeetingTimeArray.unshift(data.scheduleMeetingTime);
          actualMeetingTimeArray.unshift(data.actualMeetingTime);
          efficiencyArray.unshift(efficiency);
        });
    
        new Chart(ctx, {
          type: 'bar',
          data: {
            labels: dateLabelArray,
            datasets: [
              {
                type: 'bar',
                label: '予定所要時間',
                data: scheduleMeetingTimeArray,
                backgroundColor: colors.blue,
                borderColor: borderColors.blue,
                borderWidth: 1,
                yAxisID: 'y1'
              },
              {
                type: 'bar',
                label: '実所要時間',
                data: actualMeetingTimeArray,
                backgroundColor: colors.green,
                borderColor: borderColors.blue,
                borderWidth: 1.2,
                yAxisID: 'y1'
              },
              {
                type: 'line',
                label: '会議効率',
                data: efficiencyArray,
                backgroundColor: colors.pink,
                borderColor: borderColors.pink,
                borderWidth: 1.2,
                pointBackgroundColor: colors.pink,
                pointStyle: 'circle',
                radius: 4,
                pointHoverBackgroundColor: colors.pink,
                pointHoverRadius: 7,
                pointHoverBorderColor: colors.red,
                pointHoverBorderWidth: 2,
                lineTension: 0,
                fill: false,
                yAxisID: 'y2'
              }
            ]
          },
          options: {
            title: {
              display: true,
              position: 'top',
              fontSize: 25,
              fontColor: 'black',
              fontStyle: 'bold',
              padding: 10,
              text: '比較グラフ'
            },
            legend: {
              display: true
            },
            tooltips: {
              enabled: true
            },
            scales: {
              // Y軸のオプション
              yAxes: [
                {
                  id: 'y1',
                  scaleLabel: {
                    fontColor: 'black'
                  },
                  gridLines: {
                    color: colors.gray,
                    zeroLineColor: 'black'
                  },
                  ticks: {
                    fontColor: 'black',
                    beginAtZero: true,
                    suggestedMax: 200,
                    stepSize: 50
                  }
                },
                {
                  id: 'y2',
                  position: 'right',
                  autoSkip: true,
                  gridLines: {
                    display: false
                  },
                  ticks: {
                    fontColor: 'black',
                    beginAtZero: true,
                    suggestedMax: 200,
                    stepSize: 20,
                    callback: function callback(val) {
                      return val + '%';
                    }
                  }
                }
              ],
              // X軸のオプション
              xAxes: [
                {
                  scaleLabel: {
                    fontColor: 'black',
                    display: true,
                    labelString: '日付'
                  },
                  gridLines: {
                    color: colors.gray,
                    zeroLineColor: 'black'
                  },
                  ticks: {
                    fontColor: 'black'
                  }
                }
              ]
            },
            responsive: false,
            maintainAspectRatio: false
          }
        });
      };
    
      // 画面読み込み完了時に起動する処理
      $(document).ready(() => {
        let targetMeeting;
        getSchedules().then((events) => {
        // REST APIは予定メニューによる絞り込みデータ取得に対応していないため、抽出後に判定を行う
          targetMeeting = events.filter((event) => {
            return event.eventMenu === SCHEDULE_MENU;
          });
          return garoon.Promise.all(targetMeeting.map((event) => {
            return getDatastore(event.id);
          }));
        }).then((datastores) => {
          targetMeeting.forEach((event) => {
            const datastore = datastores.filter((ev) => {
              return ev.id === event.id;
            })[0].datastore;
            if (datastore.value.isClicked) {
              event.datastore = datastore.value;
            }
          });
          const horizonBarData = makeHorizonBarData(targetMeeting);
          const mixedChartData = makeMixedChartData(targetMeeting);
          createHorizonBarGraph(horizonBarData);
          createMixedChart(mixedChartData);
        }).catch((e) => {
          window.alert('Error!!');
          console.error(e);
        });
      });
    })(jQuery.noConflict(true));
  2. ファイルの拡張子「.js」、文字コードは「UTF-8(BOM なし)」で、ファイルに名前を付けて保存します。
    この記事では、ファイル名を portlet-customize.js としています。

Step 2: ポートレットの追加

手順の詳細は「 HTMLポートレットを追加する (External link) 」を参照してください。

  1. Garoon メニュー右の歯車アイコンをクリックし、[Garoon システム管理]を選択します。
  2. [各アプリケーションの管理]タブを選択し、[ポータル]を選択します。
  3. [HTML ポートレット]をクリックし、[HTML ポートレットを追加する]をクリックします。
  4. 次の内容を入力します。入力が終わったら、[追加する]ボタンをクリックします。
    項目 設定する値
    ポートレット名 任意の値を入力します。この記事では「会議効率化ポートレット」とします。
    グループ 任意のポートレットグループを選択します。
    Myポータル Myポータルで利用する場合はチェックを入れます。
    ポートレットの内容 ポートレットの HTML を貼り付けます。
ポートレットの HTML
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!--
* Garoon meeting efficiency customize(portlet)
* Copyright (c) 2020 Cybozu
*
* Licensed under the MIT License
* https://opensource.org/license/mit/
-->

<h1 class="title">会議効率化プロジェクト</h1>
<div class="parent">
  <canvas id="horizonBarChart" style="margin:20px;">
  </canvas>
  <canvas id="mixedChart" style="margin:20px;"></canvas>
</div>
Step3: カスタマイズの適用

引き続き、追加したポートレットにカスタマイズファイルを適用します。手順の詳細は「 ポータルのカスタマイズ (External link) 」を参照してください。

  1. 前手順で追加したポートレットを開きます。
  2. [JavaScript/CSS によるカスタマイズ]を選択します。
  3. [カスタマイズグループを追加する]をクリックします。
  4. 次の内容を入力します。入力が終わったら、[追加する]ボタンをクリックします。
    項目 設定する値
    カスタマイズ 「適用する」を選択します。
    適用対象 カスタマイズを適用するユーザーやグループを選択します。
    JavaScript カスタマイズ 以下の順で、URL およびファイルを指定します。
    • https://js.cybozu.com/jquery/3.1.1/jquery.min.js
    • https://js.cybozu.com/momentjs/2.15.1/moment-with-locales.min.js
    • https://js.cybozu.com/chartjs/v2.2.2/Chart.min.js
    • portlet-customize.js(カスタマイズファイル)
    CSS カスタマイズ 以下の順で、URL およびファイルを指定します。
    • https://js.cybozu.com/font-awesome/v5.5.0/css/fontawesome-all.min.css
    • portlet-customize.css
      カスタマイズ CSS の内容をテキストエディタにコピー&ペーストし、ファイルの拡張子「.css」、文字コードは「UTF-8(BOMなし)」で保存したものです。
カスタマイズ CSS
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
 * Garoon meeting efficiency customize(portlet)
 * Copyright (c) 2020 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

.title {
  position: relative;
  color: white;
  background: #81d0cb;
  line-height: 1.4;
  padding: 0.5em 0.5em 0.5em 1.8em;
}
.title:before {
  font-family: "Font Awesome 5 Free";
  content: "\f14a";
  position: absolute;
  left : 0.5em;
}
.portlet_base_grn {
  padding-top: 20px;
}
.parent {
  width: 100%;
  display: flex;
  padding: 20px;
}
Step4: ポータルに配置
  1. ポートレットを作成したら、ポータルに配置します(参考: ポートレットの配置 (External link)
  2. ポータルに配置したら公開設定を行います(参考: ポータルの公開 (External link)
コードの解説

コードは長いですが、ほんどがグラフに必要なデータを生成している処理です。
このコードでポイントとなる 2 点を説明します。

Garoon REST API を使って、条件に一致する予定を取得する

45 行目〜58 行目の getSchedules 関数は、ポータル画面の読み込みが完了した時に呼び出されます。

45
46
47
48
49
50
51
52
53
54
55
56
57
58
const getSchedules = function() {
  const m = moment();
  const today = m.format('YYYY-MM-DD');
  const startDate = m.add(-6, 'day').format('YYYY-MM-DD');
  const path = '/api/v1/schedule/events';
  const params = {
    orderBy: 'start asc',
    rangeStart: startDate + 'T00:00:00+09:00',
    rangeEnd: today + 'T23:59:59+09:00'
  };
  return garoon.api(path, 'GET', params).then((resp) => {
    return resp.data.events;
  });
};

ポータルからスケジュールの予定を取得するには、Garoon REST API の 複数の予定を取得する API を実行します。
またスケジュールのカスタマイズと同じく、Garoon 上で実行するので、 Garoon REST API リクエストを送信する API を使っています。

  • REST API の URL パス: /api/v1/schedule/events
  • メソッド:GET
  • API に渡すデータ:絞り込み条件
    orderBy は並び順、rangeStart / rangeEnd は取得する予定の日時の範囲です。
予定の取得に成功したら、取得した予定を元にグラフを生成する

さきほどの getSchedules 関数は、ポータル画面の読み込みが完了した時に呼び出されます(371 行目〜395 行目)。

369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
$(document).ready(() => {
  let targetMeeting;
  getSchedules().then((events) => {
    // REST APIは予定メニューによる絞り込みデータ取得に対応していないため、抽出後に判定を行う
    targetMeeting = events.filter((event) => {
      return event.eventMenu === SCHEDULE_MENU;
    });
    return garoon.Promise.all(targetMeeting.map((event) => {
      return getDatastore(event.id);
    }));
  }).then((datastores) => {
    targetMeeting.forEach((event) => {
      const datastore = datastores.filter((ev) => {
        return ev.id === event.id;
      })[0].datastore;
      if (datastore.value.isClicked) {
        event.datastore = datastore.value;
      }
    });
    const horizonBarData = makeHorizonBarData(targetMeeting);
    const mixedChartData = makeMixedChartData(targetMeeting);
    createHorizonBarGraph(horizonBarData);
    createMixedChart(mixedChartData);
  }).catch((e) => {
    window.alert('Error!!');
    console.error(e);
  });
});

getSchedules 関数では Promise が返却されます。
正常に実行できたら .then の引数で指定した関数の処理を実行し、エラーが発生したら .catch の引数で指定した関数の処理を実行します。

動作確認
  1. スケジュールのカスタマイズ - 動作確認 の手順にしたがって、会議が終了した予定を作成します。
  2. ポートレットを配置したポータルを開きます。
  3. ポータルにグラフが表示されていることを確認します。

おわりに

cybozu developer network では、さまざまな Garoon のカスタマイズ Tips を公開しています。Tips に掲載しているコードで「どんなことをしているか?」を確認しながら、ぜひカスタマイズに挑戦してみてください。

利用している Garoon API

利用しているライブラリ

  • jQuery v3.1.1, ドキュメント (External link)
    HTML 要素の生成や操作を楽に扱うことができるライブラリです。
    スケジュールのカスタマイズでは、改善成果を表示する HTML 要素の作成や予定の詳細画面への挿入などに利用しています。
  • Moment.js v2.15.1, ドキュメント (External link)
    日付の加算などの日付操作やフォーマットを楽に扱うことができるライブラリです。
    ポータルのカスタマイズで、直近 7 日の予定を取得する処理で現在時刻から 1 週間前の日付の計算などに利用しています。
  • Chart.js v2.2.2, ドキュメント (External link)
    グラフを生成や表示するライブラリです。
    ポータルのカスタマイズで、ポートレットに表示する、直近 1 週間の改善成果を棒グラフや複合グラフの生成や表示に利用しています。
  • Font Awesome v5.5.0, ドキュメント (External link)
    Web アイコンフォントを表示するスタイルシートのライブラリです。
    ポータルのカスタマイズで、ポートレットのタイトル部分のチェックボックスマークに利用しています。
  • Garoon html/css/image-Kit for Customize, ドキュメント
    ボタンなどの UI パーツを Garoon の見た目に調和させるスタイルシートのライブラリです。
    スケジュールのカスタマイズで、予定の詳細画面に追加した「会議を終了する」ボタンに利用しています。
information

この Tips は、2022 年 1 月版 Garoon で動作を確認しています。