feat(rooms): create + delete rooms (delete unassigns devices)
Issue 5: rooms couldn't be created or deleted. - "Raum anlegen" button + CreateRoom modal (name + icon picker) on the rooms index. - Delete on the room page behind a confirm that states how many devices are in it; on delete the devices are KEPT but moved to "no room" (room_id null). 3 tests. Suite green, 12/12 tabs clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
f028528f1f
commit
f3ae29d65a
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Room;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateRoom extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $icon = 'rooms';
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'icon' => 'required|string|in:'.implode(',', Room::ICON_CHOICES),
|
||||
]);
|
||||
|
||||
Room::create([
|
||||
'name' => $this->name,
|
||||
'icon' => $this->icon,
|
||||
'sort' => (int) (Room::max('sort') ?? 0) + 1,
|
||||
]);
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
return redirect()->route('rooms.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.create-room');
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,16 @@ class Show extends Component
|
|||
app(DeviceCommandService::class)->toggle($entity);
|
||||
}
|
||||
|
||||
/** Delete the room; its devices are kept but moved to "no room". */
|
||||
#[On('deleteRoom')]
|
||||
public function deleteRoom()
|
||||
{
|
||||
$this->room->devices()->update(['room_id' => null]);
|
||||
$this->room->delete();
|
||||
|
||||
return redirect()->route('rooms.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$this->room->load(['devices' => fn ($q) => $q->orderBy('name'), 'devices.entities.state']);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ class Room extends Model
|
|||
{
|
||||
use HasUuid;
|
||||
|
||||
/** Icons offered when creating a room (keys map to the x-icon set). */
|
||||
public const ICON_CHOICES = ['rooms', 'bolt', 'lamp', 'plug', 'window', 'activity', 'temp', 'devices'];
|
||||
|
||||
protected $fillable = ['uuid', 'name', 'icon', 'sort'];
|
||||
|
||||
public function devices(): HasMany
|
||||
|
|
|
|||
|
|
@ -7,4 +7,12 @@ return [
|
|||
'online' => 'online',
|
||||
'lights' => 'Licht an',
|
||||
'open' => 'offen',
|
||||
'add' => 'Raum anlegen',
|
||||
'add_title' => 'Neuer Raum',
|
||||
'name' => 'Name',
|
||||
'icon' => 'Symbol',
|
||||
'delete' => 'Raum löschen',
|
||||
'delete_title' => 'Raum löschen?',
|
||||
'delete_body' => 'Der Raum wird gelöscht. :count Gerät(e) darin werden keinem Raum mehr zugewiesen (bleiben aber erhalten).',
|
||||
'delete_body_empty' => 'Der Raum wird gelöscht. Es sind keine Geräte zugewiesen.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -7,4 +7,12 @@ return [
|
|||
'online' => 'online',
|
||||
'lights' => 'lights on',
|
||||
'open' => 'open',
|
||||
'add' => 'Add room',
|
||||
'add_title' => 'New room',
|
||||
'name' => 'Name',
|
||||
'icon' => 'Icon',
|
||||
'delete' => 'Delete room',
|
||||
'delete_title' => 'Delete room?',
|
||||
'delete_body' => 'The room is deleted. :count device(s) in it become unassigned (but are kept).',
|
||||
'delete_body_empty' => 'The room is deleted. No devices are assigned.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<form wire:submit="save">
|
||||
<x-modal :title="__('rooms.add_title')" icon="rooms">
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="r-name" class="text-[12px] font-semibold text-ink-2">{{ __('rooms.name') }}</label>
|
||||
<input wire:model="name" id="r-name" type="text" autofocus
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@error('name') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('rooms.icon') }}</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
@foreach (\App\Models\Room::ICON_CHOICES as $ic)
|
||||
<button type="button" wire:click="$set('icon', '{{ $ic }}')"
|
||||
@class([
|
||||
'grid place-items-center w-9 h-9 rounded-lg border transition-colors',
|
||||
'border-accent bg-accent/15 text-accent' => $icon === $ic,
|
||||
'border-line-soft bg-raised text-ink-2 hover:text-ink' => $icon !== $ic,
|
||||
])>
|
||||
<x-icon :name="$ic" :size="17" />
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot:footer>
|
||||
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
|
||||
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('rooms.add') }}</button>
|
||||
</x-slot:footer>
|
||||
</x-modal>
|
||||
</form>
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
<div wire:poll.30s>
|
||||
<x-topbar :title="__('nav.rooms')" :subtitle="__('rooms.subtitle')" />
|
||||
<x-topbar :title="__('nav.rooms')" :subtitle="__('rooms.subtitle')">
|
||||
<x-slot:actions>
|
||||
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.create-room' })"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">
|
||||
<x-icon name="plus" :size="15" /> <span class="hidden sm:inline">{{ __('rooms.add') }}</span>
|
||||
</button>
|
||||
</x-slot:actions>
|
||||
</x-topbar>
|
||||
|
||||
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
|
||||
@if ($rooms->isEmpty())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
<div wire:poll.30s>
|
||||
<x-topbar :title="$room->name" :subtitle="__('nav.rooms')">
|
||||
<x-slot:actions>
|
||||
@php $deviceCount = $room->devices->count(); @endphp
|
||||
<button type="button"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('rooms.delete_title')), body: @js($deviceCount > 0 ? __('rooms.delete_body', ['count' => $deviceCount]) : __('rooms.delete_body_empty')), confirmLabel: @js(__('rooms.delete')), event: 'deleteRoom', to: 'rooms.show', danger: true } })"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-line-soft bg-raised px-2.5 py-1.5 text-[12px] font-semibold text-ink-3 hover:text-offline hover:border-offline/40 transition-colors">
|
||||
<x-icon name="close" :size="14" /> <span class="hidden sm:inline">{{ __('rooms.delete') }}</span>
|
||||
</button>
|
||||
<a href="{{ route('rooms.index') }}" wire:navigate
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-line-soft bg-raised px-2.5 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink transition-colors">
|
||||
<x-icon name="chevron" :size="14" class="rotate-180" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\CreateRoom;
|
||||
use App\Livewire\Rooms\Show;
|
||||
use App\Models\Device;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RoomsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_room_modal_creates_a_room(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test(CreateRoom::class)
|
||||
->set('name', 'Wohnzimmer')
|
||||
->set('icon', 'lamp')
|
||||
->call('save')
|
||||
->assertRedirect(route('rooms.index'));
|
||||
|
||||
$this->assertDatabaseHas('rooms', ['name' => 'Wohnzimmer', 'icon' => 'lamp']);
|
||||
}
|
||||
|
||||
public function test_create_room_rejects_an_unknown_icon(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test(CreateRoom::class)
|
||||
->set('name', 'X')
|
||||
->set('icon', 'not-an-icon')
|
||||
->call('save')
|
||||
->assertHasErrors('icon');
|
||||
}
|
||||
|
||||
public function test_deleting_a_room_keeps_devices_but_unassigns_them(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$room = Room::create(['name' => 'Küche', 'icon' => 'rooms', 'sort' => 1]);
|
||||
$device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'http', 'room_id' => $room->id, 'config' => ['ip' => '1.2.3.4']]);
|
||||
|
||||
Livewire::test(Show::class, ['room' => $room])
|
||||
->call('deleteRoom')
|
||||
->assertRedirect(route('rooms.index'));
|
||||
|
||||
$this->assertModelMissing($room);
|
||||
$this->assertNull($device->fresh()->room_id); // device kept, just unassigned
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue