From 3e9fbf98aa0e0e3539ceaf67b63b36d425cadc4b Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Tue, 7 Apr 2026 09:21:53 +0700 Subject: [PATCH] feat: implement read status tracking for changelogs with user-specific notifications and UI updates --- .../Actions/CreateChangelogAction.php | 2 +- .../Changelog/Actions/EditChangelogAction.php | 2 +- .../Changelog/Actions/MarkAsReadAction.php | 36 +++++ app/Filament/Pages/System/Changelog/Index.php | 23 ++- app/Models/Changelog.php | 13 ++ app/Models/User.php | 6 + ..._07_085720_create_changelog_user_table.php | 33 ++++ database/seeders/ShieldSeeder.php | 4 + lang/id/notif.php | 8 + .../pages/system/changelog/index.blade.php | 147 ++++++++++++------ tests/Feature/ChangelogTest.php | 47 ++++++ 11 files changed, 274 insertions(+), 47 deletions(-) create mode 100644 app/Filament/Pages/System/Changelog/Actions/MarkAsReadAction.php create mode 100644 database/migrations/2026_04_07_085720_create_changelog_user_table.php diff --git a/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php b/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php index 8b639b8..549c6c3 100644 --- a/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php +++ b/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php @@ -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()); } } diff --git a/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php b/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php index 2227502..bc28e0f 100644 --- a/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php +++ b/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php @@ -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()); } diff --git a/app/Filament/Pages/System/Changelog/Actions/MarkAsReadAction.php b/app/Filament/Pages/System/Changelog/Actions/MarkAsReadAction.php new file mode 100644 index 0000000..f64474d --- /dev/null +++ b/app/Filament/Pages/System/Changelog/Actions/MarkAsReadAction.php @@ -0,0 +1,36 @@ +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); + } + }); + } +} diff --git a/app/Filament/Pages/System/Changelog/Index.php b/app/Filament/Pages/System/Changelog/Index.php index 2bb8dcb..c88441d 100644 --- a/app/Filament/Pages/System/Changelog/Index.php +++ b/app/Filament/Pages/System/Changelog/Index.php @@ -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 { diff --git a/app/Models/Changelog.php b/app/Models/Changelog.php index abe60d6..c21701a 100644 --- a/app/Models/Changelog.php +++ b/app/Models/Changelog.php @@ -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(); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 2394af9..25081fa 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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([ diff --git a/database/migrations/2026_04_07_085720_create_changelog_user_table.php b/database/migrations/2026_04_07_085720_create_changelog_user_table.php new file mode 100644 index 0000000..1edcaee --- /dev/null +++ b/database/migrations/2026_04_07_085720_create_changelog_user_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/seeders/ShieldSeeder.php b/database/seeders/ShieldSeeder.php index aa4627c..dcf3246 100644 --- a/database/seeders/ShieldSeeder.php +++ b/database/seeders/ShieldSeeder.php @@ -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", diff --git a/lang/id/notif.php b/lang/id/notif.php index abe156e..32dba5a 100644 --- a/lang/id/notif.php +++ b/lang/id/notif.php @@ -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' => [ diff --git a/resources/views/filament/pages/system/changelog/index.blade.php b/resources/views/filament/pages/system/changelog/index.blade.php index 8a24859..33b8618 100644 --- a/resources/views/filament/pages/system/changelog/index.blade.php +++ b/resources/views/filament/pages/system/changelog/index.blade.php @@ -19,8 +19,8 @@
@foreach ($this->techStack['stack'] as $tech => $version) -
-

+

+

{{ $tech }}

@@ -33,67 +33,126 @@ @if (count($this->changelogs)) -

- @foreach ($this->changelogs as $log) -
-
-
+
+ @foreach ($this->changelogs as $index => $log) +
$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 --}} + - @if ($log->description) -
- {!! $log->description !!} + {{-- Body Content --}} +
+
+ @if ($log->description) +
+ {!! $log->description !!} +
+ @endif + +
+ + Perubahan Detail + +
    + @foreach ($log->changes as $change) +
  • + @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 + + + {{ $change }} + +
  • + @endforeach +
- @endif -
- - Daftar Perubahan - -
    - @foreach ($log->changes as $change) -
  • - - {{ $change }} -
  • - @endforeach -
+
+
+ @if (!$log->is_read) + {{ ($this->markAsReadAction)(['record' => $log->id]) }} + @else +
+ + Sudah Dibaca +
+ @endif +
+ + @canAny(['Update:Changelog', 'Delete:Changelog']) +
+ {{ ($this->editChangelogAction)(['record' => $log->id]) }} + {{ ($this->deleteChangelogAction)(['record' => $log->id]) }} +
+ @endcanAny +
- - @canAny(['Update:Changelog', 'Delete:Changelog']) -
- {{ ($this->editChangelogAction)(['record' => $log->id]) }} - {{ ($this->deleteChangelogAction)(['record' => $log->id]) }} -
- @endcanAny
@endforeach
@else + class="py-12"> @endif diff --git a/tests/Feature/ChangelogTest.php b/tests/Feature/ChangelogTest.php index ed55b55..2152b0a 100644 --- a/tests/Feature/ChangelogTest.php +++ b/tests/Feature/ChangelogTest.php @@ -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'); + }); +});