feat: implement changelog management system with CRUD operations and role-based permissions
This commit is contained in:
parent
29ab36ba85
commit
bccc36ca81
55
app/Enums/ChangelogType.php
Normal file
55
app/Enums/ChangelogType.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
enum ChangelogType: string implements HasColor, HasIcon, HasLabel
|
||||
{
|
||||
case Feature = 'feature';
|
||||
case Improvement = 'improvement';
|
||||
case BugFix = 'bugfix';
|
||||
case Security = 'security';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Feature => '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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\System\Changelog\Actions;
|
||||
|
||||
use App\Filament\Actions\Cheerful\CreateAction;
|
||||
use App\Models\Changelog;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class CreateChangelogAction extends CreateAction
|
||||
{
|
||||
public static function getDefaultName(): string
|
||||
{
|
||||
return 'createChangelog';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\System\Changelog\Actions;
|
||||
|
||||
use App\Filament\Actions\Cheerful\DeleteAction;
|
||||
use App\Models\Changelog;
|
||||
|
||||
class DeleteChangelogAction extends DeleteAction
|
||||
{
|
||||
public static function getDefaultName(): string
|
||||
{
|
||||
return 'deleteChangelog';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\System\Changelog\Actions;
|
||||
|
||||
use App\Filament\Actions\Cheerful\EditAction;
|
||||
use App\Models\Changelog;
|
||||
use Filament\Support\Enums\Width;
|
||||
|
||||
class EditChangelogAction extends EditAction
|
||||
{
|
||||
public static function getDefaultName(): string
|
||||
{
|
||||
return 'editChangelog';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this
|
||||
->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());
|
||||
}
|
||||
}
|
||||
150
app/Filament/Pages/System/Changelog/Index.php
Normal file
150
app/Filament/Pages/System/Changelog/Index.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages\System\Changelog;
|
||||
|
||||
use App\Enums\ChangelogType;
|
||||
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\Support\SystemNotification;
|
||||
use App\Models\Changelog;
|
||||
use BackedEnum;
|
||||
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\InteractsWithActions;
|
||||
use Filament\Actions\Contracts\HasActions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use UnitEnum;
|
||||
|
||||
class Index extends Page implements HasActions, HasForms
|
||||
{
|
||||
use HasPageShield, InteractsWithActions, InteractsWithForms;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Sistem';
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-information-circle';
|
||||
|
||||
protected static ?string $title = 'Changelog';
|
||||
|
||||
protected static ?string $navigationLabel = 'Changelog';
|
||||
|
||||
protected static ?string $slug = 'system/changelog';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected string $view = 'filament.pages.system.changelog.index';
|
||||
|
||||
public static function getPagePermission(): string
|
||||
{
|
||||
return 'View:Changelog';
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function techStack(): array
|
||||
{
|
||||
$latestUpdate = Changelog::latest('release_date')->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Models/Changelog.php
Normal file
44
app/Models/Changelog.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ChangelogType;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Changelog extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'version',
|
||||
'release_date',
|
||||
'title',
|
||||
'changes',
|
||||
'type',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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'));
|
||||
}
|
||||
}
|
||||
37
database/factories/ChangelogFactory.php
Normal file
37
database/factories/ChangelogFactory.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ChangelogType;
|
||||
use App\Models\Changelog;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class ChangelogFactory extends Factory
|
||||
{
|
||||
protected $model = Changelog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
static $patchNumber = 0;
|
||||
$patchNumber++;
|
||||
|
||||
return [
|
||||
'version' => "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]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ChangelogType;
|
||||
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('changelogs', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
100
resources/views/filament/pages/system/changelog/index.blade.php
Normal file
100
resources/views/filament/pages/system/changelog/index.blade.php
Normal file
@ -0,0 +1,100 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="p-3 rounded-xl bg-primary-100 dark:bg-primary-500/20 text-primary-600 dark:text-primary-400">
|
||||
<x-heroicon-o-rocket-launch class="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight text-gray-950 dark:text-white">
|
||||
{{ $this->techStack['name'] }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Versi Aplikasi:
|
||||
<span class="font-semibold text-primary-600 dark:text-primary-400">
|
||||
{{ $this->techStack['version'] }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ $tech }}
|
||||
</p>
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300 truncate">
|
||||
{{ $version }}
|
||||
</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<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="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">
|
||||
{{ $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">
|
||||
{{ $log->formatted_version }}
|
||||
</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>
|
||||
|
||||
@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 !!}
|
||||
</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>
|
||||
</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"
|
||||
heading="{{ $this->emptyHeading }}"
|
||||
description="{{ $this->emptyDescription }}"
|
||||
iconColor="gray">
|
||||
</x-filament::empty-state>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
</x-filament-panels::page>
|
||||
263
tests/Feature/ChangelogTest.php
Normal file
263
tests/Feature/ChangelogTest.php
Normal file
@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ChangelogType;
|
||||
use App\Enums\RoleEnum;
|
||||
use App\Filament\Pages\System\Changelog\Index as ChangelogPage;
|
||||
use App\Models\Changelog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Setup necessary roles and permissions
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Role::findOrCreate(RoleEnum::Developer->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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user