diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php index d8913fc..e3f3e28 100644 --- a/app/Models/PlaylistItem.php +++ b/app/Models/PlaylistItem.php @@ -143,7 +143,13 @@ class PlaylistItem extends Model } $pluginMarkups = []; - $plugins = Plugin::whereIn('id', $this->getMashupPluginIds())->get(); + $pluginIds = $this->getMashupPluginIds(); + $plugins = Plugin::whereIn('id', $pluginIds)->get(); + + // Sort the collection to match plugin_ids order + $plugins = $plugins->sortBy(function ($plugin) use ($pluginIds) { + return array_search($plugin->id, $pluginIds); + })->values(); foreach ($plugins as $index => $plugin) { $size = $this->getLayoutSize($index); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index be688db..c7125c5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -34,7 +34,7 @@ class DatabaseSeeder extends Seeder $this->call([ ExampleRecipesSeeder::class, - MashupPocSeeder::class, + // MashupPocSeeder::class, ]); } } diff --git a/resources/views/flux/icon/mashup-1Tx2B.blade.php b/resources/views/flux/icon/mashup-1Tx2B.blade.php new file mode 100644 index 0000000..e66990f --- /dev/null +++ b/resources/views/flux/icon/mashup-1Tx2B.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/flux/icon/mashup-2Tx1B.blade.php b/resources/views/flux/icon/mashup-2Tx1B.blade.php new file mode 100644 index 0000000..2b4d29d --- /dev/null +++ b/resources/views/flux/icon/mashup-2Tx1B.blade.php @@ -0,0 +1,40 @@ +{{-- Credit: Lucide (https://lucide.dev) --}} + +@props([ + 'variant' => 'outline', +]) + +@php +$classes = Flux::classes('shrink-0') + ->add(match($variant) { + 'outline' => '[:where(&)]:size-6', + 'solid' => '[:where(&)]:size-6', + 'mini' => '[:where(&)]:size-5', + 'micro' => '[:where(&)]:size-4', + }); + +$strokeWidth = match ($variant) { + 'outline' => 2, + 'mini' => 2.25, + 'micro' => 2.5, + default => 2, +}; +@endphp + +class($classes) }} + data-flux-icon + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 76 44" + fill="none" + stroke="currentColor" + stroke-width="{{ $strokeWidth }}" + stroke-linecap="round" + stroke-linejoin="round" + aria-hidden="true" + data-slot="icon" +> + + + + diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 5e0d468..011be8f 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -580,7 +580,19 @@ new class extends Component { @foreach($playlist->items->sortBy('order') as $item) - {{ $item->plugin->name }} + @if($item->isMashup()) +
+
+
{{ $item->getMashupName() }}
+
+ + {{ collect($item->getMashupPluginIds())->map(fn($id) => App\Models\Plugin::find($id)->name)->join(' | ') }} +
+
+
+ @else +
{{ $item->plugin->name }}
+ @endif user()->plugins()->where('id', '!=', $this->plugin->id)->get(); + } + + public function getRequiredPluginCount(): int + { + if ($this->mashup_layout === 'full') { + return 1; + } + + return match ($this->mashup_layout) { + '1Lx1R', '1Tx1B' => 2, // Left-Right or Top-Bottom split + '1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, // Two on one side, one on other + '2x2' => 4, // Quadrant + default => 1, + }; + } + public function addToPlaylist() { $this->validate([ 'checked_devices' => 'required|array|min:1', 'selected_playlist' => 'required|string', + 'mashup_layout' => 'required|string', + 'mashup_plugins' => 'required_if:mashup_layout,1Lx1R,1Lx2R,2Lx1R,1Tx1B,2Tx1B,1Tx2B,2x2|array', ]); foreach ($this->checked_devices as $deviceId) { @@ -146,14 +169,26 @@ new class extends Component { // Add plugin to playlist $maxOrder = $playlist->items()->max('order') ?? 0; - $playlist->items()->create([ - 'plugin_id' => $this->plugin->id, - 'order' => $maxOrder + 1, - ]); + if ($this->mashup_layout === 'full') { + $playlist->items()->create([ + 'plugin_id' => $this->plugin->id, + 'order' => $maxOrder + 1, + ]); + } else { + // Create mashup + $pluginIds = array_merge([$this->plugin->id], array_map('intval', $this->mashup_plugins)); + \App\Models\PlaylistItem::createMashup( + $playlist, + $this->mashup_layout, + $pluginIds, + $this->plugin->name . ' Mashup', + $maxOrder + 1 + ); + } } - $this->reset(['checked_devices', 'playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'selected_playlist']); + $this->reset(['checked_devices', 'playlist_name', 'selected_weekdays', 'active_from', 'active_until', 'selected_playlist', 'mashup_layout', 'mashup_plugins']); Flux::modal('add-to-playlist')->close(); } @@ -181,7 +216,8 @@ new class extends Component { public function renderLayoutWithTitleBar(): string { return << +@props(['size' => 'full']) + @@ -193,7 +229,8 @@ HTML; public function renderLayoutBlank(): string { return << +@props(['size' => 'full']) + @@ -270,15 +307,16 @@ HTML; - +
Add to Playlist
-
- + +
+ @foreach(auth()->user()->devices as $device) @endforeach @@ -286,21 +324,22 @@ HTML;
@if(count($checked_devices) === 1) -
- - + +
+ + @foreach($this->getDevicePlaylists($checked_devices[0]) as $playlist) - + @endforeach - + +
- + @endif + @if($selected_playlist) @if($selected_playlist === 'new') -
+
-
@@ -321,6 +360,43 @@ HTML;
@endif + + +
+ + + + + + + + + + +
+ + @if($mashup_layout !== 'full') +
+
Mashup Slots
+
+
+
Main Plugin
+ +
+ @for($i = 0; $i < $this->getRequiredPluginCount() - 1; $i++) +
+
Plugin {{ $i + 2 }}:
+ + + @foreach($this->getAvailablePlugins() as $availablePlugin) + + @endforeach + +
+ @endfor +
+
+ @endif @endif
@@ -462,8 +538,8 @@ HTML;
@else
- Layout with Title Bar | - Blank Layout + Getting started:Responsive Layout with Title Bar + Responsive Layout
@endif
diff --git a/resources/views/recipes/home-assistant.blade.php b/resources/views/recipes/home-assistant.blade.php index 6322c07..686b33a 100644 --- a/resources/views/recipes/home-assistant.blade.php +++ b/resources/views/recipes/home-assistant.blade.php @@ -18,7 +18,7 @@
- {{ $weatherEntity['attributes']['temperature'] }} Temperature {{ $weatherEntity['attributes']['temperature_unit'] }}
diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index e033d5a..0524d4e 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -697,3 +697,63 @@ test('display endpoint updates last_refreshed_at timestamp for mirrored devices' expect($mirrorDevice->last_refreshed_at)->not->toBeNull() ->and($mirrorDevice->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); }); + +test('display endpoint handles mashup playlist items correctly', function () { + // Create a device + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create a playlist + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'update_test', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Create three plugins for the mashup + $plugin1 = Plugin::factory()->create([ + 'name' => 'Plugin 1', + 'data_strategy' => 'webhook', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + ]); + + $plugin2 = Plugin::factory()->create([ + 'name' => 'Plugin 2', + 'data_strategy' => 'webhook', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + ]); + + // Create a mashup playlist item with a 2Lx1R layout (2 plugins on left, 1 on right) + $playlistItem = PlaylistItem::createMashup( + $playlist, + '1Lx1R', + [$plugin1->id, $plugin2->id], + 'Test Mashup', + 1 + ); + + // Make request to display endpoint + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify the playlist item was marked as displayed + $playlistItem->refresh(); + expect($playlistItem->last_displayed_at)->not->toBeNull(); +}); diff --git a/tests/Unit/Models/PlaylistItemTest.php b/tests/Unit/Models/PlaylistItemTest.php index 9c00a3a..6bfe00c 100644 --- a/tests/Unit/Models/PlaylistItemTest.php +++ b/tests/Unit/Models/PlaylistItemTest.php @@ -21,3 +21,190 @@ test('playlist item belongs to plugin', function () { ->toBeInstanceOf(Plugin::class) ->id->toBe($plugin->id); }); + +test('playlist item can check if it is a mashup', function () { + $plugin = Plugin::factory()->create(); + $regularItem = PlaylistItem::factory()->create([ + 'mashup' => null, + 'plugin_id' => $plugin->id, + ]); + + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($regularItem->isMashup())->toBeFalse() + ->and($mashupItem->isMashup())->toBeTrue(); +}); + +test('playlist item can get mashup name', function () { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupName())->toBe('Test Mashup'); +}); + +test('playlist item can get mashup layout type', function () { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupLayoutType())->toBe('1Lx1R'); +}); + +test('playlist item can get mashup plugin ids', function () { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '1Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getMashupPluginIds())->toBe([$plugin1->id, $plugin2->id]); +}); + +test('playlist item can get required plugin count for different layouts', function () { + $layouts = [ + '1Lx1R' => 2, + '1Tx1B' => 2, + '1Lx2R' => 3, + '2Lx1R' => 3, + '2Tx1B' => 3, + '1Tx2B' => 3, + '2x2' => 4, + ]; + + foreach ($layouts as $layout => $expectedCount) { + $plugins = Plugin::factory()->count($expectedCount)->create(); + $pluginIds = $plugins->pluck('id')->toArray(); + + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $pluginIds[0], + 'mashup' => [ + 'mashup_layout' => $layout, + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => $pluginIds, + ], + ]); + + expect($mashupItem->getRequiredPluginCount())->toBe($expectedCount); + } +}); + +test('playlist item can get layout type', function () { + $layoutTypes = [ + '1Lx1R' => 'vertical', + '1Lx2R' => 'vertical', + '2Lx1R' => 'vertical', + '1Tx1B' => 'horizontal', + '2Tx1B' => 'horizontal', + '1Tx2B' => 'horizontal', + '2x2' => 'grid', + ]; + + foreach ($layoutTypes as $layout => $expectedType) { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => $layout, + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id], + ], + ]); + + expect($mashupItem->getLayoutType())->toBe($expectedType); + } +}); + +test('playlist item can get layout size for different positions', function () { + $plugin1 = Plugin::factory()->create(); + $plugin2 = Plugin::factory()->create(); + $plugin3 = Plugin::factory()->create(); + + $mashupItem = PlaylistItem::factory()->create([ + 'plugin_id' => $plugin1->id, + 'mashup' => [ + 'mashup_layout' => '2Lx1R', + 'mashup_name' => 'Test Mashup', + 'plugin_ids' => [$plugin1->id, $plugin2->id, $plugin3->id], + ], + ]); + + expect($mashupItem->getLayoutSize(0))->toBe('quadrant') + ->and($mashupItem->getLayoutSize(1))->toBe('quadrant') + ->and($mashupItem->getLayoutSize(2))->toBe('half_vertical'); +}); + +test('playlist item can get available layouts', function () { + $layouts = PlaylistItem::getAvailableLayouts(); + + expect($layouts)->toBeArray() + ->toHaveKeys(['1Lx1R', '1Lx2R', '2Lx1R', '1Tx1B', '2Tx1B', '1Tx2B', '2x2']) + ->and($layouts['1Lx1R'])->toBe('1 Left - 1 Right (2 plugins)'); +}); + +test('playlist item can get required plugin count for layout', function () { + $layouts = [ + '1Lx1R' => 2, + '1Tx1B' => 2, + '1Lx2R' => 3, + '2Lx1R' => 3, + '2Tx1B' => 3, + '1Tx2B' => 3, + '2x2' => 4, + ]; + + foreach ($layouts as $layout => $expectedCount) { + expect(PlaylistItem::getRequiredPluginCountForLayout($layout))->toBe($expectedCount); + } +}); + +test('playlist item can create mashup', function () { + $playlist = Playlist::factory()->create(); + $plugins = Plugin::factory()->count(3)->create(); + $pluginIds = $plugins->pluck('id')->toArray(); + $layout = '2Lx1R'; + $name = 'Test Mashup'; + $order = 1; + + $mashup = PlaylistItem::createMashup($playlist, $layout, $pluginIds, $name, $order); + + expect($mashup) + ->toBeInstanceOf(PlaylistItem::class) + ->playlist_id->toBe($playlist->id) + ->plugin_id->toBe($pluginIds[0]) + ->mashup->toHaveKeys(['mashup_layout', 'mashup_name', 'plugin_ids']) + ->mashup->mashup_layout->toBe($layout) + ->mashup->mashup_name->toBe($name) + ->mashup->plugin_ids->toBe($pluginIds) + ->is_active->toBeTrue() + ->order->toBe($order); +});