diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c791333..524f26a 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -153,105 +153,67 @@ class Plugin extends Model public function updateDataPayload(): void { - if ($this->data_strategy === 'polling' && $this->polling_url) { - - $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; - - if ($this->polling_header) { - // Resolve Liquid variables in the polling header - $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); - $headerLines = explode("\n", mb_trim($resolvedHeader)); - foreach ($headerLines as $line) { - $parts = explode(':', $line, 2); - if (count($parts) === 2) { - $headers[mb_trim($parts[0])] = mb_trim($parts[1]); - } - } - } - - // Resolve Liquid variables in the entire polling_url field first, then split by newline - $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); - $urls = array_filter( - array_map('trim', explode("\n", $resolvedPollingUrls)), - fn ($url): bool => ! empty($url) - ); - - // If only one URL, use the original logic without nesting - if (count($urls) === 1) { - $url = reset($urls); - $httpRequest = Http::withHeaders($headers); - - if ($this->polling_verb === 'post' && $this->polling_body) { - // Resolve Liquid variables in the polling body - $resolvedBody = $this->resolveLiquidVariables($this->polling_body); - $httpRequest = $httpRequest->withBody($resolvedBody); - } - - // URL is already resolved, use it directly - $resolvedUrl = $url; - - try { - // Make the request based on the verb - $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); - - $response = $this->parseResponse($httpResponse); - - $this->update([ - 'data_payload' => $response, - 'data_payload_updated_at' => now(), - ]); - } catch (Exception $e) { - Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); - $this->update([ - 'data_payload' => ['error' => 'Failed to fetch data'], - 'data_payload_updated_at' => now(), - ]); - } - - return; - } - - // Multiple URLs - use nested response logic - $combinedResponse = []; - - foreach ($urls as $index => $url) { - $httpRequest = Http::withHeaders($headers); - - if ($this->polling_verb === 'post' && $this->polling_body) { - // Resolve Liquid variables in the polling body - $resolvedBody = $this->resolveLiquidVariables($this->polling_body); - $httpRequest = $httpRequest->withBody($resolvedBody); - } - - // URL is already resolved, use it directly - $resolvedUrl = $url; - - try { - // Make the request based on the verb - $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl); - - $response = $this->parseResponse($httpResponse); - - // Check if response is an array at root level - if (array_keys($response) === range(0, count($response) - 1)) { - // Response is a sequential array, nest under .data - $combinedResponse["IDX_{$index}"] = ['data' => $response]; - } else { - // Response is an object or associative array, keep as is - $combinedResponse["IDX_{$index}"] = $response; - } - } catch (Exception $e) { - // Log error and continue with other URLs - Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage()); - $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; - } - } - - $this->update([ - 'data_payload' => $combinedResponse, - 'data_payload_updated_at' => now(), - ]); + if ($this->data_strategy !== 'polling' || !$this->polling_url) { + return; } + $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; + + // resolve headers + if ($this->polling_header) { + $resolvedHeader = $this->resolveLiquidVariables($this->polling_header); + $headerLines = explode("\n", mb_trim($resolvedHeader)); + foreach ($headerLines as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $headers[mb_trim($parts[0])] = mb_trim($parts[1]); + } + } + } + + // resolve and clean URLs + $resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); + $urls = array_values(array_filter( // array_values ensures 0, 1, 2... + array_map('trim', explode("\n", $resolvedPollingUrls)), + fn ($url): bool => filled($url) + )); + + $combinedResponse = []; + + // Loop through all URLs (Handles 1 or many) + foreach ($urls as $index => $url) { + $httpRequest = Http::withHeaders($headers); + + if ($this->polling_verb === 'post' && $this->polling_body) { + $resolvedBody = $this->resolveLiquidVariables($this->polling_body); + $httpRequest = $httpRequest->withBody($resolvedBody); + } + + try { + $httpResponse = ($this->polling_verb === 'post') + ? $httpRequest->post($url) + : $httpRequest->get($url); + + $response = $this->parseResponse($httpResponse); + + // Nest if it's a sequential array + if (array_keys($response) === range(0, count($response) - 1)) { + $combinedResponse["IDX_{$index}"] = ['data' => $response]; + } else { + $combinedResponse["IDX_{$index}"] = $response; + } + } catch (Exception $e) { + Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage()); + $combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data']; + } + } + + // unwrap IDX_0 if only one URL + $finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse; + + $this->update([ + 'data_payload' => $finalPayload, + 'data_payload_updated_at' => now(), + ]); } private function parseResponse(Response $httpResponse): array diff --git a/resources/css/app.css b/resources/css/app.css index 30cb7a1..de95b81 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -72,3 +72,39 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + +@layer components { + /* standard container for app */ + .styled-container, + .tab-button { + @apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs; + } + + .tab-button { + @apply flex items-center gap-2 px-4 py-2 text-sm font-medium; + @apply rounded-b-none shadow-none bg-inherit; + + /* This makes the button sit slightly over the box border */ + margin-bottom: -1px; + position: relative; + z-index: 1; + } + + .tab-button.is-active { + @apply text-zinc-700 dark:text-zinc-300; + @apply border-b-white dark:border-b-zinc-800; + + /* Z-index 10 ensures the bottom border of the tab hides the top border of the box */ + z-index: 10; + } + + .tab-button:not(.is-active) { + @apply text-zinc-500 border-transparent; + } + + .tab-button:not(.is-active):hover { + @apply text-zinc-700 dark:text-zinc-300; + @apply border-zinc-300 dark:border-zinc-700; + cursor: pointer; + } +} diff --git a/resources/views/components/layouts/auth/card.blade.php b/resources/views/components/layouts/auth/card.blade.php index 1a316ef..b5a62c6 100644 --- a/resources/views/components/layouts/auth/card.blade.php +++ b/resources/views/components/layouts/auth/card.blade.php @@ -15,7 +15,7 @@
-
+
{{ $slot }}
diff --git a/resources/views/livewire/device-dashboard.blade.php b/resources/views/livewire/device-dashboard.blade.php index 5db65d1..7fd48a8 100644 --- a/resources/views/livewire/device-dashboard.blade.php +++ b/resources/views/livewire/device-dashboard.blade.php @@ -16,7 +16,7 @@ new class extends Component { @if($devices->isEmpty())
+ class="styled-container">

Add your first device

+ class="styled-container">
@php $current_image_uuid =$device->current_screen_image; diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 851c604..f9d49ca 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -309,7 +309,7 @@ new class extends Component {
+ class="styled-container">
@php $current_image_uuid =$device->current_screen_image; diff --git a/resources/views/livewire/playlists/index.blade.php b/resources/views/livewire/playlists/index.blade.php index 3e786b4..6c979e6 100644 --- a/resources/views/livewire/playlists/index.blade.php +++ b/resources/views/livewire/playlists/index.blade.php @@ -332,7 +332,7 @@ new class extends Component { @endforeach @if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty())) -
+

No playlists found

Add playlists to your devices to see them here.

diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 4347aaf..d902183 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -395,7 +395,7 @@ new class extends Component { wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}" x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }" x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())" - class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> + class="styled-container">
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 38fecc9..6e96a73 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Livewire\Attributes\On; +use Livewire\Attributes\Computed; new class extends Component { public Plugin $plugin; @@ -295,8 +296,6 @@ new class extends Component { return $this->configuration[$key] ?? $default; } - - public function renderExample(string $example) { switch ($example) { @@ -431,6 +430,17 @@ HTML; $this->plugin = $this->plugin->fresh(); } + // Laravel Livewire computed property: access with $this->parsed_urls + #[Computed] + private function parsedUrls() + { + try { + return $this->plugin->resolveLiquidVariables($this->polling_url); + + } catch (\Exception $e) { + return 'PARSE_ERROR: ' . $e->getMessage(); + } + } } ?> @@ -733,15 +743,62 @@ HTML;
@if($data_strategy === 'polling') -
@else
diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 2771f81..aa9a28e 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -99,6 +99,35 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function (): expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']); }); +test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void { + $plugin = Plugin::factory()->create([ + 'data_strategy' => 'polling', + // empty lines and extra spaces between the URL to generate empty entries + 'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ", + 'polling_verb' => 'get', + ]); + + // Mock only the valid URLs + Http::fake([ + 'https://api1.example.com/data' => Http::response(['item' => 'first'], 200), + 'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200), + ]); + + $plugin->updateDataPayload(); + + // payload should only have 2 items, and they should be indexed 0 and 1 + expect($plugin->data_payload)->toHaveCount(2); + expect($plugin->data_payload)->toHaveKey('IDX_0'); + expect($plugin->data_payload)->toHaveKey('IDX_1'); + + // data is correct + expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']); + expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']); + + // no empty index exists + expect($plugin->data_payload)->not->toHaveKey('IDX_2'); +}); + test('updateDataPayload handles single URL without nesting', function (): void { $plugin = Plugin::factory()->create([ 'data_strategy' => 'polling',