From 32dbab448c9548fe05903962dc393ae1d0407972 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 14:28:09 +0330 Subject: [PATCH 1/5] feat: implement attribute_variety pivot for multi-dimensional varieties - Add attribute_variety migration (variety_id + attribute_id, unique pair, cascade both) - Add attributes() BelongsToMany on Variety and varieties() on Attribute - Add Additional Attributes multi-select (preloaded, group/value label) to VarietyResource and ProductResource inline repeater - Filter ProductResource attribute_group_id options by selected category via attribute_group_category; clear group on category change; always include current record value to avoid edit-page validation failures - Update VarietyResourceTest with pivot attach and cascade-delete coverage - Fix PageFactory slug to use Str::uuid() to avoid cross-test uniqueness collisions - Update AGENTS.md, IMPLEMENTATION.md, ShoFlow db doc.md Co-authored-by: Cursor --- admin/AGENTS.md | 12 +++- admin/IMPLEMENTATION.md | 5 +- admin/ShoFlow db doc.md | 13 +++- .../Filament/Resources/ProductResource.php | 59 ++++++++++++++++--- .../Filament/Resources/VarietyResource.php | 17 +++++- admin/app/Models/Attribute.php | 8 +++ admin/app/Models/Variety.php | 8 +++ admin/database/factories/PageFactory.php | 2 +- ..._000006_create_variety_attribute_table.php | 29 +++++++++ .../Filament/Resource/VarietyResourceTest.php | 34 +++++++++++ 10 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 admin/database/migrations/2026_06_20_000006_create_variety_attribute_table.php diff --git a/admin/AGENTS.md b/admin/AGENTS.md index c724c0b3..30b0463f 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -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 @@ -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(...)`. @@ -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 diff --git a/admin/IMPLEMENTATION.md b/admin/IMPLEMENTATION.md index 2ee0f540..a6c47d91 100644 --- a/admin/IMPLEMENTATION.md +++ b/admin/IMPLEMENTATION.md @@ -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 @@ -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 diff --git a/admin/ShoFlow db doc.md b/admin/ShoFlow db doc.md index 7a7311f4..6c07127d 100644 --- a/admin/ShoFlow db doc.md +++ b/admin/ShoFlow db doc.md @@ -552,7 +552,7 @@ Used to store products. * `no_index`: If true or 1, the corresponding product page should have a noindex meta tag. * `canonical`: Used to prevent canonicalization issues. * `image_id`: specifying the product's featured image. -* `attribute_group_id`: Specifies the filter group for the product variety. For example, this product varies in price by color, and sellers must specify which color they offer at what price in the `varieties` table. Nullable foreign key to `attribute_groups`; set to null when the group is deleted. +* `attribute_group_id`: Specifies the **primary** attribute group that differentiates the varieties of this product (e.g. "Size"). In the admin panel, the available groups are filtered to those linked to the product's category via `attribute_group_category`. Nullable foreign key to `attribute_groups`; set to null when the group is deleted. Additional variation dimensions (e.g. Color) are stored per variety in the `attribute_variety` pivot. * `category_id`: Specifies the category of the product. Each category must specify its subcategories, and these subcategories must specify their subcategories, and so on. Required foreign key to `categories`; deletion is restricted while products reference it. * `brand_id`: Specifies the brand of the product. Nullable foreign key to `brands`; set to null when the brand is deleted. * `minimum`: Specifies the minimum purchase quantity for a product. @@ -832,9 +832,16 @@ Contains product variations entered by the seller on the site. This table create * `has_stock`: Indicates the stock status (default is 1/true). If this column is false or 0, the variety is displayed as out of stock. * `status`: Publication status of the variety. -# varietie\_attribute +# attribute\_variety (variety\_attribute pivot) -> **Not implemented.** The original plan for a `variety_attribute` pivot was replaced by adding `attribute_id` directly to the `varieties` table. Each variety links to exactly one attribute; `attribute_value` and `color` are auto-populated from the linked attribute when the record is saved. No separate pivot table exists. +Stores additional attribute associations for each variety, enabling multi-dimensional varieties (e.g. Size as primary + Color as secondary). + +* Table name is `attribute_variety` (Laravel alphabetical convention). +* `variety_id`: FK to `varieties`; cascades on variety delete. +* `attribute_id`: FK to `attributes`; cascades on attribute delete. +* The pair `(variety_id, attribute_id)` is unique. Carries `created_at` / `updated_at`. +* The primary attribute is still on `varieties.attribute_id` (drives `attribute_value` and `color` auto-population). This pivot holds **additional** attributes from other groups (e.g. when the product's `attribute_group_id` is "Size", color attributes are attached here). +* **Frontend query pattern:** to find a specific Size+Color combination, join `varieties` with `attribute_variety` filtering on both `varieties.attribute_id` (size) and `attribute_variety.attribute_id` (color). Cache all varieties with their pivot attributes under `varieties.product.{product_id}` and resolve combinations in memory to avoid N+1. # variety\_serials diff --git a/admin/app/Filament/Resources/ProductResource.php b/admin/app/Filament/Resources/ProductResource.php index dfb40ca8..2b0a2f17 100644 --- a/admin/app/Filament/Resources/ProductResource.php +++ b/admin/app/Filament/Resources/ProductResource.php @@ -33,6 +33,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; @@ -110,21 +111,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() @@ -247,6 +269,27 @@ public static function form(Schema $schema): Schema ->searchable() ->nullable() ->helperText('Selecting an attribute auto-fills the value and color.'), + 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(), diff --git a/admin/app/Filament/Resources/VarietyResource.php b/admin/app/Filament/Resources/VarietyResource.php index efa48a8d..3cee2ca6 100644 --- a/admin/app/Filament/Resources/VarietyResource.php +++ b/admin/app/Filament/Resources/VarietyResource.php @@ -23,6 +23,7 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; class VarietyResource extends Resource { @@ -89,6 +90,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 variety_attribute pivot table.'), ]); } @@ -115,7 +130,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') diff --git a/admin/app/Models/Attribute.php b/admin/app/Models/Attribute.php index 6af920d1..ce61696b 100644 --- a/admin/app/Models/Attribute.php +++ b/admin/app/Models/Attribute.php @@ -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 @@ -16,6 +18,7 @@ * @property string|null $color * @property string $value * @property AttributeGroup $attributeGroup + * @property Collection $varieties */ class Attribute extends Model { @@ -31,4 +34,9 @@ public function attributeGroup(): BelongsTo { return $this->belongsTo(AttributeGroup::class); } + + public function varieties(): BelongsToMany + { + return $this->belongsToMany(Variety::class)->withTimestamps(); + } } diff --git a/admin/app/Models/Variety.php b/admin/app/Models/Variety.php index 2b001b6a..09eeeba8 100644 --- a/admin/app/Models/Variety.php +++ b/admin/app/Models/Variety.php @@ -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 @@ -22,6 +24,7 @@ * @property VarietyStatusEnum $status * @property Product $product * @property Attribute|null $attribute + * @property Collection $attributes */ class Variety extends Model { @@ -85,4 +88,9 @@ public function attribute(): BelongsTo { return $this->belongsTo(Attribute::class); } + + public function attributes(): BelongsToMany + { + return $this->belongsToMany(Attribute::class)->withTimestamps(); + } } diff --git a/admin/database/factories/PageFactory.php b/admin/database/factories/PageFactory.php index 052c0806..f096afd0 100644 --- a/admin/database/factories/PageFactory.php +++ b/admin/database/factories/PageFactory.php @@ -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(), diff --git a/admin/database/migrations/2026_06_20_000006_create_variety_attribute_table.php b/admin/database/migrations/2026_06_20_000006_create_variety_attribute_table.php new file mode 100644 index 00000000..fc5db95c --- /dev/null +++ b/admin/database/migrations/2026_06_20_000006_create_variety_attribute_table.php @@ -0,0 +1,29 @@ +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'); + } +}; diff --git a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php index c7f96f92..852cda9f 100644 --- a/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/VarietyResourceTest.php @@ -130,6 +130,40 @@ expect($product->refresh())->variety_counts->toBe(0); }); +it('can attach additional attributes to a variety via the resource.', function () { + $variety = Variety::factory()->create(); + $attribute = Attribute::factory()->create(); + + livewire(VarietyResource\Pages\EditVariety::class, [ + 'record' => $variety->getRouteKey(), + ]) + ->fillForm([ + 'attributes' => [$attribute->id], + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($variety->refresh()->attributes)->toHaveCount(1) + ->first()->id->toBe($attribute->id); +}); + +it('cascade-deletes variety_attribute pivot rows when variety is deleted.', function () { + $variety = Variety::factory()->create(); + $attribute = Attribute::factory()->create(); + $variety->attributes()->attach($attribute); + + $this->assertDatabaseHas('attribute_variety', [ + 'variety_id' => $variety->id, + 'attribute_id' => $attribute->id, + ]); + + $variety->delete(); + + $this->assertDatabaseMissing('attribute_variety', [ + 'variety_id' => $variety->id, + ]); +}); + it('auto-syncs variety_counts on product when a variety is deleted via the resource.', function () { $product = Product::factory()->create(); $variety = Variety::factory()->for($product)->create(); From 04f31f5e886e07f22142c557c8bf6249997bfb86 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 14:42:34 +0330 Subject: [PATCH 2/5] feat: add color picker to variety form and fix color overwrite bug - Add ColorPicker to VarietyResource and ProductResource repeater so admins can manually set or override the hex color (e.g. #ff8516) - Add ColorColumn to the variety table to render the color swatch - Fix critical bug: Variety saving hook now only auto-fills color and attribute_value when attribute_id actually changes (isDirty check), preserving any manually set color on subsequent saves - Fix PageResourceTest to use PageStatusEnum::PUBLISHED explicitly so the conditional published_at validation never triggers randomly - Fix tooltip typo: variety_attribute -> attribute_variety Co-authored-by: Cursor --- admin/VARIETY_GUIDE.md | 313 ++++++++++++++++++ .../Filament/Resources/ProductResource.php | 5 + .../Filament/Resources/VarietyResource.php | 11 +- admin/app/Models/Variety.php | 2 +- .../Filament/Resource/PageResourceTest.php | 13 +- 5 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 admin/VARIETY_GUIDE.md diff --git a/admin/VARIETY_GUIDE.md b/admin/VARIETY_GUIDE.md new file mode 100644 index 00000000..825e6599 --- /dev/null +++ b/admin/VARIETY_GUIDE.md @@ -0,0 +1,313 @@ +# Product, Attribute & Variety — Complete Guide + +This document explains the full attribute/product/variety system: what each table does, how they relate, and a step-by-step guide to creating a sellable product with multi-dimensional varieties (e.g. Size × Color). + +--- + +## The Big Picture + +``` +Ancestor + └── AttributeGroup (e.g. "Size", "Color", "Material") + └── Attribute (e.g. "S", "M", "L", "Red", "Blue") + +Category ──(attribute_group_category)── AttributeGroup + │ (which groups apply to this category) + └── Product ──(attribute_group_id)── AttributeGroup (primary variety dimension) + │ + ├── product_attribute (descriptive attributes: material, dimensions, etc.) + │ + └── Variety ──(attribute_id)── Attribute (primary: e.g. "M") + └── attribute_variety ── Attribute (secondary: e.g. "Red", "Blue") +``` + +--- + +## Table Reference + +### `ancestors` + +Top-level categories for organizing attribute groups. Think of them as "spec sections" on a product page. + +| Column | Purpose | +|--------|---------| +| `name` | Display name, e.g. "Technical Specifications", "Dimensions" | +| `order` | Sort order | + +--- + +### `attribute_groups` + +Groups of related attributes. Each group belongs to one Ancestor. + +| Column | Purpose | +|--------|---------| +| `ancestor_id` | Which ancestor section this group belongs to | +| `name` | Machine/admin label, e.g. "Color", "Size", "RAM" | +| `label` | Human label shown in admin, e.g. "Available Colors" | +| `order` | Sort order | + +**Example rows:** + +| id | name | label | +|----|-------|----------------| +| 1 | Color | Available Colors | +| 2 | Size | Available Sizes | + +--- + +### `attributes` + +Individual values within a group. Each attribute belongs to one AttributeGroup. + +| Column | Purpose | +|--------|---------| +| `attribute_group_id` | Which group this value belongs to | +| `value` | The display value, e.g. "Red", "M", "8GB" | +| `color` | Optional hex or color name (used for color swatches on frontend) | + +**Example rows:** + +| id | group | value | color | +|----|-------|-------|---------| +| 1 | Color | Red | #ff0000 | +| 2 | Color | Blue | #0000ff | +| 3 | Size | S | null | +| 4 | Size | M | null | +| 5 | Size | L | null | + +--- + +### `attribute_group_category` + +Links which attribute groups apply to which categories. Also controls filtering and required enforcement. + +| Column | Purpose | +|--------|---------| +| `attribute_group_id` | The group | +| `category_id` | The category | +| `as_filter` | Whether this group can be used as a filter on the category page | +| `required` | Whether products in this category MUST have this group's attributes assigned | + +**Example:** Link "Size" and "Color" groups to the "Shirts" category. Mark both as `as_filter = true`. Mark "Size" as `required = true` (every shirt must have size attributes). + +--- + +### `products` + +One row per product. Defines what the product is, its SEO data, and which attribute group drives its varieties. + +| Column | Purpose | +|--------|---------| +| `heading` | Product name | +| `slug` | URL path | +| `category_id` | Required. Which category this product is in | +| `attribute_group_id` | **The PRIMARY variety dimension** — which attribute group defines how this product's varieties differ (e.g. "Size"). Nullable. Options filtered by category in the admin panel | +| `price` | Base display price (denormalized from cheapest variety) | +| `variety_counts` | Automatically synced — count of varieties; DO NOT set manually | +| `brand_id`, `image_id` | Optional branding and featured image | + +--- + +### `product_attribute` (pivot) + +Links a product to **descriptive** attributes — things that are true for all varieties (e.g. material, screen size, weight). These are NOT the variety-defining attributes. + +| Column | Purpose | +|--------|---------| +| `product_id` | The product | +| `attribute_id` | The descriptive attribute | +| `is_highlight` | Whether to feature this attribute prominently on the product page | + +**Example:** A phone product has `material = Aluminum` and `screen = 6.1 inch` as product_attribute rows. These don't change between the 128GB and 256GB varieties. + +--- + +### `varieties` + +Each row is one specific, purchasable combination. The seller sets the price and inventory per variety. + +| Column | Purpose | +|--------|---------| +| `product_id` | Which product | +| `attribute_id` | **Primary attribute** — the attribute from the product's `attribute_group` (e.g. "M" from "Size"). Auto-populates `attribute_value` and `color` on save | +| `attribute_value` | Auto-filled from `attribute.value` when `attribute_id` is set. Can be set manually | +| `color` | Auto-filled from `attribute.color`. Used for color swatches | +| `price` | This variety's selling price | +| `sale_price` | Discounted price; shown instead of `price` when set | +| `inventory` | Units in stock | +| `has_stock` | Override: set to false to show "out of stock" regardless of inventory | +| `status` | Published / Draft / Deleted | + +**Auto-behaviour on save:** +- `attribute_value` and `color` are automatically populated from the linked `Attribute` record. +- `product.variety_counts` is automatically updated whenever a variety is created or deleted. + +--- + +### `attribute_variety` (pivot) + +Links each variety to **additional** attributes from other groups — enabling multi-dimensional varieties. + +| Column | Purpose | +|--------|---------| +| `variety_id` | The variety | +| `attribute_id` | An additional attribute (e.g. "Red" from the "Color" group) | + +**Rule:** The pair `(variety_id, attribute_id)` is unique. Both FKs cascade on delete. + +**When to use:** When a product varies by more than one dimension. The primary dimension goes into `varieties.attribute_id` (e.g. Size = M). Each additional dimension goes into this pivot (e.g. Color = Red). + +--- + +## How It All Connects — Shirt Example + +**Setup:** + +1. **Ancestor:** "Clothing Specs" +2. **Attribute Groups:** "Size" (under Clothing Specs), "Color" (under Clothing Specs) +3. **Attributes:** + - Size: S, M, L, XL + - Color: Red (#ff0000), Blue (#0000ff), Black (#000000) +4. **Category:** "Shirts" + - Linked to "Size" group via `attribute_group_category` (required = true, as_filter = true) + - Linked to "Color" group via `attribute_group_category` (required = false, as_filter = true) + +--- + +## Step-by-Step: Creating a Shirt with Size × Color Varieties + +### Step 1 — Set up the Attribute Groups and Attributes + +Go to **Attribute Groups** → Create: +- Name: `Size`, Ancestor: Clothing Specs + +Go to **Attributes** → Create for each size: +- Group: Size, Value: S +- Group: Size, Value: M +- Group: Size, Value: L +- Group: Size, Value: XL + +Go to **Attribute Groups** → Create: +- Name: `Color`, Ancestor: Clothing Specs + +Go to **Attributes** → Create for each color: +- Group: Color, Value: Red, Color: #ff0000 +- Group: Color, Value: Blue, Color: #0000ff +- Group: Color, Value: Black, Color: #000000 + +--- + +### Step 2 — Link Groups to the Category + +Go to **Attribute Group Categories** → Create: +- Attribute Group: Size, Category: Shirts, as_filter: yes, required: yes +- Attribute Group: Color, Category: Shirts, as_filter: yes, required: no + +--- + +### Step 3 — Create the Product + +Go to **Products** → Create: + +| Field | Value | +|-------|-------| +| Heading | Slim Fit Cotton Shirt | +| Category | Shirts | +| **Attribute Group** | **Size** ← this defines the primary variety dimension; options filtered by "Shirts" | +| Brand | (optional) | +| Price | (base price for display) | + +Save the product. + +--- + +### Step 4 — Add Varieties (inside the Product edit page) + +Scroll down to **Variety Details**. Add one row per available combination: + +| Attribute (Size) | Additional Attributes (Color) | Price | Inventory | +|-----------------|-------------------------------|-------|-----------| +| M | Red | 450,000 | 10 | +| M | Blue | 450,000 | 5 | +| M | Black | 450,000 | 8 | +| L | Red | 450,000 | 3 | +| L | Blue | 450,000 | 0 | +| L | Black | 450,000 | 7 | +| XL | Red | 450,000 | 2 | + +> **Note:** You only create rows for combinations that actually exist in your inventory. "L / Blue" with inventory 0 is still a row (it exists, just out of stock). "XL / Blue" not having a row means that combination is simply unavailable — the frontend will not show it. + +**How the fields work:** +- **Attribute** → select from the Size group (S, M, L, XL). Auto-populates `attribute_value` on save. +- **Additional Attributes** → select from all OTHER groups (Color group). Stored in `attribute_variety` pivot. Preloaded, searchable by "GroupName / Value". + +--- + +### Step 5 — Add Descriptive Product Attributes (optional) + +In the **Product Attributes** section of the product edit page, add attributes that describe the product as a whole (not variety-specific): + +| Attribute | Highlight? | +|-----------|-----------| +| Material: Cotton | yes | +| Fit: Slim | no | + +These go into `product_attribute` and are shown in the product spec table on the frontend. + +--- + +## How the Frontend Queries Varieties + +Given a product, the frontend should: + +1. **Fetch all varieties** with their pivot attributes in one query (cached under `varieties.product.{product_id}`): + +```php +$varieties = Variety::query() + ->with('attributes') // eager-load the pivot (color etc.) + ->where('product_id', $productId) + ->where('status', VarietyStatusEnum::PUBLISHED) + ->get(); +``` + +2. **Build available primary options** (sizes): +```php +$availableSizes = $varieties->pluck('attribute_id')->unique(); +``` + +3. **When user selects a size**, show available secondary options (colors): +```php +$colorsForM = $varieties + ->where('attribute_id', $mAttributeId) + ->flatMap->attributes + ->unique('id'); +``` + +4. **When user selects size + color**, find the exact variety: +```php +$variety = $varieties->first(function (Variety $v) use ($sizeId, $colorId) { + return $v->attribute_id === $sizeId + && $v->attributes->contains('id', $colorId); +}); +``` + +5. If `$variety` is null → that combination doesn't exist (don't show it). + If `$variety->has_stock === false` or `$variety->inventory === 0` → show as out of stock. + +> Do all of this in memory from the cached collection — do NOT run one query per combination. + +--- + +## Summary — Which Table Does What + +| Table | Role | +|-------|------| +| `ancestors` | Top-level grouping for attribute groups (spec sections) | +| `attribute_groups` | Groups of related values (Size, Color, RAM…) | +| `attributes` | Individual values within a group (M, Red, 8GB…) | +| `attribute_group_category` | Which groups apply to which category; required/filter flags | +| `products` | The product itself; `attribute_group_id` = primary variety dimension | +| `product_attribute` | Descriptive attributes shared across all varieties | +| `varieties` | One row per purchasable combination; `attribute_id` = primary (e.g. Size=M) | +| `attribute_variety` | Additional attributes per variety (e.g. Color=Red) via pivot | diff --git a/admin/app/Filament/Resources/ProductResource.php b/admin/app/Filament/Resources/ProductResource.php index 2b0a2f17..5819c69a 100644 --- a/admin/app/Filament/Resources/ProductResource.php +++ b/admin/app/Filament/Resources/ProductResource.php @@ -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; @@ -269,6 +270,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.'), Select::make('attributes') ->label('Additional Attributes') ->multiple() diff --git a/admin/app/Filament/Resources/VarietyResource.php b/admin/app/Filament/Resources/VarietyResource.php index 3cee2ca6..f7f9e183 100644 --- a/admin/app/Filament/Resources/VarietyResource.php +++ b/admin/app/Filament/Resources/VarietyResource.php @@ -14,12 +14,14 @@ 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; @@ -66,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() @@ -103,7 +109,7 @@ public static function form(Schema $schema): Schema ->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 variety_attribute pivot table.'), + ->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.'), ]); } @@ -120,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') diff --git a/admin/app/Models/Variety.php b/admin/app/Models/Variety.php index 09eeeba8..f78a9107 100644 --- a/admin/app/Models/Variety.php +++ b/admin/app/Models/Variety.php @@ -50,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); diff --git a/admin/tests/Feature/Filament/Resource/PageResourceTest.php b/admin/tests/Feature/Filament/Resource/PageResourceTest.php index 75c44b02..ba732b5b 100644 --- a/admin/tests/Feature/Filament/Resource/PageResourceTest.php +++ b/admin/tests/Feature/Filament/Resource/PageResourceTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\PageStatusEnum; use App\Filament\Resources\PageResource; use App\Models\Page; use Filament\Actions\DeleteAction; @@ -34,7 +35,7 @@ it('can update page model.', function () { $page = Page::factory()->create(); - $newPage = Page::factory()->make(); + $newPage = Page::factory()->make(['status' => PageStatusEnum::PUBLISHED]); livewire(PageResource\Pages\EditPage::class, [ 'record' => $page->getRouteKey(), @@ -45,7 +46,7 @@ 'title' => $newPage->title, 'description' => $newPage->description, 'no_index' => $newPage->no_index, - 'status' => $newPage->status->value, + 'status' => PageStatusEnum::PUBLISHED->value, ]) ->call('save') ->assertHasNoFormErrors(); @@ -53,11 +54,11 @@ expect($page->refresh()) ->heading->toBe($newPage->heading) ->slug->toBe($newPage->slug) - ->status->toBe($newPage->status); + ->status->toBe(PageStatusEnum::PUBLISHED); }); it('can create page model.', function () { - $newPage = Page::factory()->make(); + $newPage = Page::factory()->make(['status' => PageStatusEnum::PUBLISHED]); livewire(PageResource\Pages\CreatePage::class) ->fillForm([ @@ -66,7 +67,7 @@ 'title' => $newPage->title, 'description' => $newPage->description, 'no_index' => $newPage->no_index, - 'status' => $newPage->status->value, + 'status' => PageStatusEnum::PUBLISHED->value, ]) ->call('create') ->assertHasNoFormErrors(); @@ -74,7 +75,7 @@ $this->assertDatabaseHas(Page::class, [ 'heading' => $newPage->heading, 'slug' => $newPage->slug, - 'status' => $newPage->status->value, + 'status' => PageStatusEnum::PUBLISHED->value, ]); }); From 5844a8403f15283426af2c9baee0d746b0dccccc Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 14:43:44 +0330 Subject: [PATCH 3/5] docs: move db doc and variety guide into docs/ directory Co-authored-by: Cursor --- admin/{ => docs}/ShoFlow db doc.md | 0 admin/{ => docs}/VARIETY_GUIDE.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename admin/{ => docs}/ShoFlow db doc.md (100%) rename admin/{ => docs}/VARIETY_GUIDE.md (100%) diff --git a/admin/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md similarity index 100% rename from admin/ShoFlow db doc.md rename to admin/docs/ShoFlow db doc.md diff --git a/admin/VARIETY_GUIDE.md b/admin/docs/VARIETY_GUIDE.md similarity index 100% rename from admin/VARIETY_GUIDE.md rename to admin/docs/VARIETY_GUIDE.md From f62a3e7263cc8e1547d3b6e35504abe53e0583d5 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 14:44:23 +0330 Subject: [PATCH 4/5] docs: move IMPLEMENTATION.md into docs/ directory Co-authored-by: Cursor --- admin/{ => docs}/IMPLEMENTATION.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename admin/{ => docs}/IMPLEMENTATION.md (100%) diff --git a/admin/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md similarity index 100% rename from admin/IMPLEMENTATION.md rename to admin/docs/IMPLEMENTATION.md From 5268e31a5d51c963c13cf2ea668597f071bae6b1 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 14:44:36 +0330 Subject: [PATCH 5/5] docs: move CACHE.md into docs/ directory Co-authored-by: Cursor --- admin/{ => docs}/CACHE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename admin/{ => docs}/CACHE.md (100%) diff --git a/admin/CACHE.md b/admin/docs/CACHE.md similarity index 100% rename from admin/CACHE.md rename to admin/docs/CACHE.md