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 new file mode 100644 index 0000000..e8456dc --- /dev/null +++ b/database/factories/CategoryFactory.php @@ -0,0 +1,97 @@ +words(2, true)); + $slovenianName = 'SI: '.$englishName; + + $englishShortDesc = fake()->sentence(); + $slovenianShortDesc = 'SI: '.$englishShortDesc; + + $englishDesc = fake()->paragraphs(3, true); + $slovenianDesc = 'SI: '.$englishDesc; + + return [ + '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' => 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 new file mode 100644 index 0000000..2013602 --- /dev/null +++ b/database/migrations/2025_07_01_141618_create_categories_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('site_id') + ->constrained() + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->string('name'); + $table->integer('parent_id')->default(-1); + $table->string('image')->nullable(); + $table->integer('sort')->default(0)->index(); + $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/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 new file mode 100644 index 0000000..52cff17 --- /dev/null +++ b/src/Filament/Resources/CategoryResource.php @@ -0,0 +1,404 @@ +schema([ + 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')); + } + }, + ]), + ]), + + 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')), + + 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', + ]), + ]), + + 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'), + + 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')), + + Toggle::make('recursive_browsing') + ->label(__('eclipse-catalogue::categories.form.fields.recursive_browsing')) + ->default(false) + ->helperText(__('eclipse-catalogue::categories.form.fields.recursive_browsing_helper')), + ]), + ]), + + 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'), + + 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') + ->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(false) + ->lineClamp(1) + ->formatStateUsing(fn (Model $record): HtmlString => new HtmlString($record->getTreeFormattedName())) + ->tooltip(fn ($record) => $record->getFullPath()), + + 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), + + IconColumn::make('is_active') + ->label(__('eclipse-catalogue::categories.table.columns.is_active')) + ->label('Active') + ->boolean() + ->sortable(false) + ->alignCenter(), + + 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')), + + 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), + ]) + + ->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([ + 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() + ->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')), + ]); + } + + 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'), + ]; + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + + public static function getGloballySearchableAttributes(): array + { + 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 new file mode 100644 index 0000000..7a6924c --- /dev/null +++ b/src/Filament/Resources/CategoryResource/Pages/CreateCategory.php @@ -0,0 +1,21 @@ +value; + + protected function getHeaderActions(): array + { + return [ + 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 new file mode 100644 index 0000000..7b119dc --- /dev/null +++ b/src/Models/Category.php @@ -0,0 +1,265 @@ +belongsTo(Category::class, 'parent_id'); + } + + public function site(): BelongsTo + { + $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::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'); + } +};