feat(news): implement news resource with CRUD functionality

- Created NewsResource for managing news entries.
- Added pages for creating, editing, and listing news.
- Defined NewsForm schema for news entry forms.
- Configured NewsTable for displaying news records with actions.
- Implemented News model with relationships and soft deletes.
- Added factories for generating test data for news and tags.
- Created migrations for news and tags tables.
This commit is contained in:
Yoga Pangestu 2025-12-12 09:48:34 +07:00
parent 73daf8b80d
commit 2c5b057958
11 changed files with 551 additions and 0 deletions

View File

@ -0,0 +1,69 @@
<?php
namespace App\Filament\Resources\Publication\News;
use App\Filament\Resources\Publication\News\Pages\CreateNews;
use App\Filament\Resources\Publication\News\Pages\EditNews;
use App\Filament\Resources\Publication\News\Pages\ListNews;
use App\Filament\Resources\Publication\News\Schemas\NewsForm;
use App\Filament\Resources\Publication\News\Tables\NewsTable;
use App\Models\News;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use UnitEnum;
class NewsResource extends Resource
{
protected static ?string $model = News::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::Newspaper;
protected static ?string $navigationLabel = 'Berita';
protected static string|UnitEnum|null $navigationGroup = 'Publikasi';
protected static ?string $slug = 'publication/news';
protected static ?string $recordTitleAttribute = 'title';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return NewsForm::configure($schema);
}
public static function table(Table $table): Table
{
return NewsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListNews::route('/'),
'create' => CreateNews::route('/create'),
'edit' => EditNews::route('/{record}/edit'),
];
}
public static function getRecordRouteBindingEloquentQuery(): Builder
{
return parent::getRecordRouteBindingEloquentQuery()
->withoutGlobalScopes([
SoftDeletingScope::class,
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Filament\Resources\Publication\News\Pages;
use App\Filament\Resources\Publication\News\NewsResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
class CreateNews extends CreateRecord
{
protected static string $resource = NewsResource::class;
protected static ?string $title = 'Tambah Berita';
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getHeaderActions(): array
{
return [
Action::make('back')
->label('Kembali')
->url(fn (): string => route('filament.dashboard.resources.publication.news.index'))
->outlined()
->color('secondary'),
];
}
protected function handleRecordCreation(array $data): Model
{
$data['author_id'] = auth()->id();
return static::getModel()::create($data);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\Publication\News\Pages;
use App\Filament\Resources\Publication\News\NewsResource;
use Filament\Actions\Action;
use Filament\Resources\Pages\EditRecord;
class EditNews extends EditRecord
{
protected static string $resource = NewsResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('back')
->label('Kembali')
->url(fn (): string => route('filament.dashboard.resources.publication.news.index'))
->outlined()
->color('secondary'),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\Publication\News\Pages;
use App\Filament\Resources\Publication\News\NewsResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListNews extends ListRecords
{
protected static string $resource = NewsResource::class;
protected static ?string $title = 'Berita';
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Tambah'),
];
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Filament\Resources\Publication\News\Schemas;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class NewsForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make([
TextInput::make('title')
->label('Judul')
->placeholder('Berita Purwakarta Hari Ini')
->autocomplete(false)
->autofocus()
->required()
->maxLength(200),
RichEditor::make('content')
->label('Konten')
->placeholder('...')
->required()
->columnSpanFull(),
TextInput::make('link')
->label('Tautan')
->placeholder('https://www.pwknews.com')
->autocomplete(false)
->nullable()
->maxLength(50)
->url(),
])->columnSpan(2),
Section::make([
TagsInput::make('tags')
->reactive()
->afterStateHydrated(function ($component, $state, $record) {
if (! $record) {
return;
}
$component->state(
$record->tags->pluck('name')->toArray()
);
}),
SpatieMediaLibraryFileUpload::make('thumbnail')
->label('Thumbnail')
->disk(config('filesystems.default'))
->acceptedFileTypes(['images/*'])
->maxSize(1024 * 3)
->collection('news')
->image()
->required(),
]),
])
->columns(3);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources\Publication\News\Tables;
use App\Filament\Actions\DefaultBulkActions;
use App\Filament\Columns\TimestampColumns;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class NewsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label('Judul')
->searchable()
->sortable()
->wrap()
->limit(50),
TextColumn::make('author.name')
->label('Penulis')
->searchable()
->sortable()
->placeholder('Tidak ada'),
TextColumn::make('view')
->label('Dilihat')
->numeric()
->sortable()
->alignCenter(),
TextColumn::make('tags.name')
->label('Tag')
->listWithLineBreaks()
->limitList(3)
->expandableLimitedList()
->badge()
->placeholder('Tidak ada tag'),
TextColumn::make('link')
->label('Tautan')
->searchable()
->sortable()
->copyable()
->copyMessage('Tautan berhasil disalin')
->placeholder('Tidak ada'),
...TimestampColumns::make(),
])
->filters([
TrashedFilter::make()->native(false),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('Berita'),
]),
])
->emptyStateIcon('heroicon-o-newspaper')
->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.')
->defaultSort('created_at', 'desc')
->deferFilters(false);
}
}

32
app/Models/News.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;
use Spatie\Tags\HasTags;
class News extends Model implements HasMedia
{
use HasFactory, HasSlug, HasTags, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('title')
->saveSlugsTo('slug');
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Database\Factories;
use App\Models\News;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\News>
*/
class NewsFactory extends Factory
{
protected $model = News::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$title = $this->faker->sentence(6);
return [
'author_id' => User::factory(),
'title' => $title,
'slug' => Str::slug($title),
'content' => $this->faker->paragraphs(3, true),
'link' => $this->faker->optional(0.3)->url(),
'view' => $this->faker->numberBetween(0, 10000),
];
}
/**
* Indicate that the news has high views.
*/
public function popular(): static
{
return $this->state(fn (array $attributes) => [
'view' => $this->faker->numberBetween(5000, 50000),
]);
}
/**
* Indicate that the news has external link.
*/
public function withLink(): static
{
return $this->state(fn (array $attributes) => [
'link' => $this->faker->url(),
]);
}
/**
* Indicate that the news has no views.
*/
public function unpublished(): static
{
return $this->state(fn (array $attributes) => [
'view' => 0,
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Database\Factories;
use App\Models\News;
use App\Models\NewsTags;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\NewsTags>
*/
class NewsTagsFactory extends Factory
{
protected $model = NewsTags::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$tags = [
'Berita',
'Update',
'Pengumuman',
'Event',
'Kegiatan',
'Kominfo',
'Diskominfo',
'Pemerintah',
'Publik',
'Informasi',
'Layanan',
'Digital',
'Teknologi',
'Sosial',
'Ekonomi',
'Pendidikan',
'Kesehatan',
'Lingkungan',
'Infrastruktur',
'Transportasi',
];
return [
'news_id' => News::factory(),
'name' => $this->faker->randomElement($tags),
];
}
/**
* Indicate that the tag is for news category.
*/
public function news(): static
{
return $this->state(fn (array $attributes) => [
'name' => 'Berita',
]);
}
/**
* Indicate that the tag is for announcement category.
*/
public function announcement(): static
{
return $this->state(fn (array $attributes) => [
'name' => 'Pengumuman',
]);
}
/**
* Indicate that the tag is for event category.
*/
public function event(): static
{
return $this->state(fn (array $attributes) => [
'name' => 'Event',
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
use App\Models\User;
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('news', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class, 'author_id');
$table->string('title', 200);
$table->string('slug', 200);
$table->text('content');
$table->string('link', 50)->nullable();
$table->unsignedInteger('view')->default(0);
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('news');
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->id();
$table->json('name');
$table->json('slug');
$table->string('type')->nullable();
$table->integer('order_column')->nullable();
$table->timestamps();
});
Schema::create('taggables', function (Blueprint $table) {
$table->foreignId('tag_id')->constrained()->cascadeOnDelete();
$table->morphs('taggable');
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
});
}
public function down(): void
{
Schema::dropIfExists('taggables');
Schema::dropIfExists('tags');
}
};