feat: add installation function

This commit is contained in:
Benjamin Nussbaum 2025-10-29 10:47:27 +01:00
parent 73eabe8262
commit a7a541da42
2 changed files with 122 additions and 13 deletions

View file

@ -4,12 +4,14 @@ use Livewire\Volt\Component;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Auth;
new class extends Component { new class extends Component {
public array $recipes = []; public array $recipes = [];
public string $search = ''; public string $search = '';
public string $error = '';
public bool $isSearching = false; public bool $isSearching = false;
public string $installingPlugin = '';
public function mount(): void public function mount(): void
{ {
@ -18,7 +20,6 @@ new class extends Component {
private function loadNewest(): void private function loadNewest(): void
{ {
$this->error = '';
try { try {
$this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () { $this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () {
$response = Http::get('https://usetrmnl.com/recipes.json', [ $response = Http::get('https://usetrmnl.com/recipes.json', [
@ -36,13 +37,11 @@ new class extends Component {
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('TRMNL catalog load error: ' . $e->getMessage()); Log::error('TRMNL catalog load error: ' . $e->getMessage());
$this->recipes = []; $this->recipes = [];
$this->error = 'Failed to load the TRMNL catalog. Please try again later.';
} }
} }
private function searchRecipes(string $term): void private function searchRecipes(string $term): void
{ {
$this->error = '';
$this->isSearching = true; $this->isSearching = true;
try { try {
$cacheKey = 'trmnl_recipes_search_' . md5($term); $cacheKey = 'trmnl_recipes_search_' . md5($term);
@ -63,7 +62,6 @@ new class extends Component {
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('TRMNL catalog search error: ' . $e->getMessage()); Log::error('TRMNL catalog search error: ' . $e->getMessage());
$this->recipes = []; $this->recipes = [];
$this->error = 'Search failed. Please try again later.';
} finally { } finally {
$this->isSearching = false; $this->isSearching = false;
} }
@ -85,6 +83,27 @@ new class extends Component {
$this->searchRecipes($term); $this->searchRecipes($term);
} }
public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void
{
abort_unless(auth()->user() !== null, 403);
$this->installingPlugin = $recipeId;
try {
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
$plugin = $pluginImportService->importFromUrl($zipUrl, auth()->user());
$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());
} finally {
$this->installingPlugin = '';
}
}
/** /**
* @param array<int, array<string, mixed>> $items * @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
@ -124,9 +143,9 @@ new class extends Component {
<flux:badge color="gray">Newest</flux:badge> <flux:badge color="gray">Newest</flux:badge>
</div> </div>
@if($error) @error('installation')
<flux:callout variant="danger" icon="x-circle" heading="{{ $error }}" /> <flux:callout variant="danger" icon="x-circle" heading="{{$message}}" />
@endif @enderror
@if(empty($recipes)) @if(empty($recipes))
<div class="text-center py-8"> <div class="text-center py-8">
@ -170,11 +189,22 @@ new class extends Component {
@endif @endif
<div class="mt-4 flex items-center space-x-3"> <div class="mt-4 flex items-center space-x-3">
<flux:tooltip text="Installation via cloud coming soon"> @if($recipe['id'])
<flux:button disabled variant="primary"> @if($installingPlugin === $recipe['id'])
Install <flux:button
</flux:button> wire:click="installPlugin('{{ $recipe['id'] }}')"
</flux:tooltip> variant="primary"
disabled>
<flux:icon name="arrow-path" class="w-4 h-4 animate-spin" />
</flux:button>
@else
<flux:button
wire:click="installPlugin('{{ $recipe['id'] }}')"
variant="primary">
Install
</flux:button>
@endif
@endif
@if($recipe['detail_url']) @if($recipe['detail_url'])
<flux:button <flux:button

View file

@ -2,8 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User;
use App\Services\PluginImportService;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Volt\Volt; use Livewire\Volt\Volt;
use Mockery\MockInterface;
it('loads newest TRMNL recipes on mount', function () { it('loads newest TRMNL recipes on mount', function () {
Http::fake([ Http::fake([
@ -64,3 +67,79 @@ it('searches TRMNL recipes when search term is provided', function () {
->assertSee('Weather Search Result') ->assertSee('Weather Search Result')
->assertSee('Install'); ->assertSee('Install');
}); });
it('installs plugin successfully when user is authenticated', function () {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
]);
$this->actingAs($user);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
});
it('shows error when user is not authenticated', function () {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertStatus(403); // This will return 403 because user is not authenticated
});
it('shows error when plugin installation fails', function () {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
]);
$this->actingAs($user);
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
});