kintoneで他のユーザの予定を確認するJSカスタマイズを作る

この記事は[kintone Advent Calendar 2020](https://qiita.com/advent-calendar/2020/kintone)の10日目の記事です。

 + 9日目はこちら → [フィールド値変更イベントで気を付けること - Qiita](https://qiita.com/blue-collar_cat/items/bc6f302fc2664b47385f)
 + 11日目はこちら → [kintoneデータをビジネスインテリジェンス(BI)で活用する方法まとめ - Qiita](https://qiita.com/kuwazzy/items/c89776caceedc26b1678)

こんにちは、@tasshiです。
最近は12/3に発売したフィットボクシング2の体験版をやっています。[^1]
前作で微妙だったところが細かく改善されていて良い感じですね。
[^1]: [Fit Boxing 2（フィットボクシング2）| Nintendo Switch](https://fitboxing.net/2/)

# 概要

kintone上で他のユーザの予定を確認できるJSカスタマイズを作成します。
コメントのユーザにカーソルを当てるとツールチップでGaroonの予定が確認できるようにします。

![kintoneからGaroonの予定が確認できる.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/172187f5-bb97-10e3-86a6-4af1a0c2ef3a.gif)


ソースコードはこちら
https://gist.github.com/tasshi-me/43ca377788ee46a7f7a5a572cf707bfb

# Garoon REST APIについて

2018年に公開されたGaroon REST APIではスケジュールの取得/登録/更新/削除や、ワークフローの申請データ取得などが可能です。[^2]

[^2]: [2018/08/12の定期メンテナンスにおけるkintone API、Garoon API、User API更新情報(2018/07/13) – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360001073346)


同一サブドメインのkintoneからのGETリクエストはセッション認証を利用して簡単に実行することができます。
[Garoon REST APIの共通仕様 – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360000503306#pre-0)


詳しい利用方法などは公式HPを参照してください。
[Garoon API – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360039875971)

※実際の開発では有志の方が提供されているGaroon REST APIクライアントライブラリを使用すると便利です。[^3]
本記事のサンプルコードでは説明の簡略化のため[Fetch API](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)を使用しています。
[^3]: [@miyajan/garoon-rest - npm](https://www.npmjs.com/package/@miyajan/garoon-rest)

[Fetch の使用 - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch)

# 手順
ざっくりと次のような順番で進めていきます。

 1. [ツールチップを表示する](#ツールチップを表示する)
 2. [ユーザのログイン名を取得する](#ユーザのログイン名を取得する)
 3. [ログイン名からGaroonユーザIDを取得する](#ログイン名からgaroonユーザidを取得する)
 4. [GaroonユーザIDからGaroonの予定を取得する](#garoonユーザidからgaroonの予定を取得する)
 5. [ツールチップに予定を表示する](#ツールチップに予定を表示する)
 6. [動作確認](#動作確認)
 7. [おまけ:キャッシュ](#おまけキャッシュ)

※この記事のサンプルコードではエラーハンドリングやデータの検査などは行っていません。
実際にJSカスタマイズを作成する際は[kintoneのセキュアコーディング ガイドライン](https://developer.cybozu.io/hc/ja/articles/201919400)などを参考にして適切な処理を行ってください。[^4]
[^4]: [セキュアコーディング ガイドライン – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/201919400)

## ツールチップを表示する
まずはkintoneのスレッド画面でユーザ（アイコン・表示名）にカーソルを当てた際に、ツールチップを表示します。

<img width="700" alt="ユーザ名とアイコン.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/59e542d1-9828-9144-2aea-d152652921a2.png">

このユーザのアイコンと名前の要素はそれぞれ以下のセレクタで取ることができます。

|要素|セレクタ|
|---|---|
|ユーザ名|`.ocean-ui-comments-commentbase-user`|
|ユーザアイコン|`.ocean-ui-comments-commentbase-entity > a`|

※これらのDOM構造やid/class属性は予告なく変更されることがあります[^5]

[^5]: [kintone JavaScript コーディングガイドライン – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/201793484-kintone-JavaScript-%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E3%82%AC%E3%82%A4%E3%83%89%E3%83%A9%E3%82%A4%E3%83%B3#pre-0)

まずツールチップの要素を作成します。

```javascript
// ツールチップの要素を作成
const scheduleTooltipEl = document.createElement("div");
scheduleTooltipEl.classList.add("schedule-viewer-root");
scheduleTooltipEl.textContent = "ここにスケジュールが表示されます";
scheduleTooltipEl.style.display = "none";
scheduleTooltipEl.style.position = "absolute";
scheduleTooltipEl.style.pointerEvents = "none";
scheduleTooltipEl.style.backgroundColor = "white";
scheduleTooltipEl.style.padding = "0 10px";
scheduleTooltipEl.style.border = "1px solid black";
document.body.appendChild(scheduleTooltipEl);
```

次に、対象となる要素に対して`mouseenter`, `mouseleave`イベントのイベントリスナーを設定します。
[Element: mouseenter イベント - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Element/mouseenter_event)

今回はユーザ名の要素を対象としましょう。

```javascript
// 対象の要素にカーソルが当たっている間、ツールチップを表示する
const userLinkEl = document.querySelector(
  ".ocean-ui-comments-commentbase-user"
);

// カーソルが当たった時の処理
userLinkEl.addEventListener("mouseenter", async (event) => {
  const targetEl = event.target;

  //1秒後にまだカーソルが当たっていた場合、予定一覧の要素を表示する
  setTimeout(async () => {
    if (targetEl.querySelector(":hover") != null) {
      // カーソルの位置に要素を表示
      scheduleTooltipEl.style.left = event.pageX + "px";
      scheduleTooltipEl.style.top = event.pageY + "px";
      scheduleTooltipEl.style.display = "block";
    }
  }, 1000);
});

// カーソルが離れた時の処理
userLinkEl.addEventListener("mouseleave", () => {
  // ツールチップを非表示にする
  scheduleTooltipEl.style.display = "none";
});
```

これでユーザ名にカーソルを当てるとツールチップが表示されるようになりました。
ここにREST APIで取得した予定を表示します。

![ツールチップが表示される.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/570f5739-0436-8358-f25b-f28d3c140e06.gif)


## ユーザのログイン名を取得する

ユーザ名の要素から、そのユーザのログイン名を取得します。

ユーザ名の要素にはピープルへのリンクが設定されているので、そこから正規表現でログイン名を取得します。
先ほどの`mouseenter`イベントリスナのコールバック内で、処理を行います。

```javascript
// 対象の要素からユーザログイン名を取得する
// ピープルへのリンクのフラグメント: "#/people/user/<ログイン名>"
const peopleUrlHash = targetEl.hash;

// ログイン名を正規表現で抽出
const regex = /^#\/people\/user\/(.+)$/g;
const targetUserCode = [...peopleUrlHash.matchAll(regex)][0][1];
```

## ログイン名からGaroonユーザIDを取得する

対象ユーザのログイン名が分かりましたが、Garoonの予定を取得するにはGaroonユーザIDが必要です。

GaroonユーザIDは「ユーザーの一覧取得（クエリで条件を指定）」APIで取得できます。

[ユーザーの取得（GET） – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360018124651)

今回はログイン名が分かっているので、`name`パラメータにログイン名を指定して実行します。

```javascript
// Garoonユーザを取得する
const response = await fetch("/g/api/v1/base/users?name=" + code, {
  method: "GET",
  headers: {
    "Content-Type": "application/json",
    "X-Requested-With": "XMLHttpRequest",
  },
});
const body = await response.json();
```

`name`パラメータの検索対象は以下の4つなので、レスポンスには目的のユーザ以外も含まれる場合があります。

+ 表示名
+ ログイン名
+ 個人設定のローカライズで設定された名前（クラウド版のみ）
+ 英語表記（パッケージ版のみ）

そのため、レスポンスから `users[].code`が指定したログイン名と一致するユーザを取り出します。

```javascript
// 得られたユーザのうち、users[].codeが指定したログイン名と一致するユーザを取り出す
const targetUser = body.users.filter((user) => user.code === targetUserCode)[0];
const targetUserId = targetUser.id; // GaroonユーザID
```

## GaroonユーザIDからGaroonの予定を取得する

GaroonユーザIDが分かったのでいよいよ予定を取得します。

予定の取得には「予定の一覧取得（クエリで条件を指定）」APIを使用します。

[予定の取得（GET） – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/360000440583#gist-2)

リクエストに指定するパラメータは以下の通りです。

|パラメータの種類|指定する値|
|---|---|---|
|取得するプロパティ|ID, 開始日時, 終了日時, 予定メニュー, タイトル|
|ソート条件|開始日時　昇順|
|検索範囲の日時|当日の00:00から11:59まで|
|絞り込み条件|参加者に<対象ユーザ>が含まれる|

```javascript
// 対象ユーザの当日の予定を取得する
// 現在日時
const currentDate = new Date();
// 検索対象の開始日時: 当日00:00:00
const rangeStart = new Date(
  currentDate.getFullYear(),
  currentDate.getMonth(),
  currentDate.getDate()
);
// 検索対象の終了日時: 翌日00:00:00
const rangeEnd = new Date(
  currentDate.getFullYear(),
  currentDate.getMonth(),
  currentDate.getDate() + 1
);
// リクエストパラメータ
const params = {
  fields: ["id", "start", "end", "eventType", "eventMenu", "subject"],
  orderBy: "start asc",
  rangeStart: rangeStart.toISOString(),
  rangeEnd: rangeEnd.toISOString(),
  target: targetUserId,
  targetType: "user",
};

const response = await fetch(
  "/g/api/v1/schedule/events?" + new URLSearchParams(params),
  {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      "X-Requested-With": "XMLHttpRequest",
    },
  }
);
const body = await response.json();
const todayEvents =  body.events;
```

得られた予定一覧を、現在の予定、今後の予定、終日の予定に分けます。

```javascript
// 現在の予定、今後の予定、終日の予定に分ける
// 現在日時
const currentDate = new Date();

const currentEvents = new Array();
const upcomingEvents = new Array();
const allDayEvents = new Array();

for (const event of events) {
  const startDate = new Date(event.start.dateTime);
  const endDate = new Date(event.end.dateTime);
  // 終日の予定
  if (
    startDate.getHours() === 0 &&
    startDate.getMinutes() === 0 &&
    startDate.getSeconds() === 0 &&
    endDate.getHours() === 23 &&
    endDate.getMinutes() === 59 &&
    endDate.getSeconds() === 59
  ) {
    allDayEvents.push(event);
    continue;
  }
  // 現在の予定
  if (startDate <= currentDate && endDate >= currentDate) {
    currentEvents.push(event);
    continue;
  }
  // 今後の予定
  if (startDate > currentDate) {
    upcomingEvents.push(event);
    continue;
  }
}
```

## ツールチップに予定を表示する

いよいよkintone上に予定を表示します。

手順1のイベントリスナのコールバック内でツールチップにスケジュールの内容を表示します。

```javascript
// ツールチップに予定を表示する

// 予定一覧の要素を作成する関数
const createEventsEl = (events, description) => {
  const rootEl = document.createElement("div");
  const headerEl = document.createElement("p");
  headerEl.textContent = description;
  rootEl.appendChild(headerEl);

  const bodyEl = document.createElement("ol");
  for (const event of events) {
    const startDate = new Date(event.start.dateTime);
    const endDate = new Date(event.end.dateTime);

    const startDateStr =
      startDate.getHours().toString().padStart(2, "0") +
      ":" +
      startDate.getMinutes().toString().padStart(2, "0");
    const endDateStr =
      endDate.getHours().toString().padStart(2, "0") +
      ":" +
      endDate.getMinutes().toString().padStart(2, "0");

    const el = document.createElement("li");
    el.textContent = `${startDateStr}-${endDateStr} [${event.eventMenu}] ${event.subject}`;
    bodyEl.appendChild(el);
  }
  rootEl.appendChild(bodyEl);

  return rootEl;
};

// ツールチップをクリア
scheduleTooltipEl.textContent = "";
// タイトル（ユーザ名）
const userNameEl = document.createElement("p");
userNameEl.textContent = `${targetUser.name} さんの予定`;
scheduleTooltipEl.appendChild(userNameEl);
// 現在の予定
scheduleTooltipEl.appendChild(createEventsEl(currentEvents, "現在の予定"));
// 今後の予定
scheduleTooltipEl.appendChild(createEventsEl(upcomingEvents, "今後の予定"));
// 終日の予定
scheduleTooltipEl.appendChild(createEventsEl(allDayEvents, "終日の予定"));
```

## 動作確認

それでは実際の動作を確認してみましょう。
`kintoneシステム管理` > `JavaScript / CSSでカスタマイズ` からJSカスタマイズを適用します。

<img width="700" alt="JSカスタマイズを適用.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/2708191c-7e03-6325-a670-4f09589d1236.png">

ツールチップで予定を確認できるようになりました。

![動作確認.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214775/3865998b-f559-139d-e282-1e7cad08a242.gif)

最終的なコードは以下のようになります。
実行タイミング調整のため全体を`load`イベントリスナのコールバック内で実行しています。

```index.js
(async () => {
  window.addEventListener("load", async () => {
    // ツールチップの要素を作成
    const scheduleTooltipEl = document.createElement("div"); // 要素を作成
    scheduleTooltipEl.classList.add("schedule-viewer-root");
    scheduleTooltipEl.textContent = "ここにスケジュールが表示されます";
    scheduleTooltipEl.style.display = "none";
    scheduleTooltipEl.style.position = "absolute";
    scheduleTooltipEl.style.pointerEvents = "none";
    scheduleTooltipEl.style.backgroundColor = "white";
    scheduleTooltipEl.style.padding = "0 10px";
    scheduleTooltipEl.style.border = "1px solid black";
    document.body.appendChild(scheduleTooltipEl);

    // 対象となる要素
    const userLinkSelectors = [
      ".ocean-ui-comments-commentbase-user",
      ".ocean-ui-comments-commentbase-entity > a",
      ".ocean-space-thread-update",
      ".ocean-ui-plugin-mention-user.ocean-ui-plugin-linkbubble-no",
    ];
    const userLinkEls = document.querySelectorAll(userLinkSelectors.join());

    // 対象の要素にカーソルが当たっている間、ツールチップを表示する
    for (const userLinkEl of userLinkEls) {
      // カーソルが離れた時の処理
      userLinkEl.addEventListener("mouseleave", () => {
        // ツールチップを非表示にする
        scheduleTooltipEl.style.display = "none";
      });

      // カーソルが当たった時の処理
      userLinkEl.addEventListener("mouseenter", async (event) => {
        const targetEl = event.target;

        //1秒後にまだカーソルが当たっていた場合、予定一覧の要素を表示する
        setTimeout(async () => {
          if (targetEl.querySelector(":hover") != null) {
            // カーソルの位置に要素を表示
            scheduleTooltipEl.style.left = event.pageX + "px";
            scheduleTooltipEl.style.top = event.pageY + "px";
            scheduleTooltipEl.textContent = "";
            scheduleTooltipEl.style.display = "block";

            // 対象の要素からログイン名を取得する
            const targetUserCode = getUserCodeFromAnchorElement(targetEl);

            // Garoonユーザを取得する
            const targetUser = await fetchGaroonUserByCode(targetUserCode);
            const targetUserId = targetUser.id; // GaroonユーザID

            // 対象ユーザの当日の予定を取得する
            const todayEvents = await fetchTodayEventsByUserId(targetUserId);

            // 現在の予定、今後の予定、終日の予定に分ける
            const [
              currentEvents,
              upcomingEvents,
              allDayEvents,
            ] = categorizeEvents(todayEvents);

            // ツールチップに予定を表示する
            // タイトル（ユーザ名）
            const userNameEl = document.createElement("p");
            userNameEl.textContent = `${targetUser.name} さんの予定`;
            scheduleTooltipEl.appendChild(userNameEl);
            // 現在の予定
            scheduleTooltipEl.appendChild(
              createEventsEl(currentEvents, "現在の予定")
            );
            // 今後の予定
            scheduleTooltipEl.appendChild(
              createEventsEl(upcomingEvents, "今後の予定")
            );
            // 終日の予定
            scheduleTooltipEl.appendChild(
              createEventsEl(allDayEvents, "終日の予定")
            );
          }
        }, 1000);
      });
    }
  });

  // 対象の要素からログイン名を取得する
  const getUserCodeFromAnchorElement = (el) => {
    // ピープルへのリンクのフラグメント: "#/people/user/<ログイン名>"
    const peopleUrlHash = el.hash;

    // ログイン名を正規表現で抽出
    const regex = /^#\/people\/user\/(.+)$/g;
    const targetUserCode = [...peopleUrlHash.matchAll(regex)][0][1];
    return targetUserCode;
  };

  // Garoonユーザを取得する
  const fetchGaroonUserByCode = async (code) => {
    const response = await fetch("/g/api/v1/base/users?name=" + code, {
      method: "GET",
      headers: {
        "Content-Type": "application/json",
        "X-Requested-With": "XMLHttpRequest",
      },
    });
    const body = await response.json();

    // 得られたユーザのうち、users[].codeが指定したログイン名と一致するユーザを取り出す
    const targetUser = body.users.filter((user) => user.code === code)[0];
    return targetUser;
  };

  // 対象ユーザの当日の予定を取得する
  const fetchTodayEventsByUserId = async (garoonUserId) => {
    // 現在日時
    const currentDate = new Date();
    // 検索対象の開始日時: 当日00:00:00
    const rangeStart = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth(),
      currentDate.getDate()
    );
    // 検索対象の終了日時: 翌日00:00:00
    const rangeEnd = new Date(
      currentDate.getFullYear(),
      currentDate.getMonth(),
      currentDate.getDate() + 1
    );
    // リクエストパラメータ
    const params = {
      fields: ["id", "start", "end", "eventType", "eventMenu", "subject"],
      orderBy: "start asc",
      rangeStart: rangeStart.toISOString(),
      rangeEnd: rangeEnd.toISOString(),
      target: garoonUserId,
      targetType: "user",
    };

    const response = await fetch(
      "/g/api/v1/schedule/events?" + new URLSearchParams(params),
      {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          "X-Requested-With": "XMLHttpRequest",
        },
      }
    );
    const body = await response.json();
    return body.events;
  };

  // 現在の予定、今後の予定、終日の予定に分ける
  const categorizeEvents = (events) => {
    // 現在日時
    const currentDate = new Date();

    const currentEvents = new Array();
    const upcomingEvents = new Array();
    const allDayEvents = new Array();

    for (const event of events) {
      const startDate = new Date(event.start.dateTime);
      const endDate = new Date(event.end.dateTime);
      // 終日の予定
      if (
        startDate.getHours() === 0 &&
        startDate.getMinutes() === 0 &&
        startDate.getSeconds() === 0 &&
        endDate.getHours() === 23 &&
        endDate.getMinutes() === 59 &&
        endDate.getSeconds() === 59
      ) {
        allDayEvents.push(event);
        continue;
      }
      // 現在の予定
      if (startDate <= currentDate && endDate >= currentDate) {
        currentEvents.push(event);
        continue;
      }
      // 今後の予定
      if (startDate > currentDate) {
        upcomingEvents.push(event);
        continue;
      }
    }

    return [currentEvents, upcomingEvents, allDayEvents];
  };

  // 予定一覧の要素を作成する関数
  const createEventsEl = (events, description) => {
    const rootEl = document.createElement("div");
    const headerEl = document.createElement("p");
    headerEl.textContent = description;
    rootEl.appendChild(headerEl);

    const bodyEl = document.createElement("ol");
    for (const event of events) {
      const startDate = new Date(event.start.dateTime);
      const endDate = new Date(event.end.dateTime);

      const startDateStr =
        startDate.getHours().toString().padStart(2, "0") +
        ":" +
        startDate.getMinutes().toString().padStart(2, "0");
      const endDateStr =
        endDate.getHours().toString().padStart(2, "0") +
        ":" +
        endDate.getMinutes().toString().padStart(2, "0");

      const el = document.createElement("li");
      el.textContent = `${startDateStr}-${endDateStr} [${event.eventMenu}] ${event.subject}`;
      bodyEl.appendChild(el);
    }
    rootEl.appendChild(bodyEl);

    return rootEl;
  };
})();
```

## おまけ:キャッシュ

無事ツールチップにイベント情報を表示することができましたが、今の実装だとアイコンにカーソルを当てる度にAPIリクエストが発行されてしまいます。
このままだと無駄が多いので取得したユーザ情報、予定を再利用するようにします。

```javascript
// キャッシュ
const users = new Map();

let user = users.get("<ログイン名>");
if (user == undefined){
    user = await ユーザ情報をAPIで取得();
    users.set(user.code, user);
}
```

# まとめ
いかがでしたでしょうか？
kintone REST API, Garoon REST APIで相互にデータを参照/変更できるのは便利ですね。

今回はJSカスタマイズを作成しましたが、kintoneプラグインではそれぞれの環境に合わせて設定を変更できるため、より再利用性の高いカスタマイズを作成できます。
公式HPの開発手順にしたがって簡単に作成できるので、興味のある方は作成してみてはいかがでしょうか？
[kintone プラグイン開発手順 – cybozu developer network](https://developer.cybozu.io/hc/ja/articles/203455680-kintone-%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E9%96%8B%E7%99%BA%E6%89%8B%E9%A0%86)

ここまで読んでいただきありがとうございました。