Skip to content

Commit cfd66b5

Browse files
committed
Implement image uploading on product
Show loader on product form submission
1 parent 18b185c commit cfd66b5

File tree

8 files changed

+90
-27
lines changed

8 files changed

+90
-27
lines changed

app/Http/Controllers/Api/ProductController.php

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use App\Http\Resources\ProductResource;
99
use App\Models\Product;
1010
use Illuminate\Http\Request;
11+
use Illuminate\Http\UploadedFile;
12+
use Illuminate\Support\Facades\Storage;
13+
use Illuminate\Support\Str;
1114

1215
class ProductController extends Controller
1316
{
@@ -39,7 +42,23 @@ public function index()
3942
*/
4043
public function store(ProductRequest $request)
4144
{
42-
return new ProductResource(Product::create($request->validated()));
45+
$data = $request->validated();
46+
$data['created_by'] = $request->user()->id;
47+
$data['updated_by'] = $request->user()->id;
48+
49+
/** @var \Illuminate\Http\UploadedFile $image */
50+
$image = $data['image'] ?? null;
51+
// Check if image was given and save on local file system
52+
if ($image) {
53+
$relativePath = $this->saveImage($image);
54+
$data['image'] = $relativePath;
55+
$data['image_mime'] = $image->getClientMimeType();
56+
$data['image_size'] = $image->getSize();
57+
}
58+
59+
$product = Product::create($data);
60+
61+
return new ProductResource($product);
4362
}
4463

4564
/**
@@ -79,4 +98,17 @@ public function destroy(Product $product)
7998

8099
return response()->noContent();
81100
}
101+
102+
private function saveImage(UploadedFile $image)
103+
{
104+
$path = 'images/' . Str::random();
105+
if (!Storage::exists($path)) {
106+
Storage::makeDirectory($path, 0755, true);
107+
}
108+
if (!Storage::putFileAS('public/'.$path, $image, $image->getClientOriginalName())) {
109+
throw new \Exception("Unable to save file \"{$image->getClientOriginalName()}\"");
110+
}
111+
112+
return $path . '/' . $image->getClientOriginalName();
113+
}
82114
}

app/Http/Resources/ProductListResource.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
namespace App\Http\Resources;
44

55
use Illuminate\Http\Resources\Json\JsonResource;
6-
use Illuminate\Http\Resources\Json\ResourceCollection;
6+
use Illuminate\Support\Facades\Storage;
7+
use Illuminate\Support\Facades\URL;
78

89
class ProductListResource extends JsonResource
910
{
@@ -18,7 +19,7 @@ public function toArray($request)
1819
return [
1920
'id' => $this->id,
2021
'title' => $this->title,
21-
'image' => $this->image,
22+
'image_url' => $this->image ? Url::to(Storage::url($this->image)) : null,
2223
'price' => $this->price,
2324
'updated_at' => (new \DateTime($this->updated_at))->format('Y-m-d H:i:s'),
2425
];

app/Http/Resources/ProductResource.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace App\Http\Resources;
44

55
use Illuminate\Http\Resources\Json\JsonResource;
6+
use Illuminate\Support\Facades\Storage;
7+
use Illuminate\Support\Facades\URL;
68

79
class ProductResource extends JsonResource
810
{
@@ -19,7 +21,7 @@ public function toArray($request)
1921
'title' => $this->title,
2022
'slug' => $this->slug,
2123
'description' => $this->description,
22-
'image' => $this->image,
24+
'image_url' => $this->image ? Url::to(Storage::url($this->image)) : null,
2325
'price' => $this->price,
2426
'created_at' => (new \DateTime($this->created_at))->format('Y-m-d H:i:s'),
2527
'updated_at' => (new \DateTime($this->updated_at))->format('Y-m-d H:i:s'),

app/Models/Product.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Product extends Model
1414
use HasSlug;
1515
use SoftDeletes;
1616

17-
protected $fillable = ['title', 'description', 'price'];
17+
protected $fillable = ['title', 'description', 'price', 'image', 'image_mime', 'image_size', 'created_by', 'updated_by'];
1818

1919
/**
2020
* Get the options for generating the slug.

backend/src/components/core/CustomInput.vue

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,32 @@
99
class="block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
1010
:placeholder="label"></textarea>
1111
</template>
12+
<template v-else-if="type === 'file'">
13+
<input :type="type"
14+
:name="name"
15+
:required="required"
16+
:value="props.modelValue"
17+
@input="emit('change', $event.target.files[0])"
18+
class="block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
19+
:placeholder="label"/>
20+
</template>
1221
<template v-else>
1322
<input :type="type"
1423
:name="name"
1524
:required="required"
1625
:value="props.modelValue"
1726
@input="emit('update:modelValue', $event.target.value)"
1827
class="block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
19-
:placeholder="label"/>
28+
:placeholder="label"
29+
step="0.01"/>
2030
</template>
2131
</div>
2232
</template>
2333

2434
<script setup>
2535
2636
const props = defineProps({
27-
modelValue: String,
37+
modelValue: [String, Number, File],
2838
label: String,
2939
type: {
3040
type: String,
@@ -34,7 +44,7 @@ const props = defineProps({
3444
required: Boolean
3545
})
3646
37-
const emit = defineEmits(['update:modelValue'])
47+
const emit = defineEmits(['update:modelValue', 'change'])
3848
3949
</script>
4050

backend/src/store/actions.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function logout({commit}) {
2626
})
2727
}
2828

29-
export function getProducts({commit, state}, {url = null, search, per_page, sort_field, sort_direction}) {
29+
export function getProducts({commit, state}, {url = null, search = '', per_page, sort_field, sort_direction} = {}) {
3030
commit('setProducts', [true])
3131
url = url || '/products'
3232
const params = {
@@ -47,5 +47,13 @@ export function getProducts({commit, state}, {url = null, search, per_page, sort
4747
}
4848

4949
export function createProduct({commit}, product) {
50+
if (product.image instanceof File) {
51+
const form = new FormData();
52+
form.append('title', product.title);
53+
form.append('image', product.image);
54+
form.append('description', product.description);
55+
form.append('price', product.price);
56+
product = form;
57+
}
5058
return axiosClient.post('/products', product)
5159
}

backend/src/views/AddNewProduct.vue

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
1717
<DialogPanel
1818
class="relative bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full">
19+
<Spinner v-if="loading" class="absolute left-0 top-0 bg-white right-0 bottom-0 flex items-center justify-center"/>
1920
<header class="py-3 px-4 flex justify-between items-center">
2021
<DialogTitle as="h3" class="text-lg leading-6 font-medium text-gray-900"> Create new Product
2122
</DialogTitle>
@@ -40,22 +41,22 @@
4041
</button>
4142
</header>
4243
<form @submit.prevent="onSubmit">
43-
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
44-
<CustomInput class="mb-2" v-model="product.title" label="Product Title"/>
45-
<CustomInput type="textarea" class="mb-2" v-model="product.description" label="Description"/>
46-
<CustomInput type="number" class="mb-2" v-model="product.price" label="Price"/>
47-
<CustomInput class="mb-2" v-model="product.title" label="Product Title"/>
48-
</div>
49-
<footer class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
50-
<button type="submit"
51-
class="py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ml-3">
52-
Submit
53-
</button>
54-
<button type="button"
55-
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
56-
@click="show = false" ref="cancelButtonRef">Cancel
57-
</button>
58-
</footer>
44+
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
45+
<CustomInput class="mb-2" v-model="product.title" label="Product Title"/>
46+
<CustomInput type="file" class="mb-2" label="Product Image" @change="file => product.image = file"/>
47+
<CustomInput type="textarea" class="mb-2" v-model="product.description" label="Description"/>
48+
<CustomInput type="number" class="mb-2" v-model="product.price" label="Price"/>
49+
</div>
50+
<footer class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
51+
<button type="submit"
52+
class="py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ml-3">
53+
Submit
54+
</button>
55+
<button type="button"
56+
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
57+
@click="show = false" ref="cancelButtonRef">Cancel
58+
</button>
59+
</footer>
5960
</form>
6061
</DialogPanel>
6162
</TransitionChild>
@@ -71,6 +72,7 @@ import {Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot} from
7172
import {ExclamationIcon} from '@heroicons/vue/outline'
7273
import CustomInput from "../components/core/CustomInput.vue";
7374
import store from "../store/index.js";
75+
import Spinner from "../components/core/Spinner.vue";
7476
7577
const product = ref({
7678
title: null,
@@ -79,6 +81,8 @@ const product = ref({
7981
price: null
8082
})
8183
84+
const loading = ref(false)
85+
8286
const props = defineProps({
8387
modelValue: Boolean,
8488
})
@@ -95,13 +99,19 @@ function closeModal() {
9599
}
96100
97101
function onSubmit() {
102+
loading.value = true
98103
store.dispatch('createProduct', product.value)
99104
.then(response => {
100-
debugger;
105+
loading.value = false;
101106
if (response.status === 201) {
102107
// TODO show notification
103108
store.dispatch('getProducts')
109+
closeModal()
104110
}
105111
})
112+
.catch(err => {
113+
loading.value = false;
114+
debugger;
115+
})
106116
}
107117
</script>

backend/src/views/Products.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
<tr v-for="product of products.data">
6363
<td class="border-b p-2 ">{{ product.id }}</td>
6464
<td class="border-b p-2 ">
65-
<img class="w-16 h-16 object-cover" :src="product.image" :alt="product.title">
65+
<img class="w-16 h-16 object-cover" :src="product.image_url" :alt="product.title">
6666
</td>
6767
<td class="border-b p-2 max-w-[200px] whitespace-nowrap overflow-hidden text-ellipsis">{{
6868
product.title

0 commit comments

Comments
 (0)