diff --git a/app/Livewire/Forms/Studio/Master/OutletForm.php b/app/Livewire/Forms/Studio/Master/OutletForm.php index 2be627a..36bc1cf 100644 --- a/app/Livewire/Forms/Studio/Master/OutletForm.php +++ b/app/Livewire/Forms/Studio/Master/OutletForm.php @@ -28,21 +28,30 @@ class OutletForm extends Form public ?string $maps_url = null; + /** @var array */ public array $opening_hours = []; + /** @var array */ public array $facilities = ['Parkir']; public string $opened_date = ''; - public string $status = '1'; + public int $status = OutletStatus::OPERATIONAL->value; + /** @var array> */ public array $featured_image = []; + /** @var array> */ public array $images = []; public function rules(): array { - $rules = [ + return array_merge($this->getBasicRules(), $this->getOpeningHoursRules()); + } + + private function getBasicRules(): array + { + return [ 'name' => ['required', 'string', 'max:100'], 'phone_number' => ['required', 'string', new PhoneNumber], 'address' => ['required', 'string'], @@ -60,6 +69,11 @@ public function rules(): array 'featured_image' => ['required', 'array', 'max:1'], 'images' => ['required', 'array', 'max:5'], ]; + } + + private function getOpeningHoursRules(): array + { + $rules = []; foreach (Day::cases() as $day) { $rules["opening_hours.{$day->value}"] = ['required', 'array']; @@ -88,18 +102,33 @@ public function validationAttributes(): array ]; } - public function setOutlet(Outlet $outlet) + public function setOutlet(Outlet $outlet): void { - $outlet = $outlet->load(['openingHours', 'facilities']); + $outlet->load(['openingHours', 'facilities']); $this->outlet = $outlet; + $this->fillBasicAttributes($outlet); + $this->opening_hours = $this->formatOpeningHoursForForm($outlet); + $this->facilities = $outlet->facilities->pluck('name')->toArray(); + $this->featured_image = $this->mapMediaCollection($outlet->getMedia('featured_image')); + $this->images = $this->mapMediaCollection($outlet->getMedia('images')); + } + + private function fillBasicAttributes(Outlet $outlet): void + { $this->name = $outlet->name; $this->phone_number = $outlet->phone_number; $this->address = $outlet->address; $this->landmark = $outlet->landmark; $this->maps_url = $outlet->maps_url; - $this->opening_hours = $outlet->openingHours + $this->opened_date = $outlet->opened_date; + $this->status = $outlet->status->value; + } + + private function formatOpeningHoursForForm(Outlet $outlet): array + { + return $outlet->openingHours ->mapWithKeys(fn ($item) => [ $item->day->value => [ 'open_time' => $item->open_time ? formatTime($item->open_time) : null, @@ -107,88 +136,121 @@ public function setOutlet(Outlet $outlet) ], ]) ->toArray(); - $this->facilities = $outlet->facilities->pluck('name')->toArray(); - $this->opened_date = $outlet->opened_date; - $this->status = $outlet->status->value; - $this->featured_image = $this->mapMediaCollection($outlet->getMedia('featured_image')); - $this->images = $this->mapMediaCollection($outlet->getMedia('images')); } - public function store() + public function store(): void { $this->validate(); - $data = $this->all(); - - if ($data['status'] == OutletStatus::TERMINATED) { - $data['closed_date'] = now(); - } else { - $data['closed_date'] = null; - } + $data = $this->prepareDataForSave(); DB::transaction(function () use ($data) { $outlet = Outlet::create($data); - $openingHours = collect($data['opening_hours']) - ->map(fn ($time, $day) => [ - 'outlet_id' => $outlet->id, - 'day' => $day, - 'open_time' => $time['open_time'], - 'close_time' => $time['close_time'], - ]) - ->values() - ->toArray(); - - OpeningHour::insert($openingHours); - $outlet->facilities()->createMany(collect($data['facilities'])->map(fn ($facility) => ['name' => $facility])->toArray()); - - $this->uploadMedia($this->featured_image, $outlet, 'featured_image'); - $this->uploadMedia($this->images, $outlet, 'images'); + $this->createOpeningHours($outlet, $data['opening_hours']); + $this->createFacilities($outlet, $data['facilities']); + $this->handleMediaUpload($outlet); }); } - public function update() + private function prepareDataForSave(): array + { + $data = $this->all(); + + $data['closed_date'] = $data['status'] == OutletStatus::TERMINATED->value + ? now() + : null; + + return $data; + } + + private function createOpeningHours(Outlet $outlet, array $openingHours): void + { + $hoursData = collect($openingHours) + ->map(fn ($time, $day) => [ + 'outlet_id' => $outlet->id, + 'day' => $day, + 'open_time' => $time['open_time'], + 'close_time' => $time['close_time'], + ]) + ->values() + ->toArray(); + + OpeningHour::insert($hoursData); + } + + private function createFacilities(Outlet $outlet, array $facilities): void + { + $facilitiesData = collect($facilities) + ->map(fn ($facility) => ['name' => $facility]) + ->toArray(); + + $outlet->facilities()->createMany($facilitiesData); + } + + private function handleMediaUpload(Outlet $outlet): void + { + $this->uploadMedia($this->featured_image, $outlet, 'featured_image'); + $this->uploadMedia($this->images, $outlet, 'images'); + } + + public function update(): void { $this->validate(); - $data = $this->all(); - - if ($data['status'] == OutletStatus::TERMINATED->value) { - $data['closed_date'] = now()->toDateString(); - } else { - $data['closed_date'] = null; - } + $data = $this->prepareDataForSave(); DB::transaction(function () use ($data) { $this->outlet->update($data); - foreach ($data['opening_hours'] as $day => $hours) { - OpeningHour::updateOrCreate( - [ - 'outlet_id' => $this->outlet->id, - 'day' => $day, - ], - [ - 'open_time' => $hours['open_time'] ?? null, - 'close_time' => $hours['close_time'] ?? null, - ] - ); - } - - $facilities = collect($data['facilities']); - - foreach ($facilities as $facilityName) { - $this->outlet->facilities()->updateOrCreate( - ['name' => $facilityName], - ['name' => $facilityName] - ); - } - - $this->syncMedia($data['featured_image'], $this->outlet, 'featured_image'); - $this->syncMedia($data['images'], $this->outlet, 'images'); - - $this->uploadMedia($this->featured_image, $this->outlet, 'featured_image'); - $this->uploadMedia($this->images, $this->outlet, 'images'); + $this->updateOpeningHours($data['opening_hours']); + $this->updateFacilities($data['facilities']); + $this->handleMediaSync($data); + $this->handleMediaUpload($this->outlet); }); } + + private function updateOpeningHours(array $openingHours): void + { + // Delete opening hours for days that are not in the new data + $this->outlet->openingHours() + ->whereNotIn('day', array_keys($openingHours)) + ->delete(); + + // Add or update opening hours + foreach ($openingHours as $day => $hours) { + OpeningHour::updateOrCreate( + [ + 'outlet_id' => $this->outlet->id, + 'day' => $day, + ], + [ + 'open_time' => $hours['open_time'] ?? null, + 'close_time' => $hours['close_time'] ?? null, + ] + ); + } + } + + private function updateFacilities(array $facilities): void + { + // Delete facilities that are not in the new list + $this->outlet->facilities() + ->whereNotIn('name', $facilities) + ->delete(); + + // Add or update facilities + foreach ($facilities as $facilityName) { + $this->outlet->facilities()->updateOrCreate( + ['name' => $facilityName], + ['name' => $facilityName] + ); + } + } + + private function handleMediaSync(array $data): void + { + $this->syncMedia($data['featured_image'], $this->outlet, 'featured_image'); + $this->syncMedia($data['images'], $this->outlet, 'images'); + } } diff --git a/app/Livewire/Studio/Master/Outlet/Create.php b/app/Livewire/Studio/Master/Outlet/Create.php index 771f08c..15d5f00 100644 --- a/app/Livewire/Studio/Master/Outlet/Create.php +++ b/app/Livewire/Studio/Master/Outlet/Create.php @@ -7,30 +7,35 @@ use App\Traits\Outlet\WithFacilityHandler; use App\Traits\WithAuthorization; use App\Traits\WithToast; -use App\Traits\WithUpdatedData; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Title; use Livewire\Component; #[Title('Tambah Outlet')] class Create extends Component { - use WithAuthorization, WithFacilityHandler, WithToast, WithUpdatedData; + use WithAuthorization, WithFacilityHandler, WithToast; public OutletForm $form; + /** @var array */ public array $days = []; - public function mount() + public function mount(): void { $this->form->opening_hours = collect(Day::cases()) ->mapWithKeys(fn (Day $day) => [ - $day->value => ['open_time' => null, 'close_time' => null], + $day->value => [ + 'open_time' => null, + 'close_time' => null, + ], ]) ->toArray(); + $this->days = Day::cases(); } - public function save() + public function save(): void { $this->canOrAbort('create outlet'); @@ -41,7 +46,7 @@ public function save() $this->redirectRoute('studio.master.outlet.index'); } - public function render() + public function render(): View { return view('livewire.studio.master.outlet.form', [ 'pageTitle' => 'Tambah Outlet', diff --git a/app/Livewire/Studio/Master/Outlet/Edit.php b/app/Livewire/Studio/Master/Outlet/Edit.php index 67a8c2a..da5a8a5 100644 --- a/app/Livewire/Studio/Master/Outlet/Edit.php +++ b/app/Livewire/Studio/Master/Outlet/Edit.php @@ -8,27 +8,27 @@ use App\Traits\Outlet\WithFacilityHandler; use App\Traits\WithAuthorization; use App\Traits\WithToast; -use App\Traits\WithUpdatedData; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Title; use Livewire\Component; #[Title('Ubah Outlet')] class Edit extends Component { - use WithAuthorization, WithFacilityHandler, WithToast, WithUpdatedData; + use WithAuthorization, WithFacilityHandler, WithToast; public OutletForm $form; + /** @var array */ public array $days = []; - public function mount(Outlet $outlet) + public function mount(Outlet $outlet): void { $this->form->setOutlet($outlet); - $this->days = Day::cases(); } - public function save() + public function save(): void { $this->canOrAbort('update outlet'); @@ -39,7 +39,7 @@ public function save() $this->redirectRoute('studio.master.outlet.index'); } - public function render() + public function render(): View { return view('livewire.studio.master.outlet.form', [ 'pageTitle' => 'Ubah Outlet', diff --git a/app/Livewire/Studio/Master/Outlet/Index.php b/app/Livewire/Studio/Master/Outlet/Index.php index c16b91c..d0a61ff 100644 --- a/app/Livewire/Studio/Master/Outlet/Index.php +++ b/app/Livewire/Studio/Master/Outlet/Index.php @@ -4,98 +4,92 @@ use App\Enums\Day; use App\Models\Outlet; -use App\Traits\Notification\WithSubscribeNotification; use App\Traits\WithCloseModal; use App\Traits\WithConfirmation; use App\Traits\WithMediaHandler; use App\Traits\WithToast; use Flux\Flux; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Str; +use Illuminate\View\View; use Livewire\Attributes\Title; use Livewire\Component; #[Title('Outlet')] class Index extends Component { - use WithCloseModal, WithConfirmation, WithMediaHandler, WithSubscribeNotification, WithToast; + use WithCloseModal, WithConfirmation, WithMediaHandler, WithToast; - public array $outlets = []; - - public ?string $imageUrl = null; + /** @var Collection */ + public Collection $outlets; public string $search = ''; public array $status = []; - public function mount() + public function mount(): void { $this->loadOutlets(); } - protected function loadOutlets() + protected function loadOutlets(): void { $this->outlets = Outlet::with(['openingHours', 'facilities']) ->when(! empty($this->search), fn ($query) => $query->where('name', 'like', '%'.$this->search.'%')) ->when(! empty($this->status), fn ($query) => $query->whereIn('status', $this->status)) ->latest() - ->get() - ->map(fn ($outlet) => [ - 'hash' => $outlet->hash, - 'name' => $outlet->name, - 'phone_number' => $outlet->phone_number, - 'landmark' => $outlet->landmark, - 'address' => $outlet->address, - 'status' => [ - 'label' => $outlet->status->label(), - 'color' => $outlet->status->color(), - ], - 'opening_hours' => $outlet->openingHours->mapWithKeys(fn ($hour) => [ - Str::ucfirst(Str::lower(Day::from($hour->day->value)->label())) => [ - 'open' => formatTime($hour->open_time), - 'close' => formatTime($hour->close_time), - 'is_closed' => $hour->open_time === null && $hour->close_time === null, - ], - ])->toArray(), - 'facilities' => $outlet->facilities->pluck('name')->toArray(), - 'opened_date' => formatDate($outlet->opened_date), - 'opened_ago' => timeAgo($outlet->opened_date), - 'closed_date' => formatDate($outlet->closed_date), - 'closed_ago' => timeAgo($outlet->closed_date), - 'image' => data_get($this->mapMediaCollection($outlet->getMedia('featured_image'))[0] ?? null, 'temporaryUrl', asset('assets/images/logo.png')), - 'images' => collect($this->mapMediaCollection($outlet->getMedia('images')))->pluck('temporaryUrl')->filter()->values(), - ]) + ->get(); + } + + protected function formatOpeningHours(Outlet $outlet): array + { + return $outlet->openingHours->mapWithKeys(fn ($hour) => [ + Str::ucfirst(Str::lower(Day::from($hour->day->value)->label())) => [ + 'open' => formatTime($hour->open_time), + 'close' => formatTime($hour->close_time), + 'is_closed' => $hour->open_time === null && $hour->close_time === null, + ], + ])->toArray(); + } + + protected function getFeaturedImage(Outlet $outlet): string + { + $mediaCollection = $this->mapMediaCollection($outlet->getMedia('featured_image')); + + return data_get($mediaCollection[0] ?? null, 'temporaryUrl', asset('assets/images/logo.png')); + } + + protected function getGalleryImages(Outlet $outlet): array + { + return collect($this->mapMediaCollection($outlet->getMedia('images'))) + ->pluck('temporaryUrl') + ->filter() + ->values() ->toArray(); } - public function updatedSearch(string $value) + public function updatedSearch(string $value): void { $this->search = $value; - $this->loadOutlets(); } - public function updatedStatus() + public function updatedStatus(): void { $this->loadOutlets(); } - public function openImage(string $imageUrl) + public function delete(Outlet $outlet): void { - $this->imageUrl = $imageUrl; + $this->authorize('delete outlet'); - Flux::modal('image-modal')->show(); - } - - public function delete(Outlet $outlet) - { $outlet->delete(); - $this->toast('Outlet berhasil dihapus.'); - + $this->loadOutlets(); Flux::modals()->close(); } - public function render() + public function render(): View { return view('livewire.studio.master.outlet.index', [ 'pageTitle' => 'Outlet', diff --git a/resources/views/livewire/studio/master/outlet/index.blade.php b/resources/views/livewire/studio/master/outlet/index.blade.php index ca7b246..808a48c 100644 --- a/resources/views/livewire/studio/master/outlet/index.blade.php +++ b/resources/views/livewire/studio/master/outlet/index.blade.php @@ -34,14 +34,13 @@ class="text-sm"> class="space-y-2 transition-transform duration-300 ease-out hover:-translate-y-1 hover:shadow-lg">
- {{ $outlet['image'] }} + {{ $outlet->name }}
- {{ $outlet['name'] }} - - {{ $outlet['status']['label'] }} + {{ $outlet->name }} + + {{ $outlet->status->label() }}
@@ -50,14 +49,14 @@ class="w-12 h-12 rounded-full cursor-pointer"
- {{ $outlet['phone_number'] }} + {{ $outlet->phone_number }}
-
{{ $outlet['landmark'] }}
-
{{ $outlet['address'] }}
+
{{ $outlet->landmark }}
+
{{ $outlet->address }}
@@ -69,7 +68,7 @@ class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-cente Jam Operasional
- @foreach ($outlet['opening_hours'] as $day => $time) + @foreach ($this->formatOpeningHours($outlet) as $day => $time)
{{ $day }}: @@ -88,8 +87,8 @@ class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-cente Fasilitas
- @foreach ($outlet['facilities'] as $facility) - {{ $facility }} + @foreach ($outlet->facilities as $facility) + {{ $facility->name }} @endforeach
@@ -98,21 +97,22 @@ class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-cente
Tgl Buka: - {{ $outlet['opened_date'] }} - ({{ $outlet['opened_ago'] }}) + {{ formatDate($outlet->opened_date) }} + ({{ timeAgo($outlet->opened_date) }})
- @if ($outlet['closed_date']) + @if ($outlet->closed_date)
Tgl Tutup: {{ $outlet['closed_date'] }} - ({{ $outlet['closed_ago'] }}) + class="text-gray-700 dark:text-gray-300">{{ formatDate($outlet->closed_date) }} + ({{ timeAgo($outlet->closed_date) }})
@endif
- @if (count($outlet['images']) > 0) + @if ($this->getGalleryImages($outlet))

@@ -120,10 +120,9 @@ class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-cente Gambar

- @foreach ($outlet['images'] as $key => $value) - {{ $value }} + @foreach ($this->getGalleryImages($outlet) as $imageUrl) + {{ $outlet->name }} @endforeach
@@ -134,7 +133,7 @@ class="w-12 h-12 rounded-full -ml-3 first:ml-0 cursor-pointer"
@can('update outlet') - @@ -142,10 +141,10 @@ class="w-12 h-12 rounded-full -ml-3 first:ml-0 cursor-pointer" @endcan @can('delete outlet') - + + wire:click="$dispatch('fn:confirmAction', {id: '{{ $outlet->hash }}'})"> @@ -156,19 +155,18 @@ class="w-12 h-12 rounded-full -ml-3 first:ml-0 cursor-pointer" @endforeach
@else - @include('components.lottie.not-found') + @include('components.animations.lottie.not-found') @endif -
- @include('components.confirmation.delete') - - -
- Outlet Image -
-
- + @include('components.modals.confirmation', [ + 'modalName' => 'delete-confirmation', + 'modalTitle' => 'Apakah Anda yakin?', + 'modalMessage' => 'Data yang berelasi dengan data ini juga akan ikut terhapus.', + 'buttonVariant' => 'primary', + 'buttonColor' => 'danger', + 'buttonText' => 'Ya, Hapus', + ]) @assets diff --git a/routes/pages/studio.php b/routes/pages/studio.php index 6ccb144..260da06 100644 --- a/routes/pages/studio.php +++ b/routes/pages/studio.php @@ -66,7 +66,6 @@ Route::get('/', OutletIndex::class)->name('index')->middleware('can:view outlet'); Route::get('/create', OutletCreate::class)->name('create')->middleware('can:create outlet'); Route::get('/{outlet}/edit', OutletEdit::class)->name('edit')->middleware('can:update outlet'); - Route::delete('/{outlet}/delete', OutletCreate::class)->name('delete')->middleware('can:delete outlet'); }); Route::prefix('users')->name('user.')->group(function () { diff --git a/tests/Feature/Livewire/Studio/Master/Outlet/CreateTest.php b/tests/Feature/Livewire/Studio/Master/Outlet/CreateTest.php index 049b065..b7b1593 100644 --- a/tests/Feature/Livewire/Studio/Master/Outlet/CreateTest.php +++ b/tests/Feature/Livewire/Studio/Master/Outlet/CreateTest.php @@ -1,6 +1,9 @@ map(fn ($role) => Role::create(['name' => $role])); - $this->user = User::factory() ->active() ->has(Employee::factory()) - // ->has(Outlet::factory()->count(5)) ->create(); - - $this->user->roles()->attach($roles->pluck('id')); }); -function makeLivewireFile(string $name) -{ - $file = UploadedFile::fake()->image($name, 100, 100); - $tmpId = Str::random(32); - $filename = $tmpId.'-'.base64_encode($file->getClientOriginalName()).'.'.$file->getClientOriginalExtension(); - $path = "livewire-tmp/{$filename}"; - - Storage::disk('local')->putFileAs('livewire-tmp', $file, $filename); - - return [ - 'tmpFilename' => $filename, - 'name' => $file->getClientOriginalName(), - 'extension' => $file->getClientOriginalExtension(), - 'path' => storage_path("app/private/{$path}"), - 'temporaryUrl' => url("livewire/preview-file/{$filename}"), - 'size' => $file->getSize(), - ]; -} - function mountCreateComponent(User $user) { return Livewire::actingAs($user)->test(Create::class); @@ -156,8 +130,6 @@ function mountCreateComponent(User $user) 'form.phone_number', 'form.address', 'form.landmark', - 'form.opening_hours', - 'form.facilities', 'form.opened_date', 'form.featured_image', 'form.images', @@ -220,6 +192,17 @@ function mountCreateComponent(User $user) expect($component->form->facilities)->toBe(['Parkir']); }); +it('initializes opening hours for all days of week', function () { + $component = mountCreateComponent($this->user); + + foreach (Day::cases() as $day) { + expect(array_key_exists($day->value, $component->form->opening_hours))->toBeTrue(); + expect($component->form->opening_hours[$day->value])->toHaveKeys(['open_time', 'close_time']); + expect($component->form->opening_hours[$day->value]['open_time'])->toBeNull(); + expect($component->form->opening_hours[$day->value]['close_time'])->toBeNull(); + } +}); + it('creates outlet with opening hours for all days', function () { $permission = Permission::create(['name' => 'create outlet']); $this->user->givePermissionTo($permission); @@ -253,4 +236,190 @@ function mountCreateComponent(User $user) $createdOutlet = Outlet::first(); expect($createdOutlet->openingHours)->toHaveCount(count(Day::cases())); + + // Verify opening hours data + foreach ($createdOutlet->openingHours as $openingHour) { + expect($openingHour->open_time)->toBe('09:00'); + expect($openingHour->close_time)->toBe('21:00'); + } +}); + +it('creates outlet with facilities', function () { + $permission = Permission::create(['name' => 'create outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->raw(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + $facilities = ['Parkir', 'WiFi', 'AC']; + + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.status', $outlet['status']->value) + ->set('form.facilities', $facilities) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $createdOutlet = Outlet::first(); + expect($createdOutlet->facilities->pluck('name')->toArray())->toBe($facilities); +}); + +it('sets closed_date when status is TERMINATED', function () { + $permission = Permission::create(['name' => 'create outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->raw(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.status', OutletStatus::TERMINATED->value) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $createdOutlet = Outlet::first(); + expect($createdOutlet->status->value)->toBe(OutletStatus::TERMINATED->value); + expect($createdOutlet->closed_date)->not->toBeNull(); +}); + +it('does not set closed_date for non-terminated status', function () { + $permission = Permission::create(['name' => 'create outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->raw(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.status', OutletStatus::OPERATIONAL->value) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $createdOutlet = Outlet::first(); + expect($createdOutlet->closed_date)->toBeNull(); +}); + +it('can add and remove facilities dynamically', function () { + $component = mountCreateComponent($this->user); + + // Initial state + expect($component->form->facilities)->toBe(['Parkir']); + + // Add facility + $component->call('addFacility'); + expect($component->form->facilities)->toBe(['Parkir', '']); + + // Add another facility + $component->call('addFacility'); + expect($component->form->facilities)->toBe(['Parkir', '', '']); + + // Remove middle facility + $component->call('removeFacility', 1); + expect($component->form->facilities)->toBe(['Parkir', '']); + + // Remove first facility (should keep at least one empty) + $component->call('removeFacility', 0); + expect($component->form->facilities)->toBe(['']); +}); + +it('validates maps_url regex pattern', function () { + $permission = Permission::create(['name' => 'create outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->raw(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.maps_url', 'invalid-url') + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasErrors(['form.maps_url']); + + // Valid Google Maps URL should pass + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.maps_url', 'https://maps.app.goo.gl/example') + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); +}); + +it('handles facility removal edge cases', function () { + $component = mountCreateComponent($this->user); + + // Set custom facilities + $component->set('form.facilities', ['Parkir', 'WiFi', 'AC']); + + // Remove middle facility + $component->call('removeFacility', 1); + expect($component->form->facilities)->toBe(['Parkir', 'AC']); + + // Try to remove non-existent index (should not crash) + $component->call('removeFacility', 99); + expect($component->form->facilities)->toBe(['Parkir', 'AC']); +}); + +it('creates outlet with soft delete capability', function () { + $permission = Permission::create(['name' => 'create outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->raw(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountCreateComponent($this->user) + ->set('form.name', $outlet['name']) + ->set('form.phone_number', $outlet['phone_number']) + ->set('form.address', $outlet['address']) + ->set('form.landmark', $outlet['landmark']) + ->set('form.opened_date', $outlet['opened_date']) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $createdOutlet = Outlet::first(); + expect($createdOutlet)->toBeInstanceOf(Outlet::class); + + // Verify soft delete trait + $createdOutlet->delete(); + expect(Outlet::withTrashed()->find($createdOutlet->id))->not->toBeNull(); + expect(Outlet::find($createdOutlet->id))->toBeNull(); }); diff --git a/tests/Feature/Livewire/Studio/Master/Outlet/EditTest.php b/tests/Feature/Livewire/Studio/Master/Outlet/EditTest.php index 4dbddf2..9c7d4ff 100644 --- a/tests/Feature/Livewire/Studio/Master/Outlet/EditTest.php +++ b/tests/Feature/Livewire/Studio/Master/Outlet/EditTest.php @@ -1,44 +1,30 @@ user = User::factory()->create(); + $this->user = User::factory() + ->active() + ->has(Employee::factory()) + ->create(); }); -function makeLivewireFile(string $name) -{ - $file = UploadedFile::fake()->image($name, 100, 100); - $tmpId = Str::random(32); - $filename = $tmpId.'-'.base64_encode($file->getClientOriginalName()).'.'.$file->getClientOriginalExtension(); - $path = "livewire-tmp/{$filename}"; - - Storage::disk('local')->putFileAs('livewire-tmp', $file, $filename); - - return [ - 'tmpFilename' => $filename, - 'name' => $file->getClientOriginalName(), - 'extension' => $file->getClientOriginalExtension(), - 'path' => storage_path("app/private/{$path}"), - 'temporaryUrl' => url("livewire/preview-file/{$filename}"), - 'size' => $file->getSize(), - ]; -} - function mountEditComponent(User $user, Outlet $outlet) { return Livewire::actingAs($user)->test(Edit::class, ['outlet' => $outlet]); @@ -132,3 +118,229 @@ function mountEditComponent(User $user, Outlet $outlet) expect($media->getPath())->toBeFile(); } }); + +it('loads outlet data into form correctly', function () { + $outlet = Outlet::factory() + ->operational() + ->has(OpeningHour::factory()->count(7)) + ->has(Facility::factory()->count(3)) + ->create(); + + $component = mountEditComponent($this->user, $outlet); + + expect($component->form->outlet->id)->toBe($outlet->id); + expect($component->form->name)->toBe($outlet->name); + expect($component->form->phone_number)->toBe($outlet->phone_number); + expect($component->form->address)->toBe($outlet->address); + expect($component->form->landmark)->toBe($outlet->landmark); + expect($component->form->status)->toBe($outlet->status->value); + expect($component->form->facilities)->toBe($outlet->facilities->pluck('name')->toArray()); +}); + +it('loads opening hours into form correctly', function () { + $outlet = Outlet::factory()->operational()->create(); + + // Create specific opening hours + $openingHours = []; + foreach (Day::cases() as $day) { + $openingHours[] = OpeningHour::factory()->create([ + 'outlet_id' => $outlet->id, + 'day' => $day, + 'open_time' => '09:00', + 'close_time' => '21:00', + ]); + } + + $component = mountEditComponent($this->user, $outlet); + + foreach (Day::cases() as $day) { + expect($component->form->opening_hours[$day->value]['open_time'])->toBe('09:00'); + expect($component->form->opening_hours[$day->value]['close_time'])->toBe('21:00'); + } +}); + +it('updates outlet with partial data changes', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory() + ->operational() + ->has(Facility::factory()->count(2)) + ->create(); + + $newName = 'Updated Outlet Name'; + $images = [makeLivewireFile('image1.jpg')]; + + $featuredImage = makeLivewireFile('featured.jpg'); + + mountEditComponent($this->user, $outlet) + ->set('form.name', $newName) + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(Index::class); + + $outlet->refresh(); + expect($outlet->name)->toBe($newName); + expect($outlet->phone_number)->toBe($outlet->phone_number); // Unchanged +}); + +it('updates outlet status to TERMINATED and sets closed_date', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->create(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountEditComponent($this->user, $outlet) + ->set('form.status', OutletStatus::TERMINATED->value) + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->status->value)->toBe(OutletStatus::TERMINATED->value); + expect($outlet->closed_date)->not->toBeNull(); +}); + +it('updates facilities correctly', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory() + ->operational() + ->has(Facility::factory()->count(2)) + ->create(); + + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + $newFacilities = ['WiFi', 'AC', 'Parking']; + + mountEditComponent($this->user, $outlet) + ->set('form.facilities', $newFacilities) + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->facilities->pluck('name')->toArray())->toBe($newFacilities); +}); + +it('updates opening hours correctly', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->create(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + $newOpeningHours = makeValidOpeningHours(); + + mountEditComponent($this->user, $outlet) + ->set('form.opening_hours', $newOpeningHours) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->openingHours)->toHaveCount(count(Day::cases())); + + foreach ($outlet->openingHours as $openingHour) { + expect($openingHour->open_time)->toBe('09:00'); + expect($openingHour->close_time)->toBe('21:00'); + } +}); + +it('validates required fields on update', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->create(); + + mountEditComponent($this->user, $outlet) + ->set('form.name', '') // Empty required field + ->call('save') + ->assertHasErrors(['form.name']); +}); + +it('maintains outlet relationships integrity', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory() + ->operational() + ->has(Facility::factory()->count(3)) + ->create(); + + $originalOpeningHoursCount = count(Day::cases()); // Should be 7 after setOutlet + $originalFacilitiesCount = 1; // We set facilities to ['Parkir'] in the test + + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountEditComponent($this->user, $outlet) + ->set('form.name', 'Updated Name') + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->openingHours)->toHaveCount($originalOpeningHoursCount); + expect($outlet->facilities)->toHaveCount($originalFacilitiesCount); +}); + +it('handles outlet with no existing media', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->create(); + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountEditComponent($this->user, $outlet) + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->getMedia('featured_image'))->toHaveCount(1); +}); + +it('preserves outlet slug when updating', function () { + $permission = Permission::create(['name' => 'update outlet']); + $this->user->givePermissionTo($permission); + + $outlet = Outlet::factory()->operational()->create(); + $originalSlug = $outlet->slug; + $featuredImage = makeLivewireFile('featured.jpg'); + $images = [makeLivewireFile('image1.jpg')]; + + mountEditComponent($this->user, $outlet) + ->set('form.phone_number', '0812 3456 7890') // Change non-slug field + ->set('form.opening_hours', makeValidOpeningHours()) + ->set('form.facilities', ['Parkir']) + ->set('form.featured_image', [$featuredImage]) + ->set('form.images', $images) + ->call('save') + ->assertHasNoErrors(); + + $outlet->refresh(); + expect($outlet->slug)->toBe($originalSlug); +}); diff --git a/tests/Feature/Livewire/Studio/Master/Outlet/IndexTest.php b/tests/Feature/Livewire/Studio/Master/Outlet/IndexTest.php index f68b916..49ba1e6 100644 --- a/tests/Feature/Livewire/Studio/Master/Outlet/IndexTest.php +++ b/tests/Feature/Livewire/Studio/Master/Outlet/IndexTest.php @@ -7,26 +7,23 @@ use App\Models\OpeningHour; use App\Models\Outlet; use App\Models\User; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Gate; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; -use Spatie\Permission\Models\Role; +use Spatie\Permission\Models\Permission; uses(RefreshDatabase::class); beforeEach(function () { - $roles = collect(['Developer', 'Owner', 'Leader', 'Admin', 'Partner', 'Customer']) - ->map(fn ($role) => Role::create(['name' => $role])); - $this->user = User::factory() ->active() ->has(Employee::factory()) - // ->has(Outlet::factory()->count(5)) ->create(); - - $this->user->roles()->attach($roles->pluck('id')); }); -function createOutlet(array $attributes = []) +function createOutlet(array $attributes = []): Outlet { return Outlet::factory() ->has(OpeningHour::factory()->count(7)) @@ -37,7 +34,7 @@ function createOutlet(array $attributes = []) ->create(); } -function mountIndexComponent(User $user) +function mountIndexComponent(User $user): Testable { return Livewire::actingAs($user)->test(Index::class); } @@ -48,23 +45,24 @@ function mountIndexComponent(User $user) ->assertViewHas('pageTitle', 'Outlet'); }); -it('mounts outlets correctly', function () { +it('loads outlets as Eloquent collection', function () { $outlet = createOutlet(); - mountIndexComponent($this->user) - ->assertViewHas('outlets', function ($outlets) use ($outlet) { - return collect($outlets)->contains( - fn ($o) => $o['name'] === $outlet->name && - $o['phone_number'] === $outlet->phone_number - ); - }); -}); - -it('opens image modal when openImage is called', function () { $component = mountIndexComponent($this->user); - $url = 'https://example.com/test.jpg'; - $component->call('openImage', $url)->assertSet('imageUrl', $url); + expect($component->get('outlets')) + ->toBeInstanceOf(Collection::class) + ->and($component->get('outlets')->first()->id)->toBe($outlet->id); +}); + +it('loads outlets with relationships', function () { + $outlet = createOutlet(); + + $component = mountIndexComponent($this->user); + $loadedOutlet = $component->get('outlets')->first(); + + expect($loadedOutlet->openingHours)->toHaveCount(7) + ->and($loadedOutlet->facilities)->toHaveCount(5); }); it('displays all outlets', function () { @@ -72,15 +70,25 @@ function mountIndexComponent(User $user) createOutlet(); } - $outletCount = Outlet::count(); - $firstOutlet = Outlet::first(); + $component = mountIndexComponent($this->user); - mountIndexComponent($this->user) - ->assertSee($firstOutlet->name) - ->assertViewHas('outlets', fn ($outlets) => count($outlets) === $outletCount); + expect($component->get('outlets'))->toHaveCount(5); + expect($component->get('outlets')->first())->toBeInstanceOf(Outlet::class); }); -it('deletes an outlet successfully', function () { +it('prevents delete when user is unauthorized', function () { + $outlet = createOutlet(); + Gate::define('delete outlet', fn () => false); + + mountIndexComponent($this->user) + ->call('delete', $outlet) + ->assertForbidden(); +}); + +it('deletes an outlet successfully when authorized', function () { + $permission = Permission::create(['name' => 'delete outlet']); + $this->user->givePermissionTo($permission); + $outlet = createOutlet(); mountIndexComponent($this->user) @@ -97,18 +105,19 @@ function mountIndexComponent(User $user) $component = mountIndexComponent($this->user); - expect(count($component->get('outlets')))->toBe(3); + expect($component->get('outlets'))->toHaveCount(3); - $component->set('search', 'Jakarta') - ->assertSet('search', 'Jakarta'); + // Test filtering by Jakarta + $component->set('search', 'Jakarta'); + $filteredComponent = mountIndexComponent($this->user)->set('search', 'Jakarta'); - expect(count($component->get('outlets')))->toBe(1); - expect($component->get('outlets.0.name'))->toBe('Outlet Jakarta'); + expect($filteredComponent->get('outlets'))->toHaveCount(1); + expect($filteredComponent->get('outlets')->first()->name)->toBe('Outlet Jakarta'); - $component->set('search', 'Outlet') - ->assertSet('search', 'Outlet'); + // Test filtering by Outlet + $filteredComponent2 = mountIndexComponent($this->user)->set('search', 'Outlet'); - expect(count($component->get('outlets')))->toBe(2); + expect($filteredComponent2->get('outlets'))->toHaveCount(2); }); it('filters outlets by status', function () { @@ -118,20 +127,20 @@ function mountIndexComponent(User $user) $component = mountIndexComponent($this->user); - expect(count($component->get('outlets')))->toBe(3); + expect($component->get('outlets'))->toHaveCount(3); - $component->set('status', [OutletStatus::OPERATIONAL->value]) - ->assertSet('status', [OutletStatus::OPERATIONAL->value]); + $filteredComponent = mountIndexComponent($this->user)->set('status', [OutletStatus::OPERATIONAL->value]); - expect(count($component->get('outlets')))->toBe(1); - expect($component->get('outlets.0.name'))->toBe($outlet1->name); + expect($filteredComponent->get('outlets'))->toHaveCount(1); + expect($filteredComponent->get('outlets')->first()->name)->toBe($outlet1->name); - $component->set('status', [ + // Test filtering by multiple statuses + $filteredComponent2 = mountIndexComponent($this->user)->set('status', [ OutletStatus::OPERATIONAL->value, OutletStatus::UNDER_CONSTRUCTION->value, ]); - expect(count($component->get('outlets')))->toBe(2); + expect($filteredComponent2->get('outlets'))->toHaveCount(2); }); it('filters outlets by both search and status', function () { @@ -143,35 +152,19 @@ function mountIndexComponent(User $user) ->set('search', 'Outlet') ->set('status', [OutletStatus::OPERATIONAL->value]); - expect(count($component->get('outlets')))->toBe(2); + expect($component->get('outlets'))->toHaveCount(2); }); -it('loads outlet data with all required fields', function () { +it('loads outlets with all required relationships', function () { $outlet = createOutlet(); $component = mountIndexComponent($this->user); - $outletData = $component->get('outlets.0'); + $loadedOutlet = $component->get('outlets')->first(); - expect($outletData)->toHaveKeys([ - 'hash', - 'name', - 'phone_number', - 'landmark', - 'address', - 'status', - 'opening_hours', - 'facilities', - 'opened_date', - 'opened_ago', - 'closed_date', - 'closed_ago', - 'image', - 'images', - ]); - - expect($outletData['status'])->toHaveKeys(['label', 'color']); - expect($outletData['name'])->toBe($outlet->name); - expect($outletData['phone_number'])->toBe($outlet->phone_number); + expect($loadedOutlet->openingHours)->toHaveCount(7) + ->and($loadedOutlet->facilities)->toHaveCount(5) + ->and($loadedOutlet->name)->toBe($outlet->name) + ->and($loadedOutlet->phone_number)->toBe($outlet->phone_number); }); it('loads outlets ordered by latest', function () { @@ -181,6 +174,66 @@ function mountIndexComponent(User $user) $component = mountIndexComponent($this->user); - expect($component->get('outlets.0.name'))->toBe('Second'); - expect($component->get('outlets.1.name'))->toBe('First'); + expect($component->get('outlets')->first()->name)->toBe('Second'); + expect($component->get('outlets')->last()->name)->toBe('First'); +}); + +it('provides helper methods for view formatting', function () { + $outlet = createOutlet(); + + $component = mountIndexComponent($this->user); + + // Test that outlets are loaded with proper data structure + $loadedOutlet = $component->get('outlets')->first(); + expect($loadedOutlet)->toBeInstanceOf(Outlet::class); + expect($loadedOutlet->openingHours)->toBeInstanceOf(Collection::class); + expect($loadedOutlet->facilities)->toBeInstanceOf(Collection::class); +}); + +it('handles empty search results gracefully', function () { + createOutlet(['name' => 'Existing Outlet']); + + $component = mountIndexComponent($this->user)->set('search', 'NonExistentOutlet'); + + expect($component->get('outlets'))->toHaveCount(0); +}); + +it('maintains search state across component lifecycle', function () { + $outlet1 = createOutlet(['name' => 'Test Outlet']); + $outlet2 = createOutlet(['name' => 'Another Outlet']); + + $component = mountIndexComponent($this->user)->set('search', 'Test'); + + expect($component->get('outlets'))->toHaveCount(1); + expect($component->get('search'))->toBe('Test'); + + // Re-mount should maintain state + $newComponent = mountIndexComponent($this->user)->set('search', 'Test'); + + expect($newComponent->get('outlets'))->toHaveCount(1); +}); + +it('handles status filter with empty array', function () { + createOutlet(['status' => OutletStatus::OPERATIONAL]); + createOutlet(['status' => OutletStatus::TERMINATED]); + + $component = mountIndexComponent($this->user)->set('status', []); + + expect($component->get('outlets'))->toHaveCount(2); +}); + +it('loads outlets efficiently with eager loading', function () { + $outlets = collect(); + for ($i = 0; $i < 10; $i++) { + $outlets->push(createOutlet()); + } + + $component = mountIndexComponent($this->user); + + // Verify relationships are loaded + $loadedOutlets = $component->get('outlets'); + foreach ($loadedOutlets as $loadedOutlet) { + expect($loadedOutlet->relationLoaded('openingHours'))->toBeTrue(); + expect($loadedOutlet->relationLoaded('facilities'))->toBeTrue(); + } }); diff --git a/tests/Feature/Livewire/Studio/Master/Outlet/TestHelpers.php b/tests/Feature/Livewire/Studio/Master/Outlet/TestHelpers.php new file mode 100644 index 0000000..6e8c2b2 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Master/Outlet/TestHelpers.php @@ -0,0 +1,37 @@ +image($name, 100, 100); + $tmpId = Str::random(32); + $filename = $tmpId.'-'.base64_encode($file->getClientOriginalName()).'.'.$file->getClientOriginalExtension(); + $path = "livewire-tmp/{$filename}"; + + Storage::disk('local')->putFileAs('livewire-tmp', $file, $filename); + + return [ + 'tmpFilename' => $filename, + 'name' => $file->getClientOriginalName(), + 'extension' => $file->getClientOriginalExtension(), + 'path' => storage_path("app/private/{$path}"), + 'temporaryUrl' => url("livewire/preview-file/{$filename}"), + 'size' => $file->getSize(), + ]; +} + +function makeValidOpeningHours() +{ + return collect(Day::cases()) + ->mapWithKeys(fn ($day) => [ + $day->value => [ + 'open_time' => '09:00', + 'close_time' => '21:00', + ], + ]) + ->toArray(); +}