From 78b46e559482873e8c91abc24b5a14ac879ec77c Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 24 Sep 2026 23:42:43 +0300 Subject: [PATCH] Chore: Adding Locales to Product Custom Fields --- .../Pages/ManageProductCustomFields.php | 162 ++++++++++++++++++ .../Extensions/ProductResourceExtension.php | 78 +-------- 2 files changed, 169 insertions(+), 71 deletions(-) create mode 100644 src/Catalog/Filament/Pages/ManageProductCustomFields.php diff --git a/src/Catalog/Filament/Pages/ManageProductCustomFields.php b/src/Catalog/Filament/Pages/ManageProductCustomFields.php new file mode 100644 index 0000000..76b67ca --- /dev/null +++ b/src/Catalog/Filament/Pages/ManageProductCustomFields.php @@ -0,0 +1,162 @@ +statePath('') override, no custom mount()/ + * handleRecordUpdate() — EditRecord::mount() already fills the form from + * $record->attributesToArray() (which includes custom_fields, a real cast + * + fillable column) onto the default 'data' statePath, and save() reads + * it straight back off via $this->form->getState(). An earlier version of + * this page used ->statePath('') to bind the repeater directly to the + * record's attributes (copying ManageProductPricing) — that repointed the + * Repeater at $this->data['custom_fields'] AS THE ROOT state path itself, + * so every "add item" click re-filled the whole form from the record's + * still-unsaved value and immediately discarded the new row before it + * ever reached the page. Reverting to the plain default form/statePath is + * both simpler and is what actually works — same as the original inline + * repeater on the main product form did before this became its own page. + * + * Registered from Review\Filament\Extensions\ProductResourceExtension, not + * here — CorePlugin only allows one extension class per Lunar resource, + * and Review's already owns ProductResource's extension slot (see that + * class's own docblock). + * + * `label`/`help_text` are each stored as {locale: string} (e.g. {en: "...", + * el: "..."}) — see translatedField()'s own docblock for why that's a + * hand-rolled TextInput per language rather than Lunar's TranslatedText + * component. A product saved before this change still has a plain string + * `label` and no `help_text` at all; itemLabel() below tolerates both + * shapes, and the storefront/cart resolve either shape the same way (see + * product-custom-fields.blade.php and CartController:: + * customFieldsMeta()). `key`/`type`/`required` stay plain, single values — + * only shopper-facing copy needs a translation, not the field's own + * machine-facing configuration. + */ +class ManageProductCustomFields extends BaseEditRecord +{ + protected static string $resource = ProductResource::class; + + public static function getNavigationIcon(): ?string + { + return 'heroicon-o-adjustments-horizontal'; + } + + public function getTitle(): string + { + return 'Custom Fields'; + } + + public static function getNavigationLabel(): string + { + return 'Custom Fields'; + } + + /** + * Without this, Filament's EditRecord defaults to every relation + * manager the WHOLE ProductResource defines (see HasRelationManagers:: + * getAllRelationManagers(), which reads ProductResource::getRelations() + * regardless of which sub-page is rendering) — Channels, Customer + * Groups, Media, Pricing tabs all bleeding onto this page alongside the + * repeater below. This page has no relations of its own. + */ + public function getRelationManagers(): array + { + return []; + } + + /** + * A plain TextInput per configured language, named "{$field}.{locale}" + * so it resolves to a normal nested array under the repeater item + * (custom_fields.{item}.label.en, .label.el, ...) — NOT Lunar's + * TranslatedText component. That component's per-locale sub-fields + * set their own statePath to just the locale code itself + * (TranslatedText::prepareTranslateLocaleComponent()), which only + * resolves correctly when TranslatedText is used as a single + * top-level named field directly on a form's root state (exactly how + * every existing usage in this codebase uses it — Lunar's own + * product name/description). Nested inside a Repeater item here, that + * same statePath resolution silently failed to nest under the item's + * own label/help_text key at all, and every typed value was lost on + * save. Hand-rolling the per-locale inputs sidesteps that assumption + * entirely. + */ + private function translatedField(string $field, string $label, string $helperText, bool $required): Group + { + $languages = Language::orderBy('default', 'desc')->get(['code', 'name', 'default']); + + return Group::make( + $languages->map(fn (Language $language, int $index) => TextInput::make("{$field}.{$language->code}") + ->label($index === 0 ? $label : null) + ->hiddenLabel($index !== 0) + ->helperText($index === 0 ? $helperText : null) + ->prefix(Str::upper($language->code)) + ->required($required && $language->default))->values()->all(), + ) + ->columnSpanFull(); + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + 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.') + ->schema([ + Repeater::make('custom_fields') + ->hiddenLabel() + ->schema([ + $this->translatedField('label', 'Label', 'Shown to the shopper above the field. Only the current storefront locale is shown on the cart and checkout.', required: true), + $this->translatedField('help_text', 'Help text', 'Optional — shown under the label on the product page only, not on the cart or checkout.', required: false), + 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 => is_array($state['label'] ?? null) + ? collect($state['label'])->first(fn ($value) => filled($value)) + : ($state['label'] ?? null)), + ]), + ]); + } +} diff --git a/src/Review/Filament/Extensions/ProductResourceExtension.php b/src/Review/Filament/Extensions/ProductResourceExtension.php index 59baa9b..3f6c342 100644 --- a/src/Review/Filament/Extensions/ProductResourceExtension.php +++ b/src/Review/Filament/Extensions/ProductResourceExtension.php @@ -2,94 +2,30 @@ 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\Catalog\Filament\Pages\ManageProductCustomFields; 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." + * Catalog needed its own product-page addition, so registering + * ManageProductCustomFields lives here too rather than competing for the + * same resource slot. See that page's own docblock for what it does and + * why it's a separate sub-page rather than a section on the main form. */ class ProductResourceExtension extends ResourceExtension { public function extendPages(array $pages): array { $pages['reviews'] = ManageProductReviews::route('/{record}/reviews'); + $pages['custom-fields'] = ManageProductCustomFields::route('/{record}/custom-fields'); return $pages; } public function extendSubNavigation(array $pages): array { - 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), - ]); + return [...$pages, ManageProductReviews::class, ManageProductCustomFields::class]; } }