From c60bb2b09d114538749b8ff2757528a7a21b437f Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Mon, 29 Sep 2025 14:57:42 +0700 Subject: [PATCH] feat(category): crud -membuat tabel, form, skema db -membuat factory untuk kebutuhan test -membuat test untuk memastikan semuanya berjalan lancar -membuat seeder untuk data awal -membuat action datatable baru yaitu sort-button --- app/Livewire/Datatable/CategoriesTable.php | 89 ++++++++++++++ app/Livewire/Forms/CategoryForm.php | 110 ++++++++++++++++++ app/Livewire/Studio/Catalog/Category.php | 105 +++++++++++++++++ app/Models/Category.php | 30 +++++ database/factories/CategoryFactory.php | 23 ++++ ...5_09_29_062630_create_categories_table.php | 32 +++++ database/seeders/CategorySeeder.php | 33 ++++++ database/seeders/DatabaseSeeder.php | 1 + .../datatables/sort-button.blade.php | 5 + .../partials/studio/_sidebar.blade.php | 9 ++ .../studio/catalog/categories.blade.php | 39 +++++++ routes/web.php | 8 ++ .../Livewire/Studio/Catalog/CategoryTest.php | 67 +++++++++++ 13 files changed, 551 insertions(+) create mode 100644 app/Livewire/Datatable/CategoriesTable.php create mode 100644 app/Livewire/Forms/CategoryForm.php create mode 100644 app/Livewire/Studio/Catalog/Category.php create mode 100644 app/Models/Category.php create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/migrations/2025_09_29_062630_create_categories_table.php create mode 100644 database/seeders/CategorySeeder.php create mode 100644 resources/views/components/datatables/sort-button.blade.php create mode 100644 resources/views/livewire/studio/catalog/categories.blade.php create mode 100644 tests/Feature/Livewire/Studio/Catalog/CategoryTest.php diff --git a/app/Livewire/Datatable/CategoriesTable.php b/app/Livewire/Datatable/CategoriesTable.php new file mode 100644 index 0000000..37aa308 --- /dev/null +++ b/app/Livewire/Datatable/CategoriesTable.php @@ -0,0 +1,89 @@ +searchable(), + + Column::make('Urutan', 'sort_order') + ->sortable() + ->hideIf(false), + + Column::make('Aksi') + ->label(function ($row) { + $actions = ''; + $maxSortOrder = Category::max('sort_order'); + + if ($row->sort_order > 1) { + $actions .= view('components.datatables.sort-button', [ + 'id' => $row->id, + 'direction' => 'up', + 'tooltip' => 'Naik', + 'color' => 'cyan', + 'icon' => 'arrow-up', + ])->render(); + + $actions .= view('components.datatables.sort-button', [ + 'id' => $row->id, + 'direction' => 'first', + 'tooltip' => 'Pertama', + 'color' => 'emerald', + 'icon' => 'bars-arrow-up', + ])->render(); + } + + if ($row->sort_order < $maxSortOrder) { + $actions .= view('components.datatables.sort-button', [ + 'id' => $row->id, + 'direction' => 'down', + 'tooltip' => 'Turun', + 'color' => 'orange', + 'icon' => 'arrow-down', + ])->render(); + + $actions .= view('components.datatables.sort-button', [ + 'id' => $row->id, + 'direction' => 'last', + 'tooltip' => 'Terakhir', + 'color' => 'rose', + 'icon' => 'bars-arrow-down', + ])->render(); + } + + $actions .= view('components.datatables.edit-modal', [ + 'id' => $row->id, + 'method' => 'update', + 'modalTitle' => 'Ubah Kategori', + ])->render(); + + $actions .= view('components.datatables.delete', [ + 'id' => $row->id, + 'deleteRoute' => route('studio.catalog.category.delete', $row->id), + ])->render(); + + return $actions; + }) + ->html(), + ]; + } + + public function builder(): Builder + { + return Category::select('id', 'name', 'sort_order')->orderBy('sort_order'); + } +} diff --git a/app/Livewire/Forms/CategoryForm.php b/app/Livewire/Forms/CategoryForm.php new file mode 100644 index 0000000..ab72bc2 --- /dev/null +++ b/app/Livewire/Forms/CategoryForm.php @@ -0,0 +1,110 @@ + ['required', 'string', 'max:20', Rule::unique('categories', 'name')->whereNull('deleted_at')->ignore($this->category)], + 'description' => ['nullable', 'string'], + ]; + } + + public function validationAttributes(): array + { + return [ + 'name' => 'nama', + 'description' => 'syarat dan ketentuan', + ]; + } + + public function setCategory(Category $category) + { + $this->category = $category; + + $this->name = $category->name; + $this->description = $category->description; + } + + public function store() + { + $this->validate(); + + Category::create([ + 'name' => $this->name, + 'description' => $this->description, + 'sort_order' => Category::count() + 1, + ]); + } + + public function update() + { + $this->validate(); + + $this->category->update([ + 'name' => $this->name, + 'description' => $this->description, + ]); + } + + public function delete() + { + $this->category->delete(); + + Category::where('sort_order', '>', $this->category->sort_order) + ->orderBy('sort_order') + ->get() + ->each(function ($cat) { + $cat->update(['sort_order' => $cat->sort_order - 1]); + }); + } + + public function sortOrder(string $direction) + { + if (in_array($direction, ['up', 'down'])) { + $swap = null; + + if ($direction === 'up') { + $swap = Category::where('sort_order', '<', $this->category->sort_order) + ->orderByDesc('sort_order') + ->first(); + } else { + $swap = Category::where('sort_order', '>', $this->category->sort_order) + ->orderBy('sort_order') + ->first(); + } + + if ($swap) { + $temp = $this->category->sort_order; + $this->category->update(['sort_order' => $swap->sort_order]); + $swap->update(['sort_order' => $temp]); + } + } elseif ($direction === 'first') { + $this->category->update(['sort_order' => Category::min('sort_order') - 1]); + $this->normalizeSortOrder(); + } elseif ($direction === 'last') { + $this->category->update(['sort_order' => Category::max('sort_order') + 1]); + $this->normalizeSortOrder(); + } + } + + protected function normalizeSortOrder() + { + $categories = Category::orderBy('sort_order')->get(); + foreach ($categories as $index => $category) { + $category->update(['sort_order' => $index + 1]); + } + } +} diff --git a/app/Livewire/Studio/Catalog/Category.php b/app/Livewire/Studio/Catalog/Category.php new file mode 100644 index 0000000..a982fed --- /dev/null +++ b/app/Livewire/Studio/Catalog/Category.php @@ -0,0 +1,105 @@ +resetValidation(); + $this->resetErrorBag(); + + $this->method = $method; + $this->modalTitle = $modalTitle; + + if ($id) { + $this->form->setCategory(CategoryModel::findOrFail($id)); + } + } + + public function create() + { + $this->form->store(); + + $this->dispatch('refreshDatatable'); + + Flux::toast( + heading: 'Berhasil', + text: 'Kategori berhasil ditambahkan.', + variant: 'success', + duration: 3000 + ); + + Flux::modals()->close(); + } + + public function update() + { + $this->form->update(); + + $this->dispatch('refreshDatatable'); + + Flux::toast( + heading: 'Berhasil', + text: 'Kategori berhasil diperbarui.', + variant: 'success', + duration: 3000 + ); + + Flux::modals()->close(); + } + + public function delete(CategoryModel $category) + { + $this->form->category = $category; + + $this->form->delete(); + + $this->dispatch('refreshDatatable'); + + Flux::toast( + heading: 'Berhasil', + text: 'Kategori berhasil dihapus.', + variant: 'success', + ); + + Flux::modals()->close(); + } + + #[On('fn:sortOrder')] + public function sortOrder(CategoryModel $category, string $direction) + { + $this->form->category = $category; + + $this->form->sortOrder($direction); + + $this->dispatch('refreshDatatable'); + } + + public function render() + { + return view('livewire.studio.catalog.categories', [ + 'pageTitle' => 'Kategori', + ]); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..81cf951 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,30 @@ + 'int', + ]; + } + + public function getSlugOptions(): SlugOptions + { + return SlugOptions::create() + ->generateSlugsFrom('name') + ->saveSlugsTo('slug'); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..7b4f951 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,23 @@ + $this->faker->unique()->name(), + 'slug' => $this->faker->slug(), + 'description' => $this->faker->optional()->text(), + 'sort_order' => function () { + $max = Category::max('sort_order') ?? 0; + + return $max + 1; + }, + ]; + } +} diff --git a/database/migrations/2025_09_29_062630_create_categories_table.php b/database/migrations/2025_09_29_062630_create_categories_table.php new file mode 100644 index 0000000..843367b --- /dev/null +++ b/database/migrations/2025_09_29_062630_create_categories_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name', 20); + $table->string('slug', 30); + $table->text('description')->nullable(); + $table->unsignedInteger('sort_order'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('categories'); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..622064b --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,33 @@ + 'Floral', 'description' => 'Perfume with floral scents', 'sort_order' => 1], + ['name' => 'Citrus', 'description' => 'Fresh citrus fragrances', 'sort_order' => 2], + ['name' => 'Woody', 'description' => 'Warm woody notes', 'sort_order' => 3], + ['name' => 'Oriental', 'description' => 'Spicy and exotic oriental scents', 'sort_order' => 4], + ['name' => 'Fruity', 'description' => 'Sweet and fresh fruity fragrances', 'sort_order' => 5], + ['name' => 'Aquatic', 'description' => 'Light, oceanic, and fresh scents', 'sort_order' => 6], + ['name' => 'Gourmand', 'description' => 'Edible, sweet and dessert-like scents', 'sort_order' => 7], + ['name' => 'Green', 'description' => 'Fresh, leafy, and herbal notes', 'sort_order' => 8], + ['name' => 'Chypre', 'description' => 'Classic blend of citrus, oakmoss, and labdanum', 'sort_order' => 9], + ['name' => 'Musk', 'description' => 'Warm, sensual musky fragrances', 'sort_order' => 10], + ]; + + foreach ($categories as $category) { + Category::create([ + 'name' => $category['name'], + 'description' => $category['description'], + 'sort_order' => $category['sort_order'], + ]); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 9365482..9f7a1ec 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ public function run(): void UserSeeder::class, MembershipSeeder::class, VoucherSeeder::class, + CategorySeeder::class, ]); } } diff --git a/resources/views/components/datatables/sort-button.blade.php b/resources/views/components/datatables/sort-button.blade.php new file mode 100644 index 0000000..bcc2d9e --- /dev/null +++ b/resources/views/components/datatables/sort-button.blade.php @@ -0,0 +1,5 @@ + + + + diff --git a/resources/views/components/partials/studio/_sidebar.blade.php b/resources/views/components/partials/studio/_sidebar.blade.php index 203c904..04068b2 100644 --- a/resources/views/components/partials/studio/_sidebar.blade.php +++ b/resources/views/components/partials/studio/_sidebar.blade.php @@ -35,5 +35,14 @@ class="px-2 hidden dark:flex" /> + +
+
Katalog
+
+ Kategori + +
+
diff --git a/resources/views/livewire/studio/catalog/categories.blade.php b/resources/views/livewire/studio/catalog/categories.blade.php new file mode 100644 index 0000000..56bf5d6 --- /dev/null +++ b/resources/views/livewire/studio/catalog/categories.blade.php @@ -0,0 +1,39 @@ + +
+
+ {{ $pageTitle }} +
+
+ + Tambah + + +
+
+ +
+ +
+ + @include('components.confirmation.delete') + +
+ {{ $modalTitle }} + + + + + +
+ + + Simpan + +
+
+
+ +
diff --git a/routes/web.php b/routes/web.php index 66fe4a2..ba2ae42 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Livewire\Auth\Login; use App\Livewire\Auth\Logout; +use App\Livewire\Studio\Catalog\Category as CategoryComponent; use App\Livewire\Studio\Dashboard\Overview; use App\Livewire\Studio\Loyalty\Membership as MembershipComponent; use App\Livewire\Studio\Loyalty\Voucher\Create as VoucherCreate; @@ -68,4 +69,11 @@ Route::get('vouchers/{voucher}/edit', VoucherEdit::class)->name('edit'); Route::get('vouchers/{voucher}/delete', VoucherCreate::class)->name('delete'); }); + + Route::prefix('catalog') + ->as('studio.catalog.category.') + ->group(function () { + Route::get('categories', CategoryComponent::class)->name('index'); + Route::delete('categories/{category}/delete', CategoryComponent::class)->name('delete'); + }); }); diff --git a/tests/Feature/Livewire/Studio/Catalog/CategoryTest.php b/tests/Feature/Livewire/Studio/Catalog/CategoryTest.php new file mode 100644 index 0000000..3cf1f18 --- /dev/null +++ b/tests/Feature/Livewire/Studio/Catalog/CategoryTest.php @@ -0,0 +1,67 @@ +assertViewIs('livewire.studio.catalog.categories') + ->assertViewHas('pageTitle', 'Kategori'); +}); + +it('displays all categories', function () { + CategoryModel::factory()->count(10)->create(); + + $category = CategoryModel::first(); + + Livewire::test(Category::class)->assertSee($category->name); +}); + +it('can create a category', function () { + $data = CategoryModel::factory()->raw(); + + Livewire::test(Category::class) + ->call('openModal', 'create', 'Tambah Kategori') + ->set('form.name', $data['name']) + ->set('form.slug', $data['slug']) + ->set('form.description', $data['description']) + ->set('form.sort_order', $data['sort_order']) + ->call('create') + ->assertDispatched('refreshDatatable'); + + expect(CategoryModel::count())->toBe(1); + expect(CategoryModel::first()->name)->toBe($data['name']); +}); + +it('can update a category', function () { + $category = CategoryModel::factory()->create(); + + $data = CategoryModel::factory()->raw(); + + Livewire::test(Category::class) + ->call('openModal', 'update', 'Edit Kategori', $category->id) + ->set('form.name', $data['name']) + ->set('form.description', $data['description']) + ->set('form.sort_order', $data['sort_order']) + ->call('update') + ->assertDispatched('refreshDatatable'); + + $category->refresh(); + + expect($category->name)->toBe($data['name']); + expect($category->description)->toBe($data['description']); +}); + +it('can delete an category', function () { + $category = CategoryModel::factory()->create(); + + Livewire::test(Category::class) + ->call('delete', $category) + ->assertDispatched('refreshDatatable'); + + expect(CategoryModel::count())->toBe(0); +});