Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion admin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,22 @@ When adding a new entity, build the files in this order, matching the existing f
- Rich text uses `AmidEsfahani\FilamentTinyEditor\TinyEditor`.
- Select fields backed by an enum use `->options(SomeEnum::options())` and `->default(SomeEnum::CASE->value)`.
- Table text columns that can be long (headings, relation labels) use `->limit(30)->wrap()`.
- Enum-backed table columns render via `->getStateUsing(fn ($record) => $record->field->label())` and `->color(fn ($record) => $record->field->color())`.
- Enum-backed table columns render via `->getStateUsing(fn (ModelName $record): string => $record->field->label())` and `->color(fn (ModelName $record): string => $record->field->color())`. Always type the `$record` parameter and return type to satisfy 100% type coverage.
- Manage many-to-many pivots with a relationship multi-select: `Select::make('products')->relationship('products', 'heading')->multiple()->searchable()->preload()` (see `CouponResource`). No separate resource for pure scoping pivots.
- To filter relationship select options by another form field (reactive options): switch from `->relationship()` to `->options(fn (Get $get, ?Model $record): array => [...])`. Always include the current record's value in the options to prevent validation failures on edit: `if ($record?->field_id) { $ids = $ids->push($record->field_id)->unique(); }`.
- To reset a dependent field when its parent changes: add `->afterStateUpdated(fn (Set $set) => $set('dependent_field', null))` to the parent select alongside `->live()`.
- To show options immediately without typing, add `->preload()` to any `->multiple()` relationship select.
- `modifyQueryUsing` for relationship selects is the **3rd parameter** of `->relationship()`, not a chainable method: `->relationship('name', 'title', fn (Builder $q): Builder => $q->with('relation'))`. Calling `->modifyQueryUsing()` as a separate method throws `BadMethodCallException`.
- `->getOptionLabelFromRecordUsing(fn (Model $record): string => ...)` customises the label shown for each option in a relationship select. Pair with eager-loading in the `modifyQueryUsing` closure to avoid N+1.
- Control navigation order within a group with `protected static ?int $navigationSort = 1;` (lower = higher in the list).
- Add an explanatory subheading to a list page with `protected ?string $subheading = 'Description here.';` on the `ListRecords` page class.
- Add a tooltip to a form field with `->hintIcon('heroicon-o-information-circle')->hintIconTooltip('Explanation...')`. Use this instead of always-visible `->hint()` when the text is long.
- Always add `->image()` to `FileUpload` fields that accept images. This restricts the file picker to image types only.
- `mutateRelationshipDataBeforeSaveUsing` (and `BeforeCreateUsing`) MUST return `array`, never `null`. Returning `null` throws a `TypeError` at runtime. To skip saving, delete the related record inside the callback and still return the `$data` array.
- Self-referential FK (e.g. `parent_id`): use `$table->foreignId('parent_id')->nullable()->constrained('table_name')->nullOnDelete()`. In the factory, default `parent_id` to `null` and provide a named state (e.g. `withParent(Model $parent)`) to set it. In the `parent_id` select options closure, exclude the current record to prevent circular references: `->when($record?->id, fn (Builder $q) => $q->where('id', '!=', $record->id))`.
- If a model name clashes with a Filament concept (e.g. `Page`), alias the import in the resource file: `use App\Models\Page as PageModel`. This prevents naming ambiguity without renaming the model.
- Conditionally required fields: pair `->hidden()` and `->required()` with the same closure so the field is only required when visible. Example: `->required(fn (Get $get): bool => $get('status') === SomeEnum::CASE->value)->hidden(fn (Get $get): bool => $get('status') !== SomeEnum::CASE->value)`.
- Auto-generated slug fields use `->disabled()->dehydrated()` with `->unique(Model::class, 'slug', ignoreRecord: true)` to prevent duplicates while keeping the field read-only in the form.

## Models

Expand All @@ -232,6 +240,7 @@ When adding a new entity, build the files in this order, matching the existing f
- Use `$table->foreignIdFor(Model::class)` for foreign keys (add `->nullable()` when optional). Chain `->constrained()->cascadeOnDelete()` / `->nullOnDelete()` / `->restrictOnDelete()` to add the real FK constraint with its delete rule.
- For a second FK to the same table, use a named column: `$table->foreignId('user_creator_id')->nullable()->constrained('users')->nullOnDelete()` (see `coupons`).
- Pivot tables add `$table->unique([...])` on the key pair and `->cascadeOnDelete()` on both FKs (see `coupon_product`).
- **Pivot table naming**: Laravel derives the name alphabetically from the two model names (singular). e.g. `Attribute` + `Variety` → `attribute_variety`, NOT `variety_attribute`. Always verify with this rule before writing migration or assertions.
- Default enum columns to a case value: `$table->unsignedTinyInteger('status')->default(ProductStatusEnum::PUBLISHED->value);`.
- Always implement `down()` with `Schema::dropIfExists(...)`.

Expand All @@ -243,6 +252,7 @@ When adding a new entity, build the files in this order, matching the existing f
- Random enum values via `fake()->randomElement(SomeEnum::cases())`.
- Counts/relations that are computed at runtime (like `variety_counts`) default to `0`, not random.
- Optional related data goes in named states using `afterCreating()` (e.g. `withImage()`, `withImages()`, `withAttributes()`).
- For unique slug fields, use `Str::uuid()` not `fake()->unique()->numberBetween()`. The `unique()` state accumulates across all tests in a suite and can cause cross-test collisions.

## Seeders

Expand Down
64 changes: 56 additions & 8 deletions admin/app/Filament/Resources/ProductResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
Expand All @@ -33,6 +34,7 @@
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Component;
Expand Down Expand Up @@ -110,21 +112,42 @@ public static function form(Schema $schema): Schema
])
->columnSpanFull(),

Select::make('attribute_group_id')
->relationship('attributeGroup', 'name')
->searchable()
->native(false)
->live()
->preload()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Defines which attribute group differentiates the varieties of this product (e.g. "Color"). Changing this reloads the attribute options in the variety rows below.'),
Select::make('category_id')
->relationship('category', 'heading')
->required()
->native(false)
->searchable()
->preload()
->live()
->afterStateUpdated(fn (Set $set) => $set('attribute_group_id', null))
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('The category this product belongs to. Required attribute groups for this category will be enforced on save.'),
Select::make('attribute_group_id')
->label('Attribute Group')
->options(function (Get $get, ?Product $record): array {
$categoryId = $get('category_id');
if (! $categoryId) {
return AttributeGroup::query()->pluck('name', 'id')->toArray();
}

$groupIds = AttributeGroupCategory::query()
->where('category_id', $categoryId)
->pluck('attribute_group_id');

if ($record?->attribute_group_id) {
$groupIds = $groupIds->push($record->attribute_group_id)->unique();
}

return AttributeGroup::query()
->whereIn('id', $groupIds)
->pluck('name', 'id')
->toArray();
})
->searchable()
->native(false)
->live()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Defines which attribute group differentiates the varieties of this product (e.g. "Color"). Filtered by the selected category. Changing this reloads attribute options in the variety rows below.'),
Select::make('brand_id')
->relationship('brand', 'heading')
->required()
Expand Down Expand Up @@ -247,6 +270,31 @@ public static function form(Schema $schema): Schema
->searchable()
->nullable()
->helperText('Selecting an attribute auto-fills the value and color.'),
ColorPicker::make('color')
->nullable()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Hex color for this variety (e.g. #ff8516). Auto-filled from the selected attribute — override here if needed.'),
Select::make('attributes')
->label('Additional Attributes')
->multiple()
->relationship(
name: 'attributes',
titleAttribute: 'value',
modifyQueryUsing: function (Builder $query, Get $get): Builder {
$groupId = $get('../../attribute_group_id');
$query->with('attributeGroup');
if ($groupId) {
$query->where('attribute_group_id', '!=', (int) $groupId);
}

return $query;
},
)
->getOptionLabelFromRecordUsing(fn (AttributeModel $record): string => $record->attributeGroup->name . ' / ' . $record->value)
->searchable()
->preload()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Additional attributes from other groups, e.g. Color when the primary group is Size.'),
TextInput::make('price')
->required()
->numeric(),
Expand Down
26 changes: 24 additions & 2 deletions admin/app/Filament/Resources/VarietyResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\ColorColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class VarietyResource extends Resource
{
Expand Down Expand Up @@ -65,6 +68,10 @@ public static function form(Schema $schema): Schema
->searchable()
->nullable()
->helperText('Selecting an attribute auto-fills the value and color.'),
ColorPicker::make('color')
->nullable()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Hex color for this variety (e.g. #ff8516). Auto-filled from the selected attribute — override here if needed.'),
TextInput::make('price')
->required()
->numeric()
Expand All @@ -89,6 +96,20 @@ public static function form(Schema $schema): Schema
->required()
->options(VarietyStatusEnum::options())
->default(VarietyStatusEnum::PUBLISHED->value),
Select::make('attributes')
->label('Additional Attributes')
->multiple()
->relationship(
name: 'attributes',
titleAttribute: 'value',
modifyQueryUsing: fn (Builder $query): Builder => $query->with('attributeGroup'),
)
->getOptionLabelFromRecordUsing(fn (Attribute $record): string => $record->attributeGroup->name . ' / ' . $record->value)
->searchable()
->preload()
->columnSpanFull()
->hintIcon('heroicon-o-information-circle')
->hintIconTooltip('Secondary attributes for this variety from other groups, e.g. Color when the primary group is Size. Stored in the attribute_variety pivot table.'),
]);
}

Expand All @@ -105,7 +126,8 @@ public static function table(Table $table): Table
->searchable(),
TextColumn::make('attribute_value')
->label('Value'),
TextColumn::make('color'),
ColorColumn::make('color')
->copyable(),
TextColumn::make('price')
->money(),
TextColumn::make('sale_price')
Expand All @@ -115,7 +137,7 @@ public static function table(Table $table): Table
IconColumn::make('has_stock')
->boolean(),
TextColumn::make('status')
->getStateUsing(fn (Variety $record) => $record->status->label())
->getStateUsing(fn (Variety $record): string => $record->status->label())
->color(fn (Variety $record): string => $record->status->color())
->sortable(),
TextColumn::make('created_at')
Expand Down
8 changes: 8 additions & 0 deletions admin/app/Models/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

/**
* App\Models\Attribute
Expand All @@ -16,6 +18,7 @@
* @property string|null $color
* @property string $value
* @property AttributeGroup $attributeGroup
* @property Collection<Variety> $varieties
*/
class Attribute extends Model
{
Expand All @@ -31,4 +34,9 @@ public function attributeGroup(): BelongsTo
{
return $this->belongsTo(AttributeGroup::class);
}

public function varieties(): BelongsToMany
{
return $this->belongsToMany(Variety::class)->withTimestamps();
}
}
10 changes: 9 additions & 1 deletion admin/app/Models/Variety.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
namespace App\Models;

use App\Enums\VarietyStatusEnum;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

/**
* @property positive-int $id
Expand All @@ -22,6 +24,7 @@
* @property VarietyStatusEnum $status
* @property Product $product
* @property Attribute|null $attribute
* @property Collection<Attribute> $attributes
*/
class Variety extends Model
{
Expand All @@ -47,7 +50,7 @@ class Variety extends Model
protected static function booted(): void
{
static::saving(function (Variety $variety): void {
if ($variety->attribute_id === null) {
if ($variety->attribute_id === null || ! $variety->isDirty('attribute_id')) {
return;
}
$attribute = Attribute::find($variety->attribute_id);
Expand Down Expand Up @@ -85,4 +88,9 @@ public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class);
}

public function attributes(): BelongsToMany
{
return $this->belongsToMany(Attribute::class)->withTimestamps();
}
}
2 changes: 1 addition & 1 deletion admin/database/factories/PageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public function definition(): array
{
return [
'heading' => fake()->words(4, true),
'slug' => fn (array $attributes): string => Str::slug($attributes['heading']) . '-' . fake()->unique()->numberBetween(1, 9999),
'slug' => fn (array $attributes): string => Str::slug($attributes['heading']) . '-' . Str::uuid(),
'content' => fake()->paragraphs(3, true),
'title' => fake()->words(5, true),
'description' => fake()->sentence(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

use App\Models\Attribute;
use App\Models\Variety;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('attribute_variety', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Variety::class)->constrained()->cascadeOnDelete();
$table->foreignIdFor(Attribute::class)->constrained()->cascadeOnDelete();
$table->timestamps();

$table->unique(['variety_id', 'attribute_id']);
});
}

public function down(): void
{
Schema::dropIfExists('attribute_variety');
}
};
File renamed without changes.
5 changes: 4 additions & 1 deletion admin/IMPLEMENTATION.md → admin/docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Done. Variety has model, migration, factory, resource (+ pages), tests, and `var

- [x] Fix `VarietyResource` table: `product.heading` column (was `product.title` with `->numeric()` on a string)
- [x] `attribute_id` FK on varieties - links each variety to one attribute; auto-populates `attribute_value` and `color` from the attribute on save
- [x] `attribute_variety` pivot — each variety can link to multiple attributes from different groups (e.g. Size as primary + Color via pivot); "Additional Attributes" multi-select added to both VarietyResource and the ProductResource inline variety repeater
- [x] `attribute_group_id` in ProductResource filtered by selected category (via `attribute_group_category`); changing category clears the group selection
- [ ] Variety extensions (`warehouse_id`, `guarantee_name_id`, `variety_serials`, `variety_details`) - deferred to Phase 3, they need Warehouses / Guarantees first
- [x] Attribute `required` flag enforced in `ProductResource`: if a category has required attribute groups, saving a product without them shows a danger notification and skips the sync

Expand Down Expand Up @@ -80,7 +82,8 @@ Depend mostly on Images only.
- [ ] Shipping Methods
- [ ] Shipping Cities
- [ ] Guarantee Names
- [ ] Variety extensions: `variety_attribute`, `variety_serials`, `variety_details`
- [x] `attribute_variety` pivot (done — moved up from Phase 3)
- [ ] Variety extensions: `variety_serials`, `variety_details`

## Phase 4 - Commerce core

Expand Down
Loading
Loading