Laravel Ecommerce Tutorial: Part 6.4, Refactoring Products
Given Ncube
In the the last post we added the ability to edit products. This is part 6.4 of the on going series on building an ecommerce website in Laravel from start to deployment
As I was using the site I noticed a few things that need to be fixed
- If a user submit the create or edit products form and not fill the optional fields we get an internal server error saying that field cannot be null, let's call this "the nulls thing"
- On the store/update product request
sku
was validated as optional but it's required - The delete product button just deletes the product without confirmation
The nulls thing
Okay this nulls thing happens because laravel turns unfilled forms fields into nulls but in our migration we said those optional fields are not null
If we give a null the database will throw an integrity violation error.
To fix this, we filter the validated fields and remove the nulls then pass to Eloquent to do it's thing.
Let's start by editing the store
method of the Admin\ProductController
and add the filtering and your method should look this
/**
* Store a newly created resource in storage.
*
* @param StoreProductRequest $request
* @return RedirectResponse
* @throws Exception
*/
public function store(StoreProductRequest $request)
{
$product = Product::create(
$request
->safe()
->collect()
->filter(fn($value) => !is_null($value))
->except(['images'])
->all(),
);
collect($request->validated('images'))->each(function ($image) use (
$product,
) {
$product->attachMedia(new File(storage_path('app/' . $image)));
Storage::delete($image);
});
return to_route('admin.products.index')->with(
'success',
'Product was successfully created',
);
}
We get the safe validated fields by calling safe
then call collect
to get a collection instance then we filter to make sure that values that are null are removed then remove the images key and viola!
To make sure the code is foolproof we will write a test
Open the Tests\Feature\Http\Controllers\ProductControllerTest
and add the following test
use App\Models\Category;
use App\Models\Product;
public function test_can_create_product_when_optional_fields_are_not_filled()
{
$response = $this->actingAs($this->superAdminUser())->post(
route('admin.products.store'),
[
'name' => 'Test Product',
'description' => 'Test Description',
'price' => 100,
'category_id' => Category::factory()->create()->id,
'track_quantity' => 1,
'quantity' => 10,
'sell_out_of_stock' => 1,
'status' => 'active',
'sku' => Str::random(10),
'cost' => '',
'discounted_price' => '',
],
);
$response->assertStatus(302);
$response->assertRedirect(route('admin.products.index'));
$this->assertCount(1, Product::all());
$product = Product::first();
$this->assertEquals(0, $product->cost);
$this->assertEquals(0, $product->discounted_price);
$response = $this->actingAs($this->superAdminUser())->post(
route('admin.products.store'),
[
'name' => 'Test Product',
'description' => 'Test Description',
'price' => 100,
'category_id' => Category::factory()->create()->id,
'track_quantity' => 0,
'quantity' => null,
'sell_out_of_stock' => 0,
'cost' => 10,
'status' => 'active',
'sku' => Str::random(10),
],
);
$response->assertRedirect(route('admin.products.index'));
$this->assertCount(2, Product::all());
$product = Product::find(2);
$this->assertEquals(10, $product->cost);
$this->assertEquals(0, $product->discounted_price);
$this->assertEquals(0, $product->quantity);
}
If you don't have the superAdminUser()
method add it to your TestCase.php
file
use App\Models\User;
use Spatie\Permission\Models\Role;
/**
* Create a super admin user.
*
* @return User
*/
protected function superAdminUser(): User
{
$user = User::factory()->create();
$user->assignRole(Role::findOrCreate('super admin'));
return $user;
}
In the first request we mimic what the browser would send if the user didn't fill the optional fields and if all goes well we should be redirected to the index page and there should be 1 product in the database.
Same with the next request but this time fill one optional and makes sure that it's added the product and if all goes well your tests should pass
So this was happening to the update
method of the ProductController
as well so let's add the filtering to that too
/**
* Update the specified resource in storage.
*
* @param UpdateProductRequest $request
* @param Product $product
* @return RedirectResponse
* @throws Exception
*/
public function update(UpdateProductRequest $request, Product $product)
{
$product->update(
$request
->safe()
->collect()
->filter(fn($value) => !is_null($value))
->except(['images'])
->all(),
);
collect($request->validated('images'))->each(function ($image) use (
$product,
) {
$product->attachMedia(new File(storage_path('app/' . $image)));
Storage::delete($image);
});
return to_route('admin.products.index')->with(
'success',
'Product was successfully updated',
);
}
Then write your test to make sure that this is working exactly as it should
Make sku required
This is simple all we have to do is go the products form request and change sku key from nullable
or sometimes|nullable
to required
Your sku validation should look like this on both StoreProductRequest
and UpdateProductRequest
//StoreProductRequest
'sku' => 'required|string|unique:products,sku',
//UpdateProductReqeust
'sku' => 'required|string|unique:products,sku,' . $this->route('product')->id,
Confirm before deleting product
If a user accidentally clicks delete we want them to confirm if they actually want to delete. If a user clicks the delete button we should show a modal then make the delete
In the previous tutorials we implemented this feature, we created a stimulus controller, obliterate_controller
which has an event handler handle()
which fires Sweet Alert 2 asking the user to confirm and if they confirm it submits the delete form connected via stimulus target ,form
To register this controller find the div containing the delete and edit links and register the controller like this <div {{ stimulus_controller('obliterate') }}>
Find the delete link and replace it with a button and the delete form. The button has a stimulus action bound to the handle
method of our controller and the form is the form target of the stimulus controller
<button {{ stimulus_action('obliterate', 'handle') }}
class='btn btn-danger'>Delete</button>
<form {{ stimulus_target('obliterate', 'form') }}
method="POST"
action="{{ route('admin.products.destroy', $product) }}">
@csrf
@method('DELETE')
</form>
Your div containing the edit and delete links should look like this
<div {{ stimulus_controller('obliterate') }}>
<a href='{{ route('admin.products.edit', $product) }}'
class='btn btn-primary'>Edit</a>
<button {{ stimulus_action('obliterate', 'handle') }}
class='btn btn-danger'>Delete</button>
<form {{ stimulus_target('obliterate', 'form') }}
method="POST"
action="{{ route('admin.products.destroy', $product) }}">
@csrf
@method('DELETE')
</form>
</div>
Now if you click on delete in the products index page you should be prompted if you really want to delete and then only delete after confirmation
And that's it for this tutorial, in the next tutorial we add the ability to create product variations like color size etc. If you have any questions reach out to me on Twitter @ncubegiven_.
To make sure you don't miss the next post, subscribe to the newsletter below and get an email notification when it's published
And yeah always, Happy coding!