Feature: Adding Custom Fields to Products
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-product, customer-authored input fields — a personalized-statue
|
||||||
|
* product needing a reference photo upload and an optional engraving
|
||||||
|
* textarea, for example. Deliberately NOT modeled as a Lunar ProductOption
|
||||||
|
* (see Modules\Core\Catalog\Contracts\ProductOptionTypeInterface's own
|
||||||
|
* docblock): an option's values are a fixed, admin-authored list that
|
||||||
|
* define variants (Red/Green/Blue) — a photo upload has no such list, it's
|
||||||
|
* unique per order, and creates no variant at all. This is a genuinely
|
||||||
|
* different concept that happens to configure on the same product page.
|
||||||
|
*
|
||||||
|
* Array of {key, type: 'text'|'textarea'|'file', label, required} — `key`
|
||||||
|
* is what a submitted answer is keyed by in CartLine/OrderLine.meta (both
|
||||||
|
* already have a `meta` json column — see Modules\Core\Cart\Services\
|
||||||
|
* CartService::addLine()'s own $meta parameter), not a new table, since
|
||||||
|
* this is small, rarely-queried per-product config, the same reasoning
|
||||||
|
* ShippingMethod.data/PaymentMethod.data already follow for their own
|
||||||
|
* per-row settings.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
|
||||||
|
$table->json('custom_fields')->nullable()->after('attribute_data');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table(config('lunar.database.table_prefix').'products', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('custom_fields');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
->all();
|
||||||
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
$data['slugs'] = $model->urls->pluck('slug')->unique()->values()->all();
|
||||||
$data['skus'] = $model->variants->pluck('sku')->filter()->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['tags'] = $model->tags->pluck('value')->all();
|
||||||
$data['media'] = $model->media->map(fn (Media $media) => $this->mapMedia($media))->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();
|
$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\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
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\ProductOption;
|
||||||
use Lunar\Models\ProductOptionValue;
|
use Lunar\Models\ProductOptionValue;
|
||||||
use Modules\Core\Catalog\Events\ProductDeleted;
|
use Modules\Core\Catalog\Events\ProductDeleted;
|
||||||
use Modules\Core\Catalog\Events\ProductSaved;
|
use Modules\Core\Catalog\Events\ProductSaved;
|
||||||
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
||||||
|
use Modules\Core\Catalog\Models\Product;
|
||||||
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
||||||
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
||||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||||
@@ -40,13 +43,55 @@ class CatalogServiceProvider extends ServiceProvider
|
|||||||
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
||||||
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
||||||
|
|
||||||
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
|
// Registered on BOTH classes — Eloquent model events are keyed by
|
||||||
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
|
// 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(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
|
||||||
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
||||||
|
|
||||||
$this->app->booted(function () {
|
$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
|
// A full nightly reindex, on top of the per-event reindexing
|
||||||
// above — catches everything event-driven reindexing
|
// above — catches everything event-driven reindexing
|
||||||
// deliberately doesn't cover: a newly-created product not yet
|
// 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
|
// documents, so a deploy that changed ProductIndexer's field
|
||||||
// list self-heals here even if `lunar:meilisearch:setup`
|
// list self-heals here even if `lunar:meilisearch:setup`
|
||||||
// wasn't run manually after that deploy.
|
// 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)
|
$this->app->make(Schedule::class)
|
||||||
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
|
->command('lunar:search:index', [Product::class, '--refresh'])
|
||||||
->dailyAt('03:00');
|
->dailyAt('03:00');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,20 @@ class CustomerServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function boot(): void
|
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);
|
Event::listen(UserCreated::class, CreateCustomerForUser::class);
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,31 @@
|
|||||||
|
|
||||||
namespace Modules\Core\Review\Filament\Extensions;
|
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 Lunar\Admin\Support\Extending\ResourceExtension;
|
||||||
use Modules\Core\Review\Filament\Pages\ManageProductReviews;
|
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
|
class ProductResourceExtension extends ResourceExtension
|
||||||
{
|
{
|
||||||
public function extendPages(array $pages): array
|
public function extendPages(array $pages): array
|
||||||
@@ -18,4 +40,56 @@ class ProductResourceExtension extends ResourceExtension
|
|||||||
{
|
{
|
||||||
return [...$pages, ManageProductReviews::class];
|
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