refactor(#120): remove unnecessary js, improve cache handling

This commit is contained in:
Benjamin Nussbaum 2025-12-30 09:43:05 +01:00
parent 3cdc267809
commit 50853728bc
4 changed files with 324 additions and 274 deletions

View file

@ -1,20 +1,24 @@
<?php
use App\Services\PluginImportService;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
use Symfony\Component\Yaml\Yaml;
new
#[Lazy]
class extends Component {
class extends Component
{
public array $catalogPlugins = [];
public string $installingPlugin = '';
public string $previewingPlugin = '';
public array $previewData = [];
public function mount(): void
@ -51,7 +55,7 @@ class extends Component {
return collect($catalog)
->filter(function ($plugin) use ($currentVersion) {
// Check if Laravel compatibility is true
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
return false;
}
@ -81,8 +85,9 @@ class extends Component {
})
->sortBy('name')
->toArray();
} catch (\Exception $e) {
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
} catch (Exception $e) {
Log::error('Failed to load catalog from URL: '.$e->getMessage());
return [];
}
});
@ -94,8 +99,9 @@ class extends Component {
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
if (!$plugin || !$plugin['zip_url']) {
if (! $plugin || ! $plugin['zip_url']) {
$this->addError('installation', 'Plugin not found or no download URL available.');
return;
}
@ -113,8 +119,8 @@ class extends Component {
$this->dispatch('plugin-installed');
Flux::modal('import-from-catalog')->close();
} catch (\Exception $e) {
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
} catch (Exception $e) {
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
} finally {
$this->installingPlugin = '';
}
@ -124,32 +130,27 @@ class extends Component {
{
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
if (!$plugin) {
if (! $plugin) {
$this->addError('preview', 'Plugin not found.');
return;
}
$this->previewingPlugin = $pluginId;
$this->previewData = $plugin;
// Store scroll position for restoration later
$this->dispatch('store-scroll-position');
}
public function closePreview(): void
{
$this->previewingPlugin = '';
$this->previewData = [];
// Restore scroll position when returning to catalog
$this->dispatch('restore-scroll-position');
}
}; ?>
<div class="space-y-4">
@if(empty($catalogPlugins))
<div class="text-center py-8">
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading class="mt-2">No plugins available</flux:heading>
<flux:subheading>Catalog is empty</flux:subheading>
</div>
@ -165,25 +166,25 @@ class extends Component {
@if($plugin['logo_url'])
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
</div>
@endif
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
@if ($plugin['github'])
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
@endif
</div>
<div class="flex items-center space-x-2">
@if($plugin['license'])
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
@endif
@if($plugin['repo_url'])
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
<flux:icon name="github" class="w-5 h-5" />
</a>
@endif
@ -191,7 +192,7 @@ class extends Component {
</div>
@if($plugin['description'])
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
@endif
<div class="mt-4 flex items-center space-x-3">
@ -201,6 +202,7 @@ class extends Component {
Install
</flux:button>
@if($plugin['screenshot_url'])
<flux:modal.trigger name="catalog-preview">
<flux:button
wire:click="previewPlugin('{{ $plugin['id'] }}')"
@ -209,6 +211,7 @@ class extends Component {
Preview
</flux:button>
</flux:modal.trigger>
@endif
@ -236,34 +239,20 @@ class extends Component {
</div>
<div class="space-y-4">
@if($previewData['screenshot_url'])
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<img src="{{ $previewData['screenshot_url'] }}"
alt="Preview of {{ $previewData['name'] }}"
class="w-full h-auto max-h-[480px] object-contain">
</div>
@elseif($previewData['logo_url'])
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
<img src="{{ $previewData['logo_url'] }}"
alt="{{ $previewData['name'] }} logo"
class="mx-auto h-32 w-auto object-contain mb-4">
<p class="text-gray-600 dark:text-gray-400">No preview image available</p>
</div>
@else
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
<flux:icon name="puzzle-piece" class="mx-auto h-32 w-32 text-gray-400 mb-4" />
<p class="text-gray-600 dark:text-gray-400">No preview available</p>
</div>
@endif
@if($previewData['description'])
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Description</h4>
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $previewData['description'] }}</p>
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<flux:heading size="sm" class="mb-2">Description</flux:heading>
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
</div>
@endif
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
<flux:modal.close>
<flux:button
wire:click="installPlugin('{{ $previewingPlugin }}')"
@ -276,54 +265,3 @@ class extends Component {
@endif
</flux:modal>
</div>
@script
<script>
let catalogScrollPosition = 0;
$wire.on('store-scroll-position', () => {
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
if (catalogModal) {
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
catalogScrollPosition = scrollContainer.scrollTop || 0;
}
});
$wire.on('restore-scroll-position', () => {
// Small delay to ensure modal is fully rendered
setTimeout(() => {
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
if (catalogModal) {
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
scrollContainer.scrollTop = catalogScrollPosition;
}
}, 100);
});
// Listen for when the catalog modal is opened and restore scroll position
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-flux-modal-open') {
const target = mutation.target;
if (target.getAttribute('data-flux-modal') === 'import-from-catalog' &&
target.getAttribute('data-flux-modal-open') === 'true') {
// Modal was opened, restore scroll position
setTimeout(() => {
const scrollContainer = target.querySelector('.space-y-4') || target;
if (catalogScrollPosition > 0) {
scrollContainer.scrollTop = catalogScrollPosition;
}
}, 100);
}
}
});
});
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
if (catalogModal) {
observer.observe(catalogModal, { attributes: true });
}
});
</script>
@endscript

View file

@ -1,20 +1,24 @@
<?php
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
new
#[Lazy]
class extends Component {
class extends Component
{
public array $recipes = [];
public string $search = '';
public bool $isSearching = false;
public string $previewingRecipe = '';
public array $previewData = [];
public function mount(): void
@ -44,16 +48,17 @@ class extends Component {
'sort-by' => 'newest',
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to fetch TRMNL recipes');
if (! $response->successful()) {
throw new RuntimeException('Failed to fetch TRMNL recipes');
}
$json = $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
});
} catch (\Throwable $e) {
Log::error('TRMNL catalog load error: ' . $e->getMessage());
} catch (Throwable $e) {
Log::error('TRMNL catalog load error: '.$e->getMessage());
$this->recipes = [];
}
}
@ -62,23 +67,24 @@ class extends Component {
{
$this->isSearching = true;
try {
$cacheKey = 'trmnl_recipes_search_' . md5($term);
$cacheKey = 'trmnl_recipes_search_'.md5($term);
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
$response = Http::get('https://usetrmnl.com/recipes.json', [
'search' => $term,
'sort-by' => 'newest',
]);
if (!$response->successful()) {
throw new \RuntimeException('Failed to search TRMNL recipes');
if (! $response->successful()) {
throw new RuntimeException('Failed to search TRMNL recipes');
}
$json = $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
});
} catch (\Throwable $e) {
Log::error('TRMNL catalog search error: ' . $e->getMessage());
} catch (Throwable $e) {
Log::error('TRMNL catalog search error: '.$e->getMessage());
$this->recipes = [];
} finally {
$this->isSearching = false;
@ -87,13 +93,14 @@ class extends Component {
public function updatedSearch(): void
{
$term = trim($this->search);
$term = mb_trim($this->search);
if ($term === '') {
$this->loadNewest();
return;
}
if (strlen($term) < 2) {
if (mb_strlen($term) < 2) {
// Require at least 2 chars to avoid noisy calls
return;
}
@ -121,35 +128,44 @@ class extends Component {
$this->dispatch('plugin-installed');
Flux::modal('import-from-trmnl-catalog')->close();
} catch (\Exception $e) {
Log::error('Plugin installation failed: ' . $e->getMessage());
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
} catch (Exception $e) {
Log::error('Plugin installation failed: '.$e->getMessage());
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
}
}
public function previewRecipe(string $recipeId): void
{
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
if (!$recipe) {
$this->addError('preview', 'Recipe not found.');
return;
}
$this->previewingRecipe = $recipeId;
$this->previewData = $recipe;
// Store scroll position for restoration later
$this->dispatch('store-scroll-position');
}
public function closePreview(): void
{
$this->previewingRecipe = '';
$this->previewData = [];
// Restore scroll position when returning to catalog
$this->dispatch('restore-scroll-position');
try {
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
if ($response->successful()) {
$item = $response->json()['data'] ?? [];
$this->previewData = $this->mapRecipe($item);
} else {
// Fallback to searching for the specific recipe if single endpoint doesn't exist
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
'search' => $recipeId,
]);
if ($response->successful()) {
$data = $response->json()['data'] ?? [];
$item = collect($data)->firstWhere('id', $recipeId);
if ($item) {
$this->previewData = $this->mapRecipe($item);
}
}
}
} catch (Throwable $e) {
Log::error('TRMNL catalog preview fetch error: '.$e->getMessage());
}
if (empty($this->previewData)) {
$this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? [];
}
}
/**
@ -159,7 +175,16 @@ class extends Component {
private function mapRecipes(array $items): array
{
return collect($items)
->map(function (array $item) {
->map(fn (array $item) => $this->mapRecipe($item))
->toArray();
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
private function mapRecipe(array $item): array
{
return [
'id' => $item['id'] ?? null,
'name' => $item['name'] ?? 'Untitled',
@ -172,10 +197,8 @@ class extends Component {
'installs' => data_get($item, 'stats.installs'),
'forks' => data_get($item, 'stats.forks'),
],
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null,
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
];
})
->toArray();
}
}; ?>
@ -188,7 +211,7 @@ class extends Component {
icon="magnifying-glass"
/>
</div>
<flux:badge color="gray">Newest</flux:badge>
<flux:badge color="zinc">Newest</flux:badge>
</div>
@error('installation')
@ -197,7 +220,7 @@ class extends Component {
@if(empty($recipes))
<div class="text-center py-8">
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading class="mt-2">No recipes found</flux:heading>
<flux:subheading>Try a different search term</flux:subheading>
</div>
@ -211,8 +234,8 @@ class extends Component {
@if($thumb)
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
</div>
@endif
@ -221,12 +244,12 @@ class extends Component {
<div>
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
@if(data_get($recipe, 'stats.installs'))
<flux:text size="sm" class="text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
@endif
</div>
<div class="flex items-center space-x-2">
@if($recipe['detail_url'])
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
</a>
@endif
@ -246,7 +269,7 @@ class extends Component {
</flux:button>
@endif
@if($recipe['id'])
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
<flux:modal.trigger name="trmnl-catalog-preview">
<flux:button
wire:click="previewRecipe('{{ $recipe['id'] }}')"
@ -267,31 +290,25 @@ class extends Component {
<!-- Preview Modal -->
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
<div wire:loading wire:target="previewRecipe" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Fetching recipe details...</flux:text>
</div>
</div>
<div wire:loading.remove wire:target="previewRecipe">
@if($previewingRecipe && !empty($previewData))
<div>
<flux:heading size="lg">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
<flux:heading size="lg" class="mb-2">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
</div>
<div class="space-y-4">
@if($previewData['screenshot_url'])
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<img src="{{ $previewData['screenshot_url'] }}"
alt="Preview of {{ $previewData['name'] }}"
class="w-full h-auto max-h-[480px] object-contain">
</div>
@elseif($previewData['icon_url'])
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
<img src="{{ $previewData['icon_url'] }}"
alt="{{ $previewData['name'] }} icon"
class="mx-auto h-32 w-auto object-contain mb-4">
<flux:text class="text-gray-600 dark:text-gray-400">No preview image available</flux:text>
</div>
@else
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
<flux:icon name="puzzle-piece" class="mx-auto h-32 w-32 text-gray-400 mb-4" />
<flux:text class="text-gray-600 dark:text-gray-400">No preview available</flux:text>
</div>
@endif
@if($previewData['author_bio'])
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
@ -314,7 +331,7 @@ class extends Component {
</div>
@endif
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
@if($previewData['detail_url'])
<flux:button
href="{{ $previewData['detail_url'] }}"
@ -333,56 +350,6 @@ class extends Component {
</div>
</div>
@endif
</div>
</flux:modal>
</div>
@script
<script>
let trmnlCatalogScrollPosition = 0;
$wire.on('store-scroll-position', () => {
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
if (catalogModal) {
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
trmnlCatalogScrollPosition = scrollContainer.scrollTop || 0;
}
});
$wire.on('restore-scroll-position', () => {
// Small delay to ensure modal is fully rendered
setTimeout(() => {
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
if (catalogModal) {
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
scrollContainer.scrollTop = trmnlCatalogScrollPosition;
}
}, 100);
});
// Listen for when the catalog modal is opened and restore scroll position
document.addEventListener('DOMContentLoaded', function() {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-flux-modal-open') {
const target = mutation.target;
if (target.getAttribute('data-flux-modal') === 'import-from-trmnl-catalog' &&
target.getAttribute('data-flux-modal-open') === 'true') {
// Modal was opened, restore scroll position
setTimeout(() => {
const scrollContainer = target.querySelector('.space-y-4') || target;
if (trmnlCatalogScrollPosition > 0) {
scrollContainer.scrollTop = trmnlCatalogScrollPosition;
}
}, 100);
}
}
});
});
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
if (catalogModal) {
observer.observe(catalogModal, { attributes: true });
}
});
</script>
@endscript

View file

@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void {
$component->assertSee('testuser');
$component->assertSee('A test plugin');
$component->assertSee('MIT');
$component->assertSee('Preview');
});
it('hides preview button when screenshot_url is missing', function (): void {
// Clear cache first to ensure fresh data
Cache::forget('catalog_plugins');
// Mock the HTTP response for the catalog URL without screenshot_url
$catalogData = [
'test-plugin' => [
'name' => 'Test Plugin Without Screenshot',
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
'author_bio' => [
'description' => 'A test plugin',
],
'license' => 'MIT',
'trmnlp' => [
'zip_url' => 'https://example.com/plugin.zip',
],
'byos' => [
'byos_laravel' => [
'compatibility' => true,
],
],
'logo_url' => 'https://example.com/logo.png',
'screenshot_url' => null,
],
];
$yamlContent = Yaml::dump($catalogData);
Http::fake([
config('app.catalog_url') => Http::response($yamlContent, 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.index')
->assertSee('Test Plugin Without Screenshot')
->assertDontSeeHtml('variant="subtle" icon="eye"');
});
it('shows error when plugin not found', function (): void {
@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void {
$component->assertHasErrors();
});
it('can preview a plugin', function (): void {
// Clear cache first to ensure fresh data
Cache::forget('catalog_plugins');
// Mock the HTTP response for the catalog URL
$catalogData = [
'test-plugin' => [
'name' => 'Test Plugin',
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
'author_bio' => [
'description' => 'A test plugin description',
],
'license' => 'MIT',
'trmnlp' => [
'zip_url' => 'https://example.com/plugin.zip',
],
'byos' => [
'byos_laravel' => [
'compatibility' => true,
],
],
'logo_url' => 'https://example.com/logo.png',
'screenshot_url' => 'https://example.com/screenshot.png',
],
];
$yamlContent = Yaml::dump($catalogData);
Http::fake([
config('app.catalog_url') => Http::response($yamlContent, 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.index')
->assertSee('Test Plugin')
->call('previewPlugin', 'test-plugin')
->assertSet('previewingPlugin', 'test-plugin')
->assertSet('previewData.name', 'Test Plugin')
->assertSee('Preview Test Plugin')
->assertSee('A test plugin description');
});

View file

@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void {
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->assertSee('Install')
->assertDontSeeHtml('variant="subtle" icon="eye"')
->assertSee('Installs: 10');
});
it('shows preview button when screenshot_url is provided', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/screenshot.png',
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->assertSee('Preview');
});
it('searches TRMNL recipes when search term is provided', function (): void {
Http::fake([
// First call (mount -> newest)
@ -152,3 +176,41 @@ it('shows error when plugin installation fails', function (): void {
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
});
it('previews a recipe with async fetch', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/old.png',
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/recipes/123.json' => Http::response([
'data' => [
'id' => 123,
'name' => 'Weather Chum Updated',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/new.png',
'author_bio' => ['description' => 'New bio'],
'stats' => ['installs' => 11, 'forks' => 3],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('previewRecipe', '123')
->assertSet('previewingRecipe', '123')
->assertSet('previewData.name', 'Weather Chum Updated')
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
->assertSee('Preview Weather Chum Updated')
->assertSee('New bio');
});