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
HomeOS Bootstrap 2026-07-18 10:12:49 +02:00
parent f028528f1f
commit f3ae29d65a
9 changed files with 168 additions and 1 deletions

View File

@ -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');
}
}

View File

@ -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']);

View File

@ -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

View File

@ -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.',
];

View File

@ -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.',
];

View File

@ -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>

View File

@ -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())

View File

@ -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" />

View File

@ -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
}
}