From 03282d24639159277632f82b4a118cff07e45acd Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 9 Aug 2026 02:19:31 +0200 Subject: [PATCH] 113 - Collapse pending articles into per-feed accordions --- app/Livewire/Articles.php | 75 +++++++++-- app/Support/PendingFeedGroup.php | 24 ++++ .../components/route-article-card.blade.php | 93 +++++++++++++ resources/views/livewire/articles.blade.php | 127 +++++------------- tests/Feature/Livewire/ArticlesTest.php | 126 ++++++++++++++++- 5 files changed, 342 insertions(+), 103 deletions(-) create mode 100644 app/Support/PendingFeedGroup.php create mode 100644 resources/views/components/route-article-card.blade.php diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 2d26538f..1375748e 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -4,8 +4,12 @@ use App\Enums\ApprovalStatusEnum; use App\Jobs\ArticleDiscoveryJob; +use App\Models\Feed; use App\Models\RouteArticle; +use App\Support\PendingFeedGroup; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Collection; use Livewire\Component; use Livewire\WithPagination; @@ -19,13 +23,28 @@ class Articles extends Component public bool $isRefreshing = false; + /** @var array */ + public array $expandedFeeds = []; + public function setTab(string $tab): void { $this->tab = $tab; $this->search = ''; + $this->expandedFeeds = []; $this->resetPage(); } + public function toggleFeed(int $feedId): void + { + if (isset($this->expandedFeeds[$feedId])) { + unset($this->expandedFeeds[$feedId]); + + return; + } + + $this->expandedFeeds[$feedId] = true; + } + public function updatedSearch(): void { $this->resetPage(); @@ -64,12 +83,20 @@ public function refresh(): void public function render(): View { + $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); + + if ($this->tab === 'pending') { + return view('livewire.articles', [ + 'routeArticles' => null, + 'pendingFeeds' => $this->pendingFeeds(), + 'pendingCount' => $pendingCount, + ])->layout('layouts.app'); + } + $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) ->orderBy('created_at', 'desc'); - if ($this->tab === 'pending') { - $query->where('approval_status', ApprovalStatusEnum::PENDING); - } elseif ($this->search !== '') { + if ($this->search !== '') { $search = $this->search; $query->whereHas('article', function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") @@ -77,13 +104,45 @@ public function render(): View }); } - $routeArticles = $query->paginate(15); - - $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); - return view('livewire.articles', [ - 'routeArticles' => $routeArticles, + 'routeArticles' => $query->paginate(15), + 'pendingFeeds' => null, 'pendingCount' => $pendingCount, ])->layout('layouts.app'); } + + /** + * @return Collection + */ + private function pendingFeeds(): Collection + { + $counts = RouteArticle::query() + ->selectRaw('feed_id, COUNT(*) as aggregate') + ->where('approval_status', ApprovalStatusEnum::PENDING) + ->groupBy('feed_id') + ->pluck('aggregate', 'feed_id'); + + return Feed::whereIn('id', $counts->keys())->get() + ->map(fn (Feed $feed): PendingFeedGroup => new PendingFeedGroup( + $feed, + (int) $counts->get($feed->id, 0), + isset($this->expandedFeeds[$feed->id]) + ? $this->routeArticlesForFeed($feed->id) + : null, + )) + ->sortByDesc('count') + ->values(); + } + + /** + * @return EloquentCollection + */ + private function routeArticlesForFeed(int $feedId): EloquentCollection + { + return RouteArticle::with(['article.feed', 'feed', 'platformChannel']) + ->where('approval_status', ApprovalStatusEnum::PENDING) + ->where('feed_id', $feedId) + ->orderBy('created_at', 'desc') + ->get(); + } } diff --git a/app/Support/PendingFeedGroup.php b/app/Support/PendingFeedGroup.php new file mode 100644 index 00000000..d07019c6 --- /dev/null +++ b/app/Support/PendingFeedGroup.php @@ -0,0 +1,24 @@ +|null $routeArticles + */ + public function __construct( + public readonly Feed $feed, + public readonly int $count, + public readonly ?Collection $routeArticles, + ) {} + + public function isExpanded(): bool + { + return $this->routeArticles !== null; + } +} diff --git a/resources/views/components/route-article-card.blade.php b/resources/views/components/route-article-card.blade.php new file mode 100644 index 00000000..1511c300 --- /dev/null +++ b/resources/views/components/route-article-card.blade.php @@ -0,0 +1,93 @@ +@props(['routeArticle', 'tab']) + +
+
+
+
+ @if ($routeArticle->feed) + + @endif + {{ $routeArticle->feed?->name ?? 'Unknown feed' }} + + {{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }} +
+

+ {{ $routeArticle->article->title ?? 'Untitled Article' }} +

+

+ {{ $routeArticle->article->description ?? 'No description available' }} +

+
+ {{ $routeArticle->created_at->format('M d, Y H:i') }} +
+
+
+ {{-- Status badge (All tab) --}} + @if ($tab === 'all') + @if ($routeArticle->isApproved()) + + Approved + + @elseif ($routeArticle->isRejected()) + + Rejected + + @else + + Pending + + @endif + @endif + + {{-- Action buttons --}} + @if ($routeArticle->isPending()) + + + @elseif ($routeArticle->isRejected()) + + @endif + + {{-- Link to original --}} + @if ($routeArticle->article->url) + + + + + + @endif +
+
+
diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 49ec9be8..91392bd0 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -67,99 +67,42 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline- {{-- Route articles list --}}
- @forelse ($routeArticles as $routeArticle) -
-
-
-
isNotEmpty()) + @foreach ($pendingFeeds as $group) + @php($isExpanded = $group->isExpanded()) +
+ + @if ($isExpanded) +
+ @foreach ($group->routeArticles as $routeArticle) + + @endforeach
-

- {{ $routeArticle->article->title ?? 'Untitled Article' }} -

-

- {{ $routeArticle->article->description ?? 'No description available' }} -

-
- {{ $routeArticle->created_at->format('M d, Y H:i') }} -
-
-
- {{-- Status badge (All tab) --}} - @if ($tab === 'all') - @if ($routeArticle->isApproved()) - - Approved - - @elseif ($routeArticle->isRejected()) - - Rejected - - @else - - Pending - - @endif - @endif - - {{-- Action buttons --}} - @if ($routeArticle->isPending()) - - - @elseif ($routeArticle->isRejected()) - - @endif - - {{-- Link to original --}} - @if ($routeArticle->article->url) - - - - - - @endif -
+ @endif
-
- @empty + @endforeach + @elseif ($routeArticles !== null && $routeArticles->isNotEmpty()) + @foreach ($routeArticles as $routeArticle) + + @endforeach + @else
@@ -181,9 +124,9 @@ class="p-1.5 text-gray-400 hover:text-gray-600 rounded-md" @endif

- @endforelse + @endif - @if ($routeArticles->hasPages()) + @if ($routeArticles !== null && $routeArticles->hasPages())
{{ $routeArticles->links() }}
diff --git a/tests/Feature/Livewire/ArticlesTest.php b/tests/Feature/Livewire/ArticlesTest.php index 0b7752ad..b56c3689 100644 --- a/tests/Feature/Livewire/ArticlesTest.php +++ b/tests/Feature/Livewire/ArticlesTest.php @@ -35,6 +35,22 @@ private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusE return $routeArticle; } + private function createRouteArticleForFeed(Feed $feed, string $title, ApprovalStatusEnum $status = ApprovalStatusEnum::PENDING): RouteArticle + { + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'title' => $title]); + + /** @var RouteArticle $routeArticle */ + $routeArticle = RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => $status, + 'validated_at' => now(), + ]); + + return $routeArticle; + } + public function test_renders_successfully(): void { Livewire::test(Articles::class) @@ -49,11 +65,14 @@ public function test_defaults_to_pending_tab(): void public function test_pending_tab_shows_only_pending_route_articles(): void { - $pending = $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article'); - $approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article'); - $rejected = $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article'); + $feed = Feed::factory()->create(['name' => 'VRT News']); + + $this->createRouteArticleForFeed($feed, 'Pending Article'); + $this->createRouteArticleForFeed($feed, 'Approved Article', ApprovalStatusEnum::APPROVED); + $this->createRouteArticleForFeed($feed, 'Rejected Article', ApprovalStatusEnum::REJECTED); Livewire::test(Articles::class) + ->call('toggleFeed', $feed->id) ->assertSee('Pending Article') ->assertDontSee('Approved Article') ->assertDontSee('Rejected Article'); @@ -169,6 +188,107 @@ public function test_shows_route_name_in_listing(): void ->assertSee('VRT News'); } + public function test_pending_tab_lists_feeds_collapsed_without_articles(): 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) + ->assertSee('VRT News') + ->assertSee('Belga Press') + ->assertDontSee('VRT Headline') + ->assertDontSee('Belga Headline'); + } + + public function test_pending_feed_badge_shows_total_count_not_page_count(): void + { + $vrt = Feed::factory()->create(['name' => 'VRT News']); + + for ($i = 0; $i < 20; $i++) { + $this->createRouteArticleForFeed($vrt, "VRT Article {$i}"); + } + + $html = (string) preg_replace('/\s+/', ' ', Livewire::test(Articles::class)->html()); + + $this->assertStringContainsString('VRT News 20 ', $html); + } + + public function test_expanding_a_feed_reveals_only_that_feeds_articles(): 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('toggleFeed', $vrt->id) + ->assertSee('VRT Headline') + ->assertDontSee('Belga Headline'); + } + + public function test_expanding_a_feed_shows_all_its_articles_beyond_one_page(): void + { + $vrt = Feed::factory()->create(['name' => 'VRT News']); + + for ($i = 0; $i < 20; $i++) { + $this->travelTo(now()->addMinutes($i), function () use ($vrt, $i) { + $this->createRouteArticleForFeed($vrt, "VRT Article {$i}"); + }); + } + + $component = Livewire::test(Articles::class)->call('toggleFeed', $vrt->id); + + for ($i = 0; $i < 20; $i++) { + $component->assertSee("VRT Article {$i}"); + } + } + + public function test_toggling_an_expanded_feed_collapses_it(): void + { + $vrt = Feed::factory()->create(['name' => 'VRT News']); + + $this->createRouteArticleForFeed($vrt, 'VRT Headline'); + + Livewire::test(Articles::class) + ->call('toggleFeed', $vrt->id) + ->assertSee('VRT Headline') + ->call('toggleFeed', $vrt->id) + ->assertDontSee('VRT Headline'); + } + + public function test_switching_tabs_collapses_expanded_feeds(): void + { + $vrt = Feed::factory()->create(['name' => 'VRT News']); + + $this->createRouteArticleForFeed($vrt, 'VRT Headline'); + + Livewire::test(Articles::class) + ->call('toggleFeed', $vrt->id) + ->call('setTab', 'all') + ->call('setTab', 'pending') + ->assertSet('expandedFeeds', []) + ->assertDontSee('VRT Headline'); + } + + public function test_all_tab_shows_a_flat_list_not_feed_groups(): void + { + $vrt = Feed::factory()->create(['name' => 'VRT News']); + $belga = Feed::factory()->create(['name' => 'Belga Press']); + + $this->createRouteArticleForFeed($vrt, 'VRT Article'); + $this->createRouteArticleForFeed($belga, 'Belga Article'); + + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->assertViewHas('pendingFeeds', null) + ->assertSee('VRT Article') + ->assertSee('Belga Article'); + } + public function test_empty_state_on_pending_tab(): void { Livewire::test(Articles::class)