Aplikasi E-Commerce Laravel 6 #5: Mass Upload Products

Aplikasi E-Commerce Laravel 6 #5: Mass Upload Products

Pendahuluan

Selain fitur yang memungkinkan user untuk menambahkan data produk satu-persatu, maka alangkah lebih menariknya lagi jika kita menyediakan sebuah fitur yang memungkinkan user untuk menambahkan produk secara massal. Serial kali ini akan membahas bagaimana cara menambahkan data tersebut secara massal dengan menggunakan data yang telah disusun pada sebuah file yang berformat Excel. Tidak hanya itu saja, gambar masing-masing produk juga akan secara otomatis di-set sesuai url yang telah dimasukkan pada templating mass upload yang tersedia.

Adapun bagian yang belum dikerjakan pada artikel sebelumnya, yakni, proses edit data produk juga akan dibahas didalam artikel ini. Selain berinteraksi dengan fitur yang telah disediakan oleh Laravel, maka kita juga akan belajar bagaimana berinteraksi dengan eksternal package.

Baca Juga: Aplikasi E-Commerce Laravel 6 #5: Upload Products

External Package & Form Upload

Ketika menggunakan file dengan format tertentu, misalnya office, maka salah satu langkah yang baiknya ditempuh adalah dengan menggunakan external package sehingga kita tidak perlu lagi membuatnya dari awal hanya untuk persoalan yang sudah diselesaikan oleh orang lain.

Pada command line, install Laravel Excel dengan command

composer require maatwebsite/excel

Package ini telah menyediakan fungsi import maupun export file Excel, sehingga sebagai programmer kita hanya menggunakannya saja untuk menyelesaikan case yang sedang dihadapi. Langkah selanjutnya adalah dengan membuat form dimana user bisa meng-upload dokumen terkait yang berisi data produk. Buka file ProductController.php dan tambahkan method

public function massUploadForm()
{
    $category = Category::orderBy('name', 'DESC')->get();
    return view('products.bulk', compact('category'));
}

Kemudian buat file bulk.blade.php dan letakkan didalam folder resources/views/products, kemudian tambahkan code berikut

@extends('layouts.admin')

@section('title')
    <title>Mass Upload</title>
@endsection

@section('content')
<main class="main">
    <ol class="breadcrumb">
        <li class="breadcrumb-item">Home</li>
        <li class="breadcrumb-item active">Product</li>
    </ol>
    <div class="container-fluid">
        <div class="animated fadeIn">
          
          	<!-- ARAH ACTIONYA ADALAH KE ROUTING DENGAN NAME product.saveBulk -->
            <form action="{{ route('product.saveBulk') }}" method="post" enctype="multipart/form-data" >
                @csrf
                <div class="row">
                    <div class="col-md-6">
                        <div class="card">
                            <div class="card-body">
                                @if (session('success'))
                                    <div class="alert alert-success">{{ session('success') }}</div>
                                @endif

                                <!-- SETIAP USER HARUS MEMILIH KATEGORI PRODUK TERKAIT -->
                                <div class="form-group">
                                    <label for="category_id">Kategori</label>
                                    <select name="category_id" class="form-control">
                                        <option value="">Pilih</option>
                                        @foreach ($category as $row)
                                        <option value="{{ $row->id }}" {{ old('category_id') == $row->id ? 'selected':'' }}>{{ $row->name }}</option>
                                        @endforeach
                                    </select>
                                    <p class="text-danger">{{ $errors->first('category_id') }}</p>
                                </div>
                                <div class="form-group">
                                    <label for="file">File Excel</label>
                                    <input type="file" name="file" class="form-control" value="{{ old('file') }}" required>
                                    <p class="text-danger">{{ $errors->first('file') }}</p>
                                </div>
                                <div class="form-group">
                                    <button class="btn btn-primary btn-sm">Upload</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
</main>
@endsection

Selanjutnya adalah mendefinisikan routing-nya, buka file routes/web.php dan tambahkan route

Route::resource('product', 'ProductController')->except(['show']); //BAGIAN INI KITA TAMBAHKAN EXCETP KARENA METHOD SHOW TIDAK DIGUNAKAN
Route::get('/product/bulk', 'ProductController@massUploadForm')->name('product.bulk'); //TAMBAHKAN ROUTE INI

Bagian terakhir dari sub-bab ini adalah dengan menambahkan tombol dari halaman list produk yang berfungsi untuk mengarahkan kehalaman mass upload tersebut. Buka file /views/products/index.blade.php dan modifikasi bagian berikut

<!-- CODE SEBELUMNYA  -->

<div class="card-header">
    <h4 class="card-title">
        List Product
        <div class="float-right">
            <a href="{{ route('product.bulk') }}" class="btn btn-danger btn-sm">Mass Upload</a>
            <a href="{{ route('product.create') }}" class="btn btn-primary btn-sm">Tambah</a>
        </div>
    </h4>
</div>

<!-- CODE SETELAHNYA -->

Note: Cukup modifikasi bagian header dari card yakni dengan menambahkan tombol Mass Upload.

Import Data & Dispatching Job

Bagian ini akan dikelompokkan menjadi dua bagian, pertama adalah membuat jadwal untuk setiap request agar proses-nya tidak dibebankan kepada user sehingga proses time pada browser user menjadi cepat. Proses tersebut akan kita pindahkan ke-background dimana bebannya akan tanggung oleh server. Kedua adalah meng-extract data kemudian diolah dan diteruskan untuk disimpan ke-database.

Jika mengikuti alur diatas maka pertama kita definisikan routing-nya terlebih dahulu, buka file routes/web.php dan tambahkan code berikut

Route::post('/product/bulk', 'ProductController@massUpload')->name('product.saveBulk');

Kemudian buka file ProductController.php dan tambahkan method massUpload()

public function massUpload(Request $request)
{
  //VALIDASI DATA YANG DIKIRIM
    $this->validate($request, [
        'category_id' => 'required|exists:categories,id',
        'file' => 'required|mimes:xlsx' //PASTIKAN FORMAT FILE YANG DITERIMA ADALAH XLSX
    ]);

  	//JIKA FILE-NYA ADA
    if ($request->hasFile('file')) {
        $file = $request->file('file');
        $filename = time() . '-product.' . $file->getClientOriginalExtension();
        $file->storeAs('public/uploads', $filename); //MAKA SIMPAN FILE TERSEBUT DI STORAGE/APP/PUBLIC/UPLOADS

        //BUAT JADWAL UNTUK PROSES FILE TERSEBUT DENGAN MENGGUNAKAN JOB
        //ADAPUN PADA DISPATCH KITA MENGIRIMKAN DUA PARAMETER SEBAGAI INFORMASI
        //YAKNI KATEGORI ID DAN NAMA FILENYA YANG SUDAH DISIMPAN
        ProductJob::dispatch($request->category_id, $filename);
        return redirect()->back()->with(['success' => 'Upload Produk Dijadwalkan']);
    }
}

Jangan lupa pada file yang sama tambahkan use statement

use App\Jobs\ProductJob;

Class ProductJob belum ada, maka tugas selanjutnya adalah membuat file tersebut dengan command

php artisan make:job ProductJob

Kemudian buat file ProductJob.php yang berada didalam folder app/Jobs dan modifikasi menjadi

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Imports\ProductImport; //IMPORT CLASS PRODUCTIMPORT YANG AKAN MENG-HANDLE FILE EXCEL
use Illuminate\Support\Str;
use App\Product;
use File;

class ProductJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $category;
    protected $filename;

  	//KARENA DISPATCH MENGIRIMKAN 2 PARAMETER
  	//MAKA KITA TERIMA KEDUA DATA TERSEBUT
    public function __construct($category, $filename)
    {
        $this->category = $category;
        $this->filename = $filename;
    }

    /**
     * Execute the job.
     *
     * @return void
     */
    public function handle()
    {
        //KEMUDIAN KITA GUNAKAN PRODUCTIMPORT YANG MERUPAKAN CLASS YANG AKAN DIBUAT SELANJUTNYA
      	//IMPORT DATA EXCEL TADI YANG SUDAH DISIMPAN DI STORAGE, KEMUDIAN CONVERT MENJADI ARRAY
        $files = (new ProductImport)->toArray(storage_path('app/public/uploads/' . $this->filename));

      	//KEMUDIAN LOOPING DATANYA
        foreach ($files[0] as $row) {
            // if ($row[4] != '' && filter_var($row[4], FILTER_VALIDATE_URL)) {
          
          	//FORMATTING URLNYA UNTUK MENGAMBIL FILE-NAMENYA BESERTA EXTENSION
          	//JADI PASTIKAN PADA TEMPLATE MASS UPLOADNYA NANTI PADA BAGIAN URL
          	//HARUS DIAKHIRI DENGAN NAMA FILE YANG LENGKAP DENGAN EXTENSION
            $explodeURL = explode('/', $row[4]);
            $explodeExtension = explode('.', end($explodeURL));
            $filename = time() . Str::random(6) . '.' . end($explodeExtension);
          
          	//DOWNLOAD GAMBAR TERSEBUT DARI URL TERKAIT
            file_put_contents(storage_path('app/public/products') . '/' . $filename, file_get_contents($row[4]));

          	//KEMUDIAN SIMPAN DATANYA DI DATABASE
            Product::create([
                'name' => $row[0],
                'slug' => $row[0],
                'category_id' => $this->category,
                'description' => $row[1],
                'price' => $row[2],
                'weight' => $row[3],
                'image' => $filename,
                'status' => true
            ]);
            // }
        }
      	//JIKA PROSESNYA SUDAH SELESAI MAKA FILE YANG ADA DISTORAGE AKAN DIHAPUS
        File::delete(storage_path('app/public/uploads/' . $this->filename));
    }
}

Nah ada yang kurang nih, jadi class ProductImport sudah digunakan pada code diatas, tapi sebenarnya file tersebut belum ada. Maka kita harus membuatnya dengan command

php artisan make:import ProductImport

Kemudian buka file ProductImport.php yang berada didalam folder app/Imports dan modifikasi menjadi

<?php

namespace App\Imports;

use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithStartRow;
use Maatwebsite\Excel\Concerns\WithChunkReading;

class ProductImport implements WithStartRow, WithChunkReading
{
    /**
    * @param Collection $collection
    */
    use Importable;

    //JADI KITA BATAS DATA YANG AKAN DIGUNAKAN MULAI DARI BARIS KEDUA, KARENA BARIS PERTAMA DIGUNAKAN SEBAGAI HEADING AGAR MEMUDAHKAN ORANG YANG MENGISI DATA PADA FILE EXCEL
    public function startRow(): int
    {
        return 2;
    }

    //KEMUDIAN KITA GUNAKAN chunkSize UNTUK MENGONTROL PENGGUNAAN MEMORY DENGAN MEMBATASI LOAD DATA DALAM SEKALI PROSES
    public function chunkSize(): int
    {
        return 100;
    }
}

Langkah terakhir adalah membuat table yang akan menampung data request yang sedang dalam antrian. Pada command line jalankan command

php artisan queue:table
php artisan migrate

Jangan lupa pada bagian .env, ubah value QUEUE_CONNECTION menjadi database. Jika kamu menggunakan php artisan serve, pastikan restart terlebih dahulu sebelum melakukan testing. Adapun format file Excelnya bisa di-download di Gihutb.

Setelah testing-nya berhasil dan data antrian telah masuk ke-table jobs, maka eksekusi antrian tersebut dengan command

php artisan queue:work

Edit Data Product

Bagian yang tertinggal dari sebelumnya adalah fitur untuk mengedit data produk yang ada. Buka file ProductController.php dan tambahkan method edit()

public function edit($id)
{
    $product = Product::find($id); //AMBIL DATA PRODUK TERKAIT BERDASARKAN ID
    $category = Category::orderBy('name', 'DESC')->get(); //AMBIL SEMUA DATA KATEGORI
    return view('products.edit', compact('product', 'category')); //LOAD VIEW DAN PASSING DATANYA KE VIEW
}

Kemudian buat file edit.blade.php dan tempatkan kedalam folder resources/views/products. Tambahkan code berikut

@extends('layouts.admin')

@section('title')
    <title>Edit Produk</title>
@endsection

@section('content')
<main class="main">
    <ol class="breadcrumb">
        <li class="breadcrumb-item">Home</li>
        <li class="breadcrumb-item active">Product</li>
    </ol>
    <div class="container-fluid">
        <div class="animated fadeIn">
          
          	<!-- PASTIKAN MENGIRIMKAN ID PADA ROUTE YANG DIGUNAKAN -->
            <form action="{{ route('product.update', $product->id) }}" method="post" enctype="multipart/form-data" >
                @csrf
              	<!-- KARENA UPDATE MAKA KITA GUNAKAN DIRECTIVE DIBAWAH INI -->
                @method('PUT')

              	<!-- FORM INI SAMA DENGAN CREATE, YANG BERBEDA HANYA ADA TAMBAHKAN VALUE UNTUK MASING-MASING INPUTAN  -->
                <div class="row">
                    <div class="col-md-8">
                        <div class="card">
                            <div class="card-header">
                                <h4 class="card-title">Edit Produk</h4>
                            </div>
                            <div class="card-body">
                                <div class="form-group">
                                    <label for="name">Nama Produk</label>
                                    <input type="text" name="name" class="form-control" value="{{ $product->name }}" required>
                                    <p class="text-danger">{{ $errors->first('name') }}</p>
                                </div>
                                <div class="form-group">
                                    <label for="description">Deskripsi</label>
                                    <textarea name="description" id="description" class="form-control">{{ $product->description }}</textarea>
                                    <p class="text-danger">{{ $errors->first('description') }}</p>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="col-md-4">
                        <div class="card">
                            <div class="card-body">
                                <div class="form-group">
                                    <label for="status">Status</label>
                                    <select name="status" class="form-control" required>
                                        <option value="1" {{ $product->status == '1' ? 'selected':'' }}>Publish</option>
                                        <option value="0" {{ $product->status == '0' ? 'selected':'' }}>Draft</option>
                                    </select>
                                    <p class="text-danger">{{ $errors->first('status') }}</p>
                                </div>
                                <div class="form-group">
                                    <label for="category_id">Kategori</label>
                                    <select name="category_id" class="form-control">
                                        <option value="">Pilih</option>
                                        @foreach ($category as $row)
                                        <option value="{{ $row->id }}" {{ $product->category_id == $row->id ? 'selected':'' }}>{{ $row->name }}</option>
                                        @endforeach
                                    </select>
                                    <p class="text-danger">{{ $errors->first('category_id') }}</p>
                                </div>
                                <div class="form-group">
                                    <label for="price">Harga</label>
                                    <input type="number" name="price" class="form-control" value="{{ $product->price }}" required>
                                    <p class="text-danger">{{ $errors->first('price') }}</p>
                                </div>
                                <div class="form-group">
                                    <label for="weight">Berat</label>
                                    <input type="number" name="weight" class="form-control" value="{{ $product->weight }}" required>
                                    <p class="text-danger">{{ $errors->first('weight') }}</p>
                                </div>
                              
                              	<!-- GAMBAR TIDAK LAGI WAJIB, JIKA DIISI MAKA GAMBAR AKAN DIGANTI, JIKA DIBIARKAN KOSONG MAKA GAMBAR TIDAK AKAN DIUPDATE -->
                                <div class="form-group">
                                    <label for="image">Foto Produk</label>
                                    <br>
                                  	<!--  TAMPILKAN GAMBAR SAAT INI -->
                                    <img src="{{ asset('storage/products/' . $product->image) }}" width="100px" height="100px" alt="{{ $product->name }}">
                                    <hr>
                                    <input type="file" name="image" class="form-control">
                                    <p><strong>Biarkan kosong jika tidak ingin mengganti gambar</strong></p>
                                    <p class="text-danger">{{ $errors->first('image') }}</p>
                                </div>
                                <div class="form-group">
                                    <button class="btn btn-primary btn-sm">Update</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </form>
        </div>
    </div>
</main>
@endsection

@section('js')
    <script src="https://cdn.ckeditor.com/4.13.0/standard/ckeditor.js"></script>
    <script>
        CKEDITOR.replace('description');
    </script>
@endsection

Kembali ke file ProductController.php, tambahkan method update() untuk meng-handle proses pengubahan data produk.

public function update(Request $request, $id)
{
   //VALIDASI DATA YANG DIKIRIM
    $this->validate($request, [
        'name' => 'required|string|max:100',
        'description' => 'required',
        'category_id' => 'required|exists:categories,id',
        'price' => 'required|integer',
        'weight' => 'required|integer',
        'image' => 'nullable|image|mimes:png,jpeg,jpg' //IMAGE BISA NULLABLE
    ]);

    $product = Product::find($id); //AMBIL DATA PRODUK YANG AKAN DIEDIT BERDASARKAN ID
    $filename = $product->image; //SIMPAN SEMENTARA NAMA FILE IMAGE SAAT INI
  
    //JIKA ADA FILE GAMBAR YANG DIKIRIM
    if ($request->hasFile('image')) {
        $file = $request->file('image');
        $filename = time() . Str::slug($request->name) . '.' . $file->getClientOriginalExtension();
        //MAKA UPLOAD FILE TERSEBUT
        $file->storeAs('public/products', $filename);
      	//DAN HAPUS FILE GAMBAR YANG LAMA
        File::delete(storage_path('app/public/products/' . $product->image));
    }

  //KEMUDIAN UPDATE PRODUK TERSEBUT
    $product->update([
        'name' => $request->name,
        'description' => $request->description,
        'category_id' => $request->category_id,
        'price' => $request->price,
        'weight' => $request->weight,
        'image' => $filename
    ]);
    return redirect(route('product.index'))->with(['success' => 'Data Produk Diperbaharui']);
}

Proses update produk berakhir disini karena cara kerjanya serupa dengan add new data hanya saja ditambahkan logic persoalan gambar yang boleh kosong.

[Issue] Tombol Edit Produk

Jadi pada artikel sebelumnya terjadi kesalahan link pada tombol edit produk dihalaman list products. Buka file index.blade.php yang berada didalam folder resources/views/products dan cukup modifikasi tombol edit-nya saja menjadi

<a href="{{ route('product.edit', $row->id) }}" class="btn btn-warning btn-sm">Edit</a>

Baca Juga: Aplikasi E-Commerce Laravel 6 #3: Management Category

Kesimpulan

Artikel ini cukup singkat tapi kita telah belajar hal baru yakni bagaimana membuat jadwal permintaan yang akan dieksekusi kemudian. Tujuannya adalah membebaskan user dari 'pekerjaan' menunggu proses tersebut selesai yang dimana bisa saja terjadi kesalahan eksternal seperti koneksi internet yang mati. Maka proses tersebut yang membutuhkan load-time yang cukup dipindahkan kesisi server untuk diproses.

Adapun dokumentasi code dari artikel ini bisa dilihat di Github.

Category:
Share:

Comments