From bccc36ca81d8f02c33f26b20dfeeef13251e4218 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 2 Apr 2026 10:45:02 +0700 Subject: [PATCH] feat: implement changelog management system with CRUD operations and role-based permissions --- app/Enums/ChangelogType.php | 55 ++++ .../Actions/CreateChangelogAction.php | 32 +++ .../Actions/DeleteChangelogAction.php | 30 ++ .../Changelog/Actions/EditChangelogAction.php | 34 +++ app/Filament/Pages/System/Changelog/Index.php | 150 ++++++++++ app/Models/Changelog.php | 44 +++ database/factories/ChangelogFactory.php | 37 +++ ...6_04_02_084211_create_changelogs_table.php | 36 +++ database/seeders/ShieldSeeder.php | 9 + lang/id/notif.php | 16 ++ .../pages/system/changelog/index.blade.php | 100 +++++++ tests/Feature/ChangelogTest.php | 263 ++++++++++++++++++ 12 files changed, 806 insertions(+) create mode 100644 app/Enums/ChangelogType.php create mode 100644 app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php create mode 100644 app/Filament/Pages/System/Changelog/Actions/DeleteChangelogAction.php create mode 100644 app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php create mode 100644 app/Filament/Pages/System/Changelog/Index.php create mode 100644 app/Models/Changelog.php create mode 100644 database/factories/ChangelogFactory.php create mode 100644 database/migrations/2026_04_02_084211_create_changelogs_table.php create mode 100644 resources/views/filament/pages/system/changelog/index.blade.php create mode 100644 tests/Feature/ChangelogTest.php diff --git a/app/Enums/ChangelogType.php b/app/Enums/ChangelogType.php new file mode 100644 index 0000000..7f9beeb --- /dev/null +++ b/app/Enums/ChangelogType.php @@ -0,0 +1,55 @@ + 'Fitur Baru 🚀', + self::Improvement => 'Peningkatan ✨', + self::BugFix => 'Perbaikan Bug 🐛', + self::Security => 'Keamanan 🔐', + }; + } + + public function getColor(): string|array|null + { + return match ($this) { + self::Feature => 'success', + self::Improvement => 'info', + self::BugFix => 'danger', + self::Security => 'warning', + }; + } + + public function getIcon(): ?string + { + return match ($this) { + self::Feature => 'heroicon-o-rocket-launch', + self::Improvement => 'heroicon-o-sparkles', + self::BugFix => 'heroicon-o-bug-ant', + self::Security => 'heroicon-o-shield-check', + }; + } + + public function getColorClass(): string + { + return match ($this) { + self::Feature => 'emerald', + self::Improvement => 'blue', + self::BugFix => 'rose', + self::Security => 'amber', + }; + } +} diff --git a/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php b/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php new file mode 100644 index 0000000..8b639b8 --- /dev/null +++ b/app/Filament/Pages/System/Changelog/Actions/CreateChangelogAction.php @@ -0,0 +1,32 @@ +model(Changelog::class) + ->label('Tambah') + ->modalHeading('Tambah Riwayat') + ->modalSubmitActionLabel('Simpan') + ->modalCancelActionLabel('Batal') + ->extraModalFooterActions(fn (CreateAction $action): array => [ + $action->makeModalSubmitAction('createAnother', arguments: ['another' => true]) + ->label('Simpan dan Tambah Lagi'), + ]) + ->modalWidth(Width::ThreeExtraLarge) + ->schema(fn ($livewire) => $livewire->changelogFormSchema()); + } +} diff --git a/app/Filament/Pages/System/Changelog/Actions/DeleteChangelogAction.php b/app/Filament/Pages/System/Changelog/Actions/DeleteChangelogAction.php new file mode 100644 index 0000000..7635ad9 --- /dev/null +++ b/app/Filament/Pages/System/Changelog/Actions/DeleteChangelogAction.php @@ -0,0 +1,30 @@ +record(fn (array $arguments): Changelog => Changelog::findOrFail($arguments['record'])) + ->label('Hapus') + ->icon('heroicon-o-trash') + ->color('danger') + ->link() + ->tooltip('Hapus') + ->modalHeading('Hapus Data') + ->modalDescription('Apakah Anda yakin ingin menghapus riwayat ini? Tindakan ini tidak dapat dibatalkan.') + ->modalSubmitActionLabel('Hapus') + ->modalCancelActionLabel('Batal'); + } +} diff --git a/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php b/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php new file mode 100644 index 0000000..2227502 --- /dev/null +++ b/app/Filament/Pages/System/Changelog/Actions/EditChangelogAction.php @@ -0,0 +1,34 @@ +label('Ubah') + ->color('warning') + ->icon('heroicon-o-pencil-square') + ->link() + ->tooltip('Ubah') + ->record(fn (array $arguments): Changelog => Changelog::findOrFail($arguments['record'])) + ->modalHeading('Ubah Data') + ->modalSubmitActionLabel('Simpan') + ->modalCancelActionLabel('Batal') + ->modalWidth(Width::ThreeExtraLarge) + ->schema(fn ($livewire) => $livewire->changelogFormSchema()) + ->fillForm(fn (Changelog $record): array => $record->toArray()); + } +} diff --git a/app/Filament/Pages/System/Changelog/Index.php b/app/Filament/Pages/System/Changelog/Index.php new file mode 100644 index 0000000..2bb8dcb --- /dev/null +++ b/app/Filament/Pages/System/Changelog/Index.php @@ -0,0 +1,150 @@ +first(); + + return [ + 'name' => config('app.name'), + 'version' => $latestUpdate?->version ?? 'v1.0.0', + 'stack' => [ + 'PHP' => PHP_VERSION, + 'Laravel' => app()->version(), + 'Filament' => 'v5.x', + 'Database' => config('database.default'), + ], + ]; + } + + #[Computed] + public function changelogs(): Collection + { + return Changelog::latest('release_date')->get(); + } + + protected function getHeaderActions(): array + { + return [ + CreateChangelogAction::make() + ->visible(fn () => auth()->user()?->can('Create:Changelog')), + ]; + } + + public function editChangelogAction(): Action + { + return EditChangelogAction::make() + ->visible(fn () => auth()->user()?->can('Update:Changelog')); + } + + public function deleteChangelogAction(): Action + { + return DeleteChangelogAction::make() + ->visible(fn () => auth()->user()?->can('Delete:Changelog')); + } + + #[Computed] + public function emptyHeading(): string + { + return SystemNotification::getByKey('labels.empty_changelog.title'); + } + + #[Computed] + public function emptyDescription(): string + { + return SystemNotification::getByKey('labels.empty_changelog.description'); + } + + public function changelogFormSchema(): array + { + return [ + Grid::make(3) + ->schema([ + TextInput::make('version') + ->label('Versi') + ->placeholder('v1.0.0') + ->required() + ->unique(ignoreRecord: true), + + DatePicker::make('release_date') + ->label('Tanggal Rilis') + ->required() + ->native(false) + ->default(now()) + ->displayFormat('d F Y'), + + Select::make('type') + ->label('Tipe Update') + ->options(ChangelogType::class) + ->required() + ->native(false), + ]), + + TextInput::make('title') + ->label('Judul Update') + ->placeholder('Sistem Notifikasi & Estetika Baru') + ->required() + ->maxLength(100) + ->autocomplete(false), + + TagsInput::make('changes') + ->label('Daftar Perubahan') + ->placeholder('Tambah perubahan...') + ->required(), + + RichEditor::make('description') + ->label('Deskripsi') + ->placeholder('...') + ->required(), + ]; + } +} diff --git a/app/Models/Changelog.php b/app/Models/Changelog.php new file mode 100644 index 0000000..f775886 --- /dev/null +++ b/app/Models/Changelog.php @@ -0,0 +1,44 @@ + + */ + protected $casts = [ + 'release_date' => 'date', + 'changes' => 'array', + 'type' => ChangelogType::class, + ]; + + public function formattedVersion(): Attribute + { + return Attribute::get(fn () => strtoupper($this->version)); + } + + public function formattedReleaseDate(): Attribute + { + return Attribute::get(fn () => $this->release_date->translatedFormat('l, d M Y')); + } +} diff --git a/database/factories/ChangelogFactory.php b/database/factories/ChangelogFactory.php new file mode 100644 index 0000000..5c47f7e --- /dev/null +++ b/database/factories/ChangelogFactory.php @@ -0,0 +1,37 @@ + "v1.0.{$patchNumber}", + 'release_date' => $this->faker->dateTimeBetween('-1 year', 'now')->format('Y-m-d'), + 'title' => $this->faker->sentence(4), + 'changes' => $this->faker->sentences(3), + 'type' => $this->faker->randomElement(ChangelogType::cases()), + 'description' => $this->faker->paragraph(), + ]; + } + + public function feature(): static + { + return $this->state(['type' => ChangelogType::Feature]); + } + + public function bugfix(): static + { + return $this->state(['type' => ChangelogType::BugFix]); + } +} diff --git a/database/migrations/2026_04_02_084211_create_changelogs_table.php b/database/migrations/2026_04_02_084211_create_changelogs_table.php new file mode 100644 index 0000000..18cd26d --- /dev/null +++ b/database/migrations/2026_04_02_084211_create_changelogs_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('version')->unique(); + $table->date('release_date'); + $table->string('title', 100); + $table->json('changes'); + $table->enum('type', ChangelogType::cases()); + $table->text('description'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('changelogs'); + } +}; diff --git a/database/seeders/ShieldSeeder.php b/database/seeders/ShieldSeeder.php index 4875a30..174e21c 100644 --- a/database/seeders/ShieldSeeder.php +++ b/database/seeders/ShieldSeeder.php @@ -23,6 +23,11 @@ public function run(): void "name": "Developer", "guard_name": "web", "permissions" : [ + "View:Changelog", + "Create:Changelog", + "Update:Changelog", + "Delete:Changelog", + "Create:Assignment", "Delete:Assignment", "ForceDelete:Assignment", @@ -152,6 +157,8 @@ public function run(): void "name": "Mahasiswa", "guard_name": "web", "permissions" : [ + "View:Changelog", + "View:Assignment", "ViewAny:Assignment", @@ -175,6 +182,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 82fd635..6e874bb 100644 --- a/lang/id/notif.php +++ b/lang/id/notif.php @@ -193,6 +193,14 @@ 'title' => 'Daftar Tugas Bikin Lemes! 🤣📒', 'description' => 'Yuk, cek dan kumpulin tugasmu biar tenang hidupnya! Klik aja di tugasnya ya. 🚀👨‍💻', ], + 'changelog' => [ + 'title' => 'Catatan Rilis 📜✨', + 'description' => 'Kepoin apa aja yang baru, fitur kece, dan perbaikan bug biar nggak kudet! 🚀🔥', + ], + 'empty_changelog' => [ + 'title' => 'Belum Ada Catatan Rilis 📭', + 'description' => 'Belum ada update yang tercatat nih. Buat mulai catat riwayat pembaruan pertama! 🚀', + ], ], // Icons @@ -398,6 +406,14 @@ 'title' => 'Tugas Saya', 'description' => 'Klik pada tugas untuk melihat detail dan mengumpulkan file.', ], + 'changelog' => [ + 'title' => 'Catatan Rilis', + 'description' => 'Lihat riwayat perbaikan bug, pembaruan aplikasi, dan rilis fitur terbaru secara mendalam.', + ], + 'empty_changelog' => [ + 'title' => 'Tidak Ada Riwayat Pembaruan', + 'description' => 'Belum ada catatan rilis yang tersedia. Buat untuk mendokumentasikan pembaruan aplikasi.', + ], ], // Icons diff --git a/resources/views/filament/pages/system/changelog/index.blade.php b/resources/views/filament/pages/system/changelog/index.blade.php new file mode 100644 index 0000000..8a24859 --- /dev/null +++ b/resources/views/filament/pages/system/changelog/index.blade.php @@ -0,0 +1,100 @@ + + +
+
+ +
+
+

+ {{ $this->techStack['name'] }} +

+

+ Versi Aplikasi: + + {{ $this->techStack['version'] }} + +

+
+
+ +
+ @foreach ($this->techStack['stack'] as $tech => $version) +
+

+ {{ $tech }} +

+

+ {{ $version }} +

+
+ @endforeach +
+
+ + + @if (count($this->changelogs)) +
+ @foreach ($this->changelogs as $log) +
+
+
+
+ + {{ $log->type->getLabel() }} + + + + {{ $log->formatted_version }} + +
+ + + + {{ $log->formatted_release_date }} + +
+ +

+ {{ $log->title }} +

+ + @if ($log->description) +
+ {!! $log->description !!} +
+ @endif + +
+ + Daftar Perubahan + +
    + @foreach ($log->changes as $change) +
  • + + {{ $change }} +
  • + @endforeach +
+
+
+ + @canAny(['Update:Changelog', 'Delete:Changelog']) +
+ {{ ($this->editChangelogAction)(['record' => $log->id]) }} + {{ ($this->deleteChangelogAction)(['record' => $log->id]) }} +
+ @endcanAny +
+ @endforeach +
+ @else + + + @endif +
+
diff --git a/tests/Feature/ChangelogTest.php b/tests/Feature/ChangelogTest.php new file mode 100644 index 0000000..ed55b55 --- /dev/null +++ b/tests/Feature/ChangelogTest.php @@ -0,0 +1,263 @@ +value); + Role::findOrCreate(RoleEnum::Kosma->value); + Role::findOrCreate(RoleEnum::Student->value); + + $permissions = [ + 'View:Changelog', + 'Create:Changelog', + 'Update:Changelog', + 'Delete:Changelog', + ]; + + foreach ($permissions as $permission) { + Permission::findOrCreate($permission); + } +}); + +describe('Changelog Authorization', function () { + it('allows Developer to access the changelog page', function () { + $developer = User::factory()->create(); + $developer->assignRole(RoleEnum::Developer); + $developer->givePermissionTo('View:Changelog'); + + $this->actingAs($developer); + Livewire::test(ChangelogPage::class) + ->assertSuccessful(); + }); + + it('restricts Students from accessing the changelog page', function () { + $student = User::factory()->create(); + $student->assignRole(RoleEnum::Student); + + $this->actingAs($student); + Livewire::test(ChangelogPage::class) + ->assertStatus(403); + }); + + it('shows createChangelog action to users with Create:Changelog permission', function () { + $developer = User::factory()->create(); + $developer->assignRole(RoleEnum::Developer); + $developer->givePermissionTo(['View:Changelog', 'Create:Changelog']); + + $this->actingAs($developer); + Livewire::test(ChangelogPage::class) + ->assertActionVisible('createChangelog'); + }); + + it('hides createChangelog action from users without Create:Changelog permission', function () { + $kosma = User::factory()->create(); + $kosma->assignRole(RoleEnum::Kosma); + $kosma->givePermissionTo('View:Changelog'); + + $this->actingAs($kosma); + Livewire::test(ChangelogPage::class) + ->assertActionHidden('createChangelog'); + }); +}); + +describe('Changelog Model', function () { + it('has the correct casts configured', function () { + $changelog = Changelog::factory()->create([ + 'type' => ChangelogType::Feature, + 'changes' => ['Tambah fitur baru', 'Update UI'], + ]); + + expect($changelog->type)->toBeInstanceOf(ChangelogType::class); + expect($changelog->type)->toBe(ChangelogType::Feature); + expect($changelog->changes)->toBeArray(); + expect($changelog->release_date)->toBeInstanceOf(Carbon::class); + }); + + it('supports soft deletes', function () { + $changelog = Changelog::factory()->create(); + + $changelog->delete(); + + expect(Changelog::find($changelog->id))->toBeNull(); + expect(Changelog::withTrashed()->find($changelog->id))->not->toBeNull(); + expect($changelog->refresh()->trashed())->toBeTrue(); + }); + + it('can be force deleted', function () { + $changelog = Changelog::factory()->create(); + $changelog->delete(); + $changelog->forceDelete(); + + expect(Changelog::withTrashed()->find($changelog->id))->toBeNull(); + $this->assertDatabaseMissing('changelogs', ['id' => $changelog->id]); + }); + + it('enforces unique version constraint', function () { + Changelog::factory()->create(['version' => 'v1.0.0']); + + expect(fn () => Changelog::factory()->create(['version' => 'v1.0.0'])) + ->toThrow(QueryException::class); + }); +}); + +describe('Changelog CRUD via Filament Page', function () { + beforeEach(function () { + $user = User::factory()->create(); + $user->assignRole(RoleEnum::Developer); + $user->givePermissionTo(['View:Changelog', 'Create:Changelog', 'Update:Changelog', 'Delete:Changelog']); + $this->actingAs($user); + }); + + it('can create a changelog entry', function () { + Livewire::test(ChangelogPage::class) + ->callAction('createChangelog', [ + 'version' => 'v2.0.0', + 'release_date' => now()->toDateString(), + 'type' => ChangelogType::Feature->value, + 'title' => 'Fitur Keren Baru', + 'changes' => ['Tambah fitur A', 'Tambah fitur B'], + 'description' => 'Deskripsi singkat fitur baru.', + ]) + ->assertHasNoActionErrors(); + + $this->assertDatabaseHas('changelogs', [ + 'version' => 'v2.0.0', + 'title' => 'Fitur Keren Baru', + ]); + }); + + it('validates required fields when creating a changelog', function () { + Livewire::test(ChangelogPage::class) + ->callAction('createChangelog', [ + 'version' => '', + 'title' => '', + ]) + ->assertHasActionErrors(['version', 'title']); + }); + + it('validates unique version when creating a changelog', function () { + Changelog::factory()->create(['version' => 'v1.5.0']); + + Livewire::test(ChangelogPage::class) + ->callAction('createChangelog', [ + 'version' => 'v1.5.0', + 'release_date' => now()->toDateString(), + 'type' => ChangelogType::BugFix->value, + 'title' => 'Duplikat Versi', + 'changes' => ['Fix A'], + 'description' => 'Deskripsi.', + ]) + ->assertHasActionErrors(['version']); + }); + + it('can edit an existing changelog entry', function () { + $changelog = Changelog::factory()->create([ + 'title' => 'Judul Lama', + 'type' => ChangelogType::BugFix, + 'changes' => ['Fix satu bug'], + ]); + + Livewire::test(ChangelogPage::class) + ->callAction('editChangelog', [ + 'version' => $changelog->version, + 'release_date' => $changelog->release_date->toDateString(), + 'type' => ChangelogType::Improvement->value, + 'title' => 'Judul Baru', + 'changes' => ['Perbaikan performa'], + 'description' => $changelog->description, + ], ['record' => $changelog->id]) + ->assertHasNoActionErrors(); + + expect($changelog->refresh()->title)->toBe('Judul Baru'); + expect($changelog->refresh()->type)->toBe(ChangelogType::Improvement); + }); + + it('can delete a changelog entry (soft delete)', function () { + $changelog = Changelog::factory()->create(); + + Livewire::test(ChangelogPage::class) + ->callAction('deleteChangelog', [], ['record' => $changelog->id]) + ->assertHasNoActionErrors(); + + $this->assertSoftDeleted('changelogs', ['id' => $changelog->id]); + }); +}); + +describe('Changelog Computed Properties', function () { + beforeEach(function () { + $user = User::factory()->create(); + $user->assignRole(RoleEnum::Developer); + $user->givePermissionTo('View:Changelog'); + $this->actingAs($user); + }); + + it('returns changelogs sorted by latest release date', function () { + $oldest = Changelog::factory()->create(['release_date' => now()->subYear()]); + $newest = Changelog::factory()->create(['release_date' => now()]); + + $component = Livewire::test(ChangelogPage::class); + + // changelogs() computed prop returns latest first + $ids = Changelog::latest('release_date')->pluck('id')->toArray(); + expect($ids[0])->toBe($newest->id); + expect($ids[1])->toBe($oldest->id); + }); + + it('reflects latest version in techStack', function () { + // Clear and create changelogs in order + Changelog::factory()->create(['version' => 'v1.0.0', 'release_date' => now()->subDays(5)]); + Changelog::factory()->create(['version' => 'v2.0.0', 'release_date' => now()]); + + $latestVersion = Changelog::latest('release_date')->first()->version; + expect($latestVersion)->toBe('v2.0.0'); + }); + + it('shows changelog entries on the page', function () { + $changelog = Changelog::factory()->create(['title' => 'Update Kece']); + + Livewire::test(ChangelogPage::class) + ->assertSee('Update Kece'); + }); + + it('shows empty state message when no changelogs exist', function () { + Changelog::query()->forceDelete(); + + // The page should still render without errors when empty + Livewire::test(ChangelogPage::class) + ->assertSuccessful(); + }); +}); + +describe('ChangelogType Enum', function () { + it('returns correct label for each type', function () { + expect(ChangelogType::Feature->getLabel())->toContain('Fitur Baru'); + expect(ChangelogType::Improvement->getLabel())->toContain('Peningkatan'); + expect(ChangelogType::BugFix->getLabel())->toContain('Perbaikan Bug'); + expect(ChangelogType::Security->getLabel())->toContain('Keamanan'); + }); + + it('returns correct color for each type', function () { + expect(ChangelogType::Feature->getColor())->toBe('success'); + expect(ChangelogType::Improvement->getColor())->toBe('info'); + expect(ChangelogType::BugFix->getColor())->toBe('danger'); + expect(ChangelogType::Security->getColor())->toBe('warning'); + }); + + it('returns correct icon for each type', function () { + expect(ChangelogType::Feature->getIcon())->toBe('heroicon-o-rocket-launch'); + expect(ChangelogType::BugFix->getIcon())->toBe('heroicon-o-bug-ant'); + expect(ChangelogType::Security->getIcon())->toBe('heroicon-o-shield-check'); + }); +});