96 - Rework Articles page into route_article triage UI with tabs and actions
This commit is contained in:
parent
e7acbb6882
commit
9430158051
3 changed files with 380 additions and 33 deletions
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<div class="p-6">
|
||||
<div class="mb-8 flex items-start justify-between">
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Articles</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Articles fetched from your feeds
|
||||
Review and manage article routing
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -21,37 +21,135 @@ class="inline-flex items-center px-4 py-2 border border-transparent text-sm font
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
@forelse ($articles as $article)
|
||||
<div class="bg-white rounded-lg shadow p-6" wire:key="article-{{ $article->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
{{ $article->title ?? 'Untitled Article' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{{ $article->description ?? 'No description available' }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>Feed: {{ $article->feed?->name ?? 'Unknown' }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ $article->created_at->format('M d, Y H:i') }}</span>
|
||||
@if ($article->validated_at)
|
||||
<span>•</span>
|
||||
<span class="text-green-600">Validated</span>
|
||||
{{-- Tab bar --}}
|
||||
<div class="mb-6 border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
wire:click="setTab('pending')"
|
||||
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'pending' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}"
|
||||
>
|
||||
Pending
|
||||
@if ($pendingCount > 0)
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{{ $pendingCount }}
|
||||
</span>
|
||||
@endif
|
||||
</button>
|
||||
<button
|
||||
wire:click="setTab('all')"
|
||||
class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'all' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' }}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{{-- Tab actions --}}
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
@if ($tab === 'pending' && $pendingCount > 0)
|
||||
<button
|
||||
wire:click="clear"
|
||||
wire:confirm="Reject all {{ $pendingCount }} pending route articles?"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</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
|
||||
<span>•</span>
|
||||
<span class="text-yellow-600">Not validated</span>
|
||||
<div></div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Route articles list --}}
|
||||
<div class="space-y-4">
|
||||
@forelse ($routeArticles as $routeArticle)
|
||||
<div class="bg-white rounded-lg shadow p-5" wire:key="ra-{{ $routeArticle->id }}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-medium text-gray-900 mb-1">
|
||||
{{ $routeArticle->article->title ?? 'Untitled Article' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-2 line-clamp-2">
|
||||
{{ $routeArticle->article->description ?? 'No description available' }}
|
||||
</p>
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
{{ $routeArticle->feed?->name ?? 'Unknown' }} → {{ $routeArticle->platformChannel?->name ?? 'Unknown' }}
|
||||
</span>
|
||||
<span>{{ $routeArticle->created_at->format('M d, Y H:i') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 ml-4">
|
||||
@if ($article->url)
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
{{-- Status badge (All tab) --}}
|
||||
@if ($tab === 'all')
|
||||
@if ($routeArticle->isApproved())
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Approved
|
||||
</span>
|
||||
@elseif ($routeArticle->isRejected())
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
Rejected
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Pending
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Action buttons --}}
|
||||
@if ($routeArticle->isPending())
|
||||
<button
|
||||
wire:click="approve({{ $routeArticle->id }})"
|
||||
class="inline-flex items-center p-1.5 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-md"
|
||||
title="Approve"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
wire:click="reject({{ $routeArticle->id }})"
|
||||
class="inline-flex items-center p-1.5 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md"
|
||||
title="Reject"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
@elseif ($routeArticle->isRejected())
|
||||
<button
|
||||
wire:click="restore({{ $routeArticle->id }})"
|
||||
class="inline-flex items-center p-1.5 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md"
|
||||
title="Restore to pending"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
{{-- Link to original --}}
|
||||
@if ($routeArticle->article->url)
|
||||
<a
|
||||
href="{{ $article->url }}"
|
||||
href="{{ $routeArticle->article->url }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
||||
class="p-1.5 text-gray-400 hover:text-gray-600 rounded-md"
|
||||
title="View original article"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
|
|
@ -67,16 +165,28 @@ class="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
|||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No articles</h3>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">
|
||||
@if ($tab === 'pending')
|
||||
No pending articles
|
||||
@else
|
||||
No articles found
|
||||
@endif
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
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
|
||||
</p>
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
@if ($articles->hasPages())
|
||||
@if ($routeArticles->hasPages())
|
||||
<div class="mt-6">
|
||||
{{ $articles->links() }}
|
||||
{{ $routeArticles->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
184
tests/Feature/Livewire/ArticlesTest.php
Normal file
184
tests/Feature/Livewire/ArticlesTest.php
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Events\RouteArticleApproved;
|
||||
use App\Livewire\Articles;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\Route;
|
||||
use App\Models\RouteArticle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ArticlesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusEnum::PENDING, string $title = 'Test Article'): RouteArticle
|
||||
{
|
||||
$feed = Feed::factory()->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.');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue