Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/Livewire/Customer/Plugins/Show.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,17 @@ public function updateIcon(): void

$this->validate([
'iconGradient' => ['required', 'string', 'in:'.implode(',', array_keys(Plugin::gradientPresets()))],
'iconName' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9-]+$/'],
'iconName' => [
'required',
'string',
'max:100',
'regex:/^[a-z0-9-]+$/',
function (string $attribute, mixed $value, \Closure $fail) {
if (is_string($value) && ! Plugin::isValidIconName($value)) {
$fail('That isn\'t a Heroicon outline name. Browse the available icons at heroicons.com and use the name shown there, e.g. photo, map-pin, cube.');
}
},
],
]);

if ($this->plugin->logo_path) {
Expand Down
45 changes: 44 additions & 1 deletion app/Models/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use App\Services\OgImageService;
use App\Services\PluginSyncService;
use App\Support\PluginReadme;
use BladeUI\Icons\Exceptions\SvgNotFound;
use BladeUI\Icons\Factory as IconFactory;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
Expand Down Expand Up @@ -157,7 +159,7 @@ public function messages(): HasMany
{
return $this->hasMany(PluginActivity::class)
->messages()
->oldest();
->oldest('id');
}

/**
Expand Down Expand Up @@ -475,6 +477,47 @@ public function hasCustomIcon(): bool
return $this->hasLogo() || $this->hasGradientIcon();
}

/**
* The Heroicon used when a plugin has no icon, or an unrecognised one.
*/
public const DEFAULT_ICON_NAME = 'cube';

/**
* Whether the given name matches an outline Heroicon we can actually render.
*
* Icon names are free text typed in by developers, so they regularly don't
* exist (e.g. "image" instead of "photo", or "location" instead of "map-pin").
*/
public static function isValidIconName(?string $iconName): bool
{
if ($iconName === null || preg_match('/^[a-z0-9-]+$/', $iconName) !== 1) {
return false;
}

try {
app(IconFactory::class)->svg('heroicon-o-'.$iconName);
} catch (SvgNotFound) {
return false;
}

return true;
}

/**
* The Blade component name for this plugin's gradient icon.
*
* Always resolvable: an unknown icon name would otherwise throw out of
* `<x-dynamic-component>` and take down the whole page.
*/
public function getIconComponent(): string
{
$iconName = self::isValidIconName($this->icon_name)
? $this->icon_name
: self::DEFAULT_ICON_NAME;

return 'heroicon-o-'.$iconName;
}

/**
* Available gradient presets for plugin icons.
*
Expand Down
2 changes: 1 addition & 1 deletion resources/views/cart/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ class="flex gap-4 p-6"
<img src="{{ $item->plugin->getLogoUrl() }}" alt="{{ $item->plugin->name }}" class="size-16 rounded-lg object-cover" />
@elseif ($item->plugin->hasGradientIcon())
<div class="grid size-16 place-items-center rounded-lg bg-gradient-to-br {{ $item->plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $item->plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$item->plugin->getIconComponent()" class="size-8" />
</div>
@else
<div class="grid size-16 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/components/plugin-card.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class="size-12 shrink-0 rounded-xl object-cover"
/>
@elseif ($plugin->hasGradientIcon())
<div class="grid size-12 shrink-0 place-items-center rounded-xl bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-6" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-6" />
</div>
@else
<div class="grid size-12 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/customer/team/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }}" class="size-10 rounded-lg object-cover">
@elseif($plugin->hasGradientIcon())
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-5" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-5" />
</div>
@else
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/customer/ultra/index.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }}" class="size-10 rounded-lg object-cover">
@elseif($plugin->hasGradientIcon())
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-5" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-5" />
</div>
@else
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
12 changes: 6 additions & 6 deletions resources/views/livewire/customer/plugins/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }} logo" class="size-16 rounded-lg object-cover shadow-sm" />
@elseif ($plugin->hasGradientIcon())
<div class="grid size-16 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white shadow-sm">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-8" />
</div>
@endif
<flux:button size="sm" variant="danger" icon="trash" wire:click="deleteIcon" type="button">Remove icon</flux:button>
Expand Down Expand Up @@ -421,7 +421,7 @@ class="peer sr-only"
wire:model="iconName"
label="Heroicon name"
placeholder="cube"
description="Enter a Heroicon outline name, e.g., cube, sparkles, bolt."
description="Enter an outline icon name exactly as it appears on heroicons.com, e.g., cube, sparkles, bolt, photo, map-pin."
/>
@error('iconName')
<flux:text class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</flux:text>
Expand Down Expand Up @@ -567,7 +567,7 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }} logo" class="size-16 rounded-lg object-cover shadow-sm" />
@elseif ($plugin->hasGradientIcon())
<div class="grid size-16 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white shadow-sm">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-8" />
</div>
@endif
<flux:button size="sm" variant="danger" icon="trash" wire:click="deleteIcon" type="button">Remove icon</flux:button>
Expand Down Expand Up @@ -601,7 +601,7 @@ class="peer sr-only"
wire:model="iconName"
label="Heroicon name"
placeholder="cube"
description="Enter a Heroicon outline name, e.g., cube, sparkles, bolt."
description="Enter an outline icon name exactly as it appears on heroicons.com, e.g., cube, sparkles, bolt, photo, map-pin."
/>
@error('iconName')
<flux:text class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</flux:text>
Expand Down Expand Up @@ -675,7 +675,7 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }} logo" class="size-16 shrink-0 rounded-lg object-cover shadow-sm" />
@elseif ($plugin->hasGradientIcon())
<div class="grid size-16 shrink-0 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white shadow-sm">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-8" />
</div>
@else
<div class="grid size-16 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-sm">
Expand Down Expand Up @@ -825,7 +825,7 @@ class="block text-sm text-gray-500 file:mr-4 file:rounded-md file:border-0 file:
<img src="{{ $plugin->getLogoUrl() }}" alt="{{ $plugin->name }} logo" class="size-16 shrink-0 rounded-lg object-cover shadow-sm" />
@elseif ($plugin->hasGradientIcon())
<div class="grid size-16 shrink-0 place-items-center rounded-lg bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white shadow-sm">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-8" />
</div>
@else
<div class="grid size-16 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white shadow-sm">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<img src="{{ $pluginLicense->plugin->getLogoUrl() }}" alt="{{ $pluginLicense->plugin->name }}" class="size-10 rounded-lg object-cover">
@elseif($pluginLicense->plugin->hasGradientIcon())
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br {{ $pluginLicense->plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $pluginLicense->plugin->icon_name" class="size-5" />
<x-dynamic-component :component="$pluginLicense->plugin->getIconComponent()" class="size-5" />
</div>
@else
<div class="grid size-10 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
2 changes: 1 addition & 1 deletion resources/views/plugin-show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class="size-16 shrink-0 rounded-2xl object-cover"
/>
@elseif ($plugin->hasGradientIcon())
<div class="grid size-16 shrink-0 place-items-center rounded-2xl bg-gradient-to-br {{ $plugin->getGradientClasses() }} text-white">
<x-dynamic-component :component="'heroicon-o-' . $plugin->icon_name" class="size-8" />
<x-dynamic-component :component="$plugin->getIconComponent()" class="size-8" />
</div>
@else
<div class="grid size-16 shrink-0 place-items-center rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 text-white">
Expand Down
8 changes: 7 additions & 1 deletion tests/Feature/Livewire/Customer/PluginMessagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,19 @@ public function test_developer_can_reply_and_the_team_is_notified(): void

Notification::fake();

// Land the reply in a later second than the admin's message, so the
// thread ordering is exercised rather than hidden by equal timestamps.
$this->travel(1)->second();

$this->testable($plugin)
->set('replyMessage', 'Sure — added in v1.2.0.')
->call('sendMessage')
->assertHasNoErrors()
->assertSet('replyMessage', '');

$reply = $plugin->messages()->latest('id')->first();
// reorder() clears the relation's own "oldest first" sort; without it
// this only sorts by id within a single second of created_at.
$reply = $plugin->messages()->reorder()->latest('id')->first();

$this->assertSame(PluginActivityType::MessageFromDeveloper, $reply->type);
$this->assertSame('Sure — added in v1.2.0.', $reply->note);
Expand Down
159 changes: 159 additions & 0 deletions tests/Feature/PluginIconFallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

namespace Tests\Feature;

use App\Features\ShowPlugins;
use App\Livewire\Customer\Plugins\Show;
use App\Livewire\PluginDirectory;
use App\Models\DeveloperAccount;
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Pennant\Feature;
use Livewire\Livewire;
use Tests\TestCase;

class PluginIconFallbackTest extends TestCase
{
use RefreshDatabase;

protected function setUp(): void
{
parent::setUp();

Feature::define(ShowPlugins::class, true);
}

private function createUserWithGitHub(): User
{
$user = User::factory()->create([
'github_id' => '12345',
'github_token' => encrypt('fake-token'),
]);
DeveloperAccount::factory()->withAcceptedTerms()->create([
'user_id' => $user->id,
]);

return $user;
}

public function test_it_recognises_real_heroicon_outline_names(): void
{
$this->assertTrue(Plugin::isValidIconName('cube'));
$this->assertTrue(Plugin::isValidIconName('photo'));
$this->assertTrue(Plugin::isValidIconName('map-pin'));
}

public function test_it_rejects_names_that_are_not_heroicons(): void
{
// The names from the reported 500s: neither exists in Heroicons.
$this->assertFalse(Plugin::isValidIconName('image'));
$this->assertFalse(Plugin::isValidIconName('location'));

$this->assertFalse(Plugin::isValidIconName(null));
$this->assertFalse(Plugin::isValidIconName(''));
$this->assertFalse(Plugin::isValidIconName('Cube'));
$this->assertFalse(Plugin::isValidIconName('../../secret'));
}

public function test_icon_component_uses_the_stored_name_when_it_is_valid(): void
{
$plugin = Plugin::factory()->approved()->create([
'icon_gradient' => 'blue-cyan',
'icon_name' => 'map-pin',
]);

$this->assertSame('heroicon-o-map-pin', $plugin->getIconComponent());
}

public function test_icon_component_falls_back_when_the_stored_name_is_unknown(): void
{
$plugin = Plugin::factory()->approved()->create([
'icon_gradient' => 'blue-cyan',
'icon_name' => 'location',
]);

$this->assertSame('heroicon-o-cube', $plugin->getIconComponent());
}

public function test_developer_plugin_page_renders_with_an_unknown_stored_icon(): void
{
$user = $this->createUserWithGitHub();
$plugin = Plugin::factory()->approved()->for($user)->create([
'icon_gradient' => 'blue-cyan',
'icon_name' => 'location',
]);

[$vendor, $package] = explode('/', $plugin->name);

Livewire::actingAs($user)
->test(Show::class, ['vendor' => $vendor, 'package' => $package])
->assertStatus(200);
}

public function test_plugin_directory_renders_with_an_unknown_stored_icon(): void
{
Plugin::factory()->approved()->create([
'icon_gradient' => 'blue-cyan',
'icon_name' => 'image',
]);

Livewire::test(PluginDirectory::class)
->assertStatus(200);
}

public function test_public_plugin_listing_renders_with_an_unknown_stored_icon(): void
{
$plugin = Plugin::factory()->approved()->create([
'icon_gradient' => 'blue-cyan',
'icon_name' => 'image',
]);

$this->get(route('plugins.show', $plugin->routeParams()))
->assertOk();
}

public function test_updating_the_icon_rejects_a_name_that_is_not_a_heroicon(): void
{
$user = $this->createUserWithGitHub();
$plugin = Plugin::factory()->draft()->for($user)->create([
'icon_gradient' => null,
'icon_name' => null,
]);

[$vendor, $package] = explode('/', $plugin->name);

Livewire::actingAs($user)
->test(Show::class, ['vendor' => $vendor, 'package' => $package])
->set('iconGradient', 'blue-cyan')
->set('iconName', 'location')
->call('updateIcon')
->assertHasErrors('iconName');

$plugin->refresh();
$this->assertNull($plugin->icon_name);
$this->assertNull($plugin->icon_gradient);
}

public function test_updating_the_icon_accepts_a_real_heroicon(): void
{
$user = $this->createUserWithGitHub();
$plugin = Plugin::factory()->draft()->for($user)->create([
'icon_gradient' => null,
'icon_name' => null,
]);

[$vendor, $package] = explode('/', $plugin->name);

Livewire::actingAs($user)
->test(Show::class, ['vendor' => $vendor, 'package' => $package])
->set('iconGradient', 'blue-cyan')
->set('iconName', 'map-pin')
->call('updateIcon')
->assertHasNoErrors();

$plugin->refresh();
$this->assertSame('map-pin', $plugin->icon_name);
$this->assertSame('blue-cyan', $plugin->icon_gradient);
}
}