Feature: Adding Custom Fields to Products
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Models;
|
||||
|
||||
/**
|
||||
* Registered via Lunar\Facades\ModelManifest::replace(Lunar\Models\
|
||||
* Product::class, self::class) — see Providers\CatalogServiceProvider —
|
||||
* purely to add a cast AND fillable entry for `custom_fields` (see the
|
||||
* migration adding that column: database/migrations/
|
||||
* ..._add_custom_fields_to_products_table.php). Without the fillable
|
||||
* entry, Lunar\Models\Product's own $fillable allowlist (attribute_data,
|
||||
* product_type_id, status, brand_id — custom_fields isn't in it) silently
|
||||
* drops the field on every mass-assignment save (Filament's own
|
||||
* $record->update($data)) — no error, no exception, the admin form shows
|
||||
* the repeater's rows as saved right up until the next page load, when
|
||||
* they're simply gone. Caught in practice.
|
||||
*
|
||||
* ModelManifest::replace() only changes what code resolving Product
|
||||
* through the CONTRACT (app(Contracts\Product::class), Filament's own
|
||||
* ProductResource — its $model is ProductContract::class, not the
|
||||
* concrete class) or the morph map receives — it does NOT retroactively
|
||||
* change what a hardcoded `Lunar\Models\Product::query()`/`::find()`
|
||||
* elsewhere in this codebase (or Lunar's own internals, e.g. the
|
||||
* scheduled Meilisearch reindex command — see CatalogServiceProvider,
|
||||
* which references this subclass by name specifically so that path picks
|
||||
* it up too) resolves to. Most of this codebase's existing Product
|
||||
* references are plain type-hints (they accept whichever instance is
|
||||
* handed to them, subclass included) or don't touch `custom_fields` at
|
||||
* all, so they're unaffected either way.
|
||||
*/
|
||||
class Product extends \Lunar\Models\Product
|
||||
{
|
||||
// NOT `protected $casts = [...]` — that property assignment REPLACES
|
||||
// the parent's own $casts array wholesale rather than merging with
|
||||
// it (PHP class property redeclaration has no merge semantics), which
|
||||
// would silently drop every cast Lunar\Models\Product already
|
||||
// defines (attribute_data, status, etc.). mergeCasts() is Eloquent's
|
||||
// own documented mechanism for a subclass adding to, not replacing,
|
||||
// its parent's casts.
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
parent::__construct($attributes);
|
||||
|
||||
$this->mergeCasts([
|
||||
'custom_fields' => 'array',
|
||||
]);
|
||||
|
||||
$this->mergeFillable([
|
||||
'custom_fields',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,12 @@ class ProductIndexer extends BaseProductIndexer
|
||||
->all();
|
||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||
$data['skus'] = $model->variants->pluck('sku')->filter()->unique()->values()->all();
|
||||
// Only decoded correctly when $model is an instance of
|
||||
// Modules\Core\Catalog\Models\Product (the custom_fields cast
|
||||
// lives there, not on the base Lunar\Models\Product) — see
|
||||
// CatalogServiceProvider's own comment on why the scheduled
|
||||
// reindex command references that subclass by name specifically.
|
||||
$data['custom_fields'] = $model->custom_fields ?? [];
|
||||
$data['tags'] = $model->tags->pluck('value')->all();
|
||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->all();
|
||||
$data['variants'] = $model->variants->map(fn (ProductVariant $variant) => $this->mapVariant($variant, $currency))->all();
|
||||
|
||||
@@ -5,12 +5,15 @@ namespace Modules\Core\Providers;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Facades\ModelManifest;
|
||||
use Lunar\Models\Contracts\Product as ProductContract;
|
||||
use Lunar\Models\Product as LunarProduct;
|
||||
use Lunar\Models\ProductOption;
|
||||
use Lunar\Models\ProductOptionValue;
|
||||
use Modules\Core\Catalog\Events\ProductDeleted;
|
||||
use Modules\Core\Catalog\Events\ProductSaved;
|
||||
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
||||
use Modules\Core\Catalog\Models\Product;
|
||||
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
||||
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
|
||||
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
||||
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
||||
|
||||
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
|
||||
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
|
||||
// Registered on BOTH classes — Eloquent model events are keyed by
|
||||
// the literal class ::saved()/::deleted() was called on
|
||||
// (registerModelEvent() uses static::class at registration time),
|
||||
// not by inheritance, so a listener registered only on one class
|
||||
// never fires for an instance of the other. Code resolving Product
|
||||
// through the contract (Filament's own ProductResource, anything
|
||||
// using app(Contracts\Product::class)) gets the subclass once
|
||||
// ModelManifest::replace() below takes effect; code that still
|
||||
// hardcodes `Lunar\Models\Product` directly (e.g. MigrateImport\
|
||||
// Shopify\ShopifyExportImporter — importing has no reason to need
|
||||
// the subclass's own custom_fields cast) keeps creating base-class
|
||||
// instances. Both must dispatch ProductSaved/ProductDeleted, since
|
||||
// ReindexProductsRecommendingProduct listens to those regardless
|
||||
// of which path created/updated the product.
|
||||
$dispatchSaved = fn (LunarProduct $product) => Event::dispatch(new ProductSaved($product));
|
||||
$dispatchDeleted = fn (LunarProduct $product) => Event::dispatch(new ProductDeleted($product->id));
|
||||
|
||||
LunarProduct::saved($dispatchSaved);
|
||||
LunarProduct::deleted($dispatchDeleted);
|
||||
Product::saved($dispatchSaved);
|
||||
Product::deleted($dispatchDeleted);
|
||||
|
||||
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
|
||||
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
||||
|
||||
$this->app->booted(function () {
|
||||
// Deferred to booted() to run after every provider (Lunar's
|
||||
// own included) has finished its own boot() — matches
|
||||
// 3dealer's own AppServiceProvider, which registers Customer
|
||||
// the same way for the same reason.
|
||||
//
|
||||
// The first argument MUST be the CONTRACT
|
||||
// (Lunar\Models\Contracts\Product), not the concrete
|
||||
// Lunar\Models\Product — HasModelExtending::modelClass()
|
||||
// (which every Lunar model's __callStatic()/newModelQuery()
|
||||
// consults to decide "is there a registered replacement for
|
||||
// me") looks itself up by
|
||||
// ModelManifest::guessContractClass(static::class), which
|
||||
// resolves to the CONTRACT interface, then does
|
||||
// ModelManifest::get($thatContract) — so the manifest must be
|
||||
// keyed by the contract, or the lookup simply misses and
|
||||
// silently falls back to the base class. Caught in practice —
|
||||
// passing the concrete LunarProduct::class here (mirroring
|
||||
// Modules\Core\Customer\Providers\CustomerServiceProvider's
|
||||
// own replace() call, which has this exact same bug) left
|
||||
// Product::modelClass() resolving to Lunar\Models\Product no
|
||||
// matter what, until this was corrected.
|
||||
ModelManifest::replace(ProductContract::class, Product::class);
|
||||
|
||||
// A full nightly reindex, on top of the per-event reindexing
|
||||
// above — catches everything event-driven reindexing
|
||||
// deliberately doesn't cover: a newly-created product not yet
|
||||
@@ -58,8 +103,14 @@ class CatalogServiceProvider extends ServiceProvider
|
||||
// documents, so a deploy that changed ProductIndexer's field
|
||||
// list self-heals here even if `lunar:meilisearch:setup`
|
||||
// wasn't run manually after that deploy.
|
||||
// References the subclass, not 'Lunar\Models\Product' — Scout's
|
||||
// own reindex loop (Searchable::makeAllSearchable()) queries
|
||||
// via whatever class name is passed here, so this determines
|
||||
// which class's casts (custom_fields included) are actually
|
||||
// applied to $model in ProductIndexer::toSearchableArray()
|
||||
// during this nightly full reindex.
|
||||
$this->app->make(Schedule::class)
|
||||
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
|
||||
->command('lunar:search:index', [Product::class, '--refresh'])
|
||||
->dailyAt('03:00');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
ModelManifest::replace(LunarCustomer::class, Customer::class);
|
||||
// Deferred to booted() — LunarServiceProvider (lunarphp/core)
|
||||
// calls Facades\ModelManifest::register() from its OWN boot(),
|
||||
// which re-discovers every Lunar\Models\* class and repopulates
|
||||
// the whole manifest from scratch, silently undoing a replace()
|
||||
// call made from a boot() that ran earlier in the provider list.
|
||||
// booted() fires only once every provider's boot() has completed,
|
||||
// guaranteeing this is the one that actually sticks — same fix,
|
||||
// same reasoning, as Providers\CatalogServiceProvider's own
|
||||
// Product replace() call. This one likely only "worked" before
|
||||
// because 3dealer's own AppServiceProvider independently
|
||||
// re-registers Customer correctly in its own booted() — a
|
||||
// consuming app without that redundant registration would have
|
||||
// silently gotten the base Lunar\Models\Customer back.
|
||||
$this->app->booted(fn () => ModelManifest::replace(LunarCustomer::class, Customer::class));
|
||||
|
||||
Event::listen(UserCreated::class, CreateCustomerForUser::class);
|
||||
|
||||
|
||||
@@ -2,9 +2,31 @@
|
||||
|
||||
namespace Modules\Core\Review\Filament\Extensions;
|
||||
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Lunar\Admin\Support\Extending\ResourceExtension;
|
||||
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
||||
|
||||
/**
|
||||
* CorePlugin allows exactly one extension class per Lunar resource — this
|
||||
* one already owned ProductResource (adding the Reviews sub-page) before
|
||||
* Catalog needed its own product-form addition, so extendForm() lives
|
||||
* here too rather than competing for the same resource slot.
|
||||
*
|
||||
* extendForm() adds a "Custom Fields" repeater authoring Product::
|
||||
* $custom_fields (see the migration adding that column, and Modules\Core\
|
||||
* Catalog\Models\Product's own docblock on why this needed a first-party
|
||||
* Product subclass rather than being addable to the base Lunar model) —
|
||||
* per-product, customer-authored input (a reference photo upload, an
|
||||
* optional engraving textarea) rendered on the storefront product page,
|
||||
* NOT a Lunar ProductOption: an option's values are a fixed, admin-
|
||||
* authored list that define variants, which doesn't fit "the customer
|
||||
* uploads their own unique photo."
|
||||
*/
|
||||
class ProductResourceExtension extends ResourceExtension
|
||||
{
|
||||
public function extendPages(array $pages): array
|
||||
@@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension
|
||||
{
|
||||
return [...$pages, ManageProductReviews::class];
|
||||
}
|
||||
|
||||
public function extendForm(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
...$schema->getComponents(),
|
||||
$this->customFieldsSection(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function customFieldsSection(): Section
|
||||
{
|
||||
return Section::make('Custom Fields')
|
||||
->description('Extra input the shopper fills in on this product\'s page before adding it to their cart — a reference photo, personalization text, etc.')
|
||||
->collapsible()
|
||||
->collapsed(fn ($record) => blank($record?->custom_fields))
|
||||
->schema([
|
||||
Repeater::make('custom_fields')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
TextInput::make('label')
|
||||
->label('Label')
|
||||
->helperText('Shown to the shopper above the field.')
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->label('Field type')
|
||||
->options([
|
||||
'text' => 'Short text',
|
||||
'textarea' => 'Long text',
|
||||
'file' => 'File upload',
|
||||
])
|
||||
->default('text')
|
||||
->native(false)
|
||||
->live()
|
||||
->required(),
|
||||
TextInput::make('key')
|
||||
->label('Key')
|
||||
->helperText('Machine-facing identifier — stored on the order/cart line, used to look up this answer elsewhere. Cannot be changed once orders reference it.')
|
||||
->required()
|
||||
->alphaDash()
|
||||
->maxLength(64),
|
||||
Toggle::make('required')
|
||||
->label('Required')
|
||||
->helperText('Shopper cannot add this product to their cart without answering.')
|
||||
->default(false),
|
||||
])
|
||||
->columns(2)
|
||||
->addActionLabel('Add a custom field')
|
||||
->reorderable()
|
||||
->collapsible()
|
||||
->itemLabel(fn (array $state): ?string => $state['label'] ?? null),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user