feat: implement category management with CRUD functionality and integrate sluggable for SEO-friendly URLs
- Added CategoryController for handling category operations. - Created CategoryRequest for validation of category data. - Introduced CategoryService for business logic related to categories. - Implemented sluggable functionality in the Category model for automatic slug generation. - Developed UI components for category management, including a data table and dialogs for creating and editing categories. - Updated routes to include resourceful routes for categories.
This commit is contained in:
parent
61feb43fa2
commit
e93fd181ee
52
app/Http/Controllers/Admin/Master/CategoryController.php
Normal file
52
app/Http/Controllers/Admin/Master/CategoryController.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Master;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Master\CategoryRequest;
|
||||
use App\Models\Category;
|
||||
use App\Services\Admin\Master\CategoryService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private CategoryService $service
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/master/category/index', [
|
||||
'categories' => $this->service->getAll(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(CategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$this->service->create($request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']);
|
||||
|
||||
return to_route('admin.master.categories.index');
|
||||
}
|
||||
|
||||
public function update(CategoryRequest $request, Category $category): RedirectResponse
|
||||
{
|
||||
$this->service->update($category, $request->validated());
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil diperbarui.']);
|
||||
|
||||
return to_route('admin.master.categories.index');
|
||||
}
|
||||
|
||||
public function destroy(Category $category): RedirectResponse
|
||||
{
|
||||
$this->service->delete($category);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']);
|
||||
|
||||
return to_route('admin.master.categories.index');
|
||||
}
|
||||
}
|
||||
30
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal file
30
app/Http/Requests/Admin/Master/CategoryRequest.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Master;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$category = $this->route('category');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($category)],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'nama',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -7,8 +7,10 @@
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Sluggable\Attributes\Sluggable;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Sluggable(from: 'name', to: 'slug')]
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
31
app/Services/Admin/Master/CategoryService.php
Normal file
31
app/Services/Admin/Master/CategoryService.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Master;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class CategoryService
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Category::latest()->get();
|
||||
}
|
||||
|
||||
public function create(array $data): Category
|
||||
{
|
||||
return Category::create($data);
|
||||
}
|
||||
|
||||
public function update(Category $category, array $data): Category
|
||||
{
|
||||
$category->update($data);
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
public function delete(Category $category): bool
|
||||
{
|
||||
return $category->delete();
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,8 @@
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"spatie/laravel-permission": "^8.3"
|
||||
"spatie/laravel-permission": "^8.3",
|
||||
"spatie/laravel-sluggable": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
|
||||
80
composer.lock
generated
80
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "06614299e9ee6d35887901c6b34eb24b",
|
||||
"content-hash": "bf685f2e2b1d13f4dfba5042cdd5af8b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -4271,6 +4271,84 @@
|
||||
],
|
||||
"time": "2026-07-03T15:36:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-sluggable",
|
||||
"version": "4.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-sluggable.git",
|
||||
"reference": "572933c61b70103be4b95fe7674070bb60243254"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-sluggable/zipball/572933c61b70103be4b95fe7674070bb60243254",
|
||||
"reference": "572933c61b70103be4b95fe7674070bb60243254",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/database": "^12.0|^13.0",
|
||||
"illuminate/support": "^12.0|^13.0",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/pint": "^1.24",
|
||||
"orchestra/testbench": "^10.0|^11.0",
|
||||
"pestphp/pest": "^4.0",
|
||||
"spatie/laravel-translatable": "^6.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"SelfHealing": "Spatie\\Sluggable\\Facades\\SelfHealing"
|
||||
},
|
||||
"providers": [
|
||||
"Spatie\\Sluggable\\SluggableServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\Sluggable\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Generate slugs when saving Eloquent models",
|
||||
"homepage": "https://github.com/spatie/laravel-sluggable",
|
||||
"keywords": [
|
||||
"eloquent",
|
||||
"laravel",
|
||||
"laravel-sluggable",
|
||||
"self-healing",
|
||||
"slug",
|
||||
"slugs",
|
||||
"spatie",
|
||||
"translatable"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-sluggable/issues",
|
||||
"source": "https://github.com/spatie/laravel-sluggable/tree/4.0.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-28T13:16:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spomky-labs/cbor-php",
|
||||
"version": "3.3.0",
|
||||
|
||||
@ -9,10 +9,10 @@ class CategoryFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->word();
|
||||
$name = fake()->unique()->words(2, true);
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'name' => ucfirst($name),
|
||||
'slug' => Str::slug($name),
|
||||
];
|
||||
}
|
||||
|
||||
14
database/seeders/CategorySeeder.php
Normal file
14
database/seeders/CategorySeeder.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Category;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
Category::factory()->count(1000)->create();
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ public function run(): void
|
||||
{
|
||||
$this->call([
|
||||
UserSeeder::class,
|
||||
CategorySeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
DollarSign,
|
||||
FileText,
|
||||
HandCoins,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@ -36,6 +35,7 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { useCurrentUrl } from '@/hooks/use-current-url';
|
||||
import { dashboard } from '@/routes';
|
||||
import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||
|
||||
type NavMenuItem = { title: string; href: string; icon: LucideIcon };
|
||||
|
||||
@ -52,7 +52,7 @@ const analisaItem: NavMenuItem = {
|
||||
};
|
||||
|
||||
const masterItems: NavMenuItem[] = [
|
||||
{ title: 'Kategori', href: '#', icon: Tags },
|
||||
{ title: 'Kategori', href: categoriesIndex.url(), icon: Tags },
|
||||
{ title: 'Produk', href: '#', icon: Package },
|
||||
{ title: 'Bahan Baku', href: '#', icon: Boxes },
|
||||
{ title: 'Supplier', href: '#', icon: Truck },
|
||||
@ -64,7 +64,6 @@ const kelolaItems: NavMenuItem[] = [
|
||||
{ title: 'Cutting', href: '#', icon: Scissors },
|
||||
{ title: 'Restock', href: '#', icon: RefreshCw },
|
||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||
{ title: 'Pesanan', href: '#', icon: FileText },
|
||||
];
|
||||
|
||||
const keuanganItems: NavMenuItem[] = [
|
||||
|
||||
59
resources/js/components/confirm-dialog.tsx
Normal file
59
resources/js/components/confirm-dialog.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Konfirmasi',
|
||||
cancelLabel = 'Batal',
|
||||
variant = 'destructive',
|
||||
onConfirm,
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
371
resources/js/components/data-table.tsx
Normal file
371
resources/js/components/data-table.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
import {
|
||||
DndContext,
|
||||
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
|
||||
|
||||
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
onReorder?: (items: TData[]) => void;
|
||||
getRowId?: (item: TData) => string | number;
|
||||
}
|
||||
|
||||
const DragHandleContext = React.createContext<{
|
||||
listeners?: Record<string, unknown>;
|
||||
attributes?: Record<string, string>;
|
||||
}>({});
|
||||
|
||||
export function DragHandleTrigger() {
|
||||
const { listeners, attributes } = React.useContext(DragHandleContext);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="-ml-2 cursor-grab active:cursor-grabbing"
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTableRow({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<DragHandleContext.Provider value={{ listeners, attributes }}>
|
||||
<TableRow
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-state={isDragging ? 'dragging' : undefined}
|
||||
>
|
||||
{children}
|
||||
</TableRow>
|
||||
</DragHandleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
searchKey,
|
||||
searchPlaceholder = 'Cari...',
|
||||
emptyText = 'Tidak ada data.',
|
||||
onReorder,
|
||||
getRowId,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] =
|
||||
React.useState<ColumnFiltersState>([]);
|
||||
|
||||
const isSortable = !!onReorder && !!getRowId;
|
||||
|
||||
const visibleColumns = isSortable
|
||||
? [
|
||||
{
|
||||
id: 'drag',
|
||||
header: '',
|
||||
cell: () => <DragHandleTrigger />,
|
||||
meta: {
|
||||
className: 'w-[40px]',
|
||||
headerClassName: 'w-[40px]',
|
||||
},
|
||||
} as ColumnDef<TData, TValue>,
|
||||
...columns,
|
||||
]
|
||||
: columns;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: visibleColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
|
||||
const itemIds = React.useMemo(
|
||||
() => data.map((item) => String(getRowId ? getRowId(item) : '')),
|
||||
[data, getRowId],
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = itemIds.indexOf(String(active.id));
|
||||
const newIndex = itemIds.indexOf(String(over.id));
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reordered = [...data];
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
|
||||
onReorder?.(reordered);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchKey && (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={
|
||||
(table
|
||||
.getColumn(searchKey)
|
||||
?.getFilterValue() as string) ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn(searchKey)
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
)}
|
||||
<Card className="bg-sidebar p-0">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
(
|
||||
header.column.columnDef
|
||||
.meta as {
|
||||
headerClassName?: string;
|
||||
}
|
||||
)?.headerClassName
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
isSortable ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={itemIds}
|
||||
strategy={
|
||||
verticalListSortingStrategy
|
||||
}
|
||||
>
|
||||
{table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<SortableTableRow
|
||||
key={row.id}
|
||||
id={String(
|
||||
getRowId!(
|
||||
row.original,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={
|
||||
cell.id
|
||||
}
|
||||
className={
|
||||
(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)
|
||||
?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</SortableTableRow>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
'selected'
|
||||
}
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(
|
||||
cell
|
||||
.column
|
||||
.columnDef
|
||||
.meta as {
|
||||
className?: string;
|
||||
}
|
||||
)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{emptyText}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Halaman {table.getState().pagination.pageIndex + 1} dari{' '}
|
||||
{table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<ChevronRight className="mr-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@ -37,7 +37,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -54,28 +54,24 @@ function DialogContent({
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-xs/relaxed text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
@ -87,7 +83,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -127,7 +123,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("font-heading text-sm font-medium", className)}
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@ -140,10 +136,7 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-xs/relaxed text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
114
resources/js/components/ui/table.tsx
Normal file
114
resources/js/components/ui/table.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
114
resources/js/pages/admin/master/category/columns.tsx
Normal file
114
resources/js/pages/admin/master/category/columns.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type CreateColumnsParams = {
|
||||
handleEdit: (category: Category) => void;
|
||||
handleDeleteClick: (category: Category) => void;
|
||||
};
|
||||
|
||||
export function createCategoryColumns(
|
||||
params: CreateColumnsParams,
|
||||
): ColumnDef<Category>[] {
|
||||
const { handleEdit, handleDeleteClick } = params;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'no',
|
||||
header: () => <span className="block text-center">No</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="block text-center">
|
||||
{row.index + 1}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-[50px] text-center',
|
||||
headerClassName: 'w-[50px] text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === 'asc',
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>Nama</span>
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">
|
||||
{row.getValue('name') as string}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="block text-center">Aksi</span>,
|
||||
meta: {
|
||||
className: 'w-[100px] text-center',
|
||||
headerClassName: 'w-[100px] text-center',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const category = row.original;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleEdit(category)
|
||||
}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Edit
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
handleDeleteClick(category)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
206
resources/js/pages/admin/master/category/index.tsx
Normal file
206
resources/js/pages/admin/master/category/index.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import { Form, Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { destroy, index as categoryIndex, store, update } from '@/routes/admin/master/categories';
|
||||
import { createCategoryColumns } from './columns';
|
||||
import type { Category } from './columns';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
|
||||
type Props = {
|
||||
categories: Category[];
|
||||
};
|
||||
|
||||
export default function CategoryIndex({ categories }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
const [deleting, setDeleting] = useState<Category | null>(null);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
const columns = createCategoryColumns({
|
||||
handleEdit: (category) => setEditing(category),
|
||||
handleDeleteClick: (category) => setDeleting(category),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Kategori" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Kategori
|
||||
</h2>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<Button asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</button>
|
||||
</Button>
|
||||
<DialogContent>
|
||||
<Form action={store()} resetOnSuccess onSuccess={() => setCreateOpen(false)}>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tambah Kategori</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama{' '} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={categories}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Cari kategori..."
|
||||
emptyText="Belum ada data kategori."
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{editing && (
|
||||
<Form action={update(editing.id)} resetOnSuccess onSuccess={() => setEditing(null)}>
|
||||
{({ errors, processing }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Kategori</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-name">Nama{' '} <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
placeholder="Masukkan nama kategori"
|
||||
defaultValue={editing.name}
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Kategori"
|
||||
description={`Apakah Anda yakin ingin menghapus kategori "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CategoryIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Master',
|
||||
href: categoryIndex(),
|
||||
},
|
||||
{
|
||||
title: 'Kategori',
|
||||
href: categoryIndex(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,11 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::inertia('/', 'welcome')->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::inertia('dashboard', 'dashboard')->name('dashboard');
|
||||
|
||||
Route::prefix('admin/master')->name('admin.master.')->group(function () {
|
||||
Route::resource('categories', CategoryController::class)->except(['show', 'create', 'edit']);
|
||||
});
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
160
tests/Feature/Admin/Master/CategoryTest.php
Normal file
160
tests/Feature/Admin/Master/CategoryTest.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('admin.master.categories.index'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the category index page', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('admin.master.categories.index'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('category index page displays categories', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$categories = Category::factory()->count(3)->create();
|
||||
|
||||
$response = $this->get(route('admin.master.categories.index'));
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/master/category/index')
|
||||
->has('categories', 3)
|
||||
);
|
||||
});
|
||||
|
||||
test('category can be created', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.master.categories.store'), [
|
||||
'name' => 'Kategori Test',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('admin.master.categories.index'));
|
||||
|
||||
$this->assertDatabaseHas('categories', [
|
||||
'name' => 'Kategori Test',
|
||||
'slug' => 'kategori-test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('category name is required', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.master.categories.store'), [
|
||||
'name' => '',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
});
|
||||
|
||||
test('category name must not exceed 100 characters', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->post(route('admin.master.categories.store'), [
|
||||
'name' => str_repeat('a', 101),
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
});
|
||||
|
||||
test('category name must be unique', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Category::factory()->create(['name' => 'Existing Category']);
|
||||
|
||||
$response = $this->post(route('admin.master.categories.store'), [
|
||||
'name' => 'Existing Category',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
});
|
||||
|
||||
test('category slug is automatically generated', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post(route('admin.master.categories.store'), [
|
||||
'name' => 'Kategori Otomatis',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('categories', [
|
||||
'name' => 'Kategori Otomatis',
|
||||
'slug' => 'kategori-otomatis',
|
||||
]);
|
||||
});
|
||||
|
||||
test('category can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$response = $this->put(route('admin.master.categories.update', $category), [
|
||||
'name' => 'Kategori Updated',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('admin.master.categories.index'));
|
||||
|
||||
$category->refresh();
|
||||
expect($category->name)->toBe('Kategori Updated');
|
||||
expect($category->slug)->toBe('kategori-updated');
|
||||
});
|
||||
|
||||
test('category name can be updated to itself', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$category = Category::factory()->create(['name' => 'My Category']);
|
||||
|
||||
$response = $this->put(route('admin.master.categories.update', $category), [
|
||||
'name' => 'My Category',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors();
|
||||
});
|
||||
|
||||
test('category update name must be unique excluding itself', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$category = Category::factory()->create(['name' => 'First']);
|
||||
Category::factory()->create(['name' => 'Second']);
|
||||
|
||||
$response = $this->put(route('admin.master.categories.update', $category), [
|
||||
'name' => 'Second',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
});
|
||||
|
||||
test('category can be deleted', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$category = Category::factory()->create();
|
||||
|
||||
$response = $this->delete(route('admin.master.categories.destroy', $category));
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('admin.master.categories.index'));
|
||||
|
||||
$this->assertSoftDeleted('categories', ['id' => $category->id]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user