Release v1.4.0 #146
5 changed files with 342 additions and 103 deletions
|
|
@ -4,8 +4,12 @@
|
||||||
|
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
|
use App\Support\PendingFeedGroup;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
|
@ -19,13 +23,28 @@ class Articles extends Component
|
||||||
|
|
||||||
public bool $isRefreshing = false;
|
public bool $isRefreshing = false;
|
||||||
|
|
||||||
|
/** @var array<int, bool> */
|
||||||
|
public array $expandedFeeds = [];
|
||||||
|
|
||||||
public function setTab(string $tab): void
|
public function setTab(string $tab): void
|
||||||
{
|
{
|
||||||
$this->tab = $tab;
|
$this->tab = $tab;
|
||||||
$this->search = '';
|
$this->search = '';
|
||||||
|
$this->expandedFeeds = [];
|
||||||
$this->resetPage();
|
$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
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
|
|
@ -64,12 +83,20 @@ public function refresh(): void
|
||||||
|
|
||||||
public function render(): View
|
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'])
|
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
if ($this->tab === 'pending') {
|
if ($this->search !== '') {
|
||||||
$query->where('approval_status', ApprovalStatusEnum::PENDING);
|
|
||||||
} elseif ($this->search !== '') {
|
|
||||||
$search = $this->search;
|
$search = $this->search;
|
||||||
$query->whereHas('article', function ($q) use ($search) {
|
$query->whereHas('article', function ($q) use ($search) {
|
||||||
$q->where('title', 'like', "%{$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', [
|
return view('livewire.articles', [
|
||||||
'routeArticles' => $routeArticles,
|
'routeArticles' => $query->paginate(15),
|
||||||
|
'pendingFeeds' => null,
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, PendingFeedGroup>
|
||||||
|
*/
|
||||||
|
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<int, RouteArticle>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
app/Support/PendingFeedGroup.php
Normal file
24
app/Support/PendingFeedGroup.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\RouteArticle;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class PendingFeedGroup
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, RouteArticle>|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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
resources/views/components/route-article-card.blade.php
Normal file
93
resources/views/components/route-article-card.blade.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
@props(['routeArticle', 'tab'])
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div
|
||||||
|
class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
|
||||||
|
aria-label="Routed from {{ $routeArticle->feed?->name ?? 'Unknown feed' }} to {{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}"
|
||||||
|
>
|
||||||
|
@if ($routeArticle->feed)
|
||||||
|
<span class="h-2 w-2 rounded-full shrink-0 {{ $routeArticle->feed->displayColor()->dotClass() }}" aria-hidden="true"></span>
|
||||||
|
@endif
|
||||||
|
<span class="text-gray-900">{{ $routeArticle->feed?->name ?? 'Unknown feed' }}</span>
|
||||||
|
<span class="text-gray-400" aria-hidden="true">→</span>
|
||||||
|
<span class="text-gray-600">{{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}</span>
|
||||||
|
</div>
|
||||||
|
<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="text-xs text-gray-500">
|
||||||
|
{{ $routeArticle->created_at->format('M d, Y H:i') }}
|
||||||
|
</div>
|
||||||
|
</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="{{ $routeArticle->article->url }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -67,99 +67,42 @@ class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-
|
||||||
|
|
||||||
{{-- Route articles list --}}
|
{{-- Route articles list --}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@forelse ($routeArticles as $routeArticle)
|
@if ($pendingFeeds !== null && $pendingFeeds->isNotEmpty())
|
||||||
<div class="bg-white rounded-lg shadow p-5" wire:key="ra-{{ $routeArticle->id }}">
|
@foreach ($pendingFeeds as $group)
|
||||||
<div class="flex items-start justify-between">
|
@php($isExpanded = $group->isExpanded())
|
||||||
<div class="flex-1 min-w-0">
|
<div class="bg-white rounded-lg shadow overflow-hidden" wire:key="feed-group-{{ $group->feed->id }}">
|
||||||
<div
|
<button
|
||||||
class="flex items-center flex-wrap gap-x-1.5 text-xs font-medium mb-1.5"
|
wire:click="toggleFeed({{ $group->feed->id }})"
|
||||||
aria-label="Routed from {{ $routeArticle->feed?->name ?? 'Unknown feed' }} to {{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}"
|
type="button"
|
||||||
|
class="w-full flex items-center gap-x-3 px-5 py-4 text-left hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
|
||||||
|
aria-expanded="{{ $isExpanded ? 'true' : 'false' }}"
|
||||||
>
|
>
|
||||||
@if ($routeArticle->feed)
|
<svg
|
||||||
<span class="h-2 w-2 rounded-full shrink-0 {{ $routeArticle->feed->displayColor()->dotClass() }}" aria-hidden="true"></span>
|
class="h-4 w-4 shrink-0 text-gray-400 transition-transform {{ $isExpanded ? 'rotate-90' : '' }}"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="h-2.5 w-2.5 rounded-full shrink-0 {{ $group->feed->displayColor()->dotClass() }}" aria-hidden="true"></span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">{{ $group->feed->name }}</span>
|
||||||
|
<span class="ml-auto inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
{{ $group->count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
@if ($isExpanded)
|
||||||
|
<div class="border-t border-gray-100 bg-gray-50 p-4 space-y-4">
|
||||||
|
@foreach ($group->routeArticles as $routeArticle)
|
||||||
|
<x-route-article-card :route-article="$routeArticle" :tab="$tab" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<span class="text-gray-900">{{ $routeArticle->feed?->name ?? 'Unknown feed' }}</span>
|
|
||||||
<span class="text-gray-400" aria-hidden="true">→</span>
|
|
||||||
<span class="text-gray-600">{{ $routeArticle->platformChannel?->name ?? 'Unknown channel' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-base font-medium text-gray-900 mb-1">
|
@endforeach
|
||||||
{{ $routeArticle->article->title ?? 'Untitled Article' }}
|
@elseif ($routeArticles !== null && $routeArticles->isNotEmpty())
|
||||||
</h3>
|
@foreach ($routeArticles as $routeArticle)
|
||||||
<p class="text-sm text-gray-600 mb-2 line-clamp-2">
|
<x-route-article-card :route-article="$routeArticle" :tab="$tab" />
|
||||||
{{ $routeArticle->article->description ?? 'No description available' }}
|
@endforeach
|
||||||
</p>
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
{{ $routeArticle->created_at->format('M d, Y H:i') }}
|
|
||||||
</div>
|
|
||||||
</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
|
@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="{{ $routeArticle->article->url }}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
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">
|
|
||||||
<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>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@empty
|
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<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" />
|
<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" />
|
||||||
|
|
@ -181,9 +124,9 @@ class="p-1.5 text-gray-400 hover:text-gray-600 rounded-md"
|
||||||
@endif
|
@endif
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@endforelse
|
@endif
|
||||||
|
|
||||||
@if ($routeArticles->hasPages())
|
@if ($routeArticles !== null && $routeArticles->hasPages())
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
{{ $routeArticles->links() }}
|
{{ $routeArticles->links() }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,22 @@ private function createRouteArticle(ApprovalStatusEnum $status = ApprovalStatusE
|
||||||
return $routeArticle;
|
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
|
public function test_renders_successfully(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Articles::class)
|
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
|
public function test_pending_tab_shows_only_pending_route_articles(): void
|
||||||
{
|
{
|
||||||
$pending = $this->createRouteArticle(ApprovalStatusEnum::PENDING, 'Pending Article');
|
$feed = Feed::factory()->create(['name' => 'VRT News']);
|
||||||
$approved = $this->createRouteArticle(ApprovalStatusEnum::APPROVED, 'Approved Article');
|
|
||||||
$rejected = $this->createRouteArticle(ApprovalStatusEnum::REJECTED, 'Rejected Article');
|
$this->createRouteArticleForFeed($feed, 'Pending Article');
|
||||||
|
$this->createRouteArticleForFeed($feed, 'Approved Article', ApprovalStatusEnum::APPROVED);
|
||||||
|
$this->createRouteArticleForFeed($feed, 'Rejected Article', ApprovalStatusEnum::REJECTED);
|
||||||
|
|
||||||
Livewire::test(Articles::class)
|
Livewire::test(Articles::class)
|
||||||
|
->call('toggleFeed', $feed->id)
|
||||||
->assertSee('Pending Article')
|
->assertSee('Pending Article')
|
||||||
->assertDontSee('Approved Article')
|
->assertDontSee('Approved Article')
|
||||||
->assertDontSee('Rejected Article');
|
->assertDontSee('Rejected Article');
|
||||||
|
|
@ -169,6 +188,107 @@ public function test_shows_route_name_in_listing(): void
|
||||||
->assertSee('VRT News');
|
->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</span> <span class="ml-auto inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> 20 </span>', $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
|
public function test_empty_state_on_pending_tab(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Articles::class)
|
Livewire::test(Articles::class)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue