inuinu blog(開発用)

BOT @wagagun の開発ノウハウや、IT向け?の雑記ブログです。

素のJavascript(Vanilla)だけでフロントエンド開発(09:明細行追加・削除編)

このページでは、ReactやVueではなく、Vanillaと呼ばれる素のJavascriptのみで、フロントエンド開発が可能であるか?を解説します。今回は入力画面の明細行の追加・削除を実装します。

今回は、前回…

inuinu-tech.hatenablog.jp

の続きになります。

いきなりここにたどり着いたのでしたら、お手すきの際で構いませんので、下記の記事からも読んでみていただければ幸いです。

inuinu-tech.hatenablog.jp

今回の課題のゴールは?

明細行付きの入力画面の、明細行を追加したり削除したりします。

また、変更画面の初期表示で、DBにある明細行を表示するようにします。

明細行が必要な入力ってどんな物があるの?

例えば、交通費精算でしょうか。

多くの法人では、日ごとに精算することはまれで、その週、あるいは月単位にまとめて精算しますので、日付、行き先、交通手段、金額などの、複数行入力が必須条件になるかと思います。<

また、販売管理でも、1商品・サービスのみを売買することはまれで、1回の取引で、複数の商品・サービスや個数が入力できなくてはいけませんよね。

明細行の仕様を決める

とりあえず、明細入力画面の仕様としては…

  1. 「行追加」ボタンを押下したら、明細の追加を行う
  2. 「行削除」ボタンを押下したら、明細の削除を行う

でしょうか。

削除を繰り返しても、行番号がバッティングしないように、最大値チェック用の行カウントと、明細行に付加する行番号は別に持ちます。

明細行の実装(登録画面編)

それでは早速、登録画面のHTMLから作成していきましょう。

今回のプログラム例では、ヘッダー部分に入力項目がありませんが、あるとNGというわけではありません。
ヘッダーと明細を両方入力するスタイルの登録画面は往々にしてありますので、適宜追加して、試してみてください。

プログラム例

●test_detail01.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<script>
// form名
let FRM_NAME = 'frm';
// 最大明細行数
let MAX_DETAIL_LINE = 5;
let INIT_DETAIL_LINE = 1;

async function setInitData() {

  // 既存明細分の行を追加する
  let cnt = INIT_DETAIL_LINE;
  Array.from({ length: cnt }).forEach(function(strID) {
  //Array(cnt).fill().forEach(function(strID) {
  //[...Array(cnt)].forEach(function(strID) {
    addDetailLine();
  });

} // function

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // APIを使用した初期表示
  setInitData();
});
</script>
</head>
<body>
<template id="TEMPLATE">
  <div id="line_no_" data-line-no="">
    #<span id="line_no_disp_"></span>
    <input type="hidden" name="dummy_" value="">
    <br />
    元号2:<select name="foo2_" class="sel_gengo">
      <option value="X">選択してください</option>
      <option value="M">明治</option>
      <option value="T">大正</option>
    </select>
    日付:<input name="foo_date_" type="date" value="">
    <br />
    外出:<input type="radio" name="gaishutsu_" value="1" id="gaishutsu1_">
    <label for="gaishutsu1_">あり</label>
    <input type="radio" name="gaishutsu_" value="0" checked id="gaishutsu2_" checked>
    <label for="gaishutsu2_">なし</label>
    <br />
    勤怠:<input type="checkbox" name="kintai_" value="1" id="kintai1_">
    <label for="kintai1_">遅刻</label>
    <input type="checkbox" name="kintai_" value="2" id="kintai2_">
    <label for="kintai2_">早退</label>
    <input type="checkbox" name="kintai_" value="3" id="kintai3_">
    <label for="kintai3_">電車遅延</label>
    <br />
    メモ:<textarea name="memo_" rows="5" cols="30"></textarea>
    <button type="button" id="del_line_no_" data-line-no="" onclick="dltDetailLine(this);">行削除</button>
    <hr>
  </div>
</template>
<form name="frm" id="FORM_AREA">
ここは見出しエリア
<hr>
<!-- ここから明細エリア -->
<span id="DETAIL_AREA"></span>
<button type="button" onclick="addDetailLine();">行追加</button>
<input type="text" name="detail_count" value="0">
<input type="text" name="max_count" value="0">
<input type="text" name="detail_id" value="line_no_,del_line_no_,line_no_disp_,gaishutsu1_,gaishutsu2_,kintai1_,kintai2_,kintai3_">
<input type="text" name="detail_name" value="dummy_,foo2_,foo_date_,gaishutsu_,kintai_,memo_">
</form>
</body>
</html>

jsディレクトリを作成し、以下のJavascriptソース(vanilla-front-end.js)を作成します。

他の演習を先に行い、vanilla-front-end.jsがすでに存在する場合は、追記してください。

●vanilla-front-end.js

// 明細行追加
function addDetailLine() {
  // 現在の明細番号を取得
  let dtl_cnt = document.forms[FRM_NAME].detail_count.value;
  let max_cnt = document.forms[FRM_NAME].max_count.value;
  //console.log(dtl_cnt);

  // 最大明細数を超えた場合はダイアログを表示し、何もしない
  if (MAX_DETAIL_LINE != 0 && max_cnt >= MAX_DETAIL_LINE) {
    alert('明細行の上限' + MAX_DETAIL_LINE + '行を超えています。');
    return;
  }

  // 行を加算
  dtl_cnt++;
  max_cnt++;
  document.forms[FRM_NAME].detail_count.value = dtl_cnt;
  document.forms[FRM_NAME].max_count.value = max_cnt;

  // テンプレートを取得し、クローンを作成
  let tmp = document.getElementById('TEMPLATE');
  let cln = tmp.content.cloneNode(true);

  // クローンをコピペ
  document.getElementById('DETAIL_AREA').appendChild(cln);

  // datasetに行番号を挿入
  document.getElementById('line_no_').dataset.lineNo = dtl_cnt;
  document.getElementById('del_line_no_').dataset.lineNo = dtl_cnt;
  document.getElementById('line_no_disp_').innerText = dtl_cnt;
  // idを変更
  chgCloneID(document.forms[FRM_NAME].detail_id.value, dtl_cnt);
  // forを変更
  chgCloneFor(document.forms[FRM_NAME].detail_id.value, dtl_cnt);
  // nameを変更
  chgCloneName(document.forms[FRM_NAME].detail_name.value, dtl_cnt);
  
} // function

// idの書き換え
function chgCloneID(lstID, lineNo) {
  let arrID = lstID.split(',');
  arrID.forEach(function(strID) {
    //console.log(strID);
    //console.log(lineNo);
    //console.log(document.getElementById(strID));
    document.getElementById(strID).id = strID + lineNo;
  });
} // function

// forの書き換え
function chgCloneFor(lstID, lineNo) {
  let arrID = lstID.split(',');
  arrID.forEach(function(strID) {
    //console.log(strID);
    //console.log(lineNo);
    elm = document.querySelector('label[for="' + strID + '"]');
    if (elm) {
      elm.htmlFor = strID + lineNo;
      //console.log(elm.htmlFor);
    }
  });
} // function

// nameの書き換え
function chgCloneName(lstName, lineNo) {
  let arrName = lstName.split(',');
  arrName.forEach(function(strName) {
    //console.log(strName);
    //console.log(lineNo);
    let elm = document.querySelectorAll('[name="' + strName + '"]');
    elm.forEach(function(el) {
      //console.log(el);
      el.name = strName + lineNo;
    }); // elm.forEach(function(el) {
  }); // arrName.forEach(function(strName) {
} // function

// 明細行の削除
function dltDetailLine(objThis) {
  // objThisのdatasetからidを求める
  let strID = 'line_no_' + objThis.dataset.lineNo;
  document.getElementById(strID).remove();

  // 最大行の減算
  let max_cnt = document.forms[FRM_NAME].max_count.value;
  max_cnt--;
  document.forms[FRM_NAME].max_count.value = max_cnt;

} // function

実行方法

http://localhost/~(ユーザー名)/test_detail01.html

まず、INIT_DETAIL_LINEで指定した行数分の明細が作成されます

さらに、「行追加」ボタンで行が追加され、「行削除」ボタンでその行が削除されます。
また、行の合計が5件になったら、それ以降はダイアログが表示され、行は追加できなくなります。
(削除されれば、また追加できます。)

ボタン付近にあるINPUTタグは、デバッグのためにtype属性をtextにしておきましたが、内容が確認できたら、hidden化しておきましょう。

行が増えていく仕組み

TEMPLATEに追加したい行を忍ばせておく

まず、明細行の元ネタになる部分を、TEMPLATEタグで囲み、隠しておきます。

<template id="TEMPLATE">
  <div id="line_no_" data-line-no="">
    #<span id="line_no_disp_"></span>
    <input type="hidden" name="dummy_" value="">
    <br />
    元号2:<select name="foo2_" class="sel_gengo">
      <option value="X">選択してください</option>
      <option value="M">明治</option>
      <option value="T">大正</option>
    </select>
    日付:<input name="foo_date_" type="date" value="">
    <br />
(中略)
  </div>
</template>

TEMPLATE内のidやnameにはアクセスできない

TEMPALTEタグは、ドキュメント対象外となるので、内部のエレメントにアクセスできません。

クローンをコピペする段階でnameやidが認識できるようになります。
ただしnameもidもユニークであることが求められます。

1行のみの場合は問題はないですが、2行追加した時点でバッティングします。。

ですので、このようにします。

  1. TEMPLATEの中のidやnameもユニークな名前にする
  2. クローンをコピペした段階で、nameやidやforをリネームし、ユニークな値にする
    (classはその限りではない)
  3. そのユニークな値に対し、値をセットする

TEMPLATE上では「id="hoge_"」といった値にしておき、クローン後に新しい行番号を採番して(例えば採番した行番号が12ならば)、id="hoge_12"のような形にすればいいと思います。
値を取得するAPIも"hoge_12"となるように設計してください。

addDetailLine()

addDetailLine()の内、テンプレートからコピペする手順は下記のロジックになります。

// テンプレートを取得し、クローンを作成
let tmp = document.getElementById('TEMPLATE');
let cln = tmp.content.cloneNode(true);
(中略)
// クローンをコピペ
document.getElementById('DETAIL_AREA').appendChild(cln);

ブラウザのF12でElementを選択しておくと、増えていくさまがわかります。

ただ、これだけですと、2行目以降、nameやid、forが重複してしまうので、

// idを変更
chgCloneID(document.forms[FRM_NAME].detail_id.value, dtl_cnt);
// forを変更
chgCloneFor(document.forms[FRM_NAME].detail_id.value, dtl_cnt);
// nameを変更
chgCloneName(document.forms[FRM_NAME].detail_name.value, dtl_cnt);

で、id/for/nameに行番を付加していきます。

どのid/for/nameに対し行番を付加するのかは、別途INPUTタグ(name="detail_id"および"detail_name")内に定義しておくことで、処理の汎用化を図っています。
(forはdetail_idを流用します。存在しないものは無視するようにしています。)

また、addDetailLine()では、datasetにも値(行番)をセットしていますが、こちらは行削除に利用します。

行が削除される仕組み

<button type="button" id="del_line_no_" data-line-no="" onclick="dltDetailLine(this);">行削除</button>

一般的には、Javascriptの処理内容を変えることは(セキュリティの観点から)ご法度のようなので、dltDetailLine()の引数には、行番号を挿入することはしません(出来ません)。

代わりにthis=自分自身をセットします

こういった場合では、addDetailLine()にてdata-line-noに行番号値をセットしておき、

function dltDetailLine(objThis) {
  // objThisのdatasetからidを求める
  let strID = 'line_no_' + objThis.dataset.lineNo;
  document.getElementById(strID).remove();

上記のように、パラメータにthis、つまり自分自身(のelements)を取得できるようにし、BUTTONタグ内にあるdata-line-noから行番号を取得して、id="line_no_(行番号)"のタグを、自分自身と子供を含め根こそぎremove()します。

つまり、dataset(data-hoge)はthisだけでは取得できない、引数(パラメータ)の代わりに利用します。
引数的な項目が複数必要な場合は、detasetを適宜、複数用意しましょう。

バックエンドのフレームワークによっては、nameにを付加すると、バックエンド側で配列として扱えるようですが、そういったフレームワークをサーバーサイドで採用するかはわかりませんので、nameに的なものは付加せず、「hoge_n」といった行番号付きのユニークなnameにします。

参考:API側の注意点

行の欠番に注意

ところで、今回の仕様では行削除も考えていますが、そうすると欠番が出ますよね?

行番号を詰めることはバグを誘発するだけのような気がするで、例題ではそのままサーバーサイドに送ることにしています。

JSONに変換してPOSTするといった仕様(キメ)であれば、配列化して行番を消すことは可能かもしれませんが、一般的には画面によっては添付ファイルなどもありますので、まあ、フォームデータで統一でしょうね。
その場合はname属性がAPI側(=サーバーサイドフレームワーク側)のパラメータになります。

ですので、API側(=サーバーサイドフレームワーク側)はname="hoge_n"に欠番があることを念頭において、プログラミングをすることになります

さらに、理想的には行番号を詰めて(リナンバリングして)DBに落としたいので、API側ではそこも工夫しつつ。

API側で欠番チェックを汎用化するには、どんな明細でも必ずあるname名があれば便利ですが、なければ、ダミー用のhiddenを忍ばせておくと、そういったチェックには便利かもしれませんね。

この記事のプログラム例では、"dummy_"から始まるnameが、それを想定しています。

name="dummy_n"が存在しない場合、false的な結果を返す言語ならやりやすいでしょうが、そうでない(例外が発生してしまう)言語は、nameの値を早めに配列+構造体(あるいは連想配列)化して、詰めてしまいます

とはいえ、エラー対象のnameをJavascriptで装飾する(多くの場合はINPUTタグの枠を赤色にする)使用の場合は、詰めたとしても連想配列等で(value値の他に)name値も保持する必要はありそうですね。

明細行の実装(変更画面編)

登録はまっさらな空行を表示すれば良いのですが、変更画面では、DB上に既に明細(登録時のデータ)が存在していますよね?
なので、初期処理でデータをAPI経由で取得した上で、表示する必要があります。

そのためには、以前作成した、APIから取得したJSONをもとにnameやidに値をセットするsetInputValue()を今回も使用しますが、その直前に、APIから取得した明細行数をもとに、必要な行数分だけaddDetailLine()してあげれば、上手くいくと思います。

ここでは便宜的に「変更画面」としていますが、登録画面でもこのプログラムを利用しましょう。
API側でidやコードがなければ、setInputValue()で置き換える項目を0件で返すようにすれば、そのまま使えると思います。

プログラム例

●test_detail02.html

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="js/vanilla-front-end.js"></script>
<script>
// form名
let FRM_NAME = 'frm';
// 最大明細行数
let MAX_DETAIL_LINE = 5;
let INIT_DETAIL_LINE = 1;

async function setInitData() {

  // INPUTタグの値をセット
  // API実行
  result = await fetch("api_mock/get_detail.json", {
      method: "GET"
  });
  // JSONを取り出す
  jsonData = await result.json();
  //console.log(jsonData.result);

  // 既存明細分の行を追加する
  let cnt = (jsonData.line_count > INIT_DETAIL_LINE) ? jsonData.line_count : INIT_DETAIL_LINE;
  Array.from({ length: cnt }).forEach(function(strID) {
  //Array(cnt).fill().forEach(function(strID) {
  //[...Array(cnt)].forEach(function(strID) {
    addDetailLine();
  });

  // 入力値をセットする
  setInputValue(jsonData.result, FRM_NAME);

} // function

// DOMコンテンツのロード完了時に実行
window.addEventListener('DOMContentLoaded', (ex) => {
  // APIを使用した初期表示
  setInitData();
});
</script>
</head>
<body>
<template id="TEMPLATE">
  <div id="line_no_" data-line-no="">
    #<span id="line_no_disp_"></span>
    <input type="hidden" name="dummy_" value="">
    <br />
    元号2:<select name="foo2_" class="sel_gengo">
      <option value="X">選択してください</option>
      <option value="M">明治</option>
      <option value="T">大正</option>
    </select>
    日付:<input name="foo_date_" type="date" value="">
    <br />
    外出:<input type="radio" name="gaishutsu_" value="1" id="gaishutsu1_">
    <label for="gaishutsu1_">あり</label>
    <input type="radio" name="gaishutsu_" value="0" checked id="gaishutsu2_" checked>
    <label for="gaishutsu2_">なし</label>
    <br />
    勤怠:<input type="checkbox" name="kintai_" value="1" id="kintai1_">
    <label for="kintai1_">遅刻</label>
    <input type="checkbox" name="kintai_" value="2" id="kintai2_">
    <label for="kintai2_">早退</label>
    <input type="checkbox" name="kintai_" value="3" id="kintai3_">
    <label for="kintai3_">電車遅延</label>
    <br />
    メモ:<textarea name="memo_" rows="5" cols="30"></textarea>
    <button type="button" id="del_line_no_" data-line-no="" onclick="dltDetailLine(this);">行削除</button>
    <hr>
  </div>
</template>
<form name="frm" id="FORM_AREA">
ここは見出しエリア
<hr>
<!-- ここから明細エリア -->
<span id="DETAIL_AREA"></span>
<button type="button" onclick="addDetailLine();">行追加</button>
<input type="text" name="detail_count" value="0">
<input type="text" name="max_count" value="0">
<input type="text" name="detail_id" value="line_no_,del_line_no_,line_no_disp_,gaishutsu1_,gaishutsu2_,kintai1_,kintai2_,kintai3_">
<input type="text" name="detail_name" value="dummy_,foo2_,foo_date_,gaishutsu_,kintai_,memo_">
</form>
</body>
</html>

api_mockディレクトリにget_detail.jsonを用意します。

●get_detail.json

{
  "line_count" : 2,
  "result" : [
    {"name" : "foo2_1", "value" : "M"},
    {"name" : "foo_date_1", "value" : "2023-07-01"},
    {"name" : "gaishutsu_1", "value" : "1"},
    {"name" : "kintai_1", "value" : "1,3"},
    {"name" : "memo_1", "value" : "あああ\nいいい"},
    {"name" : "foo2_2", "value" : "T"},
    {"name" : "foo_date_2", "value" : "2023-07-10"},
    {"name" : "gaishutsu_2", "value" : "0"},
    {"name" : "kintai_2", "value" : "2"},
    {"name" : "memo_2", "value" : "abcdefg"}
  ]
}

実行方法

http://localhost/~(ユーザー名)/test_detail02.html

きちんと表示されているかを確認してみてください。
また、(JSONで取得した・しない関わらず)行の追加や削除ができることも確認してみてください。

ボタン付近にあるINPUTタグは、内容が確認できたら、hidden化しておきましょう。

変更箇所

APIを読み込む機能を実装しています。
さらに、

let cnt = (jsonData.line_count > INIT_DETAIL_LINE) ? jsonData.line_count : INIT_DETAIL_LINE;

にて、初期表示行(INIT_DETAIL_LINE)と、明細取得行(jsonData.line_count)のどちらかを採用するようにしています。

空の明細行追加の仕組み

前項で決定したのcnt回数分、addDetailLine()を繰り返し、空の明細を作成します。
その後、setInputValue()を実行すると、明細行に値が入ります。

addDetailLine()を件数分実行するループについては…

Array.from({ length: cnt }).forEach(function(strID) {
//Array(cnt).fill().forEach(function(strID) {
//[...Array(cnt)].forEach(function(strID) {

といった具合に、いくつもの手法があるようですが、ご使用のブラウザで動作するものを、ご使用していただければと思います。

明細でも選択値の制御が生じるのならば

もし、明細項目の中で、選択値の制御が生じるのならば、こちらの記事、

inuinu-tech.hatenablog.jp

を参考に、制御用functionの共通化を図ってください。
タイミング的にはこの3箇所になるかと思います。

  • 初期値
  • 行追加時
  • 各明細のonclick/onchangeのタイミング

class="is-style-big_icon_caution">異なる明細に悪影響がないように作成するのがコツです。
明細の表示制御は難易度が高いですが、私の経験では、共通化はできると思います。
頑張ってみてください!

行を追加すると同時に値を複写をするには?

今回は例示として考えませんでしたが、

コピーと同時に、どこか指定した行から値を複写するといったボタンが欲しい!

とか、言われそうではありますよね。

行追加とは別に複写ボタンを追加

これを実現するには、addDetailLine()実行後に、行の複写のロジックを実装すればよいのですが…
それには2つの方法がありそうです。

  1. Javascriptでゴリゴリ複写
    • (.valueが使用できない)radio/checkboxのコピペに難ありそう…
  2. POST(フォームデータ)とコピー元行番号、コピー先行番号をAPIに投げて、setInputValue()で使用できるデータに加工してもらう
    • hiddenでコピー対象用のnameリストを持っておくと、POSTで勝手にAPIに渡るので、API側では”result”の作成がスムーズにできそう

おすすめは2.でしょうか。
汎用的に作れば、一つのAPIで事足りそうですね。

複写元の行をどう表現するか?

参考までに、複写元の行番号をどうやって求めるか?
次のいづれかの方法があると思います。

  • 複写ボタンの横に、何行目を選択あるいは入力できるようにする
    • SELECT/OPTIONだと、行が追加、削除されるために変更する煩わしさがありそう
    • 行に欠番があると何行目と実際の行番号がわかりにくく、バグにもなりそう
  • 各行の先頭にラジオボタン等を用意し、選択した行が複写されるようにする
    • 行を追加しても、このラジオボタンのnameのリネームはしないようにする必要がある

フッター(ヘッダー?)部分に合計欄を設けたい

こちらもJavascriptでゴリゴリ作成派とAPI派に分かれると思います。

どちらにしても、実際はエラーチェックのAPIは実装すると思いますので、そちらで計算した後に、setInputValue()で値をセットしてあげれば良い思います。

以前の記事でも「処理の隠蔽化」を考えましたよね?
あと、デザイン+処理の埋め込みを担当されている方は、なるべくデザインに集中して欲しいという思いもあったり…

最後に

残されたネタ(?)は、(今回のロジックを応用した)一覧画面の実装と、共通部分の組み込みでしょうか。

次回はその中の、一覧画面にチャレンジします。

inuinu-tech.hatenablog.jp

それでは、また。