In this last lesson for the products, we will create a Livewire component to create and edit products. In this form, we will use Select2 for dropdowns and CKEditor for textarea.
Before starting, let's add Select2 to our app.
resources/views/layouts/app.blade.php:
// ... <!-- Scripts --> <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" /> @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles // ... <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script> @livewireScripts <script src="https://unpkg.com/@nextapps-be/livewire-sortablejs@0.2.0/dist/livewire-sortable.js"></script> <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script> document.addEventListener('livewire:init', () => { Livewire.on('swal:confirm', (event) => { swal.fire({ title: event[0].title, text: event[0].text, icon: event[0].type, showCancelButton: true, confirmButtonColor: 'rgb(239 68 6)', confirmButtonText: 'Yes, delete it!' }) .then((willDelete) => { if (willDelete.isConfirmed) { Livewire.dispatch(event[0].method, { id: event[0].id }); } }); }) }); </script> @stack('js') </body></html>
It's important to add jQuery before Livewire, that's why we add it to the main layout file. And CKEditor will be added in Livewire Component, that's why we added the @stack('js')
blade directive here.
Now, the component, and we will register it to the routes.
php artisan make:livewire ProductForm
routes/web.php:
// ...Route::middleware('auth')->group(function () { Route::get('categories', CategoriesList::class)->name('categories.index'); Route::get('products', ProductsList::class)->name('products.index'); Route::get('products/create', ProductForm::class)->name('products.create'); Route::get('products/{product}', ProductForm::class)->name('products.edit'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');});// ...
Next, we need to link the Create
and Edit
buttons to their pages.
resources/views/livewire/products-list.blade.php:
<a href="{{ route('products.create') }}" class="inline-flex items-center px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase bg-gray-800 rounded-md border border-transparent hover:bg-gray-700"> Create Product</a> // ... <a href="{{ route('products.edit', $product) }}" class="inline-flex items-center px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase bg-gray-800 rounded-md border border-transparent hover:bg-gray-700"> Edit</a>
Now, the basic form layout, with CKEditor added. We will add Select2 later because we will need to reuse it, we will make it into Blade Component.
resources/views/livewire/product-form.blade.php:
<div> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> Create/Edit Product </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="p-6 bg-white border-b border-gray-200"> <form wire:submit="save"> @csrf <div> <x-input-label for="name" :value="__('Name')" /> <x-text-input wire:model="name" id="name" class="block mt-1 w-full" type="text" /> <x-input-error :messages="$errors->get('name')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="description" :value="__('Description')" /> <div wire:ignore> <textarea wire:model="description" data-description="@this" id="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"></textarea> </div> <x-input-error :messages="$errors->get('description')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="price" :value="__('Price')" /> <x-text-input wire:model="price" type="number" min="0" step="0.01" class="block mt-1 w-full" id="price" /> <x-input-error :messages="$errors->get('price')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label class="mb-1" for="categories" :value="__('Categories')" /> List of categories <x-input-error :messages="$errors->get('categories')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label class="mb-1" for="country" :value="__('Country')" /> List of countries <x-input-error :messages="$errors->get('country_id')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button type="submit"> Save </x-primary-button> </div> </form> </div> </div> </div> </div></div>@push('js') <script src="https://cdn.ckeditor.com/ckeditor5/31.1.0/classic/ckeditor.js"></script> <script> document.addEventListener('livewire:init', () => { ClassicEditor .create(document.querySelector('#description')) .then(editor => { editor.model.document.on('change:data', () => { @this.set('description', editor.getData()); }) Livewire.on('reinit', () => { editor.setData('', '') }) }) .catch(error => { console.error(error); }); }) </script>@endpush
After visiting create or edit product page, you should see a similar view to below:
As you might saw, we bind every input to its own public property. Also, we will need a few more properties:
$editing
for knowing if the form is created or edited.$categories
for binding selected categories in the form.$listsForFields
will have an array of needed values to pass into Select2, in our case countries and categories list.Now let's add those properties and initialize the countries and categories list.
Before we do that, let's add an Eloquent scope to the Model.
app/Models/Category.php:
public function scopeActive($query){ $query->where('is_active', 1);}
Next, the form.
app/Livewire/ProductForm.php:
use App\Models\Country;use App\Models\Product;use App\Models\Category;use Illuminate\Contracts\View\View; class ProductForm extends Component{ public ?Product $product = null; public string $name = ''; public string $description = ''; public ?float $price; public ?int $country_id; public bool $editing = false; public array $categories = []; public array $listsForFields = []; public function mount(Product $product): void { if (! is_null($this->product)) { $this->product = $product; } $this->initListsForFields(); } public function render(): View { return view('livewire.product-form'); } protected function initListsForFields(): void { $this->listsForFields['countries'] = Country::pluck('name', 'id')->toArray(); $this->listsForFields['categories'] = Category::active()->pluck('name', 'id')->toArray(); } }
In the mount()
method if Product
exists, then we will set $editing
to true, products price convert from cents to the readable amount, and set $categories
to an array of IDs from product categories.
app/Livewire/ProductForm.php:
class ProductForm extends Component{ public Product $product; public bool $editing = false; public array $categories = []; public array $listsForFields = []; public function mount(Product $product): void { $this->initListsForFields(); if (! is_null($this->product)) { $this->product = $product; $this->editing = true; $this->name = $this->product->name; $this->description = $this->product->description; $this->price = number_format($this->product->price / 100, 2); $this->country_id = $this->product->country_id; $this->categories = $this->product->categories()->pluck('id')->toArray(); } } // ...}
We need to add validation rules.
app/Livewire/ProductForm.php:
class ProductForm extends Component{ // ... protected function rules(): array { return [ 'name' => ['required', 'string'], 'description' => ['required'], 'country_id' => ['required', 'integer', 'exists:countries,id'], 'price' => ['required'], 'categories' => ['required', 'array'] ]; } protected function initListsForFields(): void { $this->listsForFields['countries'] = Country::pluck('name', 'id')->toArray(); $this->listsForFields['categories'] = Category::where('is_active', true)->pluck('name', 'id')->toArray(); }}
And, we can change the heading to show when we edit and when creating.
resources/views/livewire/product-form.blade.php:
<x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> Create/Edit Product {{ $editing ? 'Edit ' . $product->name : 'Create Product' }} </h2></x-slot>
Now, if you would visit the edit page, you should all fields filled.
Next, let's add Select2 fields. First, we need to create the component.
php artisan make:component select2
app/View/Components/Select2.php:
use Illuminate\View\Component;use Illuminate\Contracts\View\View; class Select2 extends Component{ public function __construct(public mixed $options, public mixed $selectedOptions) {} public function render(): View { return view('components.select2'); }}
resources/views/components/select2.blade.php:
<div> <div wire:ignore class="w-full"> <select class="select2" data-placeholder="{{ __('Select your option') }}" {{ $attributes }}> @if(!isset($attributes['multiple'])) <option></option> @endif @foreach($options as $key => $value) <option value="{{ $key }}" @selected(in_array($key, \Illuminate\Support\Arr::wrap($selectedOptions)))>{{ $value }}</option> @endforeach </select> </div></div> @push('js') <script> document.addEventListener('livewire:init', () => { let el = $('#{{ $attributes['id'] }}') function initSelect() { el.select2({ placeholder: '{{ __('Select your option') }}', allowClear: !el.attr('required') }) } initSelect() Livewire.hook('message.processed', (message, component) => { initSelect() }); el.on('change', function (e) { let data = $(this).select2("val") if (data === "") { data = null } @this.set('{{ $attributes['wire:model'] }}', data) }); }); </script>@endpush
Now, we can call this component in the form.
resources/views/livewire/product-form.blade.php:
<div class="mt-4"> <x-input-label class="mb-1" for="categories" :value="__('Categories')" /> List of categories <x-select2 class="mt-1" id="categories" name="categories" :options="$this->listsForFields['categories']" wire:model="categories" :selectedOptions="$categories" multiple /> <x-input-error :messages="$errors->get('categories')" class="mt-2" /></div> <div class="mt-4"> <x-input-label class="mb-1" for="country" :value="__('Country')" /> List of countries <x-select2 class="mt-1" id="country" name="country" :options="$this->listsForFields['countries']" wire:model="country_id" :selectedOptions="$country_id" /> <x-input-error :messages="$errors->get('country_id')" class="mt-2" /></div>
But, at the moment it doesn't look like a select field.
To fix this problem, we just need to add some CSS. Create a new file select2.css
in resources\css
directory, and import it in app.css
.
resources/css/select2.css:
.select2 { @apply w-full border-0 placeholder-gray-700 text-gray-700 bg-white rounded text-sm shadow focus:outline-none focus:ring !important;} .select2-dropdown { @apply absolute block w-auto box-border bg-white shadow-lg border-gray-300 z-50 float-left;} .select2-container--default .select2-selection--single { @apply border-0 h-11 flex items-center text-sm} .select2-container--default .select2-selection--multiple { @apply border-0 text-sm} .select2-container--default.select2-container--focus .select2-selection--single,.select2-container--default.select2-container--focus .select2-selection--multiple { @apply border-0 outline-none ring ring-blue-600} .select2-container--default .select2-selection--single .select2-selection__arrow { top: 9px;} .select2-container .select2-selection--single .select2-selection__rendered { @apply py-3 text-black} .select2-container .select2-selection--multiple .select2-selection__rendered { @apply text-black} .select2-container--default .select2-selection--single .select2-selection__rendered { line-height: inherit;} .select2-selection__choice { @apply text-xs font-semibold inline-block py-1 px-2 rounded text-white bg-black border-0 !important;} .select2-selection__choice span { @apply text-white bg-black !important;} .select2-search__field:focus { outline: none;} .select2-container--default .select2-selection--single .select2-selection__clear { @apply text-red-500 ml-1 mr-0 !important} .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { @apply text-indigo-600 mr-2 p-0 !important}
resources/css/app.css:
@import 'select2.css'; @import 'tailwindcss/base';@import 'tailwindcss/components';@import 'tailwindcss/utilities'; .toggle-checkbox:checked { @apply: right-0 border-green-400; right: 0; border-color: #68D391;} .toggle-checkbox:checked + .toggle-label { @apply: bg-green-400; background-color: #68D391;}
Much better, right? All that's left is to save the Product. For that, we defined the save()
method in the form.
app/Livewire/ProductForm.php:
use Livewire\Redirector;use Illuminate\Http\RedirectResponse; class ProductForm extends Component{ // ... public function save(): void { $this->validate(); if (is_null($this->product)) { $this->product = Product::create( array_merge( $this->only('name', 'description', 'country_id'), ['price' => $this->price * 100] ) ); } else { $this->product->update( array_merge( $this->only('name', 'description', 'country_id'), ['price' => $this->price * 100] )); } $this->product->categories()->sync($this->categories); } // ...}
Saving is nothing special, we just validate, set the price to cents and then just save to the DB. Then it syncs selected categories to the product and redirects to the products list page.