Skip to content

Commit f3533d1

Browse files
authored
Merge pull request #18 from techenby/amn/item-photo
Add photos to items
2 parents a631c05 + ce242d5 commit f3533d1

File tree

8 files changed

+238
-2
lines changed

8 files changed

+238
-2
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Inventory;
6+
7+
use App\Models\Item;
8+
use App\Models\Team;
9+
use Illuminate\Http\UploadedFile;
10+
use Illuminate\Support\Arr;
11+
use Illuminate\Support\Str;
12+
13+
class CreateItem
14+
{
15+
/**
16+
* @param array<string, mixed> $data
17+
*/
18+
public function handle(Team $team, array $data): Item
19+
{
20+
$photo = Arr::pull($data, 'photo');
21+
22+
$item = $team->items()->create($data);
23+
24+
if ($photo instanceof UploadedFile) {
25+
$filename = Str::slug($item->name) . '-' . $item->id . '.' . $photo->getClientOriginalExtension();
26+
27+
$path = $photo->storeAs("teams/{$team->id}/items", $filename);
28+
29+
$item->update(['photo_path' => $path]);
30+
}
31+
32+
return $item;
33+
}
34+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\Inventory;
6+
7+
use App\Models\Item;
8+
use Illuminate\Http\UploadedFile;
9+
use Illuminate\Support\Arr;
10+
use Illuminate\Support\Facades\Storage;
11+
use Illuminate\Support\Str;
12+
13+
class UpdateItem
14+
{
15+
/**
16+
* @param array<string, mixed> $data
17+
*/
18+
public function handle(Item $item, array $data, bool $removePhoto = false): Item
19+
{
20+
$photo = Arr::pull($data, 'photo');
21+
22+
$item->update($data);
23+
24+
if ($removePhoto && ! $photo instanceof UploadedFile) {
25+
if ($item->photo_path) {
26+
Storage::delete($item->photo_path);
27+
}
28+
29+
$item->update(['photo_path' => null]);
30+
} elseif ($photo instanceof UploadedFile) {
31+
if ($item->photo_path) {
32+
Storage::delete($item->photo_path);
33+
}
34+
35+
$filename = Str::slug($item->name) . '-' . $item->id . '.' . $photo->getClientOriginalExtension();
36+
37+
$path = $photo->storeAs("teams/{$item->team_id}/items", $filename);
38+
39+
$item->update(['photo_path' => $path]);
40+
}
41+
42+
return $item;
43+
}
44+
}

app/Livewire/Forms/Inventory/ItemForm.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
namespace App\Livewire\Forms\Inventory;
66

7+
use App\Actions\Inventory\CreateItem;
8+
use App\Actions\Inventory\UpdateItem;
79
use App\Enums\ItemType;
810
use App\Models\Item;
911
use Illuminate\Support\Facades\Auth;
12+
use Illuminate\Support\Facades\Storage;
1013
use Illuminate\Validation\Rule;
14+
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
1115
use Livewire\Form;
1216

1317
class ItemForm extends Form
@@ -23,6 +27,12 @@ class ItemForm extends Form
2327
/** @var array<int, array{key: string, value: string}> */
2428
public array $metadata = [];
2529

30+
public ?TemporaryUploadedFile $photo = null;
31+
32+
public ?string $existingPhotoUrl = null;
33+
34+
public bool $removePhoto = false;
35+
2636
public function load(Item $item): void
2737
{
2838
$metadata = collect($item->metadata ?? [])
@@ -36,6 +46,7 @@ public function load(Item $item): void
3646
'type' => $item->type->value,
3747
'parent_id' => $item->parent_id,
3848
'metadata' => $metadata,
49+
'existingPhotoUrl' => $item->photo_path ? Storage::temporaryUrl($item->photo_path, now()->addMinutes(30)) : null,
3950
]);
4051
}
4152

@@ -60,9 +71,9 @@ public function save(): void
6071
->all() ?: null;
6172

6273
if ($this->editingItem) {
63-
$this->editingItem->update($data);
74+
(new UpdateItem)->handle($this->editingItem, $data, $this->removePhoto);
6475
} else {
65-
Auth::user()->currentTeam->items()->create($data);
76+
(new CreateItem)->handle(Auth::user()->currentTeam, $data);
6677
}
6778

6879
$this->reset();
@@ -78,6 +89,7 @@ protected function rules(): array
7889
'metadata' => ['nullable', 'array'],
7990
'metadata.*.key' => ['nullable', 'string', 'max:255', 'distinct'],
8091
'metadata.*.value' => ['required_with:metadata.*.key', 'nullable', 'string', 'max:255'],
92+
'photo' => ['nullable', 'image', 'max:5120'],
8193
];
8294
}
8395
}

app/Models/Item.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class Item extends Model
2424
'parent_id',
2525
'type',
2626
'name',
27+
'photo_path',
2728
'metadata',
2829
];
2930

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table('items', function (Blueprint $table) {
12+
$table->string('photo_path')->nullable()->after('name');
13+
});
14+
}
15+
16+
public function down(): void
17+
{
18+
Schema::table('items', function (Blueprint $table) {
19+
$table->dropColumn('photo_path');
20+
});
21+
}
22+
};

resources/views/pages/inventory/modals/item-form.blade.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,31 @@
44

55
<flux:input wire:model="form.name" :label="__('Name')" type="text" required />
66

7+
<flux:field>
8+
<flux:label>{{ __('Photo') }}</flux:label>
9+
10+
<flux:file-upload wire:model="form.photo">
11+
<flux:file-upload.dropzone :heading="__('Drop photo here or click to browse')" :text="__('JPG, PNG up to 5MB')" inline />
12+
</flux:file-upload>
13+
14+
<div class="mt-4 flex flex-col gap-2">
15+
@if ($this->form->photo)
16+
<flux:file-item :heading="$this->form->photo->getClientOriginalName()" :image="$this->form->photo->isPreviewable() ? $this->form->photo->temporaryUrl() : null" :size="$this->form->photo->getSize()">
17+
<x-slot name="actions">
18+
<flux:button wire:click="removePhoto" variant="ghost" size="sm" icon="x-mark" />
19+
</x-slot>
20+
</flux:file-item>
21+
@elseif ($this->form->existingPhotoUrl)
22+
<flux:file-item :heading="basename($this->form->editingItem->photo_path)" :image="$this->form->existingPhotoUrl">
23+
<x-slot name="actions">
24+
<flux:button wire:click="removePhoto" variant="ghost" size="sm" icon="x-mark" />
25+
</x-slot>
26+
</flux:file-item>
27+
@endif
28+
</div>
29+
<flux:error name="form.photo" />
30+
</flux:field>
31+
732
<flux:select wire:model="form.type" :label="__('Type')" placeholder="Select type" variant="listbox" searchable>
833
@foreach (\App\Enums\ItemType::cases() as $type)
934
<flux:select.option :value="$type->value">{{ ucfirst($type->value) }}</flux:select.option>

resources/views/pages/inventory/⚡index/index.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,17 @@ public function removeMetadata(int $index): void
123123
$this->form->removeMetadata($index);
124124
}
125125

126+
public function removePhoto(): void
127+
{
128+
if ($this->form->photo) {
129+
$this->form->photo->delete();
130+
$this->form->photo = null;
131+
} elseif ($this->form->existingPhotoUrl) {
132+
$this->form->existingPhotoUrl = null;
133+
$this->form->removePhoto = true;
134+
}
135+
}
136+
126137
public function save(): void
127138
{
128139
$this->form->save();

resources/views/pages/inventory/⚡index/index.test.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Models\User;
66
use Illuminate\Database\Eloquent\ModelNotFoundException;
77
use Illuminate\Http\UploadedFile;
8+
use Illuminate\Support\Facades\Storage;
89
use Livewire\Livewire;
910

1011
test('guests are redirected to the login page', function () {
@@ -392,6 +393,92 @@
392393
});
393394
});
394395

396+
describe('can manage item photos', function () {
397+
test('can create an item with a photo', function () {
398+
Storage::fake();
399+
$user = User::factory()->withTeam()->create();
400+
$photo = UploadedFile::fake()->image('guitar.jpg');
401+
402+
Livewire::actingAs($user)
403+
->test('pages::inventory.index')
404+
->set('form.name', 'Guitar')
405+
->set('form.type', ItemType::Item->value)
406+
->set('form.photo', $photo)
407+
->call('save')
408+
->assertHasNoErrors();
409+
410+
$item = Item::where('name', 'Guitar')->first();
411+
412+
expect($item->photo_path)->not->toBeNull();
413+
Storage::assertExists($item->photo_path);
414+
});
415+
416+
test('can create an item without a photo', function () {
417+
$user = User::factory()->withTeam()->create();
418+
419+
Livewire::actingAs($user)
420+
->test('pages::inventory.index')
421+
->set('form.name', 'Screwdriver')
422+
->set('form.type', ItemType::Item->value)
423+
->call('save')
424+
->assertHasNoErrors();
425+
426+
expect(Item::where('name', 'Screwdriver')->first()->photo_path)->toBeNull();
427+
});
428+
429+
test('can update an item with a new photo', function () {
430+
Storage::fake();
431+
$user = User::factory()->withTeam()->create();
432+
$item = Item::factory()->for($user->currentTeam)->create(['name' => 'Guitar', 'photo_path' => 'teams/1/items/guitar.jpg']);
433+
Storage::put('teams/1/items/guitar.jpg', 'old');
434+
435+
$newPhoto = UploadedFile::fake()->image('new-guitar.png');
436+
437+
Livewire::actingAs($user)
438+
->test('pages::inventory.index')
439+
->call('edit', $item->id)
440+
->set('form.photo', $newPhoto)
441+
->call('save')
442+
->assertHasNoErrors();
443+
444+
$item->refresh();
445+
446+
expect($item->photo_path)->not->toBeNull();
447+
Storage::assertExists($item->photo_path);
448+
Storage::assertMissing('teams/1/items/guitar.jpg');
449+
});
450+
451+
test('can remove an existing photo', function () {
452+
Storage::fake();
453+
$user = User::factory()->withTeam()->create();
454+
$item = Item::factory()->for($user->currentTeam)->create(['name' => 'Guitar', 'photo_path' => 'teams/1/items/guitar.jpg']);
455+
Storage::put('teams/1/items/guitar.jpg', 'content');
456+
457+
Livewire::actingAs($user)
458+
->test('pages::inventory.index')
459+
->call('edit', $item->id)
460+
->call('removePhoto')
461+
->call('save')
462+
->assertHasNoErrors();
463+
464+
expect($item->fresh()->photo_path)->toBeNull();
465+
Storage::assertMissing('teams/1/items/guitar.jpg');
466+
});
467+
468+
test('photo validation rejects non-image files', function () {
469+
$user = User::factory()->withTeam()->create();
470+
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
471+
472+
Livewire::actingAs($user)
473+
->test('pages::inventory.index')
474+
->set('form.name', 'Guitar')
475+
->set('form.type', ItemType::Item->value)
476+
->set('form.photo', $file)
477+
->call('save')
478+
->assertHasErrors('form.photo');
479+
});
480+
});
481+
395482
describe('can import items', function () {
396483
test('can import items from amazon csv', function () {
397484
$user = User::factory()->withTeam()->create();

0 commit comments

Comments
 (0)