83 - Add a recent activity panel to the dashboard
This commit is contained in:
parent
bf734feb4a
commit
42fab63eaf
6 changed files with 152 additions and 23 deletions
|
|
@ -10,9 +10,14 @@
|
|||
use App\Dashboard\Stats\PublicationsPerChannel;
|
||||
use App\Dashboard\Stats\PublishSuccessRate;
|
||||
use App\Dashboard\Stats\SeriesResult;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use App\Services\DashboardStatsService;
|
||||
use App\Support\DateRange;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Livewire\Attributes\Computed;
|
||||
|
|
@ -43,6 +48,8 @@ public function mount(): void
|
|||
'publications-per-channel',
|
||||
];
|
||||
|
||||
private const RECENT_ACTIVITY_LIMIT = 5;
|
||||
|
||||
public function applyPreset(string $preset): void
|
||||
{
|
||||
try {
|
||||
|
|
@ -179,6 +186,25 @@ private function breakdown(string $stat): BreakdownResult
|
|||
: new BreakdownResult([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately range-independent: "recent" means latest, not latest within the filter.
|
||||
*
|
||||
* @return Collection<int, ActivityLog>
|
||||
*/
|
||||
#[Computed]
|
||||
public function recentActivity(): Collection
|
||||
{
|
||||
return ActivityLog::query()
|
||||
->with(['subject' => function (Relation $morphTo): void {
|
||||
if ($morphTo instanceof MorphTo) {
|
||||
$morphTo->morphWith([Article::class => ['feed']]);
|
||||
}
|
||||
}])
|
||||
->latestFirst()
|
||||
->limit(self::RECENT_ACTIVITY_LIMIT)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -37,29 +37,7 @@ class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm f
|
|||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
@forelse ($entries as $entry)
|
||||
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
|
||||
{{ $entry->type->label() }}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
|
||||
@if ($entry->subject instanceof App\Models\Article)
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
|
||||
@endif
|
||||
@if (($entry->context['reason'] ?? null) !== null)
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<time
|
||||
datetime="{{ $entry->logged_at->toIso8601String() }}"
|
||||
title="{{ $entry->logged_at->toDayDateTimeString() }}"
|
||||
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ $entry->logged_at->diffForHumans() }}
|
||||
</time>
|
||||
</div>
|
||||
@include('livewire.partials.activity-entry', ['entry' => $entry])
|
||||
@empty
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No activity in this period.</p>
|
||||
|
|
|
|||
|
|
@ -251,5 +251,17 @@ class="rounded-md border border-gray-300 text-sm focus:border-blue-500 focus:rin
|
|||
@endisland
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-4 flex items-baseline justify-between gap-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Recent Activity</h2>
|
||||
|
||||
<a href="{{ route('activity') }}" wire:navigate class="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@include('livewire.partials.activity-panel', ['entries' => $this->recentActivity])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
23
resources/views/livewire/partials/activity-entry.blade.php
Normal file
23
resources/views/livewire/partials/activity-entry.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<div class="flex items-start gap-x-3 px-5 py-4 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<span class="mt-0.5 inline-flex shrink-0 items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $entry->type === App\Enums\ActivityTypeEnum::ERROR ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' : 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' }}">
|
||||
{{ $entry->type->label() }}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{{ $entry->message }}</p>
|
||||
@if ($entry->subject instanceof App\Models\Article)
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->subject->feed?->name }}</p>
|
||||
@endif
|
||||
@if (($entry->context['reason'] ?? null) !== null)
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{{ $entry->context['reason'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<time
|
||||
datetime="{{ $entry->logged_at->toIso8601String() }}"
|
||||
title="{{ $entry->logged_at->toDayDateTimeString() }}"
|
||||
class="shrink-0 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ $entry->logged_at->diffForHumans() }}
|
||||
</time>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<div class="bg-white rounded-lg shadow-sm overflow-hidden dark:bg-gray-800">
|
||||
@forelse ($entries as $entry)
|
||||
@include('livewire.partials.activity-entry', ['entry' => $entry])
|
||||
@empty
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Nothing has happened yet.</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Enums\ActivityTypeEnum;
|
||||
use App\Enums\ApprovalStatusEnum;
|
||||
use App\Enums\PublishStatusEnum;
|
||||
use App\Livewire\Dashboard;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Article;
|
||||
use App\Models\Feed;
|
||||
use App\Models\PlatformChannel;
|
||||
|
|
@ -471,6 +473,85 @@ public function test_it_reports_a_range_with_no_publish_attempts(): void
|
|||
$this->assertStringContainsString('No publish attempts in this range.', $fragments);
|
||||
}
|
||||
|
||||
public function test_it_lists_the_most_recent_activity(): void
|
||||
{
|
||||
ActivityLog::factory()->type(ActivityTypeEnum::PUBLISH)->loggedAt(Carbon::parse('2026-07-10 09:00:00'))->create(['message' => 'Older entry']);
|
||||
ActivityLog::factory()->type(ActivityTypeEnum::ERROR)->loggedAt(Carbon::parse('2026-07-12 09:00:00'))->create(['message' => 'Newer entry']);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('Recent Activity')
|
||||
->assertSee('Newer entry')
|
||||
->assertSee('Older entry')
|
||||
->assertSeeInOrder(['Newer entry', 'Older entry']);
|
||||
}
|
||||
|
||||
public function test_it_caps_the_activity_panel_at_five_entries(): void
|
||||
{
|
||||
foreach (range(1, 8) as $index) {
|
||||
ActivityLog::factory()
|
||||
->loggedAt(Carbon::parse('2026-07-10 09:00:00')->addMinutes($index))
|
||||
->create(['message' => "Entry {$index}"]);
|
||||
}
|
||||
|
||||
$component = Livewire::test(Dashboard::class);
|
||||
|
||||
$component->assertSee('Entry 8')->assertSee('Entry 4');
|
||||
$component->assertDontSee('Entry 3');
|
||||
}
|
||||
|
||||
public function test_the_activity_panel_ignores_the_global_range(): void
|
||||
{
|
||||
ActivityLog::factory()
|
||||
->loggedAt(Carbon::parse('2026-07-10 09:00:00'))
|
||||
->create(['message' => 'Activity outside the range']);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->call('applyRange', '2026-08-01', '2026-08-31')
|
||||
->assertSee('Activity outside the range');
|
||||
}
|
||||
|
||||
public function test_it_reports_an_empty_activity_panel(): void
|
||||
{
|
||||
Livewire::test(Dashboard::class)->assertSee('Nothing has happened yet.');
|
||||
}
|
||||
|
||||
public function test_it_links_the_activity_panel_to_the_activity_page(): void
|
||||
{
|
||||
Livewire::test(Dashboard::class)->assertSee(route('activity'), false);
|
||||
}
|
||||
|
||||
public function test_it_shows_the_feed_name_for_an_article_subject(): void
|
||||
{
|
||||
$feed = Feed::factory()->create(['name' => 'Belga News']);
|
||||
$article = Article::factory()->create(['feed_id' => $feed->id]);
|
||||
|
||||
ActivityLog::factory()
|
||||
->forSubject($article)
|
||||
->create(['message' => 'Fetched something']);
|
||||
|
||||
Livewire::test(Dashboard::class)
|
||||
->assertSee('Fetched something')
|
||||
->assertSee('Belga News');
|
||||
}
|
||||
|
||||
public function test_it_renders_an_activity_entry_without_a_subject(): void
|
||||
{
|
||||
ActivityLog::factory()->create(['message' => 'Subjectless entry']);
|
||||
|
||||
Livewire::test(Dashboard::class)->assertSee('Subjectless entry');
|
||||
}
|
||||
|
||||
public function test_it_renders_an_activity_entry_whose_subject_was_deleted(): void
|
||||
{
|
||||
$article = Article::factory()->create();
|
||||
|
||||
ActivityLog::factory()->forSubject($article)->create(['message' => 'Orphaned entry']);
|
||||
|
||||
$article->delete();
|
||||
|
||||
Livewire::test(Dashboard::class)->assertSee('Orphaned entry');
|
||||
}
|
||||
|
||||
public function test_every_range_dependent_island_re_renders_on_a_range_change(): void
|
||||
{
|
||||
$fragments = $this->islandFragments(
|
||||
|
|
|
|||
Loading…
Reference in a new issue