From 943015805156d8392b247139d8bee407ebce4e71 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 18 Mar 2026 17:39:38 +0100 Subject: [PATCH] 96 - Rework Articles page into route_article triage UI with tabs and actions --- app/Livewire/Articles.php | 63 ++++++- resources/views/livewire/articles.blade.php | 166 +++++++++++++++--- tests/Feature/Livewire/ArticlesTest.php | 184 ++++++++++++++++++++ 3 files changed, 380 insertions(+), 33 deletions(-) create mode 100644 tests/Feature/Livewire/ArticlesTest.php diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index a70dbf2..8c5d11f 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -2,8 +2,9 @@ namespace App\Livewire; +use App\Enums\ApprovalStatusEnum; use App\Jobs\ArticleDiscoveryJob; -use App\Models\Article; +use App\Models\RouteArticle; use Livewire\Component; use Livewire\WithPagination; @@ -11,8 +12,46 @@ class Articles extends Component { use WithPagination; + public string $tab = 'pending'; + + public string $search = ''; + public bool $isRefreshing = false; + public function setTab(string $tab): void + { + $this->tab = $tab; + $this->search = ''; + $this->resetPage(); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function approve(int $routeArticleId): void + { + RouteArticle::findOrFail($routeArticleId)->approve(); + } + + public function reject(int $routeArticleId): void + { + RouteArticle::findOrFail($routeArticleId)->reject(); + } + + public function restore(int $routeArticleId): void + { + $routeArticle = RouteArticle::findOrFail($routeArticleId); + $routeArticle->update(['approval_status' => ApprovalStatusEnum::PENDING]); + } + + public function clear(): void + { + RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING) + ->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + } + public function refresh(): void { $this->isRefreshing = true; @@ -24,12 +63,26 @@ public function refresh(): void public function render(): \Illuminate\Contracts\View\View { - $articles = Article::with('feed') - ->orderBy('created_at', 'desc') - ->paginate(15); + $query = RouteArticle::with(['article.feed', 'feed', 'platformChannel']) + ->orderBy('created_at', 'desc'); + + if ($this->tab === 'pending') { + $query->where('approval_status', ApprovalStatusEnum::PENDING); + } elseif ($this->search !== '') { + $search = $this->search; + $query->whereHas('article', function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + $routeArticles = $query->paginate(15); + + $pendingCount = RouteArticle::where('approval_status', ApprovalStatusEnum::PENDING)->count(); return view('livewire.articles', [ - 'articles' => $articles, + 'routeArticles' => $routeArticles, + 'pendingCount' => $pendingCount, ])->layout('layouts.app'); } } diff --git a/resources/views/livewire/articles.blade.php b/resources/views/livewire/articles.blade.php index 29a218f..1ad79b9 100644 --- a/resources/views/livewire/articles.blade.php +++ b/resources/views/livewire/articles.blade.php @@ -1,9 +1,9 @@
-
+

Articles

- Articles fetched from your feeds + Review and manage article routing

+ + +
+ + {{-- Tab actions --}} +
+ @if ($tab === 'pending' && $pendingCount > 0) + + @elseif ($tab === 'all') +
+ +
+ @else +
+ @endif +
+ + {{-- Route articles list --}} +
+ @forelse ($routeArticles as $routeArticle) +
-

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

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

-

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

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

-
- Feed: {{ $article->feed?->name ?? 'Unknown' }} - - {{ $article->created_at->format('M d, Y H:i') }} - @if ($article->validated_at) - - Validated - @else - - Not validated - @endif +
+ + + + + {{ $routeArticle->feed?->name ?? 'Unknown' }} → {{ $routeArticle->platformChannel?->name ?? 'Unknown' }} + + {{ $routeArticle->created_at->format('M d, Y H:i') }}
-
- @if ($article->url) +
+ {{-- 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) @@ -67,16 +165,28 @@ class="p-2 text-gray-400 hover:text-gray-600 rounded-md" -

No articles

+

+ @if ($tab === 'pending') + No pending articles + @else + No articles found + @endif +

- No articles have been fetched yet. + @if ($tab === 'pending') + All route articles have been reviewed. + @elseif ($search !== '') + No results for "{{ $search }}". + @else + No route articles have been created yet. + @endif

@endforelse - @if ($articles->hasPages()) + @if ($routeArticles->hasPages())
- {{ $articles->links() }} + {{ $routeArticles->links() }}
@endif
diff --git a/tests/Feature/Livewire/ArticlesTest.php b/tests/Feature/Livewire/ArticlesTest.php new file mode 100644 index 0000000..0b7752a --- /dev/null +++ b/tests/Feature/Livewire/ArticlesTest.php @@ -0,0 +1,184 @@ +create(); + /** @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) + ->assertStatus(200); + } + + public function test_defaults_to_pending_tab(): void + { + Livewire::test(Articles::class) + ->assertSet('tab', 'pending'); + } + + 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'); + + Livewire::test(Articles::class) + ->assertSee('Pending Article') + ->assertDontSee('Approved Article') + ->assertDontSee('Rejected Article'); + } + + public function test_all_tab_shows_all_route_articles(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article'); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article'); + $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article'); + + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->assertSee('Pending Article') + ->assertSee('Approved Article') + ->assertSee('Rejected Article'); + } + + public function test_all_tab_search_filters_by_title(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Belgian Politics Update'); + $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Weather Forecast Today'); + + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->set('search', 'Belgian') + ->assertSee('Belgian Politics Update') + ->assertDontSee('Weather Forecast Today'); + } + + public function test_approve_changes_status_and_dispatches_event(): void + { + Event::fake([RouteArticleApproved::class]); + + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + Livewire::test(Articles::class) + ->call('approve', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::APPROVED, $routeArticle->fresh()->approval_status); + + Event::assertDispatched(RouteArticleApproved::class); + } + + public function test_reject_changes_status(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + + Livewire::test(Articles::class) + ->call('reject', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $routeArticle->fresh()->approval_status); + } + + public function test_restore_changes_status_back_to_pending(): void + { + $routeArticle = $this->createRouteArticle(ApprovalStatusEnum::REJECTED); + + Livewire::test(Articles::class) + ->call('restore', $routeArticle->id); + + $this->assertEquals(ApprovalStatusEnum::PENDING, $routeArticle->fresh()->approval_status); + } + + public function test_clear_rejects_all_pending_route_articles(): void + { + $pending1 = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $pending2 = $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + Livewire::test(Articles::class) + ->call('clear'); + + $this->assertEquals(ApprovalStatusEnum::REJECTED, $pending1->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::REJECTED, $pending2->fresh()->approval_status); + $this->assertEquals(ApprovalStatusEnum::APPROVED, $approved->fresh()->approval_status); + } + + public function test_pending_count_badge_shows_correct_count(): void + { + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::PENDING); + $this->createRouteArticle(ApprovalStatusEnum::APPROVED); + + Livewire::test(Articles::class) + ->assertSeeInOrder(['Pending', '2']); + } + + public function test_switching_tabs_resets_search(): void + { + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->set('search', 'something') + ->call('setTab', 'pending') + ->assertSet('search', '') + ->assertSet('tab', 'pending'); + } + + public function test_shows_route_name_in_listing(): void + { + $feed = Feed::factory()->create(['name' => 'VRT News']); + /** @var Route $route */ + $route = Route::factory()->active()->create(['feed_id' => $feed->id]); + $article = Article::factory()->create(['feed_id' => $feed->id, 'title' => 'Test']); + + RouteArticle::factory()->forRoute($route)->create([ + 'article_id' => $article->id, + 'approval_status' => ApprovalStatusEnum::PENDING, + 'validated_at' => now(), + ]); + + Livewire::test(Articles::class) + ->assertSee('VRT News'); + } + + public function test_empty_state_on_pending_tab(): void + { + Livewire::test(Articles::class) + ->assertSee('No pending articles'); + } + + public function test_empty_state_on_all_tab(): void + { + Livewire::test(Articles::class) + ->call('setTab', 'all') + ->assertSee('No route articles have been created yet.'); + } +}