フォームを追加していくjs

※注意。ハムルだけどインデントおかしいのでそこは注意。多分はてなブログ自体がhtml編集できるからスペースが無視されるんだろう。エスケープするか自分で作ったアプリでも見とけ。

先にaccepts_nested_attributes_for_テーブル名でメモ書いといたけどその続きで。

一つのfom_for内で複数モデルに登録をする方法が前回の部分だった訳だけど、画像が一枚ならあのままでも大丈だが、複数枚投稿したいなら画像を追加する毎にフォームも増える必要があるので、jsが必要になってくる。fields_forを使ってfile_fieldのフォームを作った場合はinputの

typeがfileなのは当然として、

nameが”親モデル[子テーブル_attributes][0][子カラム]”

idが”親モデル_子テーブル_attributes_0_子カラム”

となっているので二つ目のフォームからは[0]の部分の数字を増やしていくことになる。

例としてこんな感じのhamlがあったとする。なんかインデントおかしいけどスペース入れてもこうなるからファイルフィールドのインデントはbrと同じだと思って。

#image-box
= f.fields_for :images do |image|
  .js-file_group{"data-index" => "#{image.index}"}
= image.file_field :image, class:"js-file"
   %br
%span.js-remove 削除

この時ちょっと注意なのはi.file_fieldの方に先ほどのnameやidが書いてあるのだが、hamlの方では直接は見えてないこと。.js-file_groupの方は普通のdivタグにカスタムデータ属性でindexの番号を入れてあげてるような感じ.

 

jsはこんな感じになる

$(document).on('turbolinks:load', ()=> {
// ここは下の説明読んでね。
const buildFileField = (index)=> {
const html = `<div data-index="${index}" class="js-file_group">
<input class="js-file" type="file"
name="item[images_attributes][${index}][image]"
id="item_images_attributes_${index}_image"><br>
<div class="js-remove">削除</div>
</div>`;
return html;
}

// file_fieldのname属性に動的なindexをつける為の配列
let fileIndex = [1,2,3,4,5,6,7,8,9,10];

$('#image-box').on('change', '.js-file', function(e) {
// fileIndexの先頭の数字を使ってinputを作る
$('#image-box').append(buildFileField(fileIndex[0]));
fileIndex.shift();
//shiftメソッドは配列の先頭を削除する
fileIndex.push(fileIndex[fileIndex.length - 1] + 1)
//pushは配列に要素を付け足す。今回は末尾に1足した数字。
});

$('#image-box').on('click', '.js-remove', function() {
$(this).parent().remove();
// 画像入力欄が0個にならないようにしておく
if ($('.js-file').length == 0) $('#image-box').append(buildFileField(fileIndex[0]));
});
});

 

説明加えてく

const buildFilefield = (index)=> {} のとこは引数にもらったindexを使ってhtml組み立てて返してるだけ。

const buildFilefield = function(index){}って書いても挙動変わんなかったから要は無名関数ってことだと思う。自分は下の方が分かり易かったのでアプリでは下を使ってるが、キモい書き方(アロー関数?)もあるってことで上も記録に残しとく。

 

その後index振るための配列をfileIndexに定義して 

もしimage-boxの中のフォームjs-fileの要素がchangeしたらさっき定義した配列の一番最初[0]の、つまりここでは1の数字をindexに入る引数といてbuildFilefieldにhtml作ってもらってappendしてる。この辺りのhtml組み立ての流れは大丈夫だと思うんだが、

$('hoge').on('change', 'hogehoge' function(e){こん中に変化があった場合の処理})

っていうchangeの後になんか指定するっていう使い方をパッと思いつけるように慣れたらいいなと思った。onの使い方はこの記事が分かり易いと思う。

https://www.sejuku.net/blog/38774

 

shifメソッドは配列の先頭を削除するのでなんか追加したらとりあえず1を削除するようになってる。その後pushで配列を一つ増やして配列の総数は変わんないようにしてる。ここで注意するのはfileIndex[]のインデックス番号は0から始まるが、fileIndex.lengthは単純に配列に要素が何個あるかなので、.lengthから1引いた数に1を足している。1引いて1足してるから結局.lengthのままでいいような気もするが、意味合い的には1足す表現があった方が分かり易いかもしれんので一応アプリでもこれ使ってっる。(でも本心では.lengthでよくねって思ってる)

 

.parentに関しては下の記事が分かり易い。まぁここでは単純に親要素だろうが。

https://uxmilk.jp/8150

 

子モデルの削除やも親モデルのコントローラーでやりたい場合はストロングパラメーターで、例えば

images_attributes: [:image, :_destroy, :id]

などとする。destroyを許可してやらないといけない感じ。ちょと抜粋すると

accepts_nested_attributes_forは、paramsの○○s_attritbutes:というキーの中で特定の値を送ることで、親モデルに紐づいた子モデルの削除、更新を行います。

らしい。

 

登録した画像の削除は編集、editページで行うだろうから、editページのフォームの話にうつる。

削除する対象は当然データベースに保存済みのものであるので.persisted?を使う。これはDBに保存済みならtrue、そうでなければfalseを返すメソッドである。(これ使ってるのでnewの方でも同じフォーム使いまわせる。)

例えば

= f.fields_for :images do |image|
.js-file_group{"data-index" => "#{image.index}"}
= image.file_field :image, class:"js-file"
%br
%span.js-remove 削除
#ここまでnewで表示される部分
#ここからedit側
- if @item.persisted?
= image.check_box :_destroy, data:{ index: image.index }, class: 'hidden-destroy'
#インデントついてなくてわかりづらいがfields_forがここまで
- if @item.persisted?
.js-file_group{"data-index" => "#{@item.images.count}"}
=file_field_tag :image, name: "item[images_attributes][#{@item.images.count}][image]", class:'js-file'
.js-remove 削除

 等とあったとするとnew側の方は今までと同じで、edit側はすでにDBに保存されているものだけ、つまり削除や更新の対象になるものだけが表示される。一つ目の.persisted?は登録されてる画像それぞれにチェックボックスを出現させている。新規作成時には削除のチェックボックスは必要ないのでedit側だけで表示している。

二つ目の.persisted?はedit側でも新規登録用のフォームが要るだろうけど、editページでビルドされるフォームは既存の画像枚数分だけなので新しくフォームを生成している。

 

 editページでは画像に添え字が添えられているので新しく画像を投稿するために辻褄を合わせる必要がある。なので、ページが読み込まれたらfileIndexから数字を取り除く必要がある。

jsを次のように編集

// file_fieldのnameに動的なindexをつける為の配列
let fileIndex = [1,2,3,4,5,6,7,8,9,10];
// 既に使われているindexを除外
lastIndex = $('.js-file_group:last').data('index');
fileIndex.splice(0, lastIndex);
$('.hidden-destroy').hide();

 

まず.js_file_group(カスタムデータが書いてあるdivタグ。ファイルフィールドから削除ボタンか削除のチェックボックスまで)の一番最後のindexが何なのか取得。

参考記事

http://js.studio-kingdom.com/jquery/selector/last_selector

 

次にsliceで配列の最初(0)から最後(lastIndex)までの数字削除することで既に使われている数字を削除している。

最後の行は削除ボックスが全部の画像の下に表示されてたら見た目が悪いので全部隠している。この後jsでクリックされたらチェックボックスにがチェックされるようなjsを書く。

 $('#image-box').on('click', '.js-remove', function() {
    const targetIndex = $(this).parent().data('index')
    // 該当indexを振られているチェックボックスを取得する
    const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
    // もしチェックボックスが存在すればチェックを入れる
    if (hiddenCheck) hiddenCheck.prop('checked', true);
    (省略)
  });

 

こんな感じ。

コメントで大体わかると思うが、補足入れると'.js-remove'は「削除」の文言を囲ってるspanタグのクラス。.hidden-destroyはチェックボックスのクラス名。

.prop()はproperty関連のメソッドで、引数1個なら指定したプロパティの値を取得できるし、2個なら引数に指定したプロパティに値を代入できる。詳しくはこの参考記事見て

https://www.sejuku.net/blog/36294

今回はcheckedプロパティをtrueにしてるって感じだろう。

ちなみにこの記事によると似たようなのにattr()があるけど、チェックボックスに使うならprop()を使うべきらしい。時間がある時にその辺りも読もうと思う。

 

 なんかまどろこしく感じるかもしれんが、実際にデータに入ってるものを削除したかったらjsでbox削除するだけじゃいかんから見えてなくてもチェックは必要だし、一回チェックしたらboxごとチェックボックスも消えるから都合いいし、DBにない物にdestroyリクエストしたらエラー出るだろうから.persistedも必要だったろうし、で何だかんだ全部必要な記述って感じ。

 

 

次にプレビューの表示の話に移る。

- if @item.persisted?
- @item.images.each_with_index do |images, i|
= image_tag images.image.url, data: { index: i }, width: "100", height: '100'

image-boxの先頭辺りにこんな感じでidがpreviewsのdivタグの中に画像を並べる。editの方の予め登録されてる画像は一応これで出る感じか。

 

ただプレビューには投稿をする前に予め見たい方のプレビューもあるのでそちらの話に移る。

まずは後でイベント発生時に書く処理で使う、いつもの、htmlを組み立てる関数をbuildImgに代入しとく。

// プレビュー用の画像をビルド
const buildImg = (index, url)=> {
const html = `<img data-index="${index}" src="${url}" width="100px" height="100px">`;
return html;
};

 

 そしてjs-fileにつまり画像のファイルフィールドの中身に変更があると動く関数を書く。

$('#image-box').on('change', '.js-file', function(e) {
const targetIndex = $(this).parent().data('index');
// ファイルのブラウザ上でのURLを取得する
const file = e.target.files[0];
const blobUrl = window.URL.createObjectURL(file);

// 該当indexを持つimgがあれば取得して変数imgに入れる(画像変更の処理)
if (img = $(`img[data-index="${targetIndex}"]`)[0]) {
img.setAttribute('src', blobUrl);
} else { // 新規画像追加の処理
$('#previews').append(buildImg(targetIndex, blobUrl));

// fileIndexの先頭の数字を使ってinputを作る
$('#image-box').append(buildFileField(fileIndex[0]));
fileIndex.shift();

// 末尾の数に1足した数を追加する
fileIndex.push(fileIndex[fileIndex.length - 1] + 1)
}
 
});
 

説明に入る。

まずjs-file_groupのdata-indexを取得(targetIndex)。

配列表してると思われる[0]って必要なのかちょっとわからんけども、fileならtargetしてる部分だし、imgの方はindexを指定してるから一つしかないので矛盾はしない・・・かな。この辺の自分の理解にあんま自信ないな。とりあえずこの解釈であってると仮定して、

 

ファイル名を取得(file)。

引数にさっきのfile渡して、ブラウザ上でのurlを作成してblobUrlに入れとく。

もしif(targetIndexで取得したindexがカスタムデータに入った物があればimgに代入して){そのimgのsrc属性をさっき作ったblobUrlのパスにする}そうじゃなかったら{このファイルフィールドに割当てられているindexと組み立てたパスblobUrlを使ってイメージタグをbuildImgで組み立ててpreviewのけつにくっつける}。

要するにif前半部分は画像があった場合なので前の画像から新しい画像に更新、なかった場合は新規なので新しく作ってくっつけるってことをしてる。

 

それ以降は元々あった記述(プレビュー関連ではなく新規フォーム作成関連の物)なので説明は割愛、というか最初の方に書いた。

 

最後に後一つだけ削除ボタン押した時の処理

$('#image-box').on('click', '.js-remove', function() {
const targetIndex = $(this).parent().data('index')
// 該当indexを振られているチェックボックスを取得する
const hiddenCheck = $(`input[data-index="${targetIndex}"].hidden-destroy`);
// 削除を押した時、もしチェックボックスが存在すればチェックを入れる。:_destroyを送るため
if (hiddenCheck) hiddenCheck.prop('checked', true);
// 削除を押すとjs-file_groupを削除
$(this).parent().remove();
// imgも削除
$(`img[data-index="${targetIndex}"]`).remove();
 
// 画像入力欄が0個にならないようにしておく
if ($('.js-file').length == 0) $('#image-box').append(buildFileField(fileIndex[0]));
});

 に

$(`img[data-index="${targetIndex}"]`).remove();

の行を追記して変更のあったファイルのindexと同じindexの画像が削除が押された時に消えるようにして終わり。

 

 

とりあえずこんな流れで作ったのでメモ。