関連レコードの列を表示切替する

kintone

kintone Advent Calendar 2024の23日目の記事です。

  Topへ↓

師走ですね。師走と言えば忘年会。一説によれば、忘年会とはその年の苦労を忘れるために催されるそうですね。
では、私もそれにならってここで今年の苦労ネタを披露し、速やかに忘れたいと思います。

苦労といいますが、実はそこまで苦労したわけではありません。むしろ、パズル感覚で楽しんで実装しました。
しかも、この実装はお客様よりご依頼に沿っているので、きちんとお役にも立っています。
実装していてワクワクし、コーディングの妙味も楽しめて、しかもお客様のお役に立てるなんてのホクホク案件だったかも。
        (記事もお客様の許可を頂き、無難なデータに変えています)。

え?じゃぁなぜ忘れるのかって?
それはここで書く実装が、ほかの場所でも使われる可能性の少ないカスタマイズだからです。
だからこそ、今年一年の思い出として、記事にしようと思ったのです。

2.どんな動きをするん?

  Topへ↑

まず、どんな動きをするのか、動画で説明します。
この動画をみて、なぜ、この実装はほかには使わないと思うのか、考えてみてください。この記事の最後に明かします。

なお、動画を見るのが面倒であれば、二つの画像を見てもらえればと。

備考がレ点で隠された関連レコード

詳細はこちら

備考タイトルをクリックすると備考が表示されます

詳細はこちら

なお、動画の中で話しませんでしたが、この実装には以下の考慮点があります。
以下はいただいたご要望です(若干の脚色あり)。
1.「初期表示の際、レ点を表示するのは、備考欄にデータがある行(レコード)のみにしてや~。」
2.「レ点を表示する時、内容列(文字列複数行)は表示させてや(行高は内容列のデータ量に依存してや)〜」
3.「備考欄を再表示させるとき、レ点はいらんで〜」
4.「なるべく備考欄の文字を見えないよう(本番では40ある)関連レコードの全てが表示される前に非表示処理を行って〜」

それではこのご要望を踏まえて実装に移りましょう。

3.どうやって実装したん?

  Topへ↑

まず、この実装はDOM操作を行います。また、サイボウズ社が公式に技術資料として出していないオブジェクトを扱います。
そのため、ある日突然使えなくなる可能性があります。
それも今後この実装を行わない理由の一つです。
私の方ではこの実装を行ったことによる責任はとれませんので、使用上の注意をよく読み、用法 用量を守って正しくお使い下さい。

まず、アプリを構築します。
今回は「管理施設マスタ」「管理施設沿革履歴」の二つのアプリを作ります。
まずは管理施設マスタです。

フィールド名 フィールドコード フィールドタイプ
管理施設名 管理施設名 SINGLE_LINE_TEXT
付属施設開設 付属施設開設 REFERENCE_TABLE
歴代施設長 歴代施設長 REFERENCE_TABLE
歴代防火管理者 歴代防火管理者 REFERENCE_TABLE
歴代事務長 歴代事務長 REFERENCE_TABLE
歴代守衛主任 歴代守衛主任 REFERENCE_TABLE
高額(大型)設備投資 高額大型設備投資 REFERENCE_TABLE
部署 部署 REFERENCE_TABLE
新規事業、新規サービス 新規事業新規サービス REFERENCE_TABLE
職員数(4月1日付) 職員数4月1日付 REFERENCE_TABLE

なお、関連レコードは小項目の値を設定していただく必要があります。
また、この後のソースコードの中で「内容」「備考」が必要となりますので、そこは忘れずに。なお、列の順番は問いません。
また、関連レコードは、この下のアプリと紐づけてください。

フィールド名 フィールドコード フィールドタイプ
管理施設名 管理施設名 SINGLE_LINE_TEXT
期間開始 期間開始 DATE
期間終了 期間終了 DATE
添付ファイル 添付ファイル FILE
備考 備考 MULTI_LINE_TEXT
小項目 小項目 SINGLE_LINE_TEXT
添付ファイルチェック 添付ファイルチェック CHECK_BOX
内容 内容 MULTI_LINE_TEXT

本番では、大項目中項目もあり、小項目の数も多いためルックアップで引っ張ってきていますが、適宜ドロップダウンに変えてもよいかもしれません。

なお、アプリテンプレートとデータも用意してあります。
「テンプレート」
データ
「管理施設マスタデータ」
データ
「管理施設沿革履歴データ」
データ

今回、JavaScriptによってカスタマイズを行うのは「管理施設マスタ」側です。
DOM操作を行うので、jQueryを入れています。Cybozu CDNから持ってきましょう。最新バージョンでよいです。

では、いよいよJavaScriptをご説明します。
ただ、一つ一つのフローの説明は、割愛します。コメントを以下のソースの中に記載しています。

/*
 * プログラム名:備考制御[備考制御_ControlNoteField.js]
 * サブドメイン:
 * 内容説明:
 *  新規/編集レコード表示時に関連レコードの備考の文字をクリックすると列の幅を変える
 * 対象アプリ:
 *            
 *
 */

jQuery.noConflict();
(function($) {
  'use strict';

  const getElementId = function(fieldname) { // 処理を行う関連レコードのフィールドコードから関連レコードの部品を特定するための関数
    let returnvalue;
    $.each(cybozu.data.page.FORM_DATA.schema.table.fieldList, (index, fields) => {
      if (fields.var === fieldname) {
        returnvalue = fields.id;
      }
    });
    return returnvalue;
  };

  const calculateHeight = function(div) { // div要素の数毎に20pxをかける
    const innerDivs = div.querySelectorAll('div');
    return innerDivs.length * 20 + 'px';
  };

  const eventlists = ['app.record.detail.show', 'app.record.edit.show'];
  kintone.events.on(eventlists, (e) => {
    const fieldid = getElementId('歴代防火管理者');
    const fieldid2 = getElementId('高額大型設備投資');
    const fieldid3 = getElementId('職員数4月1日付');

    const tbodyTimerId = setInterval(() => {
      if ($('.field-' + fieldid + ' .value-' + fieldid + ' table').length > 0 ||
       $('.field-' + fieldid2 + ' .value-' + fieldid2 + ' table').length > 0 ||
       $('.field-' + fieldid3 + ' .value-' + fieldid3 + ' table').length > 0) { // この三つの関連レコードが表示されたら実行します(本番では40テーブルあるので、4テーブルごとに判断)
        if ($('.field-' + fieldid + ' .value-' + fieldid + ' table').length > 0) { // 定期処理を停止するのは、末尾の「歴代防火管理者」の関連レコードが表示された後です。
          clearInterval(tbodyTimerId);
        }

        const tables = document.querySelectorAll('.subtable-gaia.reference-subtable-gaia'); // 関連レコードはテーブルタグなので、その要素を全てNodeListに格納します。

        tables.forEach(table => { // 各テーブルごとに処理を行います。
          const spans = Array.from(table.querySelectorAll('.subtable-label-inner-gaia')) // 各テーブルごとに見出行の文字が 備考 の要素を格納します。
            .filter(span => span.textContent.trim() === '備考');
          const 内容spans = Array.from(table.querySelectorAll('.subtable-label-inner-gaia')) // 各テーブルごとに見出行の文字が 内容 の要素を格納します。
            .filter(span => span.textContent.trim() === '内容');
          let 内容initialindex = 0;
          内容spans.forEach(内容span => { // 文字が 内容 を持つ要素のそれぞれの親要素(Table)の行ごとに内容のセルの行数(最低行高)を計算して格納します。
            const 内容initialth = 内容span.closest('th');
            内容initialindex = Array.from(内容initialth.parentNode.children).indexOf(内容initialth);
            const 内容initialrows = 内容initialth.closest('table').rows;

            for (let i = 1; i < 内容initialrows.length; i++) {

              const 内容cell = 内容initialrows[i].cells[内容initialindex];
              const 内容innertddiv = 内容cell.querySelector('div.control-gaia div.control-value-gaia');
              if (内容innertddiv) {
                const newHeight = calculateHeight(内容innertddiv);
                内容innertddiv.style.height = newHeight;
              }
            }
          });
          spans.forEach(span => { // 文字が 備考 を持つ要素のそれぞれの親要素(Table)の行ごとに内容のセルの行数(最低行高)を計算して格納します。
            const initialth = span.closest('th');
            const initialindex = Array.from(initialth.parentNode.children).indexOf(initialth);
            const initialrows = initialth.closest('table').rows;

            for (let i = 1; i < initialrows.length; i++) { // ここでは初期表示時の表示設定を行っています。テーブルごとの各行ごと備考欄を内容の値に応じて設定し、備考欄
              const cell = initialrows[i].cells[initialindex];
              const innertddiv = cell.querySelector('div.control-gaia div.control-value-gaia'); // 各行の備考列
              if (innertddiv) {
                const 内容cell = initialrows[i].cells[内容initialindex]; // 各行の内容列
                const 内容innertddiv = 内容cell.querySelector('div.control-gaia div.control-value-gaia'); // 各行の内容列の中身
                let newHeight = '';
                if (内容innertddiv) {
                  newHeight = calculateHeight(内容innertddiv); // ここでは内容セルの値を基にdivの数で行高を算出します。文字列複数行の項目は関連レコードに持ってくるとdivタグで分かれます。
                  innertddiv.style.maxHeight = newHeight;
                  innertddiv.style.display = 'block'; // 備考セルの中の要素をblock要素にし、
                }

                const innertddiv2 = innertddiv.querySelectorAll('div'); // 備考セルの中の全てのdiv要素(複数行ごとに)
                if (innertddiv2.length > 0) {
                  innertddiv2.forEach(div => {
                    div.style.width = '20px'; // 幅20px
                    div.style.whiteSpace = 'nowrap'; //折り返し無 
                    div.style.overflow = 'hidden'; // はみ出た部分は表示しない
                    div.style.textOverflow = 'ellipsis'; // はみ出た部分の文字列を...で表示
                    div.style.color = '#ffff00'; // 文字の色
                    div.style.backgroundColor = '#ffff00'; // 背景色
                    div.style.display = 'none'; // 表示しない
                  });
                  const checkmarkSpan = document.createElement('span');
                  checkmarkSpan.textContent = '✔️'; // レ点を追加
                  checkmarkSpan.className = 'reten';
                  if (!innertddiv.querySelector('.reten')) {
                    innertddiv.prepend(checkmarkSpan); // レ点のspan要素を先頭に追加する
                  }
                }

              }
            }

            if (!span.getAttribute('data-listener-attached')) { // データ属性がない場合、以下のイベントリスナーを追加する(イベントリスナー多重登録防止)
              span.addEventListener('click', function() {
                const th = this.closest('th'); // 親のth要素を取得
                const index = Array.from(th.parentNode.children).indexOf(th); // 列のインデックス取得
                const rows = th.closest('table').rows; // テーブル行取得

                const event内容spans = Array.from(th.closest('table').querySelectorAll('.subtable-label-inner-gaia')) // クリックしたテーブルの内容列を要素と取得する
                  .filter(eventspan => eventspan.textContent.trim() === '内容');
                let event内容initialindex = 0;
                event内容spans.forEach(event内容span => {
                  const event内容initialth = event内容span.closest('th');
                  event内容initialindex = Array.from(event内容initialth.parentNode.children).indexOf(event内容initialth); // クリックした備考の属するテーブルの内容列の位置を取得する
                });

                // 列幅のトグル
                for (let i = 1; i < rows.length; i++) {
                  const cell = rows[i].cells[index]; // 各行の備考列
                  const 内容cell = rows[i].cells[event内容initialindex]; // 各行の内容列
                  const innerDiv = cell.querySelector('div.control-gaia div.control-value-gaia'); // 各行の備考列の中身
                  const 内容innerDiv = 内容cell.querySelector('div.control-gaia div.control-value-gaia'); // 各行の内容列の中身
                  let innerDiv2;

                  let newHeight = '';

                  if (cell.style.width === '20px') { // 幅が20pxの場合(非表示の場合)
                    cell.style.whiteSpace = 'normal'; // セルの表示を初期値にする
                    cell.style.width = 'auto'; // セルの幅を自動にする
                    if (innerDiv) {
                      innerDiv.style.height = 'auto'; // 備考列の行高を自動にする
                      innerDiv.style.maxHeight = 'none'; // 備考列の行高の最低高さなし

                      innerDiv2 = innerDiv.querySelectorAll('div'); // 備考セルの中の全てのdiv要素(複数行ごとに)

                      innerDiv2.forEach(div => {
                        div.style.width = 'auto'; // 幅自動
                        div.style.whiteSpace = 'normal'; // 折り返し自動
                        div.style.overflow = 'visible'; // はみ出た部分は表示
                        div.style.textOverflow = 'initial'; // はみ出た分の文字列を初期設定に応じて表示
                        div.style.color = 'inherit'; // 文字の色(上層要素に依存)
                        div.style.backgroundColor = 'inherit'; // 背景色(上層要素に依存)
                        div.style.display = 'block'; // 表示する
                      });
                      if (innerDiv.querySelector('.reten')) {
                        innerDiv.insertBefore(innerDiv.querySelector('.reten'), innerDiv.querySelector(':nth-child(' + (innerDiv.children.length + 1) + ')')); // レ点のspan要素を兄弟要素の最後に持ってくる
                        innerDiv.querySelector('.reten').textContent = ''; // レ点のspan要素の中身を空白にする
                      }

                    }
                  } else { // 幅が20pxでない場合(表示されていた場合)
                    cell.style.whiteSpace = 'nowrap'; // セルの表示を折り返し無しにする
                    cell.style.width = '20px'; // セルの幅を20pxにする
                    if (innerDiv) {

                      newHeight = calculateHeight(内容innerDiv); // 内容セルの中の全てのdiv要素から行高を算出する
                      innerDiv.style.maxHeight = newHeight;

                      innerDiv2 = innerDiv.querySelectorAll('div'); // 備考セルの中の全てのdiv要素(複数行ごとに)

                      innerDiv2.forEach(div => {
                        div.style.width = '20px'; // 幅20px
                        div.style.whiteSpace = 'nowrap'; //折り返し無
                        div.style.overflow = 'hidden'; // はみ出た部分は非表示
                        div.style.textOverflow = 'ellipsis'; // はみ出た分の文字列を...で表示
                        div.style.color = '#ffff00'; // 文字の色
                        div.style.backgroundColor = '#ffff00'; // 背景色
                        div.style.display = 'none'; // 表示しない
                      });
                      if (innerDiv.querySelector('.reten')) {
                        innerDiv.insertBefore(innerDiv.querySelector('.reten'), innerDiv.querySelector(':nth-child(' + 1 + ')')); // レ点のspan要素を兄弟要素の最初に持ってくる
                        innerDiv.querySelector('.reten').textContent = '✔️'; // レ点のspan要素の中身を ㇾ にする
                      }

                    }
                  }
                }
              });
              
              span.setAttribute('data-listener-attached', 'true'); // データ属性を追加してリスナーが追加されたことを記録
            }
          });
        });

      }
    }, 2000); // 45行目のsetIntervalの実行感覚=2秒
  });
})(jQuery);
		

4.種明かしするわ。

  Topへ↑

さて、一生懸命実装して、お客様のご要望は満たせました。
しかし、最初のお話があった時、私はかなり抵抗しました。

なぜかわかりますか?
それは、関連レコードの特定の列を表示非表示を制御する意味とは?と疑問を持ったためです。
列を表示するならする、しないならしないとすっきりできないでしょうか?と。

ところが、お客様曰く、このご要望はエンドユーザー様によるものということです。
ある人は備考項目を一覧で見たい。でもある役職の人には見せたくない、ということでした。

では次に思うのは、権限分けすればよいのでは?ということです。
フィールドのアクセス権限を設定すれば、こんなややこしい実装も不要です。
参照先アプリのフィールドをアカウントによって表示制御をすれば、関連レコードを小細工せずに表示制御できる。そう思いますよね?

ところが、権限を分けたくても、現場は共有アカウントを使って利用しておられます。
つまり担当者によってアカウントが分けられないのです。

結局、共有アカウントを前提とし、関連レコード上で履歴を一括で確認するしかないのです。
では、どうすればよいか。
いろいろと検討しましたが、このような実装になりました。

冒頭に、私がこの実装は他では使わないと思うと書いたのは、そういうことです。
多分、私が設計段階から関わっていたら、最初からアプリの構成を考えるか、それともアカウントを別にご用意するよう提案するでしょうね。

ちなみに、動画をアップした後、一か所ソースコードを修正しています。
ちょっと間違いがあったためです。
もしご興味がありましたら、考えてみて下さい。正月の暇つぶしにどうぞ。

5.まとめ

  Topへ↑

冒頭に書いた通り、この実装はコーディングしていて楽しめましたし、結果的にお客様に価値をもたらしたのではないかと思います。

先日のnoteにも書きましたが、kintoneにはこういう技術者としての遊びを受け入れる余裕が必要だと思うのです。
kintoneのノーコードカスタマイズもさらにできる範囲が増えていくはずです。
「定型的なアプリ間のデータ連携はJavaScriptよりノーコードカスタマイズで済ませてしまうべき。それらではできないカスタマイズはまだ多数ありますので、その場合にJavaScript/php/pythonなどの言語に任せるようにしたいと思います。」と上のnoteに書きました。

カスタマイズできる部分とは、この記事で書いたような内容なのでしょう。
ただ、このカスタマイズも本当にやるべきカスタマイズかどうかは、上に書いた通りです。
カスタマイズせずとも、本来はアカウントの使い方や設計によって改善できる可能性があります。
なんでもかんでもカスタマイズを行うのではなく、行うべきカスタマイズかどうか、標準機能またはノーコードカスタマイズプラグインやサービスが用いられないか、考えることは大切です。
そこまで考えてもいい知恵が浮かばなければ、カスタマイズを楽しみましょう。
使用上の注意をよく読み、用法 用量を守って楽しいkintoneライフを!

コメント

  1. […] ・ 「Qiita kintone Advent Calendar 2024」に投稿しました(ページ)(12/23)。 […]

タイトルとURLをコピーしました