Courses

Practical Livewire 3: Order Management System Step-by-Step

Products Create: Select2 and CKEditor

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.

finished form

Before starting, let's add Select2 to our app.

resources/views/layouts/app.blade.php:

// ...
<!-- Scripts -->
<link href="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/dist/js/select2.min.js"></script>
@livewireScripts
<script src="https://unpkg.com/@nextapps-be/[email protected]/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:

create/edit basic form

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.

product form filled fields

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.

broken select2

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;
}

working select2

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.

finished form

Previous: Products Export to XLS / CSV / PDF
avatar

Image is missing after following phrase:

After visiting create or edit product page, you should see similar view to below

avatar

Thanks for reporting! Fixed now.

avatar
You can use Markdown
avatar

In Http\Livewire\ProductForm.php protected function initListsForFields(): void { $this->listsForFields['countries'] = Country::pluck('name', 'id')->toArray();

    // THIS IS EXPLAINED BUT IT FAILS IN MY CASE:
			// $this->listsForFields['categories'] =** Category::active()**->pluck('name', 'id')->toArray();
    
			// CHANGED FOR the following and it now works
			$this->listsForFields['categories'] = **Category::where('is_active', true)**->pluck('name', 'id')->toArray();
} 
avatar

Sorry, as Emil pointed in another comment, I forgot to add scopeActive() to the tutorial, will fix it now. But you've done the same thing, just without the scope, all good.

avatar
You can use Markdown
avatar

You are using the scope Active() on Model Category, but the scope is not made anywhere in the tutorial. Inside App/Models/Category add

public function scopeActive($query)
{
    $query->where('is_active', 1);
}
👍 1
avatar

Thanks for flagging, Albert in another comment also had this issue. Will add the info about the scope now.

avatar

@Povilas, in the code where validation is added, the eloquent is without the scope again. Guess that's why it didnt fail when testing the repo. Love this tutorial, was working on an order-system with services, so this was spot on, right when I needed it!

avatar

Great to hear that it helped you!

avatar
You can use Markdown
avatar

i get NULL when I place : dd($this->initListsForFields()); into the mount

shouldn't i have a full list of categories and countries at this point? The reason I am trying to see what is happening right there (and) the main issue I am having is that I get this error:

trim(): Argument #1 ($string) must be of type string, array given its an error on the select2/blade.php file, line 6 - its the attributes

avatar

i decided to back up the code i wrote along with this, and then test. i am getting the same exact error from the code in the repository, so i took a screenshot for clarity. Then realized cant attach the screenshot, so I will copy and paste... I don't understand the cause of the error, there is an array here, but it must be an array, because it was specified to be from the livewire component, right?

TypeError PHP 8.1.7 9.51.0 trim(): Argument #1 ($string) must be of type string, array given

the only thing i see that raises an eyebrow is this:

SELECT name, id FROM categories WHERE is_active = ?

avatar

Hard to answer without debugging. Could you put your current code on GitHub, and invite me (username PovilasKorop) and I will try to find time to debug it for you next week.

avatar

The code that fails - it is an exact duplicate of what is in the repository. i downloaded the respository Wednesday evening, and replaced my code with that downloaded version last evening when I had tried for several hours to solve and could not. I am running on a linux server, so all my commands are to run build and not dev. i mean literal file replacement, not copy and paste. so there could be no differences, no errors on my part.

avatar

Not sure I understand the part of "replaced my code with that downloaded version", are you sure that nothing could be broken there?

So, anyway, how can I reproduce it to debug? You're saying I should download the repository and there will be an error already?

Otherwise, I can't really debug the situation or guess what you did wrong. Or maybe indeed the bug is somewhere in the repository, please help me to identify it.

avatar

Thank you for responding! I was in deadlines all day yesterday because of internet outages here I couldn't get any work done and now I am behind schedule for work. as soon as possible I will respond to this with a better explanation. thank you again!

avatar

I finally was able to get internet back up. and I pulled the entire repository and replaced your code with mine - some things must have been different. ** I am happy to report that the issue was not in your code. ** I will go line by line through the code you have vs the code I wrote along with the training - until I spot the issue. Thank you for responding and trying to help. I am very excited to discover where I made the error, because I know it was definitely in my code!

avatar

not sure exactly why this error is gone, all the code matches perfectly to what I had created on my own. two things happened today, I did an npm install, and then a ran npm run build. this was not the first time, I did that many times when it was failing last week, - but i beleve that the npm install command I ran today must have fixed it.

avatar
You can use Markdown
avatar

Create product page display this error see the link Error. Help please

avatar

Was unable to reproduce this error. Could you push your code to GitHub, invite me (username PovilasKorop) and then I may try to debug next week.

avatar

Thanks. I will again try to debug and in case of any failure, I will do what you recommended.

avatar

I have already invited you. db:seed (all users use same password which is password). Under Product page, click Create Product to find the error I have been facing (trim(): Argument #1 ($string) must be of type string, array given).

avatar

Thanks, I will try to debug with the team in upcoming days.

avatar

Adding @props(['options']) to components blade seems to fix this problem, at least for me. Please try.

avatar

Thanks, @Nerijus, that helped me out. I think that the error occours for us who are using PHP 8.

avatar
You can use Markdown
avatar

Great tutorial, loving it. One thing I noted was the first letter lowercase of the component model from this artisan command:

php artisan make:component select2

In my IDE setup (PHPStorm's terminal on Mac Os Ventura) it is case sensitive, so I had to manually change the first letter to uppercase:

"Select2.php" inside of app->View-->Components->Select2.php

not sure of relevancy, but I do note that it is uppercase in the final source code repository.

avatar
You can use Markdown
avatar
Ikaro Campos Laborda

In my case (using livewire 3) I had to use an array to store the values on the bindings and only when saving, create a new model instance with fill(), but the select2 component is not working.

avatar
You can use Markdown
avatar

I like to have a flash message when a product is created or updated. Part solution: Install jantinnerezo/livewire-alert and then you can redirect like this:

 $this->flash('success', 'Good job', [], '/products');

This works when you create a new product, but not when you update one. Why is that?

avatar

Part solution: Install jantinnerezo/livewire-alert and then you can redirect like this:

 $this->flash('success', 'Good job', [], '/products');

This works when you create a new product, but not when you update one.

avatar

Read the docs https://livewire.laravel.com/docs/redirecting#flash-messages Also, instead of @if (session('error')) you should be able to use @session('error')

avatar
You can use Markdown
avatar

The final instruction on the save() method in ProductForm is missing the sync and redirect which is in the repo version


$this->product->categories()->sync($this->categories);

$this->redirect(route('products.index'));

avatar
You can use Markdown
avatar
You can use Markdown