#はじめに
某プログラミングスクールの課題で、メルカリのコピーサイトを作りました。
前回の記事では、商品出品の際のコードについて書きました。今回は出品した商品の編集です。
「こんなもん出品のときのコードちょちょいといじれば余裕でしょ〜」と舐めていたら痛い目見ました。
#仕様
- 画像を10枚まで登録できる。
- 1枚ずつプレビュー表示される。
- 5枚目以降は2段目にプレビュー表示される。
- ビュー上で削除ができる。
- すでに登録してある画像は初期表示する。
#コード
<コントローラ>
def edit
@item = Item.find(params[:id])
gon.item = @item
gon.item_images = @item.item_images
# @item.item_imagse.image_urlをバイナリーデータにしてビューで表示できるようにする
require 'base64'
require 'aws-sdk'
gon.item_images_binary_datas = []
if Rails.env.production?
client = Aws::S3::Client.new(
region: 'ap-northeast-1',
access_key_id: Rails.application.credentials.aws[:access_key_id],
secret_access_key: Rails.application.credentials.aws[:secret_access_key],
)
@item.item_images.each do |image|
binary_data = client.get_object(bucket: 'freemarket-sample-51a', key: image.image_url.file.path).body.read
gon.item_images_binary_datas << Base64.strict_encode64(binary_data)
end
else
@item.item_images.each do |image|
binary_data = File.read(image.image_url.file.file)
gon.item_images_binary_datas << Base64.strict_encode64(binary_data)
end
end
end
def update
# ブランド名がstringでparamsに入ってくるので、id番号に書き換え
if brand = Brand.find_by(name: params[:item][:brand_id])
params[:item][:brand_id] = brand.id
else
params[:item][:brand_id] = Brand.create(name: params[:item][:brand_id]).id
end
@item = Item.find(params[:id])
# 登録済画像のidの配列を生成
ids = @item.item_images.map{|image| image.id }
# 登録済画像のうち、編集後もまだ残っている画像のidの配列を生成(文字列から数値に変換)
exist_ids = registered_image_params[:ids].map(&:to_i)
# 登録済画像が残っていない場合(配列に0が格納されている)、配列を空にする
exist_ids.clear if exist_ids[0] == 0
if (exist_ids.length != 0 || new_image_params[:images][0] != " ") && @item.update(item_params)
# 登録済画像のうち削除ボタンをおした画像を削除
unless ids.length == exist_ids.length
# 削除する画像のidの配列を生成
delete_ids = ids - exist_ids
delete_ids.each do |id|
@item.item_images.find(id).destroy
end
end
# 新規登録画像があればcreate
unless new_image_params[:images][0] == " "
new_image_params[:images].each do |image|
@item.item_images.create(image_url: image, item_id: @item.id)
end
end
flash[:notice] = '編集が完了しました'
redirect_to item_path(@item), data: {turbolinks: false}
else
flash[:alert] = '未入力項目があります'
redirect_back(fallback_location: root_path)
end
end
private
def item_params
params.require(:item).permit(:name, :text, :category_id, :size_id, :brand_id, :condition, :delivery_fee_payer, :delivery_type, :delibery_from_area, :delivery_days, :price)
end
def registered_image_params
params.require(:registered_images_ids).permit({ids: []})
end
def new_image_params
params.require(:new_images).permit({images: []})
end
<ビュー>
#edit_item
.item
.item__title
%h2 商品の情報を入力
= form_for @item do |f|
.item__img
%ul
%li
%h3 出品画像
%li
.required__icon 必須
.clearfix
%p 最大10枚までアップロードできます
= f.fields_for :item_images, @item.item_images.first do |image|
.item__img__dropzone.clearfix
#preview
-# 1〜5枚目プレビュー表示
.item__img__dropzone__input
= image.label :image, for: "upload-image" do
.input-area
= image.file_field :image_url, id: "upload-image", class: "upload-image", 'data-image': 0
.item__img__dropzone__input__description
.item__img__dropzone__input__description__text
ドラッグアンドドロップ
%br
またはクリックしてファイルをアップロード
.item__img__dropzone2.clearfix
#preview2
-# 6〜10枚目プレビュー表示
.item__img__dropzone2__input2
= image.label :image, for: "upload-image" do
.input-area
= image.file_field :image_url, id: "upload-image", class: "upload-image", 'data-image': 0
.item__img__dropzone2__input2__description
.item__img__dropzone2__input2__description__text
ドラッグアンドドロップ
%br
またはクリックしてファイルをアップロード
<js>
かなり長くてめまいがしそうですが、大きく4つのパートでできてます。
①登録済画像をプレビュー表示
②新しく画像を追加したときのアクション
③削除ボタンを押したときのアクション
④submitを押したときのアクション
②〜④は商品出品のときとほとんど同じです。
$(window).on("turbolinks:load", function() {
var dropzone = $(".item__img__dropzone__input");
var dropzone2 = $(".item__img__dropzone2__input2");
var appendzone = $(".item__img__dropzone2")
var input_area = $(".input-area");
var preview = $("#preview");
var preview2 = $("#preview2");
// 登録済画像と新規追加画像を全て格納する配列(ビュー用)
var images = [];
// 登録済画像データだけの配列(DB用)
var registered_images_ids =[]
// 新規追加画像データだけの配列(DB用)
var new_image_files = [];
// 登録済画像のプレビュー表示
gon.item_images.forEach(function(image, index){
var img = $(`<div class= "add_img"><div class="img_area"><img class="image"></div></div>`);
// カスタムデータ属性を付与
img.data("image", index)
var btn_wrapper = $('<div class="btn_wrapper"><a class="btn_edit">編集</a><a class="btn_delete">削除</a></div>');
// 画像に編集・削除ボタンをつける
img.append(btn_wrapper);
binary_data = gon.item_images_binary_datas[index]
// 表示するビューにバイナリーデータを付与
img.find("img").attr({
src: "data:image/jpeg;base64," + binary_data
});
// 登録済画像のビューをimagesに格納
images.push(img)
registered_images_ids.push(image.id)
})
// 画像が4枚以下のとき
if (images.length <= 4) {
$('#preview').empty();
$.each(images, function(index, image) {
image.data('image', index);
preview.append(image);
})
dropzone.css({
'width': `calc(100% - (20% * ${images.length}))`
})
// 画像が5枚のとき1段目の枠を消し、2段目の枠を出す
} else if (images.length == 5) {
$("#preview").empty();
$.each(images, function(index, image) {
image.data("image", index);
preview.append(image);
});
appendzone.css({
display: "block"
});
dropzone.css({
display: "none"
});
preview2.empty();
// 画像が6枚以上のとき
} else if (images.length >= 6) {
// 1〜5枚目の画像を抽出
var pickup_images1 = images.slice(0, 5);
// 1〜5枚目を1段目に表示
$('#preview').empty();
$.each(pickup_images1, function(index, image) {
image.data('image', index);
preview.append(image);
})
// 6枚目以降の画像を抽出
var pickup_images2 = images.slice(5);
// 6枚目以降を2段目に表示
$.each(pickup_images2, function(index, image) {
image.data('image', index + 5);
preview2.append(image);
})
dropzone.css({
'display': 'none'
})
appendzone.css({
'display': 'block'
})
dropzone2.css({
'display': 'block',
'width': `calc(100% - (20% * ${images.length - 5}))`
})
// 画像が10枚になったら枠を消す
if (images.length == 10) {
dropzone2.css({
display: "none"
});
}
}
var new_image = $(
`<input multiple= "multiple" name="item_images[image][]" class="upload-image" data-image= ${images.length} type="file" id="upload-image">`
);
input_area.append(new_image);
// 画像を新しく追加する場合
$("#edit_item .item__img__dropzone, #edit_item .item__img__dropzone2").on("change", 'input[type= "file"].upload-image', function() {
var file = $(this).prop("files")[0];
new_image_files.push(file)
var reader = new FileReader();
var img = $(`<div class= "add_img"><div class="img_area"><img class="image"></div></div>`);
reader.onload = function(e) {
var btn_wrapper = $('<div class="btn_wrapper"><a class="btn_edit">編集</a><a class="btn_delete">削除</a></div>');
// 画像に編集・削除ボタンをつける
img.append(btn_wrapper);
img.find("img").attr({
src: e.target.result
});
};
reader.readAsDataURL(file);
images.push(img);
// 画像が4枚以下のとき
if (images.length <= 4) {
$('#preview').empty();
$.each(images, function(index, image) {
image.data('image', index);
preview.append(image);
})
dropzone.css({
'width': `calc(100% - (20% * ${images.length}))`
})
// 画像が5枚のとき1段目の枠を消し、2段目の枠を出す
} else if (images.length == 5) {
$("#preview").empty();
$.each(images, function(index, image) {
image.data("image", index);
preview.append(image);
});
appendzone.css({
display: "block"
});
dropzone.css({
display: "none"
});
preview2.empty();
// 画像が6枚以上のとき
} else if (images.length >= 6) {
// 配列から6枚目以降の画像を抽出
var pickup_images = images.slice(5);
$.each(pickup_images, function(index, image) {
image.data("image", index + 5);
preview2.append(image);
dropzone2.css({
width: `calc(100% - (20% * ${images.length - 5}))`
});
});
// 画像が10枚になったら枠を消す
if (images.length == 10) {
dropzone2.css({
display: "none"
});
}
}
var new_image = $(
`<input multiple= "multiple" name="item_images[image][]" class="upload-image" data-image= ${images.length} type="file" id="upload-image">`
);
input_area.append(new_image);
});
// 削除ボタン
$("#edit_item .item__img__dropzone, #edit_item .item__img__dropzone2").on('click', '.btn_delete', function() {
// 削除ボタンを押した画像を取得
var target_image = $(this).parent().parent();
// 削除画像のdata-image番号を取得
var target_image_num = target_image.data('image');
// 対象の画像をビュー上で削除
target_image.remove();
// 対象の画像を削除した新たな配列を生成
images.splice(target_image_num, 1);
// target_image_numが登録済画像の数以下の場合は登録済画像データの配列から削除、それより大きい場合は新たに追加した画像データの配列から削除
if (target_image_num < registered_images_ids.length) {
registered_images_ids.splice(target_image_num, 1);
} else {
new_image_files.splice((target_image_num - registered_images_ids.length), 1);
}
if(images.length == 0) {
$('input[type= "file"].upload-image').attr({
'data-image': 0
})
}
// 削除後の配列の中身の数で条件分岐
// 画像が4枚以下のとき
if (images.length <= 4) {
$('#preview').empty();
$.each(images, function(index, image) {
image.data('image', index);
preview.append(image);
})
dropzone.css({
'width': `calc(100% - (20% * ${images.length}))`,
'display': 'block'
})
appendzone.css({
'display': 'none'
})
// 画像が5枚のとき1段目の枠を消し、2段目の枠を出す
} else if (images.length == 5) {
$('#preview').empty();
$.each(images, function(index, image) {
image.data('image', index);
preview.append(image);
})
appendzone.css({
'display': 'block',
})
dropzone2.css({
'width': '100%'
})
dropzone.css({
'display': 'none'
})
preview2.empty();
// 画像が6枚以上のとき
} else {
// 1〜5枚目の画像を抽出
var pickup_images1 = images.slice(0, 5);
// 1〜5枚目を1段目に表示
$('#preview').empty();
$.each(pickup_images1, function(index, image) {
image.data('image', index);
preview.append(image);
})
// 6枚目以降の画像を抽出
var pickup_images2 = images.slice(5);
// 6枚目以降を2段目に表示
$.each(pickup_images2, function(index, image) {
image.data('image', index + 5);
preview2.append(image);
dropzone2.css({
'display': 'block',
'width': `calc(100% - (20% * ${images.length - 5}))`
})
})
}
})
$('.edit_item').on('submit', function(e){
// 通常のsubmitイベントを止める
e.preventDefault();
// images以外のform情報をformDataに追加
var formData = new FormData($(this).get(0));
// 登録済画像が残っていない場合は便宜的に0を入れる
if (registered_images_ids.length == 0) {
formData.append("registered_images_ids[ids][]", 0)
// 登録済画像で、まだ残っている画像があればidをformDataに追加していく
} else {
registered_images_ids.forEach(function(registered_image){
formData.append("registered_images_ids[ids][]", registered_image)
});
}
// 新しく追加したimagesがない場合は便宜的に空の文字列を入れる
if (new_image_files.length == 0) {
formData.append("new_images[images][]", " ")
// 新しく追加したimagesがある場合はformDataに追加する
} else {
new_image_files.forEach(function(file){
formData.append("new_images[images][]", file)
});
}
$.ajax({
url: '/items/' + gon.item.id,
type: "PATCH",
data: formData,
contentType: false,
processData: false,
})
});
});
#ポイント
####1.DBからひっぱってきた画像をバイナリーデータにしてjsにわたす
プレビュー表示するお仕事は全てjsに任せたいです。したがって、登録済画像をDBからひっぱってきてjsに渡します。Gemのgonを使って以下のように変数定義すると、jsないでダイレクトに使えるようになります。
gon.item_images = @item.item_images
ところがですねぇ、このデータでjsにプレビュー表示させたらちゃんと表示してくれないんですよね。
調べてみると、どうやらバイナリーデータにしてやる必要がありそうだということがわかりました。そこで、以下の処理をしてやります。
require 'base64'
gon.item_images_binary_datas = []
@item.item_images.each do |image|
binary_data = File.read(image.image_url.file.file)
gon.item_images_binary_datas << Base64.strict_encode64(binary_data)
end
で、受け取ったjs側で最後の仕上げです。
gon.item_images.forEach(function(image, index){
var img = $(`<div class= "add_img"><div class="img_area"><img class="image"></div></div>`);
// カスタムデータ属性を付与
img.data("image", index)
var btn_wrapper = $('<div class="btn_wrapper"><a class="btn_edit">編集</a><a class="btn_delete">削除</a></div>');
// 画像に編集・削除ボタンをつける
img.append(btn_wrapper);
binary_data = gon.item_images_binary_datas[index]
// 表示するビューにバイナリーデータを付与
img.find("img").attr({
src: "data:image/jpeg;base64," + binary_data
});
// 登録済画像のビューをimagesに格納
images.push(img)
registered_images_ids.push(image.id)
})
以下部分のコードでsrcを定義してやることで、jsで画像を表示させることができます。
// 表示するビューにバイナリーデータを付与
img.find("img").attr({
src: "data:image/jpeg;base64," + binary_data
});
####2.ビュー表示用画像の配列、登録済画像のidの配列、DB保存用画像の配列(新規登録画像)の3つの配列を用意する
これがビュー用。登録済画像も新規登録画像も全部入ってる。HTMLそのものを格納。
var images = [];
これには登録済画像のidが入ってる。なぜidなのはかは後述。
var registered_images_ids =[]
これには新規登録画像のファイルデータを格納。
var new_image_files = [];
#####① なぜ登録済画像と新規登録画像を分けたのか
商品出品のときと同じように処理したかったので、初めは一緒に配列に格納していました。しかし、実際入れてみると形式が違ったんですね。登録済み画像はidをもっていて、新規登録済み画像は当然idなんてもってない。自分の力量ではそれをそろえることは難しそうだったので、やむなく分けました。
#####② 登録済画像はなぜidを格納していくのか
登録済み画像って、①現状維持 ②減る(消す) のどっちかなんですよね。ってことは、減った時にはDBから該当画像をdestroyしたい。この処理を実現するためにidを格納してます。
jsの最後の方に以下のような記述があります。ここで、登録済み画像においてまだ残っている画像のidをコントローラに送ってやるわけです。
// 登録済画像が残っていない場合は便宜的に0を入れる
if (registered_images_ids.length == 0) {
formData.append("registered_images_ids[ids][]", 0)
// 登録済画像で、まだ残っている画像があればidをformDataに追加していく
} else {
registered_images_ids.forEach(function(registered_image){
formData.append("registered_images_ids[ids][]", registered_image)
});
}
んで、コントローラで以下のような処理をします。すでに登録されている画像のidと、ajaxで送られてきたまだ残っている画像のidを見比べて、どの画像が消されたのか判断します。消されたと分かった画像をdestroyしてやるって感じですね。以下のコードには他の処理も混ざってるのでちょっとわかりづらいですが…
# 登録済画像のidの配列を生成
ids = @item.item_images.map{|image| image.id }
# 登録済画像のうち、編集後もまだ残っている画像のidの配列を生成(文字列から数値に変換)
exist_ids = registered_image_params[:ids].map(&:to_i)
# 登録済画像が残っていない場合(配列に0が格納されている)、配列を空にする
exist_ids.clear if exist_ids[0] == 0
if (exist_ids.length != 0 || new_image_params[:images][0] != " ") && @item.update(item_params)
# 登録済画像のうち削除ボタンをおした画像を削除
unless ids.length == exist_ids.length
# 削除する画像のidの配列を生成
delete_ids = ids - exist_ids
delete_ids.each do |id|
@item.item_images.find(id).destroy
end
end
# 新規登録画像があればcreate
unless new_image_params[:images][0] == " "
new_image_params[:images].each do |image|
@item.item_images.create(image_url: image, item_id: @item.id)
end
end
flash[:notice] = '編集が完了しました'
redirect_to item_path(@item), data: {turbolinks: false}
else
flash[:alert] = '未入力項目があります'
redirect_back(fallback_location: root_path)
end
private
def item_params
params.require(:item).permit(:name, :text, :category_id, :size_id, :brand_id, :condition, :delivery_fee_payer, :delivery_type, :delibery_from_area, :delivery_days, :price)
end
def registered_image_params
params.require(:registered_images_ids).permit({ids: []})
end
def new_image_params
params.require(:new_images).permit({images: []})
end
#おわりに
今回の記事では、商品編集の際のポイントだけをおさえて書きました。商品出品のときと重なるポイントもあるので、こちらの記事も併せて見てみるとより構造が理解されやすくなるかと思います。
#参考記事
https://qiita.com/shinnosuke960801/items/66f2a511803d7dac53a3
https://qiita.com/yamayu_504/items/bdde3eeb9ae06a3876bc