kintone通知画面からタスク登録してみる。


[kintone Advent Calendar 2018](https://qiita.com/advent-calendar/2018/kintone)の記事です。

:confetti_ball:Advent Calendar19日目:confetti_ball:

今回の記事では、kintone全体カスタマイズを使って
通知画面からkintoneアプリへレコード登録する処理を実装します。
全体jsを使えばkintoneの通知画面も結構カスタマイズできるんだな。
と思って頂ければ嬉しいです。

# はじめに
以下3点注意事項です。

![pop_chuui.png](https://qiita-image-store.s3.amazonaws.com/0/214670/5106c1ce-6721-25ad-694b-0614dd0a8ee5.png)


・今回のカスタマイズではDOM操作を行っています。
以下にあるように、公式非推奨なのでよろしくお願いしますm(__)m
[kintone JavaScript コーディングガイドライン
](https://developer.cybozu.io/hc/ja/articles/201793484)
> **カスタマイズをされる際は、id/class属性の値やDOM構造を変更するカスタマイズを加えないようご注意ください。**


・全体カスタマイズを行っているので、kintone内で何か起こる可能性があります。
適用する場合は、ご自身の責任でカスタマイズを行ってくださいm(__)m

・chromeでしか動作確認してません。
おそらくIEでも動くと思いますが、特に確認はしていません。

では早速、つくっていきましょう。
# kintoneアプリ作成
タスク登録用のアプリを作成します。
手間をかけずにアプリストアのTo Doアプリを使います。

# 全体JSカスタマイズをする前に
### 1. 全体JSが読み込まれるタイミングを調べる
下記のsample.jsを全体JSカスタマイズに適用して、どのタイミングで読み込まれるか確認します。

```javascript:sample.js
(function() {
    'use strict';
    alert('読み込まれた');
})();
```
いろいろな遷移方法で通知画面を開いてみると、全体JSが読み込まれるタイミングがわかります。  

- 直で通知画面 
- ポータル ⇒ 通知画面
- アプリ ⇒ 通知画面
- スペース ⇒ 通知画面
- etc

#### 結論

ハッシュパラメータの変更時はページリフレッシュが発生しないので、全体JSを再読み込みしてくれないようです。
ポータルもスペースも通知画面も https://{subdomain}.cybozu.com/k/#/ から始まるので、
それ以外のURLで読み込まれたらリターンすれば良さそうです。

あと、ポータル画面は↓のURLでもアクセスできるので、このURLでも読み込ませる必要があります。
https://{subdomain}.cybozu.com/k/


### 2. 通知画面の描画を待つ
次に、通知画面に対してDOM操作を行うので、描画を待ってから処理を実行する必要があります。
登録フォームの設置場所をどこにするかにもよりますが、
今回は ocean-ntf-ntflist というクラス名の要素が出現してから処理を開始したいので、その要素が描画されるのを待ちます。

使用したのは [MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver) というAPIです。
ほとんどのブラウザでサポートされているので使い勝手も良く便利です。

# JSカスタマイズ

#### notificationView.js
タスク登録フォームを作成するコードを作成します。
domain と taskAPPID はご自身の環境に合わせて、適当な名前で保存してください。

思いのほか長くなったので、[GitHub](https://github.com/Naoto00/notificationCustomize/blob/master/notificationView.js) にも公開しました。

```javascript:notificationView.js
jQuery.noConflict();

(function($) {
    'use strict';
    // アプリカスタマイズjsと競合する可能性があるのでアプリ画面ではロードしない
    const domain = '{subdomain}.cybozu.com'; // ご自身の環境のサブドメイン
    if (!(location.href.indexOf('https://' + domain + '/k/#/') === 0 || location.href === 'https://' + domain + '/k/')) return;

    const taskAPPID = xx; // To DoアプリID
    const loginUserCode = kintone.getLoginUser().code;

    // スピナー表示
    function showSpinner() {
        // Initialize
        if ($('.kintone-spinner').length === 0) {
            // Create elements for the spinner and the background of the spinner
            const spin_div = $('<div id ="kintone-spin" class="kintone-spinner"></div>');
            const spin_bg_div = $('<div id ="kintone-spin-bg" class="kintone-spinner"></div>');

            // Append spinner to the body
            $(document.body).append(spin_div, spin_bg_div);

            // Set a style for the spinner
            $(spin_div).css({
                'position': 'fixed',
                'top': '50%',
                'left': '50%',
                'z-index': '510',
                'background-color': '#fff',
                'padding': '26px',
                '-moz-border-radius': '4px',
                '-webkit-border-radius': '4px',
                'border-radius': '4px'
            });
            $(spin_bg_div).css({
                'position': 'absolute',
                'top': '0px',
                'left': '0px',
                'z-index': '500',
                'width': '100%',
                'height': '200%',
                'background-color': '#000',
                'opacity': '0.5',
                'filter': 'alpha(opacity=50)',
                '-ms-filter': 'alpha(opacity=50)'
            });

            // Set options for the spinner
            const opts = {
                'color': '#000'
            };

            // Create the spinner
            new Spinner(opts).spin(document.getElementById('kintone-spin'));
        }

        // Display the spinner
        $('.kintone-spinner').show();
    }

    // bodyのスピナー非表示
    function hideSpinner() {
        // Hide the spinner
        $('.kintone-spinner').hide();
    }

    // タスク登録アプリにレコードを登録する
    function postAction() {
        const body = {
            'app': taskAPPID,
            'record': {
                'To_Do': {
                    'value': $('.form-example [name=task]').val()
                },
                'Details': {
                    'value': $('.form-example [name=detail]').val()
                },
                'Duedate': {
                    'value': $('.form-example [name=date]').val()
                },
                'From': {
                    'value': moment().format('YYYY-MM-DD')
                },
                'Priority': {
                    'value': $('[name=radio]:checked').val()
                },
                'Assignees': {
                    'value': [{
                        'code': loginUserCode
                    }]
                }
            }
        };
        return kintone.api('/k/v1/record', 'POST', body).then(function(resp) {
            console.log(resp);
            return resp;
        }).catch(function(err) {
            console.log(err);
        });
    }

    // 登録したレコードのステータスを進める
    function updateAssignee(resp) {
        const body = {
            'app': taskAPPID,
            'id': resp.id,
            'action': '依頼する（担当者を設定後）',
            'assignee': loginUserCode
        };
        return kintone.api('/k/v1/record/status', 'PUT', body);
    }

    // タスク登録フォームをリセットする
    function resetAction() {
        $('.form-example [name=task]').val('');
        $('.form-example [name=detail]').val('');
        $('#date').val(moment().format('YYYY-MM-DD'));
        $('#radio-0').prop('checked', true);
    }

    // タスク登録フォーム作成
    function taskElmRender() {
        // タスク登録用の要素
        const boxHtml = $('<div class="sampleBox">' +
                            '<div class="kintoneplugin-label">タスク登録</div>' +
                            '<form class="form-example">' +
                                // タスク名
                                '<div class="form-block">' +
                                    '<div><label>Task name: </label></div>' +
                                    '<input type="text" name="task" id="name" required>' +
                                '</div>' +
                                // タスク詳細
                                '<div class="form-block">' +
                                    '<div><label>Details: </label></div>' +
                                    '<textarea name="detail" id="detail"></textarea>' +
                                '</div>' +
                                // 優先度ラジオボタン
                                '<div class="form-block">' +
                                    '<div><label>Priority: </label></div>' +
                                    '<div class="kintoneplugin-input-radio">' +
                                        '<span class="kintoneplugin-input-radio-item">' +
                                            '<input type="radio" name="radio" value="A" id="radio-0" checked="">' +
                                            '<label for="radio-0">A</label>' +
                                            '</span>' +
                                            '<span class="kintoneplugin-input-radio-item">' +
                                            '<input type="radio" name="radio" value="B" id="radio-1">' +
                                            '<label for="radio-1">B</label>' +
                                        '</span>' +
                                        '<span class="kintoneplugin-input-radio-item">' +
                                            '<input type="radio" name="radio" value="C" id="radio-2">' +
                                            '<label for="radio-2">C</label>' +
                                        '</span>' +
                                    '</div>' +
                                '</div>' +
                                // タスク期限
                                '<div class="form-block">' +
                                    '<div><label>Due date: </label></div>' +
                                    '<input type="date" name="date" id="date">' +
                                '</div>' +
                                // 登録リセットボタン
                                '<div class="form-block">' +
                                    '<button class="kintoneplugin-button-dialog-ok" id="post" type="submit">登録</button>' +
                                    '<button class="kintoneplugin-button-dialog-cancel" type="reset">リセット</button>' +
                                '</div>' +
                            '</form>' +
                        '</div>');
        boxHtml.css({
            'display': 'inline-block',
            'float': 'right',
            'vertical-align': 'top',
            'width': '25%',
            'max-width': '340px',
            'background-color': $('.gaia-header-header').css('background-color')
        });
        // 通知ブロック編集
        $('.ocean-ntf-ntflist').css({
            'display': 'inline-block',
            'vertical-align': 'top',
            'width': '75%',
        });
        // 登録フォーム描画
        $('.ocean-ntf-ntflist').after(boxHtml);
        // 登録フォームCSS編集
        $('.form-example').css({
            'min-width': '335px',
            'margin-left': '5px'
        });
        // 登録フォームパーツ編集
        $('.form-block').css({
            'margin-bottom': '10px'
        });
        // 登録リセットボタン綺麗に
        $('.kintoneplugin-button-dialog-ok').css({
            'margin-right': '5px'
        });
        // 登録フォーム日付初期値
        $('#date').val(moment().format('YYYY-MM-DD'));

        // 登録ボタン押下時の処理
        $('.form-example').on('submit', function(e) {
            e.preventDefault();
            showSpinner();
            postAction().then(function(resp) {
                return updateAssignee(resp);
            }).then(function(resp) {
                hideSpinner();
                alert('タスク登録しました。');
                resetAction();
            }).catch(function(err) {
                alert(err.message);
            });
        });

        // リセットボタン押下時の処理
        $('.form-example').on('reset', function(e) {
            e.preventDefault();
            resetAction();
        });
    }

    // バインドする
    const body = $('.body-top');
    const observer = new MutationObserver(function(MutationRecord) {
        const noticesBlock = $('.ocean-ntf-ntfitem');
        // 通知要素が出現するまでリターン
        if (!noticesBlock.length) return;
        // 既にフォームが存在する場合
        console.log($('.sampleBox').length);
        if ($('.sampleBox').length) return;
        // タスク登録フォーム作成
        taskElmRender();
        // すべての処理が終わったらバインド終了
        observer.disconnect();
    });

    // バインドする要素のオプション
    const options = {
        childList: true
    };

    observer.observe(body[0], options);

    // ハッシュ切り替えの時もバインドが必要
    $(window).on('hashchange', function() {
        // 通知画面以外のハッシュ値変更の場合リターン
        if (!location.href.indexOf('https://' + domain + '/k/#/ntf/') === 0) return;
        observer.observe(body[0], options);
    });

})(jQuery);

```
#### 使用しているjs, cssライブラリ
- jQuery  
- moment.js
- spin.js  
- [51-modern-default.css](https://developer.cybozu.io/hc/ja/articles/203302214)

jsライブラリはCDNではなく、ローカルファイルとして保存してください。
全体カスタマイズはURL参照ができないので、ファイルとして保存する必要があります。

画像のように notificationView.js が最後に来るように適用します。

![finish.png](https://qiita-image-store.s3.amazonaws.com/0/214670/3dd6c948-ef32-217c-422e-5ae52fd48fa6.png)

# 動作確認
![sample-01.gif](https://qiita-image-store.s3.amazonaws.com/0/214670/74be7024-c13a-9ac0-eb20-33479402a6d9.gif)


![mokuhyou_tassei_man.png](https://qiita-image-store.s3.amazonaws.com/0/214670/f9f717f7-3694-7a4f-7b23-d3e6be0e27bd.png)


できました！


# まとめ
kintoneはbody以下がすべてkintone内のjsで描画されているため、
DOM生成待つ処理を実装するのが大変でした。
[MutationObserver](https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)めっちゃ便利。

全体jsが読み込まれるタイミングを確認するのが大変でした。
今回のカスタマイズも、通知画面への遷移によってはうまく登録フォームが表示されないかもしれません。。。
逆に考えると、全体js読み込まれるタイミングさえ分かれば、どの画面でもカスタマイズできそうですね。

![zentai.png](https://qiita-image-store.s3.amazonaws.com/0/214670/b11054cb-bbda-324b-dae9-6591b8ccd4fc.png)

上の赤枠で囲われた部分をクリックしたときの遷移先の画面では全体jsが2回発火されます。。。

今回のカスタマイズだと、notificationView.js 以外のライブラリはどの画面でも読み込まれるので、
その部分は改善したほうが良さそうです。


# 参考にしたcybozu developer networkの記事
- [spin.js を使って、スピナー（ローディングアイコン）を設定しよう！](https://developer.cybozu.io/hc/ja/articles/204528874)  
showSpinner(), hideSpinner()の部分
- [レコード保存後に自動でステータス更新！](https://developer.cybozu.io/hc/ja/articles/214639003)  
updateAssignee()の部分