feat(#152): preview polling url

add error handling for preview

fix idx bug and add tests

fix light mode styling and remove transitions

add global styling class
This commit is contained in:
jerremyng 2026-01-08 19:18:30 +00:00 committed by Benjamin Nussbaum
parent 043f683db7
commit 53d4a8399f
9 changed files with 195 additions and 114 deletions

View file

@ -153,12 +153,13 @@ class Plugin extends Model
public function updateDataPayload(): void
{
if ($this->data_strategy === 'polling' && $this->polling_url) {
if ($this->data_strategy !== 'polling' || !$this->polling_url) {
return;
}
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
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) {
@ -169,90 +170,51 @@ class Plugin extends Model
}
}
// Resolve Liquid variables in the entire polling_url field first, then split by newline
// resolve and clean URLs
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_filter(
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
array_map('trim', explode("\n", $resolvedPollingUrls)),
fn ($url): bool => ! empty($url)
);
fn ($url): bool => filled($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 = [];
// 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) {
// 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);
$httpResponse = ($this->polling_verb === 'post')
? $httpRequest->post($url)
: $httpRequest->get($url);
$response = $this->parseResponse($httpResponse);
// Check if response is an array at root level
// Nest if it's a sequential array
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());
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' => $combinedResponse,
'data_payload' => $finalPayload,
'data_payload_updated_at' => now(),
]);
}
}
private function parseResponse(Response $httpResponse): array
{

View file

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

View file

@ -15,7 +15,7 @@
</a>
<div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="styled-container">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>

View file

@ -16,7 +16,7 @@ new class extends Component {
@if($devices->isEmpty())
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
<h1 class="text-xl font-medium dark:text-zinc-200">Add your first device</h1>
<flux:button href="{{ route('devices') }}" class="mt-4" icon="plus-circle" variant="primary"
@ -30,7 +30,7 @@ new class extends Component {
@foreach($devices as $device)
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;

View file

@ -309,7 +309,7 @@ new class extends Component {
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;

View file

@ -332,7 +332,7 @@ new class extends Component {
@endforeach
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="styled-container">
<div class="px-10 py-8">
<h1 class="text-xl font-medium dark:text-zinc-200">No playlists found</h1>
<p class="text-sm dark:text-zinc-400 mt-2">Add playlists to your devices to see them here.</p>

View file

@ -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">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
class="block h-full">
<div class="flex items-center space-x-4 px-10 py-8 h-full">

View file

@ -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;
</div>
@if($data_strategy === 'polling')
<div class="mb-4">
<flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
<flux:label>Polling URL</flux:label>
<div x-data="{ subTab: 'settings' }" class="mt-2 mb-4">
<div class="flex">
<button
@click="subTab = 'settings'"
class="tab-button"
:class="subTab === 'settings' ? 'is-active' : ''"
>
<flux:icon.cog-6-tooth class="size-4"/>
Settings
</button>
<button
@click="subTab = 'preview'"
class="tab-button"
:class="subTab === 'preview' ? 'is-active' : ''"
>
<flux:icon.eye class="size-4" />
Preview URL
</button>
</div>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<div x-show="subTab === 'settings'">
<flux:field>
<flux:description>Enter the URL(s) to poll for data:</flux:description>
<flux:textarea
wire:model.live="polling_url"
placeholder="https://example.com/api"
class="block w-full" type="text" name="polling_url" autofocus>
</flux:input>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
rows="5"
/>
<flux:description>
{!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with <a href="https://help.usetrmnl.com/en/articles/12689499-dynamic-polling-urls">Liquid syntax</a>. ' !!}
</flux:description>
</flux:field>
</div>
<div x-show="subTab === 'preview'" x-cloak>
<flux:field>
<flux:description>Preview computed URLs here (readonly):</flux:description>
<flux:textarea
readonly
placeholder="Nothing to show..."
rows="5"
>
{{ $this->parsed_urls }}
</flux:textarea>
</flux:field>
</div>
<flux:button variant="primary" icon="cloud-arrow-down" wire:click="updateData" class="w-full mt-4">
Fetch data now
</flux:button>
</div>
</div>
<div class="mb-4">
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
@ -905,9 +962,6 @@ HTML;
</div>
</flux:field>
</div>
@else
<div class="flex items-center gap-6 mb-4 mt-4">

View file

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