release/v1.3.2 #107

Merged
myrmidex merged 4 commits from release/v1.3.2 into main 2026-07-31 00:39:54 +02:00
3 changed files with 338 additions and 1 deletions
Showing only changes of commit 4b12f9df30 - Show all commits

View file

@ -2,12 +2,25 @@
namespace App\Livewire;
use App\Actions\CreateFeedAction;
use App\Models\Feed;
use App\Models\Language;
use Illuminate\Contracts\View\View;
use InvalidArgumentException;
use Livewire\Component;
class Feeds extends Component
{
public bool $showCreateModal = false;
public string $newName = '';
public string $newProvider = '';
public ?int $newLanguageId = null;
public string $newDescription = '';
public function toggle(int $feedId): void
{
$feed = Feed::findOrFail($feedId);
@ -15,12 +28,64 @@ public function toggle(int $feedId): void
$feed->save();
}
public function openCreateModal(): void
{
$this->reset(['newName', 'newProvider', 'newLanguageId', 'newDescription']);
$this->resetErrorBag();
$this->showCreateModal = true;
}
public function closeCreateModal(): void
{
$this->showCreateModal = false;
}
public function createFeed(CreateFeedAction $action): void
{
$providers = array_keys($this->activeProviders());
$this->validate([
'newName' => 'required|string|max:255',
'newProvider' => ['required', 'string', 'in:'.implode(',', $providers)],
'newLanguageId' => 'required|integer|exists:languages,id',
]);
try {
$action->execute(
$this->newName,
$this->newProvider,
$this->newLanguageId,
// Blade textarea binds an empty string when blank; the action expects null for "no description".
$this->newDescription !== '' ? $this->newDescription : null,
);
} catch (InvalidArgumentException $e) {
$this->addError('newProvider', 'This provider is not available for the selected language.');
return;
}
$this->closeCreateModal();
}
/**
* @return array<string, array<string, mixed>>
*/
private function activeProviders(): array
{
/** @var array<string, array<string, mixed>> $providers */
$providers = config('feed.providers', []);
return array_filter($providers, fn (array $provider): bool => ($provider['is_active'] ?? false) === true);
}
public function render(): View
{
$feeds = Feed::orderBy('name')->get();
return view('livewire.feeds', [
'feeds' => $feeds,
'providers' => $this->activeProviders(),
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
])->layout('layouts.app');
}
}

View file

@ -1,5 +1,15 @@
<div class="p-6">
<x-page-header title="Feeds" subtitle="Manage your news feed sources" />
<x-page-header title="Feeds" subtitle="Manage your news feed sources">
<button
wire:click="openCreateModal"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Feed
</button>
</x-page-header>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@forelse ($feeds as $feed)
@ -76,7 +86,112 @@ class="text-gray-400 hover:text-gray-600"
<p class="mt-1 text-sm text-gray-500">
No feeds have been configured yet.
</p>
<div class="mt-6">
<button
wire:click="openCreateModal"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Feed
</button>
</div>
</div>
@endforelse
</div>
<!-- Create Feed Modal -->
@if ($showCreateModal)
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" wire:click="closeCreateModal"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">Add Feed</h3>
<button wire:click="closeCreateModal" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<form wire:submit="createFeed" class="space-y-4">
<div>
<label for="new-feed-name" class="block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="new-feed-name"
wire:model="newName"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
/>
@error('newName') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="new-feed-provider" class="block text-sm font-medium text-gray-700">Provider</label>
<select
id="new-feed-provider"
wire:model="newProvider"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Select a provider</option>
@foreach ($providers as $code => $provider)
<option value="{{ $code }}">{{ $provider['name'] }}</option>
@endforeach
</select>
@error('newProvider') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="new-feed-language" class="block text-sm font-medium text-gray-700">Language</label>
<select
id="new-feed-language"
wire:model="newLanguageId"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
>
<option value="">Select a language</option>
@foreach ($languages as $language)
<option value="{{ $language->id }}">{{ $language->name }}</option>
@endforeach
</select>
@error('newLanguageId') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label for="new-feed-description" class="block text-sm font-medium text-gray-700">Description <span class="text-gray-400">(optional)</span></label>
<textarea
id="new-feed-description"
wire:model="newDescription"
rows="2"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
></textarea>
@error('newDescription') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
wire:click="closeCreateModal"
class="inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
<button
type="submit"
wire:loading.attr="disabled"
wire:target="createFeed"
class="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Livewire;
use App\Livewire\Feeds;
use App\Models\Feed;
use App\Models\Language;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class FeedsTest extends TestCase
{
use RefreshDatabase;
public function test_add_button_renders_when_feeds_exist(): void
{
Feed::factory()->create(['name' => 'Existing Feed']);
Livewire::test(Feeds::class)
->assertSee('Add Feed')
->assertSee('Existing Feed');
}
public function test_add_button_renders_in_empty_state(): void
{
Livewire::test(Feeds::class)
->assertSee('No feeds')
->assertSee('Add Feed');
}
public function test_open_create_modal_shows_modal(): void
{
Livewire::test(Feeds::class)
->assertSet('showCreateModal', false)
->call('openCreateModal')
->assertSet('showCreateModal', true);
}
public function test_create_feed_requires_name(): void
{
$language = Language::factory()->english()->create();
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newProvider', 'guardian')
->set('newLanguageId', $language->id)
->call('createFeed')
->assertHasErrors(['newName' => 'required']);
}
public function test_create_feed_requires_provider(): void
{
$language = Language::factory()->english()->create();
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Tech News')
->set('newLanguageId', $language->id)
->call('createFeed')
->assertHasErrors(['newProvider' => 'required']);
}
public function test_create_feed_requires_language(): void
{
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Tech News')
->set('newProvider', 'guardian')
->call('createFeed')
->assertHasErrors(['newLanguageId' => 'required']);
}
public function test_create_feed_rejects_unknown_provider(): void
{
$language = Language::factory()->english()->create();
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Tech News')
->set('newProvider', 'nonexistent')
->set('newLanguageId', $language->id)
->call('createFeed')
->assertHasErrors('newProvider');
$this->assertDatabaseCount('feeds', 0);
}
public function test_create_feed_succeeds_and_resolves_url(): void
{
$language = Language::factory()->english()->create();
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Guardian News')
->set('newProvider', 'guardian')
->set('newLanguageId', $language->id)
->set('newDescription', 'British daily')
->call('createFeed')
->assertHasNoErrors()
->assertSet('showCreateModal', false);
$this->assertDatabaseHas('feeds', [
'name' => 'Guardian News',
'provider' => 'guardian',
'language_id' => $language->id,
'url' => 'https://www.theguardian.com/international/rss',
'description' => 'British daily',
'is_active' => true,
]);
}
public function test_create_feed_leaves_description_null_when_blank(): void
{
$language = Language::factory()->english()->create();
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Guardian News')
->set('newProvider', 'guardian')
->set('newLanguageId', $language->id)
->call('createFeed')
->assertHasNoErrors();
$this->assertDatabaseHas('feeds', [
'name' => 'Guardian News',
'description' => null,
]);
}
public function test_create_feed_surfaces_invalid_provider_language_combination(): void
{
// Belga only supports 'en'; a non-'en' language triggers InvalidArgumentException in the action.
$language = Language::factory()->create(['short_code' => 'nl', 'name' => 'Dutch', 'is_active' => true]);
Livewire::test(Feeds::class)
->call('openCreateModal')
->set('newName', 'Belga NL')
->set('newProvider', 'belga')
->set('newLanguageId', $language->id)
->call('createFeed')
->assertHasErrors('newProvider')
->assertSet('showCreateModal', true);
$this->assertDatabaseCount('feeds', 0);
}
public function test_toggle_flips_active_state(): void
{
$feed = Feed::factory()->create(['is_active' => true]);
Livewire::test(Feeds::class)
->call('toggle', $feed->id);
$this->assertFalse($feed->fresh()->is_active);
}
}