Release v1.4.0 #146
3 changed files with 210 additions and 13 deletions
|
|
@ -21,6 +21,8 @@ class Articles extends Component
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
|
public ?int $feedId = null;
|
||||||
|
|
||||||
public bool $isRefreshing = false;
|
public bool $isRefreshing = false;
|
||||||
|
|
||||||
/** @var array<int, bool> */
|
/** @var array<int, bool> */
|
||||||
|
|
@ -34,6 +36,12 @@ public function setTab(string $tab): void
|
||||||
$this->resetPage();
|
$this->resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedFeedId(): void
|
||||||
|
{
|
||||||
|
$this->expandedFeeds = [];
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleFeed(int $feedId): void
|
public function toggleFeed(int $feedId): void
|
||||||
{
|
{
|
||||||
if (isset($this->expandedFeeds[$feedId])) {
|
if (isset($this->expandedFeeds[$feedId])) {
|
||||||
|
|
@ -89,6 +97,7 @@ public function render(): View
|
||||||
return view('livewire.articles', [
|
return view('livewire.articles', [
|
||||||
'routeArticles' => null,
|
'routeArticles' => null,
|
||||||
'pendingFeeds' => $this->pendingFeeds(),
|
'pendingFeeds' => $this->pendingFeeds(),
|
||||||
|
'feedOptions' => $this->feedOptions(),
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
|
|
@ -96,6 +105,10 @@ public function render(): View
|
||||||
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
$query = RouteArticle::with(['article.feed', 'feed', 'platformChannel'])
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
if ($this->feedId !== null) {
|
||||||
|
$query->where('feed_id', $this->feedId);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->search !== '') {
|
if ($this->search !== '') {
|
||||||
$search = $this->search;
|
$search = $this->search;
|
||||||
$query->whereHas('article', function ($q) use ($search) {
|
$query->whereHas('article', function ($q) use ($search) {
|
||||||
|
|
@ -107,6 +120,7 @@ public function render(): View
|
||||||
return view('livewire.articles', [
|
return view('livewire.articles', [
|
||||||
'routeArticles' => $query->paginate(15),
|
'routeArticles' => $query->paginate(15),
|
||||||
'pendingFeeds' => null,
|
'pendingFeeds' => null,
|
||||||
|
'feedOptions' => $this->feedOptions(),
|
||||||
'pendingCount' => $pendingCount,
|
'pendingCount' => $pendingCount,
|
||||||
])->layout('layouts.app');
|
])->layout('layouts.app');
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +133,7 @@ private function pendingFeeds(): Collection
|
||||||
$counts = RouteArticle::query()
|
$counts = RouteArticle::query()
|
||||||
->selectRaw('feed_id, COUNT(*) as aggregate')
|
->selectRaw('feed_id, COUNT(*) as aggregate')
|
||||||
->where('approval_status', ApprovalStatusEnum::PENDING)
|
->where('approval_status', ApprovalStatusEnum::PENDING)
|
||||||
|
->when($this->feedId !== null, fn ($q) => $q->where('feed_id', $this->feedId))
|
||||||
->groupBy('feed_id')
|
->groupBy('feed_id')
|
||||||
->pluck('aggregate', 'feed_id');
|
->pluck('aggregate', 'feed_id');
|
||||||
|
|
||||||
|
|
@ -134,6 +149,16 @@ private function pendingFeeds(): Collection
|
||||||
->values();
|
->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>
|
* @return EloquentCollection<int, RouteArticle>
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,29 @@ class="whitespace-nowrap pb-3 px-1 border-b-2 font-medium text-sm {{ $tab === 'a
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Tab actions --}}
|
{{-- 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)
|
@if ($tab === 'pending' && $pendingCount > 0)
|
||||||
<button
|
<button
|
||||||
wire:click="clear"
|
wire:click="clear"
|
||||||
|
|
@ -51,17 +73,6 @@ class="inline-flex items-center px-3 py-2 border border-transparent text-sm lead
|
||||||
</svg>
|
</svg>
|
||||||
Clear All
|
Clear All
|
||||||
</button>
|
</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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -115,10 +126,14 @@ class="h-4 w-4 shrink-0 text-gray-400 transition-transform {{ $isExpanded ? 'rot
|
||||||
@endif
|
@endif
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
<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.
|
All route articles have been reviewed.
|
||||||
@elseif ($search !== '')
|
@elseif ($search !== '')
|
||||||
No results for "{{ $search }}".
|
No results for "{{ $search }}".
|
||||||
|
@elseif ($feedId !== null)
|
||||||
|
No articles for the selected feed.
|
||||||
@else
|
@else
|
||||||
No route articles have been created yet.
|
No route articles have been created yet.
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -51,6 +52,17 @@ private function createRouteArticleForFeed(Feed $feed, string $title, ApprovalSt
|
||||||
return $routeArticle;
|
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
|
public function test_renders_successfully(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Articles::class)
|
Livewire::test(Articles::class)
|
||||||
|
|
@ -289,6 +301,151 @@ public function test_all_tab_shows_a_flat_list_not_feed_groups(): void
|
||||||
->assertSee('Belga Article');
|
->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
|
public function test_empty_state_on_pending_tab(): void
|
||||||
{
|
{
|
||||||
Livewire::test(Articles::class)
|
Livewire::test(Articles::class)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue