From 99703bcdee2409f7c518ffb46b547ec22eb8dbae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Omer=20=C5=A0abi=C4=87?= Date: Wed, 2 Jul 2025 10:41:39 +0200 Subject: [PATCH 1/3] feat: add categories prototype --- database/factories/CategoryFactory.php | 30 +++++ ...5_07_01_141618_create_categories_table.php | 39 ++++++ src/Filament/Resources/CategoryResource.php | 123 ++++++++++++++++++ .../CategoryResource/Pages/CreateCategory.php | 18 +++ .../CategoryResource/Pages/EditCategory.php | 23 ++++ .../CategoryResource/Pages/ListCategories.php | 19 +++ src/Models/Category.php | 50 +++++++ 7 files changed, 302 insertions(+) create mode 100644 database/factories/CategoryFactory.php create mode 100644 database/migrations/2025_07_01_141618_create_categories_table.php create mode 100644 src/Filament/Resources/CategoryResource.php create mode 100644 src/Filament/Resources/CategoryResource/Pages/CreateCategory.php create mode 100644 src/Filament/Resources/CategoryResource/Pages/EditCategory.php create mode 100644 src/Filament/Resources/CategoryResource/Pages/ListCategories.php create mode 100644 src/Models/Category.php diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php new file mode 100644 index 0000000..0920361 --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,30 @@ + $this->faker->words(), + 'sort' => $this->faker->randomNumber(), + 'is_active' => $this->faker->boolean(), + 'recursive_browsing' => $this->faker->boolean(), + 'sef_key' => $this->faker->word(), + 'short_desc' => $this->faker->words(10), + 'description' => $this->faker->text(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'site_id' => Site::inRandomOrder()->first()?->id ?? Site::factory()->create()->id, + ]; + } +} diff --git a/database/migrations/2025_07_01_141618_create_categories_table.php b/database/migrations/2025_07_01_141618_create_categories_table.php new file mode 100644 index 0000000..6898a8e --- /dev/null +++ b/database/migrations/2025_07_01_141618_create_categories_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('site_id') + ->constrained() + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->string('name'); + $table->foreignId('parent_id') + ->nullable() + ->constrained('catalogue_categories', 'id') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->string('image')->nullable(); + $table->unsignedInteger('sort')->nullable(); + $table->boolean('is_active'); + $table->string('code')->nullable(); + $table->boolean('recursive_browsing')->nullable(); + $table->string('sef_key')->nullable(); + $table->text('short_desc')->nullable(); + $table->text('description')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('catalogue_categories'); + } +}; diff --git a/src/Filament/Resources/CategoryResource.php b/src/Filament/Resources/CategoryResource.php new file mode 100644 index 0000000..30aac8f --- /dev/null +++ b/src/Filament/Resources/CategoryResource.php @@ -0,0 +1,123 @@ +schema([ + TextInput::make('name') + ->required(), + + TextInput::make('parent_id') + ->integer(), + + Checkbox::make('is_active'), + + TextInput::make('code'), + + Checkbox::make('recursive_browsing'), + + TextInput::make('sef_key'), + + TextInput::make('short_desc'), + + TextInput::make('description'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + ImageColumn::make('image'), + + TextColumn::make('name') + ->searchable() + ->sortable(), + + TextColumn::make('is_active'), + + TextColumn::make('code'), + + TextColumn::make('recursive_browsing'), + + TextColumn::make('short_desc'), + ]) + ->reorderable('sort') + ->filters([ + TrashedFilter::make(), + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + RestoreBulkAction::make(), + ForceDeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListCategories::route('/'), + 'create' => Pages\CreateCategory::route('/create'), + 'edit' => Pages\EditCategory::route('/{record}/edit'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function getGloballySearchableAttributes(): array + { + return ['name', 'short_desc', 'description']; + } +} diff --git a/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php b/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php new file mode 100644 index 0000000..1ae06a2 --- /dev/null +++ b/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php @@ -0,0 +1,18 @@ +belongsTo(Site::class); + } + + protected function casts(): array + { + return [ + 'is_active' => 'boolean', + 'recursive_browsing' => 'boolean', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $category) { + $category->site_id = Filament::getTenant()->id; + }); + } +} From a8eb1636e903d5c920a8b9252d337c23b0b19e3b Mon Sep 17 00:00:00 2001 From: SlimDeluxe <131700+SlimDeluxe@users.noreply.github.com> Date: Wed, 2 Jul 2025 08:42:04 +0000 Subject: [PATCH 2/3] style: fix code style --- .../migrations/2025_07_01_141618_create_categories_table.php | 3 ++- src/Filament/Resources/CategoryResource.php | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/database/migrations/2025_07_01_141618_create_categories_table.php b/database/migrations/2025_07_01_141618_create_categories_table.php index 6898a8e..1745edd 100644 --- a/database/migrations/2025_07_01_141618_create_categories_table.php +++ b/database/migrations/2025_07_01_141618_create_categories_table.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('catalogue_categories', function (Blueprint $table) { diff --git a/src/Filament/Resources/CategoryResource.php b/src/Filament/Resources/CategoryResource.php index 30aac8f..fd33cf7 100644 --- a/src/Filament/Resources/CategoryResource.php +++ b/src/Filament/Resources/CategoryResource.php @@ -2,10 +2,9 @@ namespace Eclipse\Catalogue\Filament\Resources; +use Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; use Eclipse\Catalogue\Models\Category; use Filament\Forms\Components\Checkbox; -use Filament\Forms\Components\Placeholder; -use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; @@ -22,9 +21,7 @@ use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; -use Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; class CategoryResource extends Resource { From 4a6cf384fb26cc61447daa3d0b92c623b83e6255 Mon Sep 17 00:00:00 2001 From: "Thapa Godar Ank." <76224530+thapacodes4u@users.noreply.github.com> Date: Tue, 5 Aug 2025 00:51:15 -0700 Subject: [PATCH 3/3] feat: implement categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 7/23 done , 2 work in progress * feat: completed 6 more task * improve list UI, refactor hierarchy selects, update seeder images, fix sorting breadcrumbs, add i18n * add unique SEF key validation and auto-generation from category name * incomplete test cases * refactor: make tenancy configurable * feat: refactoring, adding test cases * feat: adding product filter test * fix: implement requested changes from code review * fix: formatting code * fix: reverting changes back to original for .gitignore * refactor: applying requested changes * fix: unable to update due to sef key issue --------- Co-authored-by: ankitcodes4u Co-authored-by: Omer Šabić --- composer.json | 1 + config/eclipse-catalogue.php | 11 +- database/factories/CategoryFactory.php | 87 ++++- database/factories/ProductFactory.php | 2 + ...5_07_01_141618_create_categories_table.php | 8 +- ...d_category_to_catalogue_products_table.php | 28 ++ database/seeders/CategorySeeder.php | 38 ++ resources/lang/en/categories.php | 81 +++++ resources/lang/en/plugin-template.php | 5 - src/CatalogueServiceProvider.php | 2 + src/Filament/Resources/CategoryResource.php | 340 ++++++++++++++++-- .../CategoryResource/Pages/CreateCategory.php | 5 +- .../CategoryResource/Pages/EditCategory.php | 13 +- .../CategoryResource/Pages/ListCategories.php | 19 +- .../Pages/SortingCategory.php | 73 ++++ src/Filament/Resources/ProductResource.php | 31 +- src/Models/Category.php | 225 +++++++++++- src/Models/Product.php | 8 + src/Policies/CategoryPolicy.php | 92 +++++ tests/Feature/CategoryResourceTest.php | 97 +++++ tests/Feature/ExampleTest.php | 5 - tests/Feature/ProductResourceTest.php | 38 ++ tests/TestCase.php | 14 +- workbench/app/Models/Site.php | 29 ++ .../app/Providers/AdminPanelProvider.php | 3 + .../Providers/WorkbenchServiceProvider.php | 6 +- workbench/database/factories/SiteFactory.php | 19 + .../2025_01_01_000000_create_sites_table.php | 23 ++ 28 files changed, 1228 insertions(+), 75 deletions(-) create mode 100644 database/migrations/2025_07_09_091509_add_category_to_catalogue_products_table.php create mode 100644 database/seeders/CategorySeeder.php create mode 100644 resources/lang/en/categories.php delete mode 100644 resources/lang/en/plugin-template.php create mode 100644 src/Filament/Resources/CategoryResource/Pages/SortingCategory.php create mode 100644 src/Policies/CategoryPolicy.php create mode 100644 tests/Feature/CategoryResourceTest.php delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/ProductResourceTest.php create mode 100644 workbench/app/Models/Site.php create mode 100644 workbench/database/factories/SiteFactory.php create mode 100644 workbench/database/migrations/2025_01_01_000000_create_sites_table.php diff --git a/composer.json b/composer.json index 9ed9dde..7cfea6e 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "eclipsephp/common": "dev-main", "filament/filament": "^3.3", "filament/spatie-laravel-translatable-plugin": "^3.2", + "solution-forest/filament-tree": "^2.1", "spatie/laravel-package-tools": "^1.19", "spatie/laravel-translatable": "^6.11" }, diff --git a/config/eclipse-catalogue.php b/config/eclipse-catalogue.php index 3ac44ad..8d72342 100644 --- a/config/eclipse-catalogue.php +++ b/config/eclipse-catalogue.php @@ -1,5 +1,14 @@ [ + 'model' => null, + 'foreign_key' => null, + ], ]; diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php index 0920361..e8456dc 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -3,9 +3,9 @@ namespace Eclipse\Catalogue\Factories; use Eclipse\Catalogue\Models\Category; -use Eclipse\Core\Models\Site; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; class CategoryFactory extends Factory { @@ -13,18 +13,85 @@ class CategoryFactory extends Factory public function definition(): array { + $englishName = mb_ucfirst(fake()->words(2, true)); + $slovenianName = 'SI: '.$englishName; + + $englishShortDesc = fake()->sentence(); + $slovenianShortDesc = 'SI: '.$englishShortDesc; + + $englishDesc = fake()->paragraphs(3, true); + $slovenianDesc = 'SI: '.$englishDesc; + return [ - 'name' => $this->faker->words(), - 'sort' => $this->faker->randomNumber(), - 'is_active' => $this->faker->boolean(), - 'recursive_browsing' => $this->faker->boolean(), - 'sef_key' => $this->faker->word(), - 'short_desc' => $this->faker->words(10), - 'description' => $this->faker->text(), + 'name' => [ + 'en' => $englishName, + 'sl' => $slovenianName, + ], + 'parent_id' => null, + 'image' => self::generateCategoryImage($englishName), + 'sort' => fake()->randomNumber(), + 'is_active' => fake()->boolean(), + 'code' => fake()->optional()->bothify('CAT-####'), + 'recursive_browsing' => fake()->boolean(), + 'sef_key' => [ + 'en' => Str::slug($englishName), + 'sl' => Str::slug($slovenianName), + ], + 'short_desc' => [ + 'en' => $englishShortDesc, + 'sl' => $slovenianShortDesc, + ], + 'description' => [ + 'en' => $englishDesc, + 'sl' => $slovenianDesc, + ], 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), - - 'site_id' => Site::inRandomOrder()->first()?->id ?? Site::factory()->create()->id, + 'site_id' => null, ]; } + + private static function generateCategoryImage(string $name): ?string + { + $colors = ['3B82F6', '10B981', 'F59E0B', 'EF4444', '8B5CF6', '06B6D4', 'F97316', 'EC4899']; + $backgrounds = ['E0F2FE', 'ECFDF5', 'FFFBEB', 'FEF2F2', 'F3E8FF', 'ECFEFF', 'FFF7ED', 'FDF2F8']; + + $color = fake()->randomElement($colors); + $background = fake()->randomElement($backgrounds); + + return fake()->optional(0.8)->passthrough( + 'https://ui-avatars.com/api/?name='.urlencode($name). + '&size=400&background='.$background. + '&color='.$color. + '&bold=true&format=png' + ); + } + + public function parent(): static + { + return $this->state(fn (array $attributes): array => [ + 'parent_id' => null, + ]); + } + + public function child(?Category $parent = null): static + { + return $this->state(fn (array $attributes): array => [ + 'parent_id' => $parent?->id ?? Category::factory()->parent()->create()->id, + ]); + } + + public function active(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => true, + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attributes): array => [ + 'is_active' => false, + ]); + } } diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index c9ff002..1a04566 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -2,6 +2,7 @@ namespace Eclipse\Catalogue\Factories; +use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Carbon; @@ -32,6 +33,7 @@ public function definition(): array 'en' => $englishName, 'sl' => $slovenianName, ], + 'category_id' => Category::inRandomOrder()->first()?->id ?? Category::factory(), 'short_description' => [ 'en' => $englishShortDesc, 'sl' => $slovenianShortDesc, diff --git a/database/migrations/2025_07_01_141618_create_categories_table.php b/database/migrations/2025_07_01_141618_create_categories_table.php index 1745edd..2013602 100644 --- a/database/migrations/2025_07_01_141618_create_categories_table.php +++ b/database/migrations/2025_07_01_141618_create_categories_table.php @@ -15,13 +15,9 @@ public function up(): void ->cascadeOnDelete() ->cascadeOnUpdate(); $table->string('name'); - $table->foreignId('parent_id') - ->nullable() - ->constrained('catalogue_categories', 'id') - ->cascadeOnDelete() - ->cascadeOnUpdate(); + $table->integer('parent_id')->default(-1); $table->string('image')->nullable(); - $table->unsignedInteger('sort')->nullable(); + $table->integer('sort')->default(0)->index(); $table->boolean('is_active'); $table->string('code')->nullable(); $table->boolean('recursive_browsing')->nullable(); diff --git a/database/migrations/2025_07_09_091509_add_category_to_catalogue_products_table.php b/database/migrations/2025_07_09_091509_add_category_to_catalogue_products_table.php new file mode 100644 index 0000000..c4a689a --- /dev/null +++ b/database/migrations/2025_07_09_091509_add_category_to_catalogue_products_table.php @@ -0,0 +1,28 @@ +foreignId('category_id') + ->nullable() + ->after('name') + ->constrained('catalogue_categories') + ->nullOnDelete() + ->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('catalogue_products', function (Blueprint $table) { + $table->dropForeign(['category_id']); + $table->dropColumn('category_id'); + }); + } +}; diff --git a/database/seeders/CategorySeeder.php b/database/seeders/CategorySeeder.php new file mode 100644 index 0000000..e55e20c --- /dev/null +++ b/database/seeders/CategorySeeder.php @@ -0,0 +1,38 @@ +parent() + ->active() + ->count(3) + ->create([$tenantFK => $tenant->id]); + + foreach ($parents as $index => $parent) { + $childrenCount = match ($index) { + 0 => 3, + 1 => 2, + 2 => 2, + }; + + Category::factory() + ->child($parent) + ->active() + ->count($childrenCount) + ->create([$tenantFK => $tenant->id]); + } + } + } +} diff --git a/resources/lang/en/categories.php b/resources/lang/en/categories.php new file mode 100644 index 0000000..5f7058a --- /dev/null +++ b/resources/lang/en/categories.php @@ -0,0 +1,81 @@ + 'Categories', + 'navigation_group' => 'Catalogue', + 'sorting' => 'Sorting', + + 'form' => [ + 'sections' => [ + 'basic_information' => 'Basic Information', + 'content' => 'Content', + 'media_settings' => 'Media & Settings', + 'system_information' => 'System Information', + ], + 'fields' => [ + 'parent_id' => 'Parent Category', + 'parent_id_placeholder' => 'Select parent category (optional)', + 'name' => 'Name', + 'name_placeholder' => 'Enter category name', + 'code' => 'Code', + 'code_placeholder' => 'Enter category code', + 'sef_key' => 'SEF Key', + 'sef_key_placeholder' => 'URL-friendly key (auto-generated if empty)', + 'sef_key_helper' => 'Leave empty to auto-generate from category name', + 'short_desc' => 'Short Description', + 'short_desc_placeholder' => 'Enter a brief description', + 'description' => 'Full Description', + 'description_placeholder' => 'Enter detailed category description', + 'image' => 'Category Image', + 'is_active' => 'Active', + 'is_active_helper' => 'Whether this category is visible', + 'recursive_browsing' => 'Recursive Browsing', + 'recursive_browsing_helper' => 'Allow browsing subcategories recursively', + 'created_at' => 'Created Date', + 'updated_at' => 'Last Modified Date', + 'not_yet_saved' => 'Not yet saved', + ], + 'errors' => [ + 'sef_key' => 'The SEF key has already been taken.', + 'parent_id' => 'Cannot select this category as parent - it would create a circular reference', + ], + ], + + 'table' => [ + 'columns' => [ + 'image' => 'Image', + 'name' => 'Category', + 'sef_key' => 'SEF Key', + 'is_active' => 'Active', + 'code' => 'Code', + 'recursive_browsing' => 'Recursive', + 'description' => 'Long Desc.', + 'short_desc' => 'Short Description', + ], + 'tooltips' => [ + 'recursive_browsing' => 'Include products from subcategories', + 'has_description' => 'Has description', + 'no_description' => 'No description', + ], + ], + + 'filters' => [ + 'parent_category' => 'Parent Category', + 'category_placeholder' => 'All Categories', + 'is_active' => 'Status', + 'active' => 'Active', + 'inactive' => 'Inactive', + 'all_statuses' => 'All Statuses', + 'has_description' => 'Has Description', + ], + + 'actions' => [ + 'create' => 'Create', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'restore' => 'Restore', + 'force_delete' => 'Force Delete', + 'sorting' => 'Sorting', + 'bulk_actions' => 'Bulk Actions', + ], +]; diff --git a/resources/lang/en/plugin-template.php b/resources/lang/en/plugin-template.php deleted file mode 100644 index 3ac44ad..0000000 --- a/resources/lang/en/plugin-template.php +++ /dev/null @@ -1,5 +0,0 @@ - Product::getTypesenseSettings(), + Category::class => Category::getTypesenseSettings(), ]; Config::set('scout.typesense.model-settings', $settings); diff --git a/src/Filament/Resources/CategoryResource.php b/src/Filament/Resources/CategoryResource.php index fd33cf7..52cff17 100644 --- a/src/Filament/Resources/CategoryResource.php +++ b/src/Filament/Resources/CategoryResource.php @@ -2,12 +2,25 @@ namespace Eclipse\Catalogue\Filament\Resources; +use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; use Eclipse\Catalogue\Models\Category; -use Filament\Forms\Components\Checkbox; +use Filament\Facades\Filament; +use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Forms\Form; +use Filament\Forms\Get; +use Filament\Forms\Set; +use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; +use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; @@ -16,15 +29,23 @@ use Filament\Tables\Actions\ForceDeleteBulkAction; use Filament\Tables\Actions\RestoreAction; use Filament\Tables\Actions\RestoreBulkAction; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\Filter; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Support\HtmlString; +use Illuminate\Support\Str; -class CategoryResource extends Resource +class CategoryResource extends Resource implements HasShieldPermissions { + use Translatable; + protected static ?string $model = Category::class; protected static ?string $slug = 'catalogue/categories'; @@ -35,64 +56,310 @@ class CategoryResource extends Resource protected static ?string $recordTitleAttribute = 'name'; + protected static ?string $tenantOwnershipRelationshipName = 'site'; + + public static function getNavigationGroup(): ?string + { + return __('eclipse-catalogue::categories.navigation_group'); + } + + public static function getPluralModelLabel(): string + { + return __('eclipse-catalogue::categories.title'); + } + public static function form(Form $form): Form { return $form ->schema([ - TextInput::make('name') - ->required(), + Section::make(__('eclipse-catalogue::categories.form.sections.basic_information')) + ->columns(2) + ->schema([ + Select::make('parent_id') + ->label(__('eclipse-catalogue::categories.form.fields.parent_id')) + ->options(Category::getHierarchicalOptions()) + ->afterStateHydrated(function ($state, $context, Set $set): void { + if ($context === 'edit' && $state == -1) { + $set('parent_id', null); + } + }) + ->searchable() + ->placeholder(__('eclipse-catalogue::categories.form.fields.parent_id_placeholder')) + ->rules([ + fn (?Category $record): callable => function (string $attribute, $value, $fail) use ($record): void { + if (empty($value) || ! $record) { + return; + } + + $current = Category::find($value); + $visited = []; + + while ($current && ! in_array($current->id, $visited)) { + if ($current->id === $record->id) { + $fail(__('eclipse-catalogue::categories.form.errors.parent_id')); + + return; + } + + $visited[] = $current->id; + $current = $current->parent_id ? Category::find($current->parent_id) : null; + + if (count($visited) > 50) { + break; + } + } + }, + ]), + TextInput::make('name') + ->label(__('eclipse-catalogue::categories.form.fields.name')) + ->required() + ->maxLength(255) + ->placeholder(__('eclipse-catalogue::categories.form.fields.name_placeholder')) + ->helperText(fn ($record) => $record?->getFullPath()) + ->live(debounce: 300) + ->afterStateUpdated(function ($state, Set $set, Get $get): void { + $sefKey = Str::slug($state); + $set('sef_key', $sefKey); + }), + TextInput::make('code') + ->label(__('eclipse-catalogue::categories.form.fields.code')) + ->maxLength(255) + ->placeholder(__('eclipse-catalogue::categories.form.fields.code_placeholder')), + TextInput::make('sef_key') + ->label(__('eclipse-catalogue::categories.form.fields.sef_key')) + ->maxLength(255) + ->placeholder(__('eclipse-catalogue::categories.form.fields.sef_key_placeholder')) + ->helperText(__('eclipse-catalogue::categories.form.fields.sef_key_helper')) + ->rules([ + fn (Get $get, ?Model $record): callable => function (string $attribute, $value, $fail) use ($record): void { + if (empty($value)) { + return; + } + + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $siteId = Filament::getTenant()?->id; + $currentLocale = app()->getLocale(); + + $query = Category::query(); + + if ($tenantFK && $siteId) { + $query->where($tenantFK, $siteId); + } + + $query->where(function ($q) use ($value, $currentLocale): void { + $q->whereJsonContains('sef_key', $value) + ->orWhereJsonContains("sef_key->{$currentLocale}", $value); + }); + + if ($record && $record->exists) { + $query->where('id', '!=', $record->id); + } + + if ($query->exists()) { + $fail(__('eclipse-catalogue::categories.form.errors.sef_key')); + } + }, + ]), + ]), - TextInput::make('parent_id') - ->integer(), + Section::make(__('eclipse-catalogue::categories.form.sections.content')) + ->compact() + ->schema([ + Textarea::make('short_desc') + ->label(__('eclipse-catalogue::categories.form.fields.short_desc')) + ->rows(3) + ->placeholder(__('eclipse-catalogue::categories.form.fields.short_desc_placeholder')), - Checkbox::make('is_active'), + RichEditor::make('description') + ->label(__('eclipse-catalogue::categories.form.fields.description')) + ->placeholder(__('eclipse-catalogue::categories.form.fields.description_placeholder')) + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + 'link', + 'undo', + 'redo', + ]), + ]), - TextInput::make('code'), + Section::make(__('eclipse-catalogue::categories.form.sections.media_settings')) + ->compact() + ->schema([ + FileUpload::make('image') + ->columnSpanFull() + ->label(__('eclipse-catalogue::categories.form.fields.image')) + ->image() + ->imageEditor() + ->directory('categories') + ->visibility('public'), - Checkbox::make('recursive_browsing'), + Grid::make(2) + ->schema([ + Toggle::make('is_active') + ->label(__('eclipse-catalogue::categories.form.fields.is_active')) + ->default(true) + ->helperText(__('eclipse-catalogue::categories.form.fields.is_active_helper')), - TextInput::make('sef_key'), + Toggle::make('recursive_browsing') + ->label(__('eclipse-catalogue::categories.form.fields.recursive_browsing')) + ->default(false) + ->helperText(__('eclipse-catalogue::categories.form.fields.recursive_browsing_helper')), + ]), + ]), - TextInput::make('short_desc'), + Section::make(__('eclipse-catalogue::categories.form.sections.system_information')) + ->compact() + ->schema([ + Grid::make(2) + ->schema([ + Placeholder::make('created_at') + ->label(__('eclipse-catalogue::categories.form.fields.created_at')) + ->content(fn (?Category $record): string => $record?->created_at?->diffForHumans() ?? 'Not yet saved'), - TextInput::make('description'), + Placeholder::make('updated_at') + ->label(__('eclipse-catalogue::categories.form.fields.updated_at')) + ->content(fn (?Category $record): string => $record?->updated_at?->diffForHumans() ?? 'Not yet saved'), + ]), + ]) + ->collapsible() + ->collapsed(), ]); } public static function table(Table $table): Table { return $table + ->modifyQueryUsing(function (Builder $query): Builder { + $selectArray = Category::selectArray(); + + unset($selectArray[Category::defaultParentKey()]); + $orderedIds = array_keys($selectArray); + + if (! empty($orderedIds)) { + $idsString = implode(',', $orderedIds); + + return $query->orderByRaw("FIELD(id, {$idsString})"); + } + + return $query->orderBy('sort'); + }) ->columns([ - ImageColumn::make('image'), + ImageColumn::make('image') + ->label(__('eclipse-catalogue::categories.table.columns.image')) + ->size(40) + ->circular() + ->sortable(false) + ->defaultImageUrl(fn (Model $record): string => 'https://ui-avatars.com/api/?name='.urlencode($record->name).'&color=7F9CF5&background=EBF4FF'), TextColumn::make('name') + ->label(__('eclipse-catalogue::categories.table.columns.name')) ->searchable() - ->sortable(), + ->sortable(false) + ->lineClamp(1) + ->formatStateUsing(fn (Model $record): HtmlString => new HtmlString($record->getTreeFormattedName())) + ->tooltip(fn ($record) => $record->getFullPath()), - TextColumn::make('is_active'), + TextColumn::make('sef_key') + ->label(__('eclipse-catalogue::categories.table.columns.sef_key')) + ->label('SEF Key') + ->searchable() + ->fontFamily('mono') + ->copyable() + ->size('sm') + ->sortable(false) + ->toggleable(isToggledHiddenByDefault: true), - TextColumn::make('code'), + IconColumn::make('is_active') + ->label(__('eclipse-catalogue::categories.table.columns.is_active')) + ->label('Active') + ->boolean() + ->sortable(false) + ->alignCenter(), - TextColumn::make('recursive_browsing'), + TextColumn::make('code') + ->label(__('eclipse-catalogue::categories.table.columns.code')) + ->searchable() + ->fontFamily('mono') + ->placeholder('—') + ->sortable(false) + ->copyable(), + + IconColumn::make('recursive_browsing') + ->label(__('eclipse-catalogue::categories.table.columns.recursive_browsing')) + ->boolean() + ->alignCenter() + ->sortable(false) + ->tooltip(__('eclipse-catalogue::categories.table.tooltips.recursive_browsing')), - TextColumn::make('short_desc'), + IconColumn::make('description') + ->label(__('eclipse-catalogue::categories.table.columns.description')) + ->alignCenter() + ->sortable(false) + ->getStateUsing(fn ($record) => ! empty($record->description)) + ->icon(fn ($state) => $state ? 'heroicon-o-document-text' : 'heroicon-o-document') + ->color(fn ($state) => $state ? 'success' : 'gray') + ->tooltip(fn ($record) => ! empty($record->description) ? __('eclipse-catalogue::categories.table.tooltips.has_description') : __('eclipse-catalogue::categories.table.tooltips.no_description')), + + TextColumn::make('short_desc') + ->sortable(false) + ->label(__('eclipse-catalogue::categories.table.columns.short_desc')) + ->lineClamp(2), ]) - ->reorderable('sort') + + ->paginated([10, 25, 50, 100]) + ->searchable() ->filters([ TrashedFilter::make(), + + SelectFilter::make('parent_id') + ->label(__('eclipse-catalogue::categories.filters.parent_category')) + ->multiple() + ->options(Category::getHierarchicalOptions()) + ->placeholder(__('eclipse-catalogue::categories.filters.category_placeholder')), + + SelectFilter::make('is_active') + ->label(__('eclipse-catalogue::categories.filters.is_active')) + ->options([ + 1 => __('eclipse-catalogue::categories.filters.active'), + 0 => __('eclipse-catalogue::categories.filters.inactive'), + ]) + ->placeholder('All Statuses'), + + Filter::make('has_description') + ->label(__('eclipse-catalogue::categories.filters.has_description')) + ->query(fn (Builder $query): Builder => $query->whereNotNull('description')) + ->toggle(), + ]) ->actions([ - EditAction::make(), - DeleteAction::make(), - RestoreAction::make(), - ForceDeleteAction::make(), + ActionGroup::make([ + EditAction::make() + ->label(__('eclipse-catalogue::categories.actions.edit')), + DeleteAction::make() + ->label(__('eclipse-catalogue::categories.actions.delete')), + RestoreAction::make() + ->label(__('eclipse-catalogue::categories.actions.restore')), + ForceDeleteAction::make() + ->label(__('eclipse-catalogue::categories.actions.force_delete')), + ]) + ->hiddenLabel() + ->icon('heroicon-m-ellipsis-vertical') + ->size('sm') + ->color('gray') + ->button(), ]) ->bulkActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), - RestoreBulkAction::make(), - ForceDeleteBulkAction::make(), - ]), + DeleteBulkAction::make() + ->label(__('eclipse-catalogue::categories.actions.delete')), + RestoreBulkAction::make() + ->label(__('eclipse-catalogue::categories.actions.restore')), + ForceDeleteBulkAction::make() + ->label(__('eclipse-catalogue::categories.actions.force_delete')), + ])->label(__('eclipse-catalogue::categories.actions.bulk_actions')), ]); } @@ -101,6 +368,7 @@ public static function getPages(): array return [ 'index' => Pages\ListCategories::route('/'), 'create' => Pages\CreateCategory::route('/create'), + 'sorting' => Pages\SortingCategory::route('/sorting'), 'edit' => Pages\EditCategory::route('/{record}/edit'), ]; } @@ -115,6 +383,22 @@ public static function getEloquentQuery(): Builder public static function getGloballySearchableAttributes(): array { - return ['name', 'short_desc', 'description']; + return ['name', 'short_desc', 'description', 'sef_key', 'code']; + } + + public static function getPermissionPrefixes(): array + { + return [ + 'view_any', + 'view', + 'create', + 'update', + 'restore', + 'restore_any', + 'delete', + 'delete_any', + 'force_delete', + 'force_delete_any', + ]; } } diff --git a/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php b/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php index 1ae06a2..7a6924c 100644 --- a/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php +++ b/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php @@ -3,16 +3,19 @@ namespace Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; use Eclipse\Catalogue\Filament\Resources\CategoryResource; +use Filament\Actions; use Filament\Resources\Pages\CreateRecord; class CreateCategory extends CreateRecord { + use CreateRecord\Concerns\Translatable; + protected static string $resource = CategoryResource::class; protected function getHeaderActions(): array { return [ - + Actions\LocaleSwitcher::make(), ]; } } diff --git a/src/Filament/Resources/CategoryResource/Pages/EditCategory.php b/src/Filament/Resources/CategoryResource/Pages/EditCategory.php index 2f63e7d..d88aaaa 100644 --- a/src/Filament/Resources/CategoryResource/Pages/EditCategory.php +++ b/src/Filament/Resources/CategoryResource/Pages/EditCategory.php @@ -3,21 +3,22 @@ namespace Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; use Eclipse\Catalogue\Filament\Resources\CategoryResource; -use Filament\Actions\DeleteAction; -use Filament\Actions\ForceDeleteAction; -use Filament\Actions\RestoreAction; +use Filament\Actions; use Filament\Resources\Pages\EditRecord; class EditCategory extends EditRecord { + use EditRecord\Concerns\Translatable; + protected static string $resource = CategoryResource::class; protected function getHeaderActions(): array { return [ - DeleteAction::make(), - ForceDeleteAction::make(), - RestoreAction::make(), + Actions\LocaleSwitcher::make(), + Actions\DeleteAction::make(), + Actions\ForceDeleteAction::make(), + Actions\RestoreAction::make(), ]; } } diff --git a/src/Filament/Resources/CategoryResource/Pages/ListCategories.php b/src/Filament/Resources/CategoryResource/Pages/ListCategories.php index d05a924..fc4270e 100644 --- a/src/Filament/Resources/CategoryResource/Pages/ListCategories.php +++ b/src/Filament/Resources/CategoryResource/Pages/ListCategories.php @@ -3,17 +3,32 @@ namespace Eclipse\Catalogue\Filament\Resources\CategoryResource\Pages; use Eclipse\Catalogue\Filament\Resources\CategoryResource; -use Filament\Actions\CreateAction; +use Eclipse\Common\Foundation\Pages\HasScoutSearch; +use Filament\Actions; use Filament\Resources\Pages\ListRecords; +use Filament\Resources\Pages\ListRecords\Concerns\Translatable; +use Filament\Support\Enums\MaxWidth; class ListCategories extends ListRecords { + use HasScoutSearch, Translatable; + protected static string $resource = CategoryResource::class; + protected ?string $maxContentWidth = MaxWidth::Full->value; + protected function getHeaderActions(): array { return [ - CreateAction::make(), + Actions\LocaleSwitcher::make(), + Actions\Action::make('sorting') + ->label(__('eclipse-catalogue::categories.actions.sorting')) + ->icon('heroicon-o-arrows-up-down') + ->color('gray') + ->url(fn () => self::$resource::getUrl('sorting')), + Actions\CreateAction::make() + ->label(__('eclipse-catalogue::categories.actions.create')) + ->icon('heroicon-o-plus-circle'), ]; } } diff --git a/src/Filament/Resources/CategoryResource/Pages/SortingCategory.php b/src/Filament/Resources/CategoryResource/Pages/SortingCategory.php new file mode 100644 index 0000000..20863b7 --- /dev/null +++ b/src/Filament/Resources/CategoryResource/Pages/SortingCategory.php @@ -0,0 +1,73 @@ +getCreateAction() + ->translateLabel() + ->label(__('eclipse-catalogue::categories.actions.create')) + ->icon('heroicon-o-plus-circle'), + ]; + } + + public function getTreeRecordTitle(?Model $record = null): string + { + if (! $record) { + return ''; + } + + return $record->name; + } + + protected function hasDeleteAction(): bool + { + return false; + } + + protected function hasEditAction(): bool + { + return true; + } + + protected function hasViewAction(): bool + { + return false; + } + + protected function getHeaderWidgets(): array + { + return []; + } + + protected function getFooterWidgets(): array + { + return []; + } + + public function getTreeRecordIcon(?Model $record = null): ?string + { + return null; + } +} diff --git a/src/Filament/Resources/ProductResource.php b/src/Filament/Resources/ProductResource.php index bdb4013..9b3bb5a 100644 --- a/src/Filament/Resources/ProductResource.php +++ b/src/Filament/Resources/ProductResource.php @@ -4,13 +4,16 @@ use BezhanSalleh\FilamentShield\Contracts\HasShieldPermissions; use Eclipse\Catalogue\Filament\Resources\ProductResource\Pages; +use Eclipse\Catalogue\Models\Category; use Eclipse\Catalogue\Models\Product; use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\RichEditor; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Concerns\Translatable; use Filament\Resources\Resource; +use Filament\Tables\Actions\ActionGroup; use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; @@ -20,6 +23,7 @@ use Filament\Tables\Actions\RestoreAction; use Filament\Tables\Actions\RestoreBulkAction; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; @@ -65,6 +69,12 @@ public static function form(Form $form): Form TextInput::make('name'), + Select::make('category_id') + ->label('Category') + ->options(Category::getHierarchicalOptions()) + ->searchable() + ->placeholder('Category (optional)'), + TextInput::make('short_description'), RichEditor::make('description'), @@ -88,6 +98,8 @@ public static function table(Table $table): Table TextColumn::make('name') ->toggleable(false), + TextColumn::make('category.name'), + TextColumn::make('short_description') ->words(5), @@ -111,12 +123,23 @@ public static function table(Table $table): Table ->searchable() ->filters([ TrashedFilter::make(), + SelectFilter::make('category_id') + ->label('Categories') + ->multiple() + ->options(Category::getHierarchicalOptions()), ]) ->actions([ - EditAction::make(), - DeleteAction::make(), - RestoreAction::make(), - ForceDeleteAction::make(), + ActionGroup::make([ + EditAction::make(), + DeleteAction::make(), + RestoreAction::make(), + ForceDeleteAction::make(), + ]) + ->hiddenLabel() + ->icon('heroicon-m-ellipsis-vertical') + ->size('sm') + ->color('gray') + ->button(), ]) ->bulkActions([ BulkActionGroup::make([ diff --git a/src/Models/Category.php b/src/Models/Category.php index a3e3cc6..7b119dc 100644 --- a/src/Models/Category.php +++ b/src/Models/Category.php @@ -2,16 +2,20 @@ namespace Eclipse\Catalogue\Models; -use Eclipse\Core\Models\Site; +use Eclipse\Catalogue\Factories\CategoryFactory; +use Eclipse\Common\Foundation\Models\IsSearchable; use Filament\Facades\Filament; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use SolutionForest\FilamentTree\Concern\ModelTree; +use Spatie\Translatable\HasTranslations; class Category extends Model { - use HasFactory, SoftDeletes; + use HasFactory, HasTranslations, IsSearchable, ModelTree, SoftDeletes; protected $table = 'catalogue_categories'; @@ -26,25 +30,236 @@ class Category extends Model 'sef_key', 'short_desc', 'description', + 'site_id', ]; + public array $translatable = [ + 'name', + 'sef_key', + 'short_desc', + 'description', + ]; + + public function determineOrderColumnName(): string + { + return 'sort'; + } + + public function determineTitleColumnName(): string + { + return 'name'; + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + public function site(): BelongsTo { - return $this->belongsTo(Site::class); + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key', 'site_id'); + + return $this->belongsTo($tenantModel, $tenantFK); + } + + protected static function formatTreeName(string $value): array + { + if (! str_starts_with($value, '-')) { + return ['name' => $value, 'level' => 0]; + } + + $dashCount = 0; + while ($dashCount < strlen($value) && $value[$dashCount] === '-') { + $dashCount++; + } + + $level = intval($dashCount / 3); + $cleanName = ltrim($value, '-'); + + return ['name' => $cleanName, 'level' => $level]; + } + + protected static function getTreePrefix(int $level): string + { + $indent = str_repeat('. . . . ', $level); + $connector = $level > 0 ? '└─ ' : ''; + + return $indent.$connector; + } + + public static function getHierarchicalOptions(): array + { + $options = static::selectArray(5); + + unset($options[static::defaultParentKey()]); + + foreach ($options as $key => $value) { + $formatted = self::formatTreeName($value); + $options[$key] = self::getTreePrefix($formatted['level']).$formatted['name']; + } + + return $options; + } + + public function getTreeFormattedName(): string + { + $selectArray = static::selectArray(); + $formattedName = $selectArray[$this->id] ?? $this->name; + + $formatted = self::formatTreeName($formattedName); + + return self::getTreePrefix($formatted['level']).e($formatted['name']); } protected function casts(): array { return [ + 'name' => 'array', + 'sef_key' => 'array', + 'short_desc' => 'array', + 'description' => 'array', 'is_active' => 'boolean', 'recursive_browsing' => 'boolean', ]; } + public function getFullPath(): string + { + $allNodes = static::allNodes()->keyBy('id'); + + $path = []; + $current = $this; + + while ($current) { + $path[] = $current->name; + $parentId = $current->{$this->determineParentColumnName()}; + + if ($parentId && $parentId !== static::defaultParentKey() && isset($allNodes[$parentId])) { + $current = $allNodes[$parentId]; + } else { + $current = null; + } + } + + return implode(' > ', array_reverse($path)); + } + + protected static function newFactory(): CategoryFactory + { + return CategoryFactory::new(); + } + protected static function booted(): void { - static::creating(function (self $category) { - $category->site_id = Filament::getTenant()->id; + static::addGlobalScope('tenant', function (Builder $builder) { + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenant = Filament::getTenant(); + + if ($tenantModel && $tenantFK && $tenant) { + $builder->where($tenantFK, $tenant->id); + } + }); + + static::creating(function (self $category): void { + $tenantModel = config('eclipse-catalogue.tenancy.model'); + $tenantFK = config('eclipse-catalogue.tenancy.foreign_key'); + $tenant = Filament::getTenant(); + + if ($tenantModel && $tenantFK && empty($category->{$tenantFK})) { + if ($tenant) { + $category->{$tenantFK} = $tenant->id; + } else { + $siteModel = app($tenantModel); + $firstSite = $siteModel::first(); + if ($firstSite) { + $category->{$tenantFK} = $firstSite->id; + } + } + } }); } + + public static function getTypesenseSettings(): array + { + return [ + 'collection-schema' => [ + 'fields' => [ + [ + 'name' => 'id', + 'type' => 'string', + ], + [ + 'name' => 'site_id', + 'type' => 'string', + ], + [ + 'name' => 'parent_id', + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'code', + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'created_at', + 'type' => 'int64', + ], + // Support both string and translation patterns + [ + 'name' => 'name', + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'name_.*', // For translations + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'sef_key_.*', // For translations + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'short_desc_.*', // For translations + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'description_.*', // For translations + 'type' => 'string', + 'optional' => true, + ], + [ + 'name' => 'is_active', + 'type' => 'bool', + ], + [ + 'name' => 'sort', + 'type' => 'int32', + ], + [ + 'name' => '__soft_deleted', + 'type' => 'int32', + 'optional' => true, + ], + ], + ], + 'search-parameters' => [ + 'query_by' => implode(', ', [ + 'name_*', + 'short_desc_*', + 'description_*', + 'code', + 'sef_key_*', + ]), + 'filter_by' => 'is_active:=true', + 'sort_by' => 'sort:asc', + ], + ]; + } } diff --git a/src/Models/Product.php b/src/Models/Product.php index c9295c6..0ad796a 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -6,6 +6,7 @@ use Eclipse\Common\Foundation\Models\IsSearchable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Translatable\HasTranslations; @@ -23,6 +24,7 @@ class Product extends Model 'net_weight', 'gross_weight', 'name', + 'category_id', 'short_description', 'description', ]; @@ -38,8 +40,14 @@ class Product extends Model 'short_description' => 'array', 'description' => 'array', 'deleted_at' => 'datetime', + 'category_id' => 'integer', ]; + public function category(): BelongsTo + { + return $this->belongsTo(Category::class); + } + protected static function newFactory(): ProductFactory { return ProductFactory::new(); diff --git a/src/Policies/CategoryPolicy.php b/src/Policies/CategoryPolicy.php new file mode 100644 index 0000000..5d5508b --- /dev/null +++ b/src/Policies/CategoryPolicy.php @@ -0,0 +1,92 @@ +can('view_any_category'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(Authorizable $user, Category $category): bool + { + return $user->can('view_category'); + } + + /** + * Determine whether the user can create models. + */ + public function create(Authorizable $user): bool + { + return $user->can('create_category'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(Authorizable $user, Category $category): bool + { + return $user->can('update_category'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(Authorizable $user, Category $category): bool + { + return $user->can('delete_category'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(Authorizable $user): bool + { + return $user->can('delete_any_category'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(Authorizable $user, Category $category): bool + { + return $user->can('force_delete_category'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(Authorizable $user): bool + { + return $user->can('force_delete_any_category'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(Authorizable $user, Category $category): bool + { + return $user->can('restore_category'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(Authorizable $user): bool + { + return $user->can('restore_any_category'); + } +} diff --git a/tests/Feature/CategoryResourceTest.php b/tests/Feature/CategoryResourceTest.php new file mode 100644 index 0000000..97d2f50 --- /dev/null +++ b/tests/Feature/CategoryResourceTest.php @@ -0,0 +1,97 @@ +setUpSuperAdmin(); +}); + +it('can render category index page', function (): void { + $this->get(CategoryResource::getUrl('index')) + ->assertSuccessful(); +}); + +it('can create category', function (): void { + $factoryData = Category::factory()->definition(); + + Livewire::test(CategoryResource\Pages\CreateCategory::class) + ->fillForm([ + 'name' => $factoryData['name']['en'], + 'sef_key' => $factoryData['sef_key']['en'], + 'short_desc' => $factoryData['short_desc']['en'], + 'description' => $factoryData['description']['en'], + 'is_active' => $factoryData['is_active'], + 'recursive_browsing' => $factoryData['recursive_browsing'], + 'code' => $factoryData['code'], + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $createdCategory = Category::latest()->first(); + expect($createdCategory->name)->not()->toBeNull(); +}); + +it('can edit category', function (): void { + $category = Category::factory()->create(); + + Livewire::test(CategoryResource\Pages\EditCategory::class, [ + 'record' => $category->getRouteKey(), + ]) + ->fillForm([ + 'name' => 'Updated Name', + 'sef_key' => 'updated-name', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($category->fresh()) + ->name->toBe('Updated Name') + ->sef_key->toBe('updated-name'); +}); + +it('can delete category', function (): void { + $category = Category::factory()->create(); + + Livewire::test(CategoryResource\Pages\EditCategory::class, [ + 'record' => $category->getRouteKey(), + ]) + ->callAction('delete'); + + expect($category->fresh()->trashed())->toBeTrue(); +}); + +it('validates unique sef_key within same site', function (): void { + $existingCategory = Category::factory()->create([ + 'sef_key' => 'electronics', + ]); + + Livewire::test(CategoryResource\Pages\CreateCategory::class) + ->fillForm([ + 'name' => 'New Category', + 'sef_key' => 'electronics', + ]) + ->call('create') + ->assertHasFormErrors(['sef_key']); +}); + +it('prevents circular parent relationship', function (): void { + $parent = Category::factory()->create(['name' => 'Parent']); + $child = Category::factory()->create([ + 'name' => 'Child', + 'parent_id' => $parent->id, + ]); + + Livewire::test(CategoryResource\Pages\EditCategory::class, [ + 'record' => $parent->getRouteKey(), + ]) + ->fillForm([ + 'parent_id' => $child->id, + ]) + ->call('save') + ->assertHasFormErrors(['parent_id']); +}); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 61cd84c..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); diff --git a/tests/Feature/ProductResourceTest.php b/tests/Feature/ProductResourceTest.php new file mode 100644 index 0000000..53b0822 --- /dev/null +++ b/tests/Feature/ProductResourceTest.php @@ -0,0 +1,38 @@ +setUpSuperAdmin(); +}); + +it('can filter products by category in table', function (): void { + $electronics = Category::factory()->create(['name' => 'Electronics']); + $books = Category::factory()->create(['name' => 'Books']); + + $laptop = Product::factory()->create([ + 'name' => 'Gaming Laptop', + 'category_id' => $electronics->id, + ]); + + $novel = Product::factory()->create([ + 'name' => 'Science Fiction Novel', + 'category_id' => $books->id, + ]); + + $orphanProduct = Product::factory()->create([ + 'name' => 'No Category Product', + 'category_id' => null, + ]); + + Livewire::test(ProductResource\Pages\ListProducts::class) + ->filterTable('category_id', [$electronics->id]) + ->assertCanSeeTableRecords([$laptop]) + ->assertCanNotSeeTableRecords([$novel, $orphanProduct]); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 68f5785..5d2540a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,7 @@ use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; +use Workbench\App\Models\Site; use Workbench\App\Models\User; abstract class TestCase extends BaseTestCase @@ -14,6 +15,8 @@ abstract class TestCase extends BaseTestCase protected ?User $user = null; + protected ?Site $site = null; + protected function setUp(): void { // Always show errors when testing @@ -22,7 +25,16 @@ protected function setUp(): void parent::setUp(); + // Disable Scout during tests to prevent indexing operations + // This speeds up tests and avoids external dependencies on search services + config(['scout.driver' => null]); + $this->withoutVite(); + + // Ensure we have at least one site for testing + if (Site::count() === 0) { + Site::factory()->create(); + } } /** @@ -36,7 +48,7 @@ protected function migrate(): self } /** - * Set up default "super admin" user + * Set up default "super admin" user and tenant (site) */ protected function setUpSuperAdmin(): self { diff --git a/workbench/app/Models/Site.php b/workbench/app/Models/Site.php new file mode 100644 index 0000000..f44baec --- /dev/null +++ b/workbench/app/Models/Site.php @@ -0,0 +1,29 @@ +hasMany(Category::class); + } +} diff --git a/workbench/app/Providers/AdminPanelProvider.php b/workbench/app/Providers/AdminPanelProvider.php index 4c5b426..225ae9a 100644 --- a/workbench/app/Providers/AdminPanelProvider.php +++ b/workbench/app/Providers/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Pages\Dashboard; use Filament\Panel; use Filament\PanelProvider; +use Filament\SpatieLaravelTranslatablePlugin; use Filament\Support\Facades\FilamentView; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; @@ -46,6 +47,8 @@ public function panel(Panel $panel): Panel ->plugins([ FilamentShieldPlugin::make(), CataloguePlugin::make(), + SpatieLaravelTranslatablePlugin::make() + ->defaultLocales(['en']), ]) ->pages([ Dashboard::class, diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php index 5c21824..1f5c51e 100644 --- a/workbench/app/Providers/WorkbenchServiceProvider.php +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -3,6 +3,7 @@ namespace Workbench\App\Providers; use Illuminate\Support\ServiceProvider; +use Workbench\App\Models\Site; class WorkbenchServiceProvider extends ServiceProvider { @@ -19,6 +20,9 @@ public function register(): void */ public function boot(): void { - // + config([ + 'eclipse-catalogue.tenancy.model' => Site::class, + 'eclipse-catalogue.tenancy.foreign_key' => 'site_id', + ]); } } diff --git a/workbench/database/factories/SiteFactory.php b/workbench/database/factories/SiteFactory.php new file mode 100644 index 0000000..24c53a1 --- /dev/null +++ b/workbench/database/factories/SiteFactory.php @@ -0,0 +1,19 @@ + fake()->company(), + 'domain' => fake()->unique()->domainName(), + ]; + } +} diff --git a/workbench/database/migrations/2025_01_01_000000_create_sites_table.php b/workbench/database/migrations/2025_01_01_000000_create_sites_table.php new file mode 100644 index 0000000..2ed6db7 --- /dev/null +++ b/workbench/database/migrations/2025_01_01_000000_create_sites_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name'); + $table->string('domain')->unique(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sites'); + } +};