diff --git a/app/Livewire/Activity.php b/app/Livewire/Activity.php new file mode 100644 index 00000000..68db7c9a --- /dev/null +++ b/app/Livewire/Activity.php @@ -0,0 +1,55 @@ +resetPage(); + } + + public function updatedDays(): void + { + $this->resetPage(); + } + + /** + * @return Builder + */ + private function query(): Builder + { + return ActivityLog::query() + ->with('subject') + ->since(now()->subDays($this->days)) + ->when( + $this->type !== '', + fn (Builder $q) => $q->where('type', $this->type), + ) + ->latestFirst(); + } + + public function render(ActivitySummary $summary): View + { + return view('livewire.activity', [ + 'entries' => $this->query()->paginate(25), + 'typeOptions' => ActivityTypeEnum::options(), + 'summaryDay' => $summary->since(now()->subDay()), + 'summaryWeek' => $summary->since(now()->subWeek()), + ])->layout('layouts.app'); + } +} diff --git a/app/Livewire/Articles.php b/app/Livewire/Articles.php index 29ec16a9..1f999708 100644 --- a/app/Livewire/Articles.php +++ b/app/Livewire/Articles.php @@ -2,7 +2,9 @@ namespace App\Livewire; +use App\Enums\ActivityTypeEnum; use App\Enums\ApprovalStatusEnum; +use App\Events\ActivityLogged; use App\Jobs\ArticleDiscoveryJob; use App\Models\Feed; use App\Models\RouteArticle; @@ -77,7 +79,19 @@ public function restore(int $routeArticleId): void public function clear(): void { - $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + $feed = $this->feedId !== null ? Feed::find($this->feedId) : null; + $cleared = $this->clearableQuery()->update(['approval_status' => ApprovalStatusEnum::REJECTED]); + + if ($cleared > 0) { + ActivityLogged::dispatch( + ActivityTypeEnum::REJECT, + $feed !== null + ? "Cleared {$cleared} pending articles from {$feed->name}" + : "Cleared {$cleared} pending articles", + ['cleared' => $cleared], + $feed, + ); + } $this->expandedFeeds = []; } diff --git a/app/Services/Activity/ActivitySummary.php b/app/Services/Activity/ActivitySummary.php new file mode 100644 index 00000000..b9c4fefd --- /dev/null +++ b/app/Services/Activity/ActivitySummary.php @@ -0,0 +1,34 @@ + + */ + public function since(Carbon $since): array + { + /** @var array $counts */ + $counts = ActivityLog::query() + ->since($since) + ->selectRaw('type, COUNT(*) as aggregate') + ->groupBy('type') + ->pluck('aggregate', 'type') + ->all(); + + $summary = []; + + foreach (ActivityTypeEnum::cases() as $case) { + $summary[$case->value] = (int) ($counts[$case->value] ?? 0); + } + + return $summary; + } +} diff --git a/resources/views/layouts/navigation-items.blade.php b/resources/views/layouts/navigation-items.blade.php index 101c6f50..4f9fe67c 100644 --- a/resources/views/layouts/navigation-items.blade.php +++ b/resources/views/layouts/navigation-items.blade.php @@ -5,6 +5,7 @@ ['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'], ['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'], ['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'], + ['name' => 'Activity', 'route' => 'activity', 'icon' => 'clock'], ['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'], ]; @endphp @@ -41,6 +42,11 @@ class="group flex items-center px-2 py-2 text-sm font-medium rounded-md mb-1 {{ @break + @case('clock') + + + + @break @case('cog-6-tooth') diff --git a/resources/views/livewire/activity.blade.php b/resources/views/livewire/activity.blade.php new file mode 100644 index 00000000..b74ad0c3 --- /dev/null +++ b/resources/views/livewire/activity.blade.php @@ -0,0 +1,72 @@ +
+ + +
+ @foreach ($typeOptions as $value => $label) +
+

{{ $label }}

+

{{ $summaryDay[$value] }}

+

{{ $summaryWeek[$value] }} in 7d

+
+ @endforeach +
+ +
+ + + +
+ +
+ @forelse ($entries as $entry) +
+ + {{ $entry->type->label() }} + + +
+

{{ $entry->message }}

+ @if (($entry->context['reason'] ?? null) !== null) +

{{ $entry->context['reason'] }}

+ @endif +
+ + +
+ @empty +
+

No activity in this period.

+
+ @endforelse +
+ + @if ($entries->hasPages()) +
+ {{ $entries->links() }} +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 06541fe8..5c072c4d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::get('/dashboard', Dashboard::class)->name('dashboard'); Route::get('/articles', Articles::class)->name('articles'); + Route::get('/activity', Activity::class)->name('activity'); Route::get('/feeds', Feeds::class)->name('feeds'); Route::get('/channels', Channels::class)->name('channels'); Route::get('/routes', Routes::class)->name('routes'); diff --git a/tests/Feature/Livewire/ActivityTest.php b/tests/Feature/Livewire/ActivityTest.php new file mode 100644 index 00000000..143ff30d --- /dev/null +++ b/tests/Feature/Livewire/ActivityTest.php @@ -0,0 +1,104 @@ +assertStatus(200); + } + + public function test_shows_recent_entries(): void + { + ActivityLog::factory()->create(['message' => 'Published something']); + + Livewire::test(Activity::class)->assertSee('Published something'); + } + + public function test_filtering_by_type_hides_other_types(): void + { + ActivityLog::factory()->type(ActivityTypeEnum::PUBLISH)->create(['message' => 'A publish entry']); + ActivityLog::factory()->type(ActivityTypeEnum::FETCH)->create(['message' => 'A fetch entry']); + + Livewire::test(Activity::class) + ->set('type', ActivityTypeEnum::PUBLISH->value) + ->assertSee('A publish entry') + ->assertDontSee('A fetch entry'); + } + + public function test_entries_outside_the_day_range_are_hidden(): void + { + ActivityLog::factory()->loggedAt(now()->subHours(2))->create(['message' => 'Recent entry']); + ActivityLog::factory()->loggedAt(now()->subDays(10))->create(['message' => 'Old entry']); + + Livewire::test(Activity::class) + ->set('days', 1) + ->assertSee('Recent entry') + ->assertDontSee('Old entry'); + } + + public function test_changing_a_filter_resets_pagination(): void + { + ActivityLog::factory()->count(30)->create(); + + Livewire::test(Activity::class) + ->call('setPage', 2) + ->set('type', ActivityTypeEnum::PUBLISH->value) + ->assertSet('paginators.page', 1); + } + + public function test_entry_without_a_subject_renders(): void + { + ActivityLog::factory()->create(['message' => 'No subject entry', 'subject_type' => null, 'subject_id' => null]); + + Livewire::test(Activity::class) + ->assertStatus(200) + ->assertSee('No subject entry'); + } + + public function test_entry_with_a_deleted_subject_renders(): void + { + $article = Article::factory()->create(); + ActivityLog::factory()->for_subject($article)->create(['message' => 'Dangling subject']); + $article->delete(); + + Livewire::test(Activity::class) + ->assertStatus(200) + ->assertSee('Dangling subject'); + } + + public function test_empty_state_is_shown_when_there_is_no_activity(): void + { + Livewire::test(Activity::class)->assertSee('No activity in this period.'); + } + + public function test_a_reason_in_context_is_shown_under_the_message(): void + { + ActivityLog::factory()->create([ + 'message' => 'Failed to publish something', + 'context' => ['reason' => 'Could not recover the article content'], + ]); + + Livewire::test(Activity::class)->assertSee('Could not recover the article content'); + } + + public function test_a_message_containing_markup_is_escaped(): void + { + ActivityLog::factory()->create(['message' => 'Published ""']); + + Livewire::test(Activity::class) + ->assertDontSee('', false) + ->assertSee('<script>', false); + } +}