113 - Filter articles by feed on both tabs

This commit is contained in:
myrmidex 2026-08-09 09:41:47 +02:00
parent 03282d2463
commit 817219df61
3 changed files with 210 additions and 13 deletions

View file

@ -21,6 +21,8 @@ class Articles extends Component
public string $search = '';
public ?int $feedId = null;
public bool $isRefreshing = false;
/** @var array<int, bool> */
@ -34,6 +36,12 @@ public function setTab(string $tab): void
$this->resetPage();
}
public function updatedFeedId(): void
{
$this->expandedFeeds = [];
$this->resetPage();
}
public function toggleFeed(int $feedId): void
{
if (isset($this->expandedFeeds[$feedId])) {
@ -89,6 +97,7 @@ public function render(): View
return view('livewire.articles', [
'routeArticles' => null,
'pendingFeeds' => $this->pendingFeeds(),
'feedOptions' => $this->feedOptions(),
'pendingCount' => $pendingCount,
])->layout('layouts.app');
}
@ -96,6 +105,10 @@ public function render(): View
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
->orderBy('created_at', 'desc');
if ($this->feedId !== null) {
$query->where('feed_id', $this->feedId);
}
if ($this->search !== '') {
$search = $this->search;
$query->whereHas('article', function ($q) use ($search) {
@ -107,6 +120,7 @@ public function render(): View
return view('livewire.articles', [
'routeArticles' => $query->paginate(15),
'pendingFeeds' => null,
'feedOptions' => $this->feedOptions(),
'pendingCount' => $pendingCount,
])->layout('layouts.app');
}
@ -119,6 +133,7 @@ private function pendingFeeds(): Collection
$counts = RouteArticle::query()
->selectRaw('feed_id, COUNT(*) as aggregate')
->where('approval_status', ApprovalStatusEnum::PENDING)
->when($this->feedId !== null, fn ($q) => $q->where('feed_id', $this->feedId))
->groupBy('feed_id')
->pluck('aggregate', 'feed_id');
@ -134,6 +149,16 @@ private function pendingFeeds(): Collection
->values();
}
/**
* @return EloquentCollection<int, Feed>
*/
private function feedOptions(): EloquentCollection
{
return Feed::whereIn('id', RouteArticle::query()->select('feed_id')->distinct())
->orderBy('name')
->get();
}
/**
* @return EloquentCollection<int, RouteArticle>
*/

View file

@ -39,7 +39,29 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'a
</div>
{{-- Tab actions --}}
<div class="mb-4 flex items-center justify-between">
<div class="mb-4 flex items-center justify-between gap-x-3">
<div class="flex items-center gap-x-3 flex-1">
<select
wire:model.live="feedId"
aria-label="Filter by feed"
class="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">All feeds</option>
@foreach ($feedOptions as $feedOption)
<option value="{{ $feedOption->id }}">{{ $feedOption->name }}</option>
@endforeach
</select>
@if ($tab === 'all')
<div class="flex-1 max-w-sm">
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search articles..."
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
@endif
</div>
@if ($tab === 'pending' && $pendingCount > 0)
<button
wire:click="clear"
@ -51,17 +73,6 @@ class="inline-flex items-center px-3 py-2 border border-transparent text-sm lead
</svg>
Clear All
</button>
@elseif ($tab === 'all')
<div class="flex-1 max-w-sm">
<input
type="text"
wire:model.live.debounce.300ms="search"
placeholder="Search articles..."
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
</div>
@else
<div></div>
@endif
</div>
@ -115,10 +126,14 @@ class="h-4 w-4 shrink-0 text-gray-400 transition-transform {{ $isExpanded ? 'rot
@endif
</h3>
<p class="mt-1 text-sm text-gray-500">
@if ($tab === 'pending')
@if ($tab === 'pending' && $feedId !== null)
No pending articles for the selected feed.
@elseif ($tab === 'pending')
All route articles have been reviewed.
@elseif ($search !== '')
No results for "{{ $search }}".
@elseif ($feedId !== null)
No articles for the selected feed.
@else
No route articles have been created yet.
@endif

View file

@ -11,6 +11,7 @@
use App\Models\RouteArticle;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\TestCase;
@ -51,6 +52,17 @@ private function createRouteArticleForFeed(Feed $feed, string $title, ApprovalSt
return $routeArticle;
}
/**
* @param Testable<Articles> $component
* @return array<int, int>
*/
private function renderedFeedGroupIds(Testable $component): array
{
preg_match_all('/wire:key="feed-group-(\d+)"/', $component->html(), $matches);
return array_map('intval', $matches[1]);
}
public function test_renders_successfully(): void
{
Livewire::test(Articles::class)
@ -289,6 +301,151 @@ public function test_all_tab_shows_a_flat_list_not_feed_groups(): void
->assertSee('Belga Article');
}
public function test_feed_filter_narrows_pending_tab_to_one_feed(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$belga = Feed::factory()->create(['name' => 'Belga Press']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
$this->createRouteArticleForFeed($belga, 'Belga Headline');
$component = Livewire::test(Articles::class)->set('feedId', $vrt->id);
$this->assertSame([$vrt->id], $this->renderedFeedGroupIds($component));
}
public function test_feed_filter_narrows_all_tab_to_one_feed(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$belga = Feed::factory()->create(['name' => 'Belga Press']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
$this->createRouteArticleForFeed($belga, 'Belga Headline');
Livewire::test(Articles::class)
->call('setTab', 'all')
->set('feedId', $vrt->id)
->assertSee('VRT Headline')
->assertDontSee('Belga Headline');
}
public function test_feed_filter_combines_with_search_on_all_tab(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$belga = Feed::factory()->create(['name' => 'Belga Press']);
$this->createRouteArticleForFeed($vrt, 'Belgian Politics Update');
$this->createRouteArticleForFeed($vrt, 'Weather Forecast Today');
$this->createRouteArticleForFeed($belga, 'Belgian Economy Report');
Livewire::test(Articles::class)
->call('setTab', 'all')
->set('feedId', $vrt->id)
->set('search', 'Belgian')
->assertSee('Belgian Politics Update')
->assertDontSee('Weather Forecast Today')
->assertDontSee('Belgian Economy Report');
}
public function test_feed_filter_combines_with_tab_status_filtering(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$this->createRouteArticleForFeed($vrt, 'VRT Pending');
$this->createRouteArticleForFeed($vrt, 'VRT Approved', ApprovalStatusEnum::APPROVED);
Livewire::test(Articles::class)
->set('feedId', $vrt->id)
->call('toggleFeed', $vrt->id)
->assertSee('VRT Pending')
->assertDontSee('VRT Approved');
}
public function test_clearing_feed_filter_restores_all_feeds(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$belga = Feed::factory()->create(['name' => 'Belga Press']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
$this->createRouteArticleForFeed($belga, 'Belga Headline');
$component = Livewire::test(Articles::class)->set('feedId', $vrt->id);
$this->assertSame([$vrt->id], $this->renderedFeedGroupIds($component));
$component->set('feedId', null);
$this->assertEqualsCanonicalizing([$vrt->id, $belga->id], $this->renderedFeedGroupIds($component));
}
public function test_changing_feed_filter_resets_pagination(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
for ($i = 0; $i < 20; $i++) {
$this->createRouteArticleForFeed($vrt, "VRT Article {$i}");
}
Livewire::test(Articles::class)
->call('setTab', 'all')
->set('paginators.page', 2)
->set('feedId', $vrt->id)
->assertSet('paginators.page', 1);
}
public function test_changing_feed_filter_collapses_expanded_feeds(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
Livewire::test(Articles::class)
->call('toggleFeed', $vrt->id)
->assertSee('VRT Headline')
->set('feedId', $vrt->id)
->assertSet('expandedFeeds', [])
->assertDontSee('VRT Headline');
}
public function test_feed_filter_persists_across_tab_switches(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
Livewire::test(Articles::class)
->set('feedId', $vrt->id)
->call('setTab', 'all')
->assertSet('feedId', $vrt->id)
->call('setTab', 'pending')
->assertSet('feedId', $vrt->id);
}
public function test_feed_filter_options_only_include_feeds_with_route_articles(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$unused = Feed::factory()->create(['name' => 'Unused Feed']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
Livewire::test(Articles::class)
->assertSeeHtml('<option value="'.$vrt->id.'">VRT News</option>')
->assertDontSeeHtml('<option value="'.$unused->id.'">Unused Feed</option>');
}
public function test_empty_state_when_feed_filter_matches_nothing_on_pending_tab(): void
{
$vrt = Feed::factory()->create(['name' => 'VRT News']);
$belga = Feed::factory()->create(['name' => 'Belga Press']);
$this->createRouteArticleForFeed($vrt, 'VRT Headline');
$this->createRouteArticleForFeed($belga, 'Belga Approved', ApprovalStatusEnum::APPROVED);
Livewire::test(Articles::class)
->set('feedId', $belga->id)
->assertSee('No pending articles for the selected feed.');
}
public function test_empty_state_on_pending_tab(): void
{
Livewire::test(Articles::class)