Release v1.4.0 #146
7 changed files with 288 additions and 1 deletions
55
app/Livewire/Activity.php
Normal file
55
app/Livewire/Activity.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\ActivityTypeEnum;
|
||||||
|
use App\Models\ActivityLog;
|
||||||
|
use App\Services\Activity\ActivitySummary;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
class Activity extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $type = '';
|
||||||
|
|
||||||
|
public int $days = 7;
|
||||||
|
|
||||||
|
public function updatedType(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedDays(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Builder<ActivityLog>
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\ActivityTypeEnum;
|
||||||
use App\Enums\ApprovalStatusEnum;
|
use App\Enums\ApprovalStatusEnum;
|
||||||
|
use App\Events\ActivityLogged;
|
||||||
use App\Jobs\ArticleDiscoveryJob;
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
use App\Models\Feed;
|
use App\Models\Feed;
|
||||||
use App\Models\RouteArticle;
|
use App\Models\RouteArticle;
|
||||||
|
|
@ -77,7 +79,19 @@ public function restore(int $routeArticleId): void
|
||||||
|
|
||||||
public function clear(): 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 = [];
|
$this->expandedFeeds = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
app/Services/Activity/ActivitySummary.php
Normal file
34
app/Services/Activity/ActivitySummary.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Activity;
|
||||||
|
|
||||||
|
use App\Enums\ActivityTypeEnum;
|
||||||
|
use App\Models\ActivityLog;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class ActivitySummary
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Counts per type since $since, including types with no rows.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function since(Carbon $since): array
|
||||||
|
{
|
||||||
|
/** @var array<string, int> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'],
|
['name' => 'Feeds', 'route' => 'feeds', 'icon' => 'rss'],
|
||||||
['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'],
|
['name' => 'Channels', 'route' => 'channels', 'icon' => 'hashtag'],
|
||||||
['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'],
|
['name' => 'Routes', 'route' => 'routes', 'icon' => 'arrow-path'],
|
||||||
|
['name' => 'Activity', 'route' => 'activity', 'icon' => 'clock'],
|
||||||
['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'],
|
['name' => 'Settings', 'route' => 'settings', 'icon' => 'cog-6-tooth'],
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
@ -41,6 +42,11 @@ class="group flex items-center px-2 py-2 text-sm font-medium rounded-md mb-1 {{
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
@break
|
@break
|
||||||
|
@case('clock')
|
||||||
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||||
|
</svg>
|
||||||
|
@break
|
||||||
@case('cog-6-tooth')
|
@case('cog-6-tooth')
|
||||||
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
<svg class="mr-3 h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
|
|
||||||
72
resources/views/livewire/activity.blade.php
Normal file
72
resources/views/livewire/activity.blade.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<x-page-header title="Activity" subtitle="What FFR has been doing automatically" />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6 mb-6">
|
||||||
|
@foreach ($typeOptions as $value => $label)
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm px-4 py-3">
|
||||||
|
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">{{ $label }}</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">{{ $summaryDay[$value] }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $summaryWeek[$value] }} in 7d</p>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
wire:model.live="type"
|
||||||
|
aria-label="Filter by activity type"
|
||||||
|
class="w-56 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
@foreach ($typeOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
wire:model.live="days"
|
||||||
|
aria-label="Filter by time range"
|
||||||
|
class="w-44 shrink-0 pl-3 pr-10 py-2 border border-gray-300 rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<option value="1">Last 24 hours</option>
|
||||||
|
<option value="7">Last 7 days</option>
|
||||||
|
<option value="30">Last 30 days</option>
|
||||||
|
<option value="90">Last 90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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->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>
|
||||||
|
@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>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($entries->hasPages())
|
||||||
|
<div class="mt-4">
|
||||||
|
{{ $entries->links() }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
|
use App\Livewire\Activity;
|
||||||
use App\Livewire\Articles;
|
use App\Livewire\Articles;
|
||||||
use App\Livewire\Channels;
|
use App\Livewire\Channels;
|
||||||
use App\Livewire\Dashboard;
|
use App\Livewire\Dashboard;
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
Route::middleware(['auth', 'onboarding.complete'])->group(function () {
|
Route::middleware(['auth', 'onboarding.complete'])->group(function () {
|
||||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||||
Route::get('/articles', Articles::class)->name('articles');
|
Route::get('/articles', Articles::class)->name('articles');
|
||||||
|
Route::get('/activity', Activity::class)->name('activity');
|
||||||
Route::get('/feeds', Feeds::class)->name('feeds');
|
Route::get('/feeds', Feeds::class)->name('feeds');
|
||||||
Route::get('/channels', Channels::class)->name('channels');
|
Route::get('/channels', Channels::class)->name('channels');
|
||||||
Route::get('/routes', Routes::class)->name('routes');
|
Route::get('/routes', Routes::class)->name('routes');
|
||||||
|
|
|
||||||
104
tests/Feature/Livewire/ActivityTest.php
Normal file
104
tests/Feature/Livewire/ActivityTest.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\ActivityTypeEnum;
|
||||||
|
use App\Livewire\Activity;
|
||||||
|
use App\Models\ActivityLog;
|
||||||
|
use App\Models\Article;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ActivityTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_renders_successfully(): void
|
||||||
|
{
|
||||||
|
Livewire::test(Activity::class)->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 "<script>alert(1)</script>"']);
|
||||||
|
|
||||||
|
Livewire::test(Activity::class)
|
||||||
|
->assertDontSee('<script>alert(1)</script>', false)
|
||||||
|
->assertSee('<script>', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue