feat: implement read status tracking for changelogs with user-specific notifications and UI updates

This commit is contained in:
Yoga Pangestu 2026-04-07 09:21:53 +07:00
parent a932a030ad
commit 3e9fbf98aa
11 changed files with 274 additions and 47 deletions

View File

@ -26,7 +26,7 @@ protected function setUp(): void
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label('Simpan dan Tambah Lagi'),
])
->modalWidth(Width::ThreeExtraLarge)
->modalWidth(Width::FourExtraLarge)
->schema(fn ($livewire) => $livewire->changelogFormSchema());
}
}

View File

@ -27,7 +27,7 @@ protected function setUp(): void
->modalHeading('Ubah Data')
->modalSubmitActionLabel('Simpan')
->modalCancelActionLabel('Batal')
->modalWidth(Width::ThreeExtraLarge)
->modalWidth(Width::FourExtraLarge)
->schema(fn ($livewire) => $livewire->changelogFormSchema())
->fillForm(fn (Changelog $record): array => $record->toArray());
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Filament\Pages\System\Changelog\Actions;
use App\Filament\Support\SystemNotification;
use App\Models\Changelog;
use Filament\Actions\Action;
class MarkAsReadAction extends Action
{
public static function getDefaultName(): string
{
return 'markAsReadAction';
}
protected function setUp(): void
{
parent::setUp();
$this->record(fn (array $arguments): Changelog => Changelog::findOrFail($arguments['record']))
->label('Tandai Sudah Baca')
->icon('heroicon-m-check-badge')
->size('sm')
->color('primary')
->action(function (Changelog $record, $livewire) {
if (! $record->users()->where('user_id', auth()->id())->exists()) {
$record->users()->attach(auth()->id(), ['read_at' => now()]);
SystemNotification::send('changelog_read', ['title' => $record->title])->send();
$livewire->dispatch('refresh-changelog');
$livewire->dispatch('refresh-expansion', id: $livewire->changelogs()->first()?->id);
}
});
}
}

View File

@ -6,6 +6,7 @@
use App\Filament\Pages\System\Changelog\Actions\CreateChangelogAction;
use App\Filament\Pages\System\Changelog\Actions\DeleteChangelogAction;
use App\Filament\Pages\System\Changelog\Actions\EditChangelogAction;
use App\Filament\Pages\System\Changelog\Actions\MarkAsReadAction;
use App\Filament\Support\SystemNotification;
use App\Models\Changelog;
use BackedEnum;
@ -44,6 +45,13 @@ class Index extends Page implements HasActions, HasForms
protected string $view = 'filament.pages.system.changelog.index';
public static function getNavigationBadge(): ?string
{
$unreadCount = Changelog::whereDoesntHave('users', fn ($query) => $query->where('user_id', auth()->id()))->count();
return $unreadCount > 0 ? (string) $unreadCount : null;
}
public static function getPagePermission(): string
{
return 'View:Changelog';
@ -69,7 +77,15 @@ public function techStack(): array
#[Computed]
public function changelogs(): Collection
{
return Changelog::latest('release_date')->get();
return Changelog::with([
'users' => fn ($query) => $query->where('user_id', auth()->id()),
])
->get()
->sortBy([
fn ($a, $b) => $a->is_read <=> $b->is_read,
['release_date', 'desc'],
])
->values();
}
protected function getHeaderActions(): array
@ -92,6 +108,11 @@ public function deleteChangelogAction(): Action
->visible(fn () => auth()->user()?->can('Delete:Changelog'));
}
public function markAsReadAction(): Action
{
return MarkAsReadAction::make();
}
#[Computed]
public function emptyHeading(): string
{

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Changelog extends Model
@ -54,4 +55,16 @@ protected function formattedUpdatedAt(): Attribute
{
return Attribute::get(fn () => $this->updated_at?->translatedFormat('l, d M Y H:i'));
}
protected function isRead(): Attribute
{
return Attribute::get(fn () => $this->users->isNotEmpty());
}
// --- Relations ---
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->withPivot('read_at')->withTimestamps();
}
}

View File

@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -78,6 +79,11 @@ protected function formattedUpdatedAt(): Attribute
// --- Relations ---
public function changelogs(): BelongsToMany
{
return $this->belongsToMany(Changelog::class)->withPivot('read_at')->withTimestamps();
}
public function settings(): HasOne
{
return $this->hasOne(UserSetting::class)->withDefault([

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('changelog_user', function (Blueprint $table) {
$table->id();
$table->foreignId('changelog_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('read_at')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->unique(['changelog_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('changelog_user');
}
};

View File

@ -158,6 +158,8 @@ public function run(): void
"name": "Mahasiswa",
"guard_name": "web",
"permissions" : [
"View:Changelog",
"View:Assignment",
"ViewAny:Assignment",
@ -181,6 +183,8 @@ public function run(): void
"name": "Kosma",
"guard_name": "web",
"permissions" : [
"View:Changelog",
"Create:Assignment",
"Delete:Assignment",
"ForceDelete:Assignment",

View File

@ -141,6 +141,10 @@
'title' => 'Pengguna Dinonaktifkan ⛔👋',
'body' => 'Status akun pengguna telah berhasil dinonaktifkan. Istirahat dulu ya... 😴',
],
'changelog_read' => [
'title' => 'Mantap! Sudah Dibaca ✅✨',
'body' => 'Pembaruan ":title" sudah ditandai. Makin update makin jago! 🚀🔥',
],
// UI Labels & Descriptions
'labels' => [
@ -429,6 +433,10 @@
'title' => 'Penonaktifan Akun Berhasil',
'body' => 'Status akun pengguna terpilih telah diubah menjadi tidak aktif.',
],
'changelog_read' => [
'title' => 'Pembaruan Berhasil Ditandai',
'body' => 'Catatan rilis ":title" telah berhasil ditandai sebagai sudah dibaca.',
],
// UI Labels & Descriptions
'labels' => [

View File

@ -19,8 +19,8 @@
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-gray-100 dark:border-white/5">
@foreach ($this->techStack['stack'] as $tech => $version)
<div class="p-3 rounded-lg bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 transition hover:border-primary-500/30">
<p class="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500">
<div class="p-4 rounded-xl bg-gray-50 dark:bg-white/5 border border-gray-100 dark:border-white/10 transition duration-200 hover:border-primary-500/30 hover:shadow-sm">
<p class="text-[10px] uppercase tracking-wider font-bold text-gray-400 dark:text-gray-500 mb-1">
{{ $tech }}
</p>
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 truncate">
@ -33,67 +33,126 @@
<x-filament::section>
@if (count($this->changelogs))
<div class="space-y-4">
@foreach ($this->changelogs as $log)
<div class="fi-card rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm hover:shadow-md transition">
<div class="p-5 space-y-4">
<div class="flex items-start justify-between gap-3">
<div
class="space-y-4"
x-data="{ expanded: @js($this->changelogs->first()?->id) }"
x-on:refresh-expansion.window="expanded = $event.detail.id"
>
@foreach ($this->changelogs as $index => $log)
<div wire:key="changelog-{{ $log->id }}" @class([
'fi-card rounded-xl border transition overflow-hidden shadow-sm hover:shadow-md',
'border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900' => $log->is_read,
'border-primary-500/50 dark:border-primary-400/30 bg-primary-50/30 dark:bg-primary-400/5 shadow-primary-500/5' => !$log->is_read,
])>
{{-- Header / Clickable Toggle --}}
<button
type="button"
x-on:click="expanded = (expanded === {{ $log->id }} ? null : {{ $log->id }})"
class="w-full text-left p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4 transition duration-200 hover:bg-gray-50/50 dark:hover:bg-white/5"
>
<div class="space-y-2">
<div class="flex items-center gap-3">
<h3 class="text-lg font-bold leading-tight text-gray-950 dark:text-white">
{{ $log->title }}
</h3>
@if (!$log->is_read)
<span class="inline-flex items-center rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-bold text-primary-700 dark:bg-primary-900/30 dark:text-primary-400 animate-pulse">
Baru
</span>
@endif
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center rounded-md bg-{{ $log->type->getColorClass() }}-50 dark:bg-{{ $log->type->getColorClass() }}-500/10 px-2 py-1 text-xs font-semibold text-{{ $log->type->getColorClass() }}-700 dark:text-{{ $log->type->getColorClass() }}-300 ring-1 ring-inset ring-{{ $log->type->getColorClass() }}-600/20">
<x-filament::badge :color="$log->type->getColor()" :icon="$log->type->getIcon()" size="sm">
{{ $log->type->getLabel() }}
</span>
</x-filament::badge>
<span class="inline-flex items-center rounded-md bg-gray-50 dark:bg-gray-800/50 px-2 py-1 text-xs font-bold text-gray-600 dark:text-gray-300 ring-1 ring-inset ring-gray-200 dark:ring-gray-700">
<x-filament::badge color="gray" icon="heroicon-m-tag" size="sm">
{{ $log->formatted_version }}
</x-filament::badge>
<span class="inline-flex items-center text-xs text-gray-500 dark:text-gray-400 shrink-0">
<x-heroicon-m-calendar class="w-4 h-4 mr-1.5 opacity-70" />
{{ $log->formatted_release_date }}
</span>
</div>
<span class="inline-flex items-center text-xs text-gray-500 dark:text-gray-400 shrink-0">
<x-heroicon-m-calendar class="w-4 h-4 mr-1 opacity-70" />
{{ $log->formatted_release_date }}
</span>
</div>
<h3 class="text-base font-semibold leading-tight text-gray-950 dark:text-white">
{{ $log->title }}
</h3>
<div class="flex items-center gap-2">
<x-heroicon-m-chevron-down
class="w-5 h-5 text-gray-400 transition duration-300"
x-bind:class="{ 'rotate-180': expanded === {{ $log->id }} }"
/>
</div>
</button>
@if ($log->description)
<div class="text-sm text-gray-500 dark:text-gray-400 prose prose-sm dark:prose-invert max-w-none">
{!! $log->description !!}
{{-- Body Content --}}
<div
x-show="expanded === {{ $log->id }}"
x-collapse
x-cloak
>
<div class="px-6 pb-6 pt-2 space-y-6 border-t border-gray-100 dark:border-white/5">
@if ($log->description)
<div class="text-sm text-gray-600 dark:text-gray-300 prose prose-sm dark:prose-invert fi-prose max-w-none">
{!! $log->description !!}
</div>
@endif
<div class="pt-2">
<span class="text-[10px] font-bold uppercase text-gray-500 dark:text-gray-400 tracking-widest">
Perubahan Detail
</span>
<ul class="mt-3 space-y-2.5">
@foreach ($log->changes as $change)
<li class="flex items-start gap-3">
@php
$colorClass = match ($log->type->getColor()) {
'success' => 'text-success-500 dark:text-success-400',
'info' => 'text-info-500 dark:text-info-400',
'danger' => 'text-danger-500 dark:text-danger-400',
'warning' => 'text-warning-500 dark:text-warning-400',
default => 'text-primary-500 dark:text-primary-400',
};
@endphp
<x-heroicon-m-check-circle class="w-5 h-5 mt-0.5 shrink-0 {{ $colorClass }}" />
<span class="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{{ $change }}
</span>
</li>
@endforeach
</ul>
</div>
@endif
<div>
<span class="text-[10px] font-bold uppercase text-gray-400 tracking-widest leading-none">
Daftar Perubahan
</span>
<ul class="mt-2 space-y-1.5">
@foreach ($log->changes as $change)
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<x-heroicon-m-check-circle class="w-4 h-4 mt-0.5 shrink-0 text-{{ $log->type->getColorClass() }}-500" />
<span>{{ $change }}</span>
</li>
@endforeach
</ul>
<div class="flex items-center justify-between gap-4 pt-4 mt-6 border-t border-gray-100 dark:border-white/5">
<div class="flex items-center gap-2">
@if (!$log->is_read)
{{ ($this->markAsReadAction)(['record' => $log->id]) }}
@else
<div class="flex items-center gap-1.5 text-xs font-semibold text-success-600 dark:text-success-400 bg-success-50 dark:bg-success-500/10 px-3 py-1.5 rounded-lg border border-success-200 dark:border-success-500/20">
<x-heroicon-m-check-badge class="w-4 h-4" />
Sudah Dibaca
</div>
@endif
</div>
@canAny(['Update:Changelog', 'Delete:Changelog'])
<div class="flex items-center gap-2">
{{ ($this->editChangelogAction)(['record' => $log->id]) }}
{{ ($this->deleteChangelogAction)(['record' => $log->id]) }}
</div>
@endcanAny
</div>
</div>
</div>
@canAny(['Update:Changelog', 'Delete:Changelog'])
<div class="flex items-center justify-end gap-1 px-5 pb-4 pt-3 border-t border-gray-100 dark:border-gray-800">
{{ ($this->editChangelogAction)(['record' => $log->id]) }}
{{ ($this->deleteChangelogAction)(['record' => $log->id]) }}
</div>
@endcanAny
</div>
@endforeach
</div>
@else
<x-filament::empty-state
icon="heroicon-o-information-circle"
icon="heroicon-o-document-text"
heading="{{ $this->emptyHeading }}"
description="{{ $this->emptyDescription }}"
iconColor="gray">
class="py-12">
</x-filament::empty-state>
@endif
</x-filament::section>

View File

@ -261,3 +261,50 @@
expect(ChangelogType::Security->getIcon())->toBe('heroicon-o-shield-check');
});
});
describe('Changelog Read Status', function () {
beforeEach(function () {
$user = User::factory()->create();
$user->assignRole(RoleEnum::Developer);
$user->givePermissionTo('View:Changelog');
$this->actingAs($user);
});
it('can mark a changelog as read', function () {
$changelog = Changelog::factory()->create();
Livewire::test(ChangelogPage::class)
->callAction('markAsReadAction', [], ['record' => $changelog->id])
->assertDispatched('refresh-changelog')
->assertDispatched('refresh-expansion', id: $changelog->id)
->assertNotified();
$this->assertDatabaseHas('changelog_user', [
'changelog_id' => $changelog->id,
'user_id' => auth()->id(),
]);
expect($changelog->refresh()->is_read)->toBeTrue();
});
it('sorts unread changelogs to the top', function () {
$read = Changelog::factory()->create(['release_date' => now()->subDays(1)]);
$unread = Changelog::factory()->create(['release_date' => now()->subDays(2)]);
// Mark one as read
$read->users()->attach(auth()->id(), ['read_at' => now()]);
$component = Livewire::test(ChangelogPage::class);
$changelogs = $component->get('changelogs');
// Unread should be first even if release_date is older
expect($changelogs->first()->id)->toBe($unread->id);
expect($changelogs->last()->id)->toBe($read->id);
});
it('shows a navigation badge for unread changelogs', function () {
Changelog::factory()->count(3)->create();
expect(ChangelogPage::getNavigationBadge())->toBe('3');
});
});