feat: implement read status tracking for changelogs with user-specific notifications and UI updates
This commit is contained in:
parent
a932a030ad
commit
3e9fbf98aa
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user