E-commerce với Laravel 9 – Quản lý danh mục sản phẩm

Trong bài này chúng ta sẽ tiến hành xây dựng các Livewire component để thực hiện các thao tác liệt kê, thêm mới, chỉnh sửa và xóa bỏ danh mục sản phẩm.

E-commerce với Laravel là loạt bài ghi lại toàn bộ quá trình xây dựng hệ thống thương mại điện tử được thực hiện bởi Transmoni team nhắm hướng dẫn mọi người làm quen với Laravel, Livewire, Alpine.js và Tailwind CSS. Hiện dự án đang được cập nhật liên tục, toàn bộ mã nguồn của dự án sẽ được công khai miễn phí theo hình thức mã nguồn mở trên trang Github của Transmoni sau khi hoàn tất loạt bài hướng dẫn này.

Trong bài trước chúng ta đã hoàn tất việc tạo dựng cơ sở dữ liệu cho tính năng danh mục sản phẩm cũng như tạo các bản ghi mẫu bằng seeder. Ở bài này chúng ta sẽ tiến hành xây dựng tính năng quản lý cho phần Admin của dự án bao gồm thêm mới, chính sửa và xóa bỏ các danh mục đã tạo với Livewire component.

Trước hết chúng ta tiến hành tạo một full-page livewire component để đổ dữ liệu của các bản ghi có sẵn, hay còn gọi là datatable bằng lệnh livewire:make:

php artisan livewire:make Admin\\CategoryList

lưu ý rằng chúng ta sử dụng tiền tố Admin phân cách bởi hai dấu \\ có nghĩa là tạo component này vào thư mục con có tên là Admin trong app/Livewire/ để tách biệt với phần Guest sau này. Ngay lập tức chúng ta sẽ có 2 file mới được khởi tạo là app/Http/Livewire/Admin/CategoryList.php và ở phần view là resources/views/livewire/admin/category-list.blade.php.

Vì là một full-page component nên chúng ta cần khai báo một route riêng cho nó tại routes/web.php trong nhóm admin như sau:

use App\Http\Livewire\Admin\CategoryList;

Route::group(['prefix' => 'admin', 'as' => 'admin.', 'middleware' => ['auth', 'isAdmin']], function () {
    Route::get('/categories', CategoryList::class)->name('categories.index');
});

tiếp theo tại component app/Http/Livewire/Admin/CategoryList.php chúng ta tiến hành nhúng danh mục sản phẩm được lấy từ cơ sở dữ liệu sang phần view như sau:

<?php

namespace App\Http\Livewire\Admin;

use App\Models\Category;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Component;

class CategoryList extends Component
{
    public function render()
    {
        return view('livewire.admin.category-list', [
            'categories' => Category::with('parent')->latest()->get(),
        ])->layout('layouts.admin');
    }
}

Và ở phần view của component này chúng ta thực hiện như sau:

<div>
    <div class="md:flex md:items-center md:justify-between">
        <div class="flex-1 min-w-0">
            <h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">Category</h2>
        </div>
        <div class="mt-4 flex md:mt-0 md:ml-4">
            <x-primary-button type="button" wire:click="$emitTo('admin.category-form', 'create')">
                Create
            </x-primary-button>
        </div>
    </div>

    <div class="mt-6 flex flex-col">
        <div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
            <div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
                <div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
                    <table class="min-w-full divide-y divide-gray-200">
                        <thead class="bg-gray-50">
                        <tr>
                            <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
                            <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Parent</th>
                        </tr>
                        </thead>
                        <tbody>
                        @foreach($categories as $category)
                            <tr class="odd:bg-white even:bg-gray-100">
                                <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    {{ $category->name }}
                                </td>
                                <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                                    {{ $category->parent->name ?? '---' }}
                                </td>
                            </tr>
                        @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
</div>

Tiến hành truy cập admin/categories chúng ta sẽ có kết quả như hình sau:

Danh sách các danh mục sản phẩm

Tạo Livewire component để Thêm mới hoặc Chỉnh sửa danh mục

Việc tiếp theo cần làm là thực hiện xử lý các tác vụ thêm mới và chỉnh sửa bản ghi cho danh mục sản phẩm. Với cách làm truyền thống của Laravel thì chúng ta cần tạo 4 routes mới là create, store, edit, và update cộng với 2 views dành cho create và edit. Tuy nhiên với Livewire thì không cần nhiều như vậy, thay vào đó chúng ta sẽ tiến hành tạo 1 component duy nhất và không cần khai báo thêm route để thực hiện các tác vụ này.

Tiến hành khởi tạo Livewire component, ở đây tôi đặt tên là CategoryForm

php artisan livewire:make Admin\\CategoryForm

như thường lệ ta sẽ có 2 file mới là app/Http/Livewire/Admin/CategoryForm.phpresources/views/admin/category-form.blade.php. Với component này chúng ta không dùng full-page mà thay vào đó sử dụng component thông thường và sẽ hiển thị dưới dạng modal.

Tiến hành nhúng CategoryForm component vào resources/views/livewire/admin/category-list.blade.php như sau:

<div>
    ...
    <livewire:admin.category-form />
    ...
</div>

Tại resources/views/livewire/admin/category-form.blade.php chúng ta tiến hành tạo một modal

<div>
    <form wire:submit.prevent="save">
        <x-dialog-modal wire:model="isShown">
            <x-slot name="title">

            </x-slot>

            <x-slot name="content">

            </x-slot>

            <x-slot name="footer">

            </x-slot>
        </x-dialog-modal>
    </form>
</div>

Như bạn có thể thấy modal của chúng ta nằm trong một form và khi submit sẽ gọi đến phương thức save trong component. Và để modal này ẩn hay hiện chúng ta sử dụng một boolean public property tên là isShown với giá trị mặc định là false nghĩa là ẩn.

<?php

namespace App\Http\Livewire\Admin;

use App\Models\Category;
use Livewire\Component;

class CategoryForm extends Component
{
    public bool $isShown = false;

    public function render()
    {
        return view('livewire.admin.category-form');
    }
}

Thao tác thêm mới

tạo danh mục sản phẩm mới

Để thực hiện thao tác thêm mới với CategoryForm component chúng ta tiến hành gửi sự kiện từ CategoryList component bằng cách tạo một button như sau tại resources/views/admin/category-list.blade.php:

<div>
    <div class="mt-4 flex md:mt-0 md:ml-4">
        <x-primary-button type="button" wire:click="$emitTo('admin.category-form', 'create')">
            Create
        </x-primary-button>
    </div>
</div>

Lúc này tại CategoryForm để bắt sự kiện create được gửi từ CategoryList ta tiến hành khai báo $listeners như sau:

class CategoryForm extends Component
{
    public bool $isShown = false;
    public Category $category;

    protected $listeners = ['create'];

    public function create()
    {
        $this->isShown = true;
        $this->category = new Category();
    }

    public function render()
    {
        return view('livewire.admin.category-form');
    }
}

Lúc này, khi nhấn vào nút Create trên trang danh mục sản phẩm sẽ xuất hiện modal từ phần view của CategoryForm component, dĩ nhiên là chưa còn trống do đó cần tiến hành đổ nội dung cho nó như sau:

<div>
    <form wire:submit.prevent="save">
        <x-dialog-modal wire:model="isShown">
            <x-slot name="title">
                Category form
            </x-slot>
            <x-slot name="content">
                <div class="space-y-6 sm:space-y-5">
                    <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
                        <x-label for="name" value="Name" class="sm:mt-px sm:pt-2" />
                        <div class="mt-1 sm:mt-0 sm:col-span-2">
                            <x-input wire:model.defer="category.name" type="text" name="name" id="name" class="max-w-lg block w-full sm:max-w-xs sm:text-sm" />
                            <x-input-error for="category.name" class="mt-2" />
                        </div>
                    </div>

                    <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
                        <x-label for="slug" value="Slug" class="sm:mt-px sm:pt-2" />
                        <div class="mt-1 sm:mt-0 sm:col-span-2">
                            <x-input wire:model.defer="category.slug" type="text" name="slug" id="slug" class="max-w-lg block w-full sm:max-w-xs sm:text-sm" />
                            <x-input-error for="category.slug" class="mt-2" />
                        </div>
                    </div>

                    <div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
                        <x-label for="description" value="Description" class="sm:mt-px sm:pt-2" />
                        <div class="mt-1 sm:mt-0 sm:col-span-2">
                            <x-textarea wire:model.defer="category.description" name="description" id="description" class="max-w-lg block w-full sm:max-w-xs sm:text-sm" />
                            <x-input-error for="category.description" class="mt-2" />
                        </div>
                    </div>
                </div>
            </x-slot>
            <x-slot name="footer">
                <div class="flex items-center">
                    <x-action-message on="saved" class="mr-2" />
                    <x-button>
                        Save
                    </x-button>
                </div>
            </x-slot>
        </x-dialog-modal>
    </form>
</div>

Tiếp đến, với các input field trong form tiến hành khai báo quy tắc (rules) cho chúng tại app/Http/Liveiwre/CategoryForm.php:

class CategoryForm extends Component
{
    ...
    protected $rules = [
        'category.name' => 'required|string',
        'category.slug' => 'required|string',
        'category.description' => 'nullable|string',
    ];
    ...
}

Và cuối cùng là function save để lưu lại những gì chúng ta đã điền:

class CategoryForm extends Component
{
    public function save()
    {
        $this->validate();
        $this->category->save();
    }
}

Vậy là xong phần thêm mới cho danh mục sản phẩm, lúc này với các danh mục đã tạo chúng ta sẽ thêm tính năng chỉnh sửa để cập nhật thông tin mới cho từng bản ghi.

Thao tác chỉnh sửa

Chỉnh sửa danh mục sản phẩm

Tại bảng dữ liệu danh mục trong resources/views/livewire/admin/category-list.blade.php chúng ta thêm cột Actions và ứng với mỗi dòng sẽ có một button để gửi sử kiện Edit sang component CategoryForm. Cách làm như sau:

<table class="min-w-full divide-y divide-gray-200">
    <thead class="bg-gray-50">
        <tr>
            <th scope="col" class="relative px-6 py-3">
                ...
                <span class="sr-only">Edit</span>
            </th>
        </tr>
    </thead>
    <tbody>
        @foreach($categories as $category)
            <tr class="odd:bg-white even:bg-gray-100">
                ...
                <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                    <button wire:click="$emitTo('admin.category-form', 'edit', '{{ $category->slug }}')" type="button" class="text-gray-400 hover:text-indigo-500">
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                            <path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
                            <path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
                        </svg>
                    </button>
                </td>
            </tr>
        @endforeach
    </tbody>
</table>

Tại component CategoryForm chúng ta thêm edit vào danh sách các sự kiện cần bắt

class CategoryForm extends Component
{
    ...
    protected $listeners = ['create', 'edit'];
    ...
}

và tương ứng với sự kiện edit chúng ta tạo function để xử lý tác vụ chỉnh sửa

class CategoryForm extends Component
{
    ...
    public function edit(Category $category)
    {
        $this->category = $category;
        $this->isShown = true;
    }
    ...
}

Done! Như vậy là chúng ta đã có thể sử dụng 2 thao tác thêm mới và chỉnh sửa chỉ với một form duy nhất trong component CategoryForm. Tiếp đến chúng ta tiến hành xử lý tác vụ xóa bỏ đối với các bản ghi đã được thiết lập.

Thao tác xóa bỏ

Xóa bỏ danh mục sản phẩm

Với tác vụ này chúng ta sẽ tiến hành xử lý ngay trên component CategoryList chứ không sử dụng thêm component mới nữa nhé.

Tại view resources/views/livewire/admin/category-list.blade.php chúng ta thêm một nút nhấn để xóa bỏ bản ghi vào bảng dữ liệu kế bên nút nhấn chỉnh sửa như sau:

<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
    <button wire:click="confirmCategoryDeletionFor('{{ $category->slug }}')" type="button" class="text-gray-400 hover:text-red-500">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
            <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
        </svg>
    </button>
</td>

Trên thực tế nút nhấn xóa bỏ không thực sự xóa dữ liệu mà chỉ gọi ra một confirmation modal, để thực sự xóa đi bản ghi chúng ta đã chọn, tại confirmation modal này sẽ có nút nhấn xác nhận việc xóa bản ghi danh mục sản phẩm:

<x-confirmation-modal wire:model.defer="confirmingCategoryDeletion">
    <x-slot name="title">
        Please confirm your action!
    </x-slot>
    <x-slot name="content">
        <p class="text-sm text-gray-500">
            Are you sure you want to delete this category? This action cannot be undone!
        </p>
    </x-slot>
    <x-slot name="footer">
        <div>
            <x-secondary-button wire:click="$set('confirmingCategoryDeletion', false)">Cancel</x-secondary-button>
            <x-danger-button wire:click="delete">Delete</x-danger-button>
        </div>
    </x-slot>
</x-confirmation-modal>

Tiếp theo tại component app/Http/Livewire/Admin/CategoryList.php chúng ta tiến hành xây dựng như sau:

class CategoryList extends Component
{
    public Category $categoryBeingDeleted;
    public bool $confirmingCategoryDeletion = false;

    public function confirmCategoryDeletionFor(Category $category)
    {
        $this->confirmingCategoryDeletion = true;

        $this->categoryBeingDeleted = $category;
    }

    public function delete()
    {
        $this->categoryBeingDeleted->delete();

        $this->confirmingCategoryDeletion = false;
    }
}

Như vậy là xong, đến đây chúng ta đã hoàn tất việc tạo Livewire component để xử lý các tác vụ hiển thị danh sách danh mục sản phẩm, thêm danh mục mới, chỉnh sửa và xóa bỏ các danh mục đã tạo. Trong bài tiếp theo chúng ta sẽ tìm hiểu về cách tạo và quản lý Sản phẩm.

Hẹn gặp lại các bạn!