components( $this->replaceChargeByField( $this->replaceNameField( $this->replaceDriverField($schema->getComponents()) ) ) ); } /** * Replaces the vendor's plain-string `name` TextInput with * Lunar's own TranslatedText — `name` is now a locale-keyed JSON * column (see database/migrations/..._make_shipping_methods_name_translatable.php), * same shape/resolution as PaymentMethod.name and Product/Collection * names (Lunar\Base\Traits\HasTranslations). */ private function replaceNameField(array $components): array { return array_map(function (Component $component) { if (method_exists($component, 'getName') && $component->getName() === 'name') { return self::translatedNameField(); } if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { $component->schema($this->replaceNameField($component->getDefaultChildComponents())); } return $component; }, $components); } /** * ShippingMethod.name is a locale-keyed JSON column (see database/ * migrations/..._make_shipping_methods_name_translatable.php), but * ShippingMethod is a vendor Eloquent model with no cast declared for * it — Lunar\Shipping\Models\ShippingMethod only casts `data`, and * there's no ModelManifest contract wired up to swap in a first-party * subclass that adds one (Contracts\ShippingMethod exists but is * never bound — see this class's own git history/nameColumn() for * the same gap on the read side). TranslatedText itself round-trips * plain array state, so afterStateHydrated()/dehydrateStateUsing() * decode/encode the raw JSON string at the field boundary instead — * the model attribute is a string on the way in and out, only ever * an array while Filament's schema state holds it. */ /** * Also called directly by Modules\Core\Shipping\Extensions\ * ShippingMethodListExtension — the create action's form is built * inline by the vendor's ListShippingMethod page rather than through * this class's own extendForm() pipeline, so it needs the same * translated field wired in separately. */ public static function translatedNameField(): TranslatedText { $field = TranslatedText::make('name') ->label('Name') ->required() ->afterStateHydrated(function (TranslatedText $component, $state) { // On create there is no record yet, so Filament hydrates // this from the field's own default/current state — already // an array (or null), never the raw JSON string edit gets // from the model attribute. Only decode when it's a string. if (is_string($state)) { $state = json_decode($state, true); } $component->state(is_array($state) ? $state : []); }) ->dehydrateStateUsing(fn ($state) => json_encode(is_array($state) ? $state : [])); $field->expanded = true; return $field; } /** * Extend the vendor's cart_total/weight charge_by Select with a third * "live" option — only offered when the currently selected driver * supports live pricing (see SupportsLivePricing). Picking it is what * tells the driver to call its carrier API instead of resolving a * price break. */ private function replaceChargeByField(array $components): array { return array_map(function (Component $component) { if (method_exists($component, 'getName') && $component->getName() === 'charge_by') { return $this->chargeBySelect(); } if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { $component->schema( $this->replaceChargeByField($component->getDefaultChildComponents()) ); } return $component; }, $components); } private function chargeBySelect(): Select { return Select::make('charge_by') ->label('Charge by') ->options(function (Get $get) { $options = [ 'cart_total' => 'Cart Total', 'weight' => 'Weight', ]; // "charge_by" is nested inside a Group with // ->statePath('data'), while "driver" sits one level up, at // the form root. Note: an *absolute* path here would need to // additionally account for the page's own form wrapper // (EditRecord::getFormStatePath() === 'data'), which relative // paths never cross — so "../driver" (relative) is the // correct, page-independent way to reach it, not an // absolute 'driver' string. if ($this->driverSupportsLivePricing($get('../driver'))) { $options['live'] = 'Live API pricing'; } return $options; }) ->live(); } private function driverSupportsLivePricing(?string $driver): bool { if (! $driver) { return false; } try { return Shipping::driver($driver) instanceof SupportsLivePricing; } catch (InvalidArgumentException) { return false; } } public function extendTable(Table $table): Table { return $table->columns( array_map(function ($column) { if (method_exists($column, 'getName') && $column->getName() === 'driver') { return $this->driverColumn(); } if (method_exists($column, 'getName') && $column->getName() === 'name') { return $this->nameColumn(); } return $column; }, $table->getColumns()) ); } private function driverColumn(): TextColumn { return TextColumn::make('driver') ->label('Type') ->formatStateUsing(fn ($state) => $this->driverLabel($state)); } /** * `name` is a locale-keyed JSON column (see Modules\Core\Shipping\ * Support\ShippingMethodName's own docblock for why it needs manual * decoding rather than a model cast). Uses ->state() rather than * ->formatStateUsing(), which would otherwise have Filament iterate a * would-be array state as a multi-value list (one formatted cell per * locale) instead of a single string. */ private function nameColumn(): TextColumn { return TextColumn::make('name') ->label('Name') ->state(fn ($record) => ShippingMethodName::resolve($record)); } private function driverLabel(string $key): string { $driver = collect(Shipping::getSupportedDrivers())->get($key); return $driver?->name() ?? $key; } /** * Recursively walk the form tree and replace the hardcoded driver * Select (nested inside Section > Group) with one listing every * registered driver, built-in or custom. * * @param array $components * @return array */ private function replaceDriverField(array $components): array { return array_map(function (Component $component) { if (method_exists($component, 'getName') && $component->getName() === 'driver') { return $this->driverSelect(); } if (in_array(HasChildComponents::class, class_uses_recursive($component), true)) { $component->schema( $this->replaceDriverField($component->getDefaultChildComponents()) ); } return $component; }, $components); } private function driverSelect(): Select { return Select::make('driver') ->label('Type') ->options(fn () => collect(Shipping::getSupportedDrivers()) ->mapWithKeys(fn ($driver, $key) => [$key => $driver->name()])) ->default('acs') ->live(); } }