feat: add UI, add tests, refinements

This commit is contained in:
Benjamin Nussbaum 2025-06-11 12:28:45 +02:00
parent 8946cabf05
commit d45859643b
9 changed files with 446 additions and 25 deletions

View file

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

View file

@ -34,7 +34,7 @@ class DatabaseSeeder extends Seeder
$this->call([
ExampleRecipesSeeder::class,
MashupPocSeeder::class,
// MashupPocSeeder::class,
]);
}
}

View file

@ -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
<svg
{{ $attributes->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"
>
<rect width="76" height="20.5" rx="3"/>
<rect x="39.5" y="23.5" width="36.5" height="20.5" rx="3"/>
<rect y="23.5" width="36.5" height="20.5" rx="3"/>
</svg>

View file

@ -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
<svg
{{ $attributes->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"
>
<rect y="23.5" width="76" height="20.5" rx="3"/>
<rect x="39.5" width="36.5" height="20.5" rx="3"/>
<rect width="36.5" height="20.5" rx="3"/>
</svg>

View file

@ -580,7 +580,19 @@ new class extends Component {
@foreach($playlist->items->sortBy('order') as $item)
<tr data-flux-row>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
{{ $item->plugin->name }}
@if($item->isMashup())
<div class="flex items-center gap-2">
<div>
<div class="font-medium">{{ $item->getMashupName() }}</div>
<div class="text-xs text-zinc-500 dark:text-zinc-400">
<flux:icon name="mashup-{{ $item->getMashupLayoutType() }}" class="inline-block pb-1" variant="mini" />
{{ collect($item->getMashupPluginIds())->map(fn($id) => App\Models\Plugin::find($id)->name)->join(' | ') }}
</div>
</div>
</div>
@else
<div class="font-medium">{{ $item->plugin->name }}</div>
@endif
</td>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
<flux:switch wire:model.live="item.is_active"

View file

@ -22,6 +22,8 @@ new class extends Component {
public string $active_from = '';
public string $active_until = '';
public string $selected_playlist = '';
public string $mashup_layout = 'full';
public array $mashup_plugins = [];
public function mount(): void
{
@ -117,11 +119,32 @@ new class extends Component {
}
}
public function getAvailablePlugins()
{
return auth()->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 <<<HTML
<x-trmnl::view>
@props(['size' => 'full'])
<x-trmnl::view size="{{\$size}}">
<x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout>
@ -193,7 +229,8 @@ HTML;
public function renderLayoutBlank(): string
{
return <<<HTML
<x-trmnl::view>
@props(['size' => 'full'])
<x-trmnl::view size="{{\$size}}">
<x-trmnl::layout>
<!-- ADD YOUR CONTENT HERE-->
</x-trmnl::layout>
@ -270,15 +307,16 @@ HTML;
</flux:button.group>
</div>
<flux:modal name="add-to-playlist" class="md:w-96">
<flux:modal name="add-to-playlist" class="min-w-2xl">
<div class="space-y-6">
<div>
<flux:heading size="lg">Add to Playlist</flux:heading>
</div>
<form wire:submit="addToPlaylist">
<div class="mb-4">
<flux:checkbox.group wire:model.live="checked_devices" label="Select Devices">
<flux:separator text="Device(s)" />
<div class="mt-4 mb-4">
<flux:checkbox.group wire:model.live="checked_devices">
@foreach(auth()->user()->devices as $device)
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
@endforeach
@ -286,21 +324,22 @@ HTML;
</div>
@if(count($checked_devices) === 1)
<div class="mb-4">
<flux:radio.group wire:model.live.debounce="selected_playlist" label="Select Playlist"
variant="segmented">
<flux:radio value="new" label="Create New"/>
<flux:separator text="Playlist" />
<div class="mt-4 mb-4">
<flux:select wire:model.live.debounce="selected_playlist">
<option value="">Select Playlist or Create New</option>
@foreach($this->getDevicePlaylists($checked_devices[0]) as $playlist)
<flux:radio value="{{ $playlist->id }}" label="{{ $playlist->name }}"/>
<option value="{{ $playlist->id }}">{{ $playlist->name }}</option>
@endforeach
</flux:radio.group>
<option value="new">Create New Playlist</option>
</flux:select>
</div>
@endif
@if($selected_playlist)
@if($selected_playlist === 'new')
<div class="mb-4">
<div class="mt-4 mb-4">
<flux:input label="Playlist Name" wire:model="playlist_name"/>
</div>
<div class="mb-4">
<flux:checkbox.group wire:model="selected_weekdays" label="Active Days (optional)">
<flux:checkbox label="Monday" value="1"/>
@ -321,6 +360,43 @@ HTML;
<flux:input type="time" label="Active Until (optional)" wire:model="active_until"/>
</div>
@endif
<flux:separator text="Mashup" />
<div class="mt-4 mb-4">
<flux:radio.group wire:model.live="mashup_layout" variant="segmented">
<flux:radio value="full" icon="mashup-1x1"/>
<flux:radio value="1Lx1R" icon="mashup-1Lx1R"/>
<flux:radio value="1Lx2R" icon="mashup-1Lx2R"/>
<flux:radio value="2Lx1R" icon="mashup-2Lx1R"/>
<flux:radio value="1Tx1B" icon="mashup-1Tx1B"/>
<flux:radio value="2Tx1B" icon="mashup-2Tx1B"/>
<flux:radio value="1Tx2B" icon="mashup-1Tx2B"/>
<flux:radio value="2x2" icon="mashup-2x2"/>
</flux:radio.group>
</div>
@if($mashup_layout !== 'full')
<div class="mb-4">
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Mashup Slots</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-24 text-sm text-zinc-500 dark:text-zinc-400">Main Plugin</div>
<flux:input :value="$plugin->name" disabled class="flex-1"/>
</div>
@for($i = 0; $i < $this->getRequiredPluginCount() - 1; $i++)
<div class="flex items-center gap-2">
<div class="w-24 text-sm text-zinc-500 dark:text-zinc-400">Plugin {{ $i + 2 }}:</div>
<flux:select wire:model="mashup_plugins.{{ $i }}" class="flex-1">
<option value="">Select a plugin...</option>
@foreach($this->getAvailablePlugins() as $availablePlugin)
<option value="{{ $availablePlugin->id }}">{{ $availablePlugin->name }}</option>
@endforeach
</flux:select>
</div>
@endfor
</div>
</div>
@endif
@endif
<div class="flex">
@ -462,8 +538,8 @@ HTML;
</div>
@else
<div class="text-accent">
<a href="#" wire:click="renderExample('layoutTitle')" class="text-xl">Layout with Title Bar</a> |
<a href="#" wire:click="renderExample('layout')" class="text-xl">Blank Layout</a>
<span class="pr-2">Getting started:</span><flux:button wire:click="renderExample('layoutTitle')" class="text-xl">Responsive Layout with Title Bar</flux:button>
<flux:button wire:click="renderExample('layout')" class="text-xl">Responsive Layout</flux:button>
</div>
@endif
</div>

View file

@ -18,7 +18,7 @@
<div class="item h--full">
<div class="meta"></div>
<div class="justify-center">
<span class="value value--xxxlarge"
<span class="value @if($size == 'full' || $size == 'half_horizontal') value--xxlarge @else value--medium @endif"
data-fit-value="true">{{ $weatherEntity['attributes']['temperature'] }}</span>
<span class="label">Temperature {{ $weatherEntity['attributes']['temperature_unit'] }}</span>
</div>

View file

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

View file

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