Files
core/src/Shipping/Extensions/ShippingMethodResourceExtension.php
T

243 lines
9.0 KiB
PHP

<?php
namespace Modules\Core\Shipping\Extensions;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Concerns\HasChildComponents;
use Filament\Schemas\Components\Utilities\Get;
use InvalidArgumentException;
use Filament\Forms\Components\Select;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Lunar\Admin\Support\Extending\ResourceExtension;
use Lunar\Admin\Support\Forms\Components\TranslatedText;
use Lunar\Shipping\Facades\Shipping;
use Modules\Core\Shipping\Contracts\SupportsLivePricing;
use Modules\Core\Shipping\Support\ShippingMethodName;
class ShippingMethodResourceExtension extends ResourceExtension
{
public function extendForm(Schema $schema): Schema
{
return $schema->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<Component> $components
* @return array<Component>
*/
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();
}
}