106 - Add feed creation modal and Add button to Feeds page
This commit is contained in:
parent
4b0d17e2ae
commit
4b12f9df30
3 changed files with 338 additions and 1 deletions
|
|
@ -2,12 +2,25 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Actions\CreateFeedAction;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
|
use App\Models\Language;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Feeds extends 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
|
public function toggle(int $feedId): void
|
||||||
{
|
{
|
||||||
$feed = Feed::findOrFail($feedId);
|
$feed = Feed::findOrFail($feedId);
|
||||||
|
|
@ -15,12 +28,64 @@ public function toggle(int $feedId): void
|
||||||
$feed->save();
|
$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
|
public function render(): View
|
||||||
{
|
{
|
||||||
$feeds = Feed::orderBy('name')->get();
|
$feeds = Feed::orderBy('name')->get();
|
||||||
|
|
||||||
return view('livewire.feeds', [
|
return view('livewire.feeds', [
|
||||||
'feeds' => $feeds,
|
'feeds' => $feeds,
|
||||||
|
'providers' => $this->activeProviders(),
|
||||||
|
'languages' => Language::where('is_active', true)->orderBy('name')->get(),
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
<div class="p-6">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@forelse ($feeds as $feed)
|
@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">
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
No feeds have been configured yet.
|
No feeds have been configured yet.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
@endforelse
|
@endforelse
|
||||||
</div>
|
</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">​</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>
|
</div>
|
||||||
|
|
|
||||||
157
tests/Feature/Livewire/FeedsTest.php
Normal file
157
tests/Feature/Livewire/FeedsTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue