From 855ab21818026597e74b92ef4aa5ba18f8c8e536 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 25 Aug 2026 10:15:17 +0100 Subject: [PATCH 01/32] Update general settings to use new forms --- .../Settings/SettingsController.php | 89 +++++++++++++++++-- 1 file changed, 80 insertions(+), 9 deletions(-) diff --git a/src/Http/Controllers/Settings/SettingsController.php b/src/Http/Controllers/Settings/SettingsController.php index c17a3e8d99..978abf2bce 100644 --- a/src/Http/Controllers/Settings/SettingsController.php +++ b/src/Http/Controllers/Settings/SettingsController.php @@ -6,15 +6,26 @@ use craft\commerce\Plugin; use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\FormResolver; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\Heading; +use CraftCms\Cms\Form\Nodes\Separator; use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\Plugins; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; +use CraftCms\Cms\Support\Url; use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Transfer\Elements\Transfer; use CraftCms\Commerce\Transfer\Transfers; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Config; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\pageTemplate; @@ -26,17 +37,77 @@ private bool $readOnly; - public function __construct(GeneralConfig $generalConfig) + public function __construct( + private GeneralConfig $generalConfig, + private FormResolver $formResolver, + ) { $this->readOnly = !$generalConfig->allowAdminChanges; } - public function edit(): string + public function edit($settings = null): CpScreenResponse { - return pageTemplate('commerce/settings/general', [ - 'settings' => Plugin::getInstance()->getSettings(), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $settings = Plugin::getInstance()->getSettings(); + $config = Config::get('craft.commerce', null); + + $overrideWarning = function($key) use ($config) { + if ($config && isset($config[$key])) { + return t("This is being overridden by the {setting} config setting in `config/{file}.php`.", [ + 'setting' => $key, + 'file' => 'commerce', + ], category: 'commerce'); + } + + return null; + }; + + $form = Form::make([ + Heading::make('units-heading', t('Units', category: 'commerce'))->level(3), + Field::make(t('Weight Unit'), Combobox::make('weightUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getWeightUnitsOptions()), $settings->getWeightUnitsOptions())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product weights.', category: 'commerce')), + Field::make(t('Dimension Unit'), Combobox::make('dimensionUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDimensionUnits()), $settings->getDimensionUnits())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product dimensions.', category: 'commerce')), + Separator::make('default-view-separator'), + Heading::make('default-view-heading', t('Control Panel Settings', category: 'commerce'))->level(3), + Field::make(t('Default View'), Combobox::make('defaultView') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDefaultViewOptions()), $settings->getDefaultViewOptions())) + ->showAllOnEmpty()) + ->required() + ->warning($overrideWarning('defaultView')) + ->instructions(t('Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access.', category: 'commerce')), + ]); + + $form = $this->formResolver->resolve($form, new FormContext( + namespace: 'settings', + values: [ + 'settings' => [ + 'weightUnits' => $settings->weightUnits, + 'dimensionUnits' => $settings->dimensionUnits, + 'defaultView' => $settings->defaultView, + ], + ], + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + )); + + return new CpScreenResponse() + ->title(t('General Settings', category: 'commerce')) + ->crumbs([ + ['label' => t('Commerce', category: 'commerce'), 'href' => Url::cpUrl('commerce')], + ]) + ->redirectUrl('commerce/settings/general') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveSettings']), + ], + ]); } public function saveSettings(Request $request): Response|string @@ -46,7 +117,7 @@ public function saveSettings(Request $request): Response|string $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings); if (!$pluginSettingsSaved) { - return pageTemplate('commerce/settings/general/index', ['settings' => $plugin->getSettings()], TemplateMode::Cp); + return $this->asFailure(t('Couldn’t save settings.', category: 'commerce')); } return $this->asSuccess(t('Settings saved.', category: 'commerce')); @@ -66,7 +137,7 @@ public function saveTransferSettings(): Response $fieldLayout->type = Transfer::class; if (!$fieldLayout->validate()) { - return $this->asFailure(t('Couldn\'t save transfer fields.', category: 'commerce')); + return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); } if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { @@ -79,7 +150,7 @@ public function saveTransferSettings(): Response $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); if (!$result) { - return $this->asFailure(t('Couldn\'t save transfer fields.')); + return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); } return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); From a68867a2e6693aa17e33bb7239550996601644a6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 25 Aug 2026 10:57:20 +0100 Subject: [PATCH 02/32] Create extendable settings controller and tidy up general and transfer settings --- routes/actions.php | 7 +- routes/cp.php | 6 +- .../Settings/GeneralSettingsController.php | 106 ++++++++ .../Settings/SettingsController.php | 240 ++++++++---------- .../Settings/TransferSettingsController.php | 66 +++++ 5 files changed, 283 insertions(+), 142 deletions(-) create mode 100644 src/Http/Controllers/Settings/GeneralSettingsController.php create mode 100644 src/Http/Controllers/Settings/TransferSettingsController.php diff --git a/routes/actions.php b/routes/actions.php index 6001e67617..80f849b4b0 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -13,6 +13,7 @@ use CraftCms\Commerce\Http\Controllers\Settings\EmailsController; use CraftCms\Commerce\Http\Controllers\FormulasController; use CraftCms\Commerce\Http\Controllers\Settings\GatewaysController; +use CraftCms\Commerce\Http\Controllers\Settings\GeneralSettingsController; use CraftCms\Commerce\Http\Controllers\InventoryController; use CraftCms\Commerce\Http\Controllers\InventoryLocationsController; use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; @@ -24,7 +25,6 @@ use CraftCms\Commerce\Http\Controllers\Settings\PdfsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\SettingsController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; @@ -34,6 +34,7 @@ use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\UserOrdersController; use CraftCms\Commerce\Http\Controllers\WebhooksController; @@ -81,8 +82,8 @@ Route::post('gateways/archive', [GatewaysController::class, 'archive']); Route::post('gateways/reorder', [GatewaysController::class, 'reorder']); - Route::post('settings/save-settings', [SettingsController::class, 'saveSettings']); - Route::post('settings/save-transfer-settings', [SettingsController::class, 'saveTransferSettings']); + Route::post('settings/save-settings', [GeneralSettingsController::class, 'saveSettings']); + Route::post('settings/save-transfer-settings', [TransferSettingsController::class, 'saveTransferSettings']); Route::post('order-settings/save', [OrderSettingsController::class, 'save']); Route::post('stores/save-store', [StoresController::class, 'saveStore']); diff --git a/routes/cp.php b/routes/cp.php index 8dfca3cfd4..a8f40e8cfc 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -11,6 +11,7 @@ use CraftCms\Commerce\Http\Controllers\InventoryController; use CraftCms\Commerce\Http\Controllers\InventoryLocationsController; use CraftCms\Commerce\Http\Controllers\OrdersController; +use CraftCms\Commerce\Http\Controllers\Settings\GeneralSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; use CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController; @@ -29,6 +30,7 @@ use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\Users\UsersController; use CraftCms\Commerce\Http\Controllers\VariantsController; @@ -43,9 +45,9 @@ Route::get('commerce/settings/gateways/new', [GatewaysController::class, 'edit']); Route::get('commerce/settings/gateways/{id}', [GatewaysController::class, 'edit'])->whereNumber('id'); - Route::get('commerce/settings/general', [SettingsController::class, 'edit']); + Route::get('commerce/settings/general', [GeneralSettingsController::class, 'edit']); Route::get('commerce/settings/ordersettings', [OrderSettingsController::class, 'edit']); - Route::get('commerce/settings/transfers', [SettingsController::class, 'editTransferSettings']); + Route::get('commerce/settings/transfers', [TransferSettingsController::class, 'editTransferSettings']); Route::get('commerce/settings/stores', [StoresController::class, 'storesIndex']); Route::get('commerce/settings/stores/new', [StoresController::class, 'editStore']); diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php new file mode 100644 index 0000000000..d9823ed09f --- /dev/null +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -0,0 +1,106 @@ +getSettings(); + $config = Config::get('craft.commerce', null); + + $overrideWarning = function($key) use ($config) { + if ($config && isset($config[$key])) { + return t("This is being overridden by the {setting} config setting in `config/{file}.php`.", [ + 'setting' => $key, + 'file' => 'commerce', + ], category: 'commerce'); + } + + return null; + }; + + $form = Form::make([ + Heading::make('units-heading', t('Units', category: 'commerce'))->level(3), + Field::make(t('Weight Unit'), Combobox::make('weightUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getWeightUnitsOptions()), $settings->getWeightUnitsOptions())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product weights.', category: 'commerce')), + Field::make(t('Dimension Unit'), Combobox::make('dimensionUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDimensionUnits()), $settings->getDimensionUnits())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product dimensions.', category: 'commerce')), + Separator::make('default-view-separator'), + Heading::make('default-view-heading', t('Control Panel Settings', category: 'commerce'))->level(3), + Field::make(t('Default View'), Combobox::make('defaultView') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDefaultViewOptions()), $settings->getDefaultViewOptions())) + ->showAllOnEmpty()) + ->required() + ->warning($overrideWarning('defaultView')) + ->instructions(t('Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access.', category: 'commerce')), + ]); + + $form = $this->formResolver->resolve($form, new FormContext( + namespace: 'settings', + values: [ + 'settings' => [ + 'weightUnits' => $settings->weightUnits, + 'dimensionUnits' => $settings->dimensionUnits, + 'defaultView' => $settings->defaultView, + ], + ], + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + )); + + return $this->cpScreenResponse() + ->title(t('General Settings', category: 'commerce')) + ->crumbs([$this->crumbs(t('General Settings', category: 'commerce'))]) + ->redirectUrl('commerce/settings/general') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveSettings']), + ], + ]); + } + + public function saveSettings(Request $request): Response|string + { + $plugin = Plugin::getInstance(); + $settings = $request->input('settings'); + $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings); + + if (!$pluginSettingsSaved) { + return $this->asFailure(t('Couldn’t save settings.', category: 'commerce')); + } + + return $this->asSuccess(t('Settings saved.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/SettingsController.php b/src/Http/Controllers/Settings/SettingsController.php index 978abf2bce..5da268365b 100644 --- a/src/Http/Controllers/Settings/SettingsController.php +++ b/src/Http/Controllers/Settings/SettingsController.php @@ -4,166 +4,132 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use craft\commerce\Plugin; use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Form\Controls\Combobox; -use CraftCms\Cms\Form\Enums\ControlMode; -use CraftCms\Cms\Form\Form; -use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Cp\Data\NavItem; use CraftCms\Cms\Form\FormResolver; -use CraftCms\Cms\Form\Nodes\Field; -use CraftCms\Cms\Form\Nodes\Heading; -use CraftCms\Cms\Form\Nodes\Separator; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\Support\Facades\Fields; -use CraftCms\Cms\Support\Facades\Plugins; -use CraftCms\Cms\Support\Facades\ProjectConfig; -use CraftCms\Cms\Support\Str; -use CraftCms\Cms\Support\Url; -use CraftCms\Cms\View\TemplateMode; -use CraftCms\Commerce\Transfer\Elements\Transfer; -use CraftCms\Commerce\Transfer\Transfers; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Config; -use Symfony\Component\HttpFoundation\Response; - -use function CraftCms\Cms\pageTemplate; + +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; -readonly class SettingsController +abstract class SettingsController { use RespondsWithFlash; - private bool $readOnly; + protected bool $readOnly; public function __construct( - private GeneralConfig $generalConfig, - private FormResolver $formResolver, + protected GeneralConfig $generalConfig, + protected FormResolver $formResolver, ) { $this->readOnly = !$generalConfig->allowAdminChanges; } - public function edit($settings = null): CpScreenResponse + /** + * @return NavItem[] + */ + protected function subnav(): array { - $settings = Plugin::getInstance()->getSettings(); - $config = Config::get('craft.commerce', null); - - $overrideWarning = function($key) use ($config) { - if ($config && isset($config[$key])) { - return t("This is being overridden by the {setting} config setting in `config/{file}.php`.", [ - 'setting' => $key, - 'file' => 'commerce', - ], category: 'commerce'); - } - - return null; - }; - - $form = Form::make([ - Heading::make('units-heading', t('Units', category: 'commerce'))->level(3), - Field::make(t('Weight Unit'), Combobox::make('weightUnits') - ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getWeightUnitsOptions()), $settings->getWeightUnitsOptions())) - ->showAllOnEmpty()) - ->required() - ->instructions(t('The unit of measurement that should be used when specifying product weights.', category: 'commerce')), - Field::make(t('Dimension Unit'), Combobox::make('dimensionUnits') - ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDimensionUnits()), $settings->getDimensionUnits())) - ->showAllOnEmpty()) - ->required() - ->instructions(t('The unit of measurement that should be used when specifying product dimensions.', category: 'commerce')), - Separator::make('default-view-separator'), - Heading::make('default-view-heading', t('Control Panel Settings', category: 'commerce'))->level(3), - Field::make(t('Default View'), Combobox::make('defaultView') - ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDefaultViewOptions()), $settings->getDefaultViewOptions())) - ->showAllOnEmpty()) - ->required() - ->warning($overrideWarning('defaultView')) - ->instructions(t('Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access.', category: 'commerce')), - ]); - - $form = $this->formResolver->resolve($form, new FormContext( - namespace: 'settings', - values: [ - 'settings' => [ - 'weightUnits' => $settings->weightUnits, - 'dimensionUnits' => $settings->dimensionUnits, - 'defaultView' => $settings->defaultView, - ], - ], - mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, - )); - - return new CpScreenResponse() - ->title(t('General Settings', category: 'commerce')) - ->crumbs([ - ['label' => t('Commerce', category: 'commerce'), 'href' => Url::cpUrl('commerce')], - ]) - ->redirectUrl('commerce/settings/general') - ->inertiaPage('Form', [ - 'form' => $form, - 'submit' => [ - 'method' => 'post', - 'url' => action([self::class, 'saveSettings']), - ], - ]); - } - - public function saveSettings(Request $request): Response|string - { - $plugin = Plugin::getInstance(); - $settings = $request->input('settings'); - $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings); - - if (!$pluginSettingsSaved) { - return $this->asFailure(t('Couldn’t save settings.', category: 'commerce')); - } - - return $this->asSuccess(t('Settings saved.', category: 'commerce')); + $path = request()->craftPath(); + + return [ + new NavItem() + ->label(t('General Settings', category: 'commerce')) + ->url(cp_url('commerce/settings/general')) + ->selected($path === 'commerce/settings/general'), + + new NavItem() + ->label(t('Stores & Sites', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Stores', category: 'commerce')) + ->url(cp_url('commerce/settings/stores')) + ->selected($path === 'commerce/settings/stores'), + new NavItem() + ->label(t('Sites')) + ->url(cp_url('commerce/settings/sites')) + ->selected($path === 'commerce/settings/sites'), + ]), + + new NavItem() + ->label(t('Products', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Product Types', category: 'commerce')) + ->url(cp_url('commerce/settings/producttypes')) + ->selected($path === 'commerce/settings/producttypes'), + ]), + + new NavItem() + ->label(t('Orders', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Order Fields', category: 'commerce')) + ->url(cp_url('commerce/settings/ordersettings')) + ->selected($path === 'commerce/settings/ordersettings'), + new NavItem() + ->label(t('Order Statuses', category: 'commerce')) + ->url(cp_url('commerce/settings/orderstatuses')) + ->selected($path === 'commerce/settings/orderstatuses'), + new NavItem() + ->label(t('Line Item Statuses', category: 'commerce')) + ->url(cp_url('commerce/settings/lineitemstatuses')) + ->selected($path === 'commerce/settings/lineitemstatuses'), + ]), + + new NavItem() + ->label(t('PDFs & Emails', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('PDFs', category: 'commerce')) + ->url(cp_url('commerce/settings/pdfs')) + ->selected($path === 'commerce/settings/pdfs'), + new NavItem() + ->label(t('Emails', category: 'commerce')) + ->url(cp_url('commerce/settings/emails')) + ->selected($path === 'commerce/settings/emails'), + ]), + + new NavItem() + ->label(t('Payments', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Gateways', category: 'commerce')) + ->url(cp_url('commerce/settings/gateways')) + ->selected($path === 'commerce/settings/gateways'), + ]), + + new NavItem() + ->label(t('Transfers', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Transfer Fields', category: 'commerce')) + ->url(cp_url('commerce/settings/transfers')) + ->selected($path === 'commerce/settings/transfers'), + ]), + ]; } - public function saveTransferSettings(): Response + /** @return list> */ + protected function crumbs(string $title, ?string $url = null): array { - $fieldLayout = Fields::assembleLayoutFromPost(); - - $fieldLayout->reservedFieldHandles = [ - 'originLocationId', - 'originLocation', - 'destinationLocationId', - 'destinationLocation', + return [ + ['label' => t('Settings'), 'href' => cp_url('settings')], + array_filter(['label' => $title, 'href' => $url]), ]; - - $fieldLayout->type = Transfer::class; - - if (!$fieldLayout->validate()) { - return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); - } - - if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { - $uid = array_key_first($currentTransfersFieldLayout); - } else { - $uid = (string)Str::uuid(); - } - - $configData = [$uid => $fieldLayout->getConfig()]; - $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); - - if (!$result) { - return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); - } - - return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); } - public function editTransferSettings(): string + protected function cpScreenResponse(): CpScreenResponse { - $fieldLayout = app(Transfers::class)->getFieldLayout(); - - return pageTemplate('commerce/settings/transfers/_edit', [ - 'fieldLayout' => $fieldLayout, - 'title' => t('Transfer Settings', category: 'commerce'), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + return new CpScreenResponse() + ->subnav($this->subnav()); } } diff --git a/src/Http/Controllers/Settings/TransferSettingsController.php b/src/Http/Controllers/Settings/TransferSettingsController.php new file mode 100644 index 0000000000..d38ea98eba --- /dev/null +++ b/src/Http/Controllers/Settings/TransferSettingsController.php @@ -0,0 +1,66 @@ +reservedFieldHandles = [ + 'originLocationId', + 'originLocation', + 'destinationLocationId', + 'destinationLocation', + ]; + + $fieldLayout->type = Transfer::class; + + if (!$fieldLayout->validate()) { + return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); + } + + if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) { + $uid = array_key_first($currentTransfersFieldLayout); + } else { + $uid = (string)Str::uuid(); + } + + $configData = [$uid => $fieldLayout->getConfig()]; + $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true); + + if (!$result) { + return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce')); + } + + return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); + } + + public function editTransferSettings(): string + { + $fieldLayout = app(Transfers::class)->getFieldLayout(); + + return pageTemplate('commerce/settings/transfers/_edit', [ + 'fieldLayout' => $fieldLayout, + 'title' => t('Transfer Settings', category: 'commerce'), + 'readOnly' => $this->readOnly, + ], TemplateMode::Cp); + } +} From 6379de16eaae14f571d7833d6b500f9bae34d8f6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 25 Aug 2026 11:08:57 +0100 Subject: [PATCH 03/32] ensure settings controllers extend the base one --- src/Http/Controllers/Settings/EmailsController.php | 12 +----------- src/Http/Controllers/Settings/GatewaysController.php | 12 +----------- .../Settings/LineItemStatusesController.php | 12 +----------- .../Controllers/Settings/OrderSettingsController.php | 12 +----------- .../Controllers/Settings/OrderStatusesController.php | 12 +----------- src/Http/Controllers/Settings/PdfsController.php | 12 +----------- .../Controllers/Settings/ProductTypesController.php | 5 +---- src/Http/Controllers/Settings/StoresController.php | 12 +----------- 8 files changed, 8 insertions(+), 81 deletions(-) diff --git a/src/Http/Controllers/Settings/EmailsController.php b/src/Http/Controllers/Settings/EmailsController.php index cd1c11f251..a60933cd9b 100644 --- a/src/Http/Controllers/Settings/EmailsController.php +++ b/src/Http/Controllers/Settings/EmailsController.php @@ -5,8 +5,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\helpers\App; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Arr; @@ -26,16 +24,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class EmailsController +class EmailsController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function index(): string { diff --git a/src/Http/Controllers/Settings/GatewaysController.php b/src/Http/Controllers/Settings/GatewaysController.php index 822f1f7d0c..83dd282398 100644 --- a/src/Http/Controllers/Settings/GatewaysController.php +++ b/src/Http/Controllers/Settings/GatewaysController.php @@ -4,8 +4,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Support\Html; use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Database\Table; @@ -19,16 +17,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class GatewaysController +class GatewaysController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function index(): string { diff --git a/src/Http/Controllers/Settings/LineItemStatusesController.php b/src/Http/Controllers/Settings/LineItemStatusesController.php index e524b3978a..bd32e14678 100644 --- a/src/Http/Controllers/Settings/LineItemStatusesController.php +++ b/src/Http/Controllers/Settings/LineItemStatusesController.php @@ -4,8 +4,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\TemplateMode; @@ -21,16 +19,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class LineItemStatusesController +class LineItemStatusesController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function index(): string { diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php index 910214cfad..dc92c4cc59 100644 --- a/src/Http/Controllers/Settings/OrderSettingsController.php +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -4,8 +4,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; @@ -17,16 +15,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class OrderSettingsController +class OrderSettingsController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function edit(): string { diff --git a/src/Http/Controllers/Settings/OrderStatusesController.php b/src/Http/Controllers/Settings/OrderStatusesController.php index 0d49bf5358..cbce8ac586 100644 --- a/src/Http/Controllers/Settings/OrderStatusesController.php +++ b/src/Http/Controllers/Settings/OrderStatusesController.php @@ -5,8 +5,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\db\Query; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\TemplateMode; @@ -25,16 +23,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class OrderStatusesController +class OrderStatusesController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function index(): string { diff --git a/src/Http/Controllers/Settings/PdfsController.php b/src/Http/Controllers/Settings/PdfsController.php index ea2f53b821..32a46813eb 100644 --- a/src/Http/Controllers/Settings/PdfsController.php +++ b/src/Http/Controllers/Settings/PdfsController.php @@ -4,8 +4,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Json; use CraftCms\Cms\View\TemplateMode; @@ -21,16 +19,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class PdfsController +class PdfsController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function index(): string { diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php index 4ede728601..d1be20a811 100644 --- a/src/Http/Controllers/Settings/ProductTypesController.php +++ b/src/Http/Controllers/Settings/ProductTypesController.php @@ -6,7 +6,6 @@ use craft\web\assets\editsection\EditSectionAsset; use CraftCms\Cms\Element\Enums\PropagationMethod; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\Sites; @@ -21,10 +20,8 @@ use function CraftCms\Cms\currentUser; use function CraftCms\Cms\t; -readonly class ProductTypesController +class ProductTypesController extends SettingsController { - use RespondsWithFlash; - public function productTypeIndex(): CpScreenResponse { $productTypes = app(ProductTypes::class)->getAllProductTypes(); diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index 53f666be7a..bb20467cbb 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -5,8 +5,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\db\Query; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Url; @@ -23,16 +21,8 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -readonly class StoresController +class StoresController extends SettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) - { - $this->readOnly = !$generalConfig->allowAdminChanges; - } public function editStore(?int $storeId = null): string { From fc8d7a2c4a74c73aec3d8eb302d13a66baf36cf1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 25 Aug 2026 11:19:19 +0100 Subject: [PATCH 04/32] Base naming convention --- routes/cp.php | 1 - .../{SettingsController.php => BaseSettingsController.php} | 2 +- src/Http/Controllers/Settings/EmailsController.php | 2 +- src/Http/Controllers/Settings/GatewaysController.php | 2 +- src/Http/Controllers/Settings/GeneralSettingsController.php | 2 +- src/Http/Controllers/Settings/LineItemStatusesController.php | 2 +- src/Http/Controllers/Settings/OrderSettingsController.php | 2 +- src/Http/Controllers/Settings/OrderStatusesController.php | 2 +- src/Http/Controllers/Settings/PdfsController.php | 2 +- src/Http/Controllers/Settings/ProductTypesController.php | 2 +- src/Http/Controllers/Settings/StoresController.php | 2 +- src/Http/Controllers/Settings/TransferSettingsController.php | 2 +- 12 files changed, 11 insertions(+), 12 deletions(-) rename src/Http/Controllers/Settings/{SettingsController.php => BaseSettingsController.php} (99%) diff --git a/routes/cp.php b/routes/cp.php index a8f40e8cfc..20b3228b01 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -20,7 +20,6 @@ use CraftCms\Commerce\Http\Controllers\ProductsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\SettingsController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; diff --git a/src/Http/Controllers/Settings/SettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php similarity index 99% rename from src/Http/Controllers/Settings/SettingsController.php rename to src/Http/Controllers/Settings/BaseSettingsController.php index 5da268365b..a2abb20913 100644 --- a/src/Http/Controllers/Settings/SettingsController.php +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -13,7 +13,7 @@ use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; -abstract class SettingsController +abstract class BaseSettingsController { use RespondsWithFlash; diff --git a/src/Http/Controllers/Settings/EmailsController.php b/src/Http/Controllers/Settings/EmailsController.php index a60933cd9b..a3c6665674 100644 --- a/src/Http/Controllers/Settings/EmailsController.php +++ b/src/Http/Controllers/Settings/EmailsController.php @@ -24,7 +24,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class EmailsController extends SettingsController +class EmailsController extends BaseSettingsController { public function index(): string diff --git a/src/Http/Controllers/Settings/GatewaysController.php b/src/Http/Controllers/Settings/GatewaysController.php index 83dd282398..e379c2cf6b 100644 --- a/src/Http/Controllers/Settings/GatewaysController.php +++ b/src/Http/Controllers/Settings/GatewaysController.php @@ -17,7 +17,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class GatewaysController extends SettingsController +class GatewaysController extends BaseSettingsController { public function index(): string diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index d9823ed09f..dbac076f75 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -24,7 +24,7 @@ use function CraftCms\Cms\t; -class GeneralSettingsController extends SettingsController +class GeneralSettingsController extends BaseSettingsController { use RespondsWithFlash; diff --git a/src/Http/Controllers/Settings/LineItemStatusesController.php b/src/Http/Controllers/Settings/LineItemStatusesController.php index bd32e14678..a0b331f81e 100644 --- a/src/Http/Controllers/Settings/LineItemStatusesController.php +++ b/src/Http/Controllers/Settings/LineItemStatusesController.php @@ -19,7 +19,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class LineItemStatusesController extends SettingsController +class LineItemStatusesController extends BaseSettingsController { public function index(): string diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php index dc92c4cc59..849504734b 100644 --- a/src/Http/Controllers/Settings/OrderSettingsController.php +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -15,7 +15,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class OrderSettingsController extends SettingsController +class OrderSettingsController extends BaseSettingsController { public function edit(): string diff --git a/src/Http/Controllers/Settings/OrderStatusesController.php b/src/Http/Controllers/Settings/OrderStatusesController.php index cbce8ac586..b1d7b7653c 100644 --- a/src/Http/Controllers/Settings/OrderStatusesController.php +++ b/src/Http/Controllers/Settings/OrderStatusesController.php @@ -23,7 +23,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class OrderStatusesController extends SettingsController +class OrderStatusesController extends BaseSettingsController { public function index(): string diff --git a/src/Http/Controllers/Settings/PdfsController.php b/src/Http/Controllers/Settings/PdfsController.php index 32a46813eb..38c63e8529 100644 --- a/src/Http/Controllers/Settings/PdfsController.php +++ b/src/Http/Controllers/Settings/PdfsController.php @@ -19,7 +19,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class PdfsController extends SettingsController +class PdfsController extends BaseSettingsController { public function index(): string diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php index d1be20a811..758298db9e 100644 --- a/src/Http/Controllers/Settings/ProductTypesController.php +++ b/src/Http/Controllers/Settings/ProductTypesController.php @@ -20,7 +20,7 @@ use function CraftCms\Cms\currentUser; use function CraftCms\Cms\t; -class ProductTypesController extends SettingsController +class ProductTypesController extends BaseSettingsController { public function productTypeIndex(): CpScreenResponse { diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index bb20467cbb..08980f0554 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -21,7 +21,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class StoresController extends SettingsController +class StoresController extends BaseSettingsController { public function editStore(?int $storeId = null): string diff --git a/src/Http/Controllers/Settings/TransferSettingsController.php b/src/Http/Controllers/Settings/TransferSettingsController.php index d38ea98eba..c707c2543e 100644 --- a/src/Http/Controllers/Settings/TransferSettingsController.php +++ b/src/Http/Controllers/Settings/TransferSettingsController.php @@ -16,7 +16,7 @@ use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; -class TransferSettingsController extends SettingsController +class TransferSettingsController extends BaseSettingsController { use RespondsWithFlash; From 9045107460a834b01c1c2d8a0e210d957ae457a6 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 25 Aug 2026 16:29:01 +0100 Subject: [PATCH 05/32] Move transfer fields to form components --- .../Settings/GeneralSettingsController.php | 6 ++- .../Settings/TransferSettingsController.php | 45 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index dbac076f75..ebaf7fa2e4 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -78,9 +78,11 @@ public function edit(): CpScreenResponse mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, )); + $title = t('General Settings', category: 'commerce'); + return $this->cpScreenResponse() - ->title(t('General Settings', category: 'commerce')) - ->crumbs([$this->crumbs(t('General Settings', category: 'commerce'))]) + ->title($title) + ->crumbs($this->crumbs($title)) ->redirectUrl('commerce/settings/general') ->inertiaPage('Form', [ 'form' => $form, diff --git a/src/Http/Controllers/Settings/TransferSettingsController.php b/src/Http/Controllers/Settings/TransferSettingsController.php index c707c2543e..226fe25cbe 100644 --- a/src/Http/Controllers/Settings/TransferSettingsController.php +++ b/src/Http/Controllers/Settings/TransferSettingsController.php @@ -4,16 +4,20 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; +use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Transfer\Elements\Transfer; use CraftCms\Commerce\Transfer\Transfers; use Symfony\Component\HttpFoundation\Response; -use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; class TransferSettingsController extends BaseSettingsController @@ -53,14 +57,39 @@ public function saveTransferSettings(): Response return $this->asSuccess(t('Transfer fields saved.', category: 'commerce')); } - public function editTransferSettings(): string + public function editTransferSettings(): CpScreenResponse { $fieldLayout = app(Transfers::class)->getFieldLayout(); - return pageTemplate('commerce/settings/transfers/_edit', [ - 'fieldLayout' => $fieldLayout, - 'title' => t('Transfer Settings', category: 'commerce'), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $form = Form::make([ + Field::make(null, FieldLayoutDesigner::make('fieldLayout') + ->elementType(Transfer::class) + ->withCardViewDesigner()), + ]); + + $form = $this->formResolver->resolve($form, new FormContext( + values: [ + 'fieldLayout' => [ + 'id' => $fieldLayout->id, + 'uid' => $fieldLayout->uid, + ...($fieldLayout->getConfig() ?? []), + ], + ], + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + )); + + $title = t('Transfer Settings', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($title)) + ->redirectUrl('commerce/settings/transfers') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveTransferSettings']), + ], + ]); } } From beef2995dd021cb70ed97e7f5603fa32046558ed Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 1 Sep 2026 13:16:16 +0100 Subject: [PATCH 06/32] WIP product types settings controller --- routes/cp.php | 1 + .../Settings/BaseSettingsController.php | 10 +- .../Settings/ProductTypesController.php | 492 ++++++++++++++++-- .../PurchasableAvailableForPurchaseField.php | 1 - .../PurchasablePromotableField.php | 1 - .../PurchasableStockField.php | 1 - 6 files changed, 455 insertions(+), 51 deletions(-) diff --git a/routes/cp.php b/routes/cp.php index 20b3228b01..6ec05f5697 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -63,6 +63,7 @@ Route::get('commerce/settings/producttypes', [ProductTypesController::class, 'productTypeIndex']); Route::get('commerce/settings/producttypes/new', [ProductTypesController::class, 'editProductType']); + Route::post('commerce/settings/producttypes/render-form', [ProductTypesController::class, 'renderForm']); Route::get('commerce/settings/producttypes/{productTypeId}', [ProductTypesController::class, 'editProductType'])->whereNumber('productTypeId'); Route::get('commerce/settings/emails', [EmailsController::class, 'index']); diff --git a/src/Http/Controllers/Settings/BaseSettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php index a2abb20913..1119ae0a40 100644 --- a/src/Http/Controllers/Settings/BaseSettingsController.php +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -119,12 +119,12 @@ protected function subnav(): array } /** @return list> */ - protected function crumbs(string $title, ?string $url = null): array + protected function crumbs(?string $title = null, ?string $url = null): array { - return [ - ['label' => t('Settings'), 'href' => cp_url('settings')], - array_filter(['label' => $title, 'href' => $url]), - ]; + return array_filter([ + ['label' => t('Settings'), 'href' => cp_url('commerce/settings')], + $title && $url ? array_filter(['label' => $title, 'href' => $url]) : null, + ]); } protected function cpScreenResponse(): CpScreenResponse diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php index 6633edfc40..a3b76be940 100644 --- a/src/Http/Controllers/Settings/ProductTypesController.php +++ b/src/Http/Controllers/Settings/ProductTypesController.php @@ -4,34 +4,104 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use craft\web\assets\editsection\EditSectionAsset; +use CraftCms\Cms\Cp\SelectOptions; use CraftCms\Cms\Element\Enums\PropagationMethod; +use CraftCms\Cms\Field\Enums\TranslationMethod; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Number; +use CraftCms\Cms\Form\Controls\Table as TableControl; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\Heading; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Separator; +use CraftCms\Cms\Form\Nodes\Table; +use CraftCms\Cms\Form\Nodes\TemplateContent; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\Support\Html; use CraftCms\Commerce\Catalog\Elements\Product; use CraftCms\Commerce\Catalog\Elements\Variant; use CraftCms\Commerce\Catalog\Models\ProductTypeSite; use CraftCms\Commerce\Catalog\ProductType\Data\ProductType; use CraftCms\Commerce\Catalog\ProductType\ProductTypes; +use CraftCms\Commerce\Shipping\Models\ShippingCategory; +use CraftCms\Commerce\Tax\Models\TaxCategory; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\currentUser; -use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; class ProductTypesController extends BaseSettingsController { - public function productTypeIndex(): string + protected function crumbs(?string $title = null, ?string $url = null): array { - $productTypes = app(ProductTypes::class)->getAllProductTypes(); + $crumbs = parent::crumbs(t('Product Types', category: 'commerce'), cp_url('commerce/settings/producttypes')); - return pageTemplate('commerce/settings/producttypes/index', [ - 'productTypes' => $productTypes, - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + if ($title || $url) { + $crumbs[] = ['label' => $title, 'href' => $url]; + } + + return $crumbs; + } + + public function productTypeIndex(): CpScreenResponse + { + $canManageShipping = (bool)currentUser()?->can('commerce-manageShipping'); + $canManageTaxes = (bool)currentUser()?->can('commerce-manageTaxes'); + + $rows = array_map(fn(ProductType $productType) => [ + 'name' => [ + 'label' => t($productType->name, category: 'site'), + 'url' => $productType->getCpEditUrl(), + ], + 'handle' => $productType->handle, + 'maxVariants' => $productType->maxVariants ?? '', + 'shippingCategories' => array_map(fn(ShippingCategory $category) => [ + 'label' => t($category->name, category: 'site'), + 'url' => $canManageShipping ? $category->getCpEditUrl() : null, + ], $productType->getShippingCategories()), + 'taxCategories' => array_map(fn(TaxCategory $category) => [ + 'label' => t($category->name, category: 'site'), + 'url' => $canManageTaxes ? $category->getCpEditUrl() : null, + ], $productType->getTaxCategories()), + ], app(ProductTypes::class)->getAllProductTypes()); + + $title = t('Product Types', category: 'commerce'); + + $form = Form::make([ + Table::make('product-types') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'maxVariants', 'label' => t('Max Variants', category: 'commerce')], + ['key' => 'shippingCategories', 'label' => t('Available Shipping Categories', category: 'commerce')], + ['key' => 'taxCategories', 'label' => t('Available Tax Categories', category: 'commerce')], + ]) + ->rows(array_values($rows)) + ->emptyMessage(t('No product types exist yet.', category: 'commerce')) + ->createAction( + $this->readOnly ? null : t('New product type', category: 'commerce'), + $this->readOnly ? null : cp_url('commerce/settings/producttypes/new'), + ), + ]); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($title)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext()), + ]); } public function editProductType(?int $productTypeId = null): CpScreenResponse @@ -47,48 +117,384 @@ public function editProductType(?int $productTypeId = null): CpScreenResponse } $title = $productTypeId ? $productType->name : t('Create a new product type', category: 'commerce'); + $values = $this->initialValues($productType, $brandNewProductType); - \Craft::$app->getView()->registerAssetBundle(EditSectionAsset::class); + $form = $this->formResolver->resolve( + $this->buildForm($productType, $values, $brandNewProductType), + new FormContext( + values: $values, + mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable, + refreshable: !$this->readOnly, + ), + ); - return new CpScreenResponse() + return $this->cpScreenResponse() ->title($title) - ->crumbs([ - ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'], - ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')], - ['label' => t('Product Types', category: 'commerce'), 'url' => 'commerce/settings/producttypes'], - ]) - ->tabs([ - 'productTypeSettings' => [ - 'label' => t('Settings'), - 'url' => '#product-type-settings', + ->crumbs($this->crumbs($brandNewProductType ? null : $title)) + ->redirectUrl('commerce/settings/producttypes') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveProductType']), ], - 'taxAndShipping' => [ - 'label' => t('Tax & Shipping', category: 'commerce'), - 'url' => '#tax-and-shipping', + 'refreshUrl' => $this->readOnly ? null : action([self::class, 'renderForm']), + ]); + } + + /** + * Re-resolves the {@see editProductType()} Form tree for the values currently in progress + * on the client, so toggles like `isStructure`/`hasProductTitleField` can branch which + * fields appear next without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + // Validate for shape only — `Request::validate()` returns just the ruled subset + // (`validated()`), which would silently strip every field but `productTypeId` back + // out of `values`. Reading the raw input keeps the full posted values intact, which + // `buildForm()`'s branching (and every other control's live value) depends on. + $request->validate([ + 'values' => ['required', 'array'], + 'values.productTypeId' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $productTypeId = $values['productTypeId'] ?? null; + + if ($productTypeId) { + $productType = app(ProductTypes::class)->getProductTypeById((int) $productTypeId); + abort_if(!$productType, 404); + } else { + $productType = new ProductType(); + } + + // The client only posts values for controls currently in the rendered tree — a field + // hidden behind a toggle that's about to flip back on is absent, not merely empty. Layer + // the posted values over this model's real defaults so a newly-revealed field (e.g. + // `defaultPlacement` when `isStructure` flips on) gets its actual value instead of null. + $values = array_replace($this->initialValues($productType, brandNew: !$productTypeId), $values); + + $form = $this->formResolver->resolve( + $this->buildForm($productType, $values, brandNew: !$productTypeId), + new FormContext( + values: $values, + mode: ControlMode::Editable, + refreshable: true, + ), + ); + + return new JsonResponse(['form' => $form]); + } + + /** + * The Form's starting values, derived from a loaded (or brand new) {@see ProductType}. + * Only used for the initial page load — {@see renderForm()} uses the client's own + * in-progress values instead, so unsaved edits aren't clobbered on every toggle. + * + * @return array + */ + private function initialValues(ProductType $productType, bool $brandNew): array + { + $siteSettings = $productType->getSiteSettings(); + $siteRows = []; + + foreach (Sites::getAllSites() as $site) { + $settings = $siteSettings[$site->id] ?? null; + + $siteRows[$site->handle] = [ + 'name' => $site->getName(), + 'enabled' => $brandNew || $settings !== null, + 'uriFormat' => $settings->uriFormat ?? '', + 'template' => $settings->template ?? '', + 'enabledByDefault' => $settings === null || $settings->enabledByDefault, + ]; + } + + $productFieldLayout = $productType->getProductFieldLayout(); + $variantFieldLayout = $productType->getVariantFieldLayout(); + + return [ + 'productTypeId' => $productType->id, + 'name' => $productType->name, + 'handle' => $productType->handle, + 'isStructure' => $productType->isStructure, + 'defaultPlacement' => $productType->defaultPlacement, + 'maxLevels' => $productType->maxLevels ?? '', + 'enableVersioning' => $productType->enableVersioning, + 'hasProductTitleField' => $productType->hasProductTitleField, + 'productTitleFormat' => $productType->productTitleFormat, + 'productTitleTranslationMethod' => $productType->productTitleTranslationMethod, + 'productTitleTranslationKeyFormat' => $productType->productTitleTranslationKeyFormat ?? '', + 'productUiLabelFormat' => $productType->productUiLabelFormat, + 'showSlugField' => $productType->showSlugField, + 'slugTranslationMethod' => $productType->slugTranslationMethod, + 'slugTranslationKeyFormat' => $productType->slugTranslationKeyFormat ?? '', + 'skuFormat' => $productType->skuFormat ?? '', + 'descriptionFormat' => $productType->descriptionFormat, + 'maxVariants' => $productType->maxVariants ?? '', + 'hasDimensions' => $productType->hasDimensions, + 'hasVariantTitleField' => $productType->hasVariantTitleField, + 'variantTitleFormat' => $productType->variantTitleFormat, + 'variantTitleTranslationMethod' => $productType->variantTitleTranslationMethod, + 'variantTitleTranslationKeyFormat' => $productType->variantTitleTranslationKeyFormat ?? '', + 'variantUiLabelFormat' => $productType->variantUiLabelFormat, + 'sites' => $siteRows, + 'previewTargets' => $productType->previewTargets ?? [], + 'propagationMethod' => $productType->propagationMethod->value, + 'fieldLayout' => [ + 'id' => $productFieldLayout->id, + 'uid' => $productFieldLayout->uid, + ...($productFieldLayout->getConfig() ?? []), + ], + 'variant-layout' => [ + 'fieldLayout' => [ + 'id' => $variantFieldLayout->id, + 'uid' => $variantFieldLayout->uid, + ...($variantFieldLayout->getConfig() ?? []), ], - 'productFields' => [ - 'label' => t('Product Fields', category: 'commerce'), - 'url' => '#product-fields', + ], + ]; + } + + /** + * Builds the edit screen's Form tree, branching on `$values` (rather than reading straight + * off `$productType`) so {@see editProductType()} and {@see renderForm()} produce the exact + * same shape for the exact same values — one from a loaded model, the other from whatever the + * client just posted mid-edit. + * + * @param array $values + */ + private function buildForm(ProductType $productType, array $values, bool $brandNew): Form + { + $isMultiSite = Sites::isMultiSite(); + + $handle = Handle::make('handle'); + + if ($brandNew) { + $handle->source('name'); + } + + $isStructureField = Field::make( + t('Enable structure for products of this type', category: 'commerce'), + Lightswitch::make('isStructure'), + ); + + if ($productType->id) { + $isStructureField->warning(t('Changing this may result in data loss.')); + } + + $settingsFields = [ + HiddenField::make('productTypeId'), + Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this product type will be called in the control panel.', category: 'commerce')) + ->required(), + Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this product type in the templates.', category: 'commerce')) + ->required(), + $isStructureField, + ]; + + if ($values['isStructure'] ?? false) { + $settingsFields[] = Field::make( + t('Default {type} Placement', ['type' => t('Product', category: 'commerce')]), + Choice::make('defaultPlacement')->options([ + ['value' => 'beginning', 'label' => t('Before other {type}', ['type' => t('products', category: 'commerce')])], + ['value' => 'end', 'label' => t('After other {type}', ['type' => t('products', category: 'commerce')])], + ]), + )->instructions(t('Where new {type} should be placed by default in the structure.', ['type' => t('products', category: 'commerce')])); + + $settingsFields[] = Field::make(t('Max Levels'), Number::make('maxLevels')->min(1)->max(32767)->size(5)) + ->instructions(t('The maximum number of levels this product type can have. Leave blank if you don’t care.', category: 'commerce')); + } + + $settingsFields[] = Field::make(t('Enable versioning for products of this type', category: 'commerce'), Lightswitch::make('enableVersioning')); + $settingsFields[] = Field::make(t('Show the Title field for products', category: 'commerce'), Lightswitch::make('hasProductTitleField')); + + if ($values['hasProductTitleField'] ?? false) { + if ($isMultiSite) { + $settingsFields[] = Field::make( + t('{name} Translation Method', ['name' => t('Title')]), + Choice::make('productTitleTranslationMethod')->options(TranslationMethod::asOptions()), + )->instructions(t('How should {name} values be translated?', ['name' => t('Title')])); + + if (($values['productTitleTranslationMethod'] ?? null) === TranslationMethod::Custom->value) { + $settingsFields[] = Field::make( + t('{name} Translation Key Format', ['name' => t('Title')]), + Text::make('productTitleTranslationKeyFormat')->monospace(), + )->instructions(t('Template that defines the {name} field’s custom “translation key” format. Values will be copied to all sites that produce the same key.', ['name' => t('Title')])); + } + } + } else { + $settingsFields[] = Field::make(t('Product Title Format', category: 'commerce'), Text::make('productTitleFormat')->monospace()) + ->instructions(t('What the auto-generated product titles should look like. You can include tags that output product properties, such as {ex1} or {ex2}. All custom fields used must be set to required.', [ + 'ex1' => Html::code('{sku}'), + 'ex2' => Html::code('{myProductsCustomField}'), + ], category: 'commerce')); + } + + $settingsFields[] = Field::make(t('UI Label Format'), Text::make('productUiLabelFormat')->monospace()) + ->instructions(t('How products should be labeled within the control panel.', category: 'commerce')); + $settingsFields[] = Field::make(t('Show the Slug field'), Lightswitch::make('showSlugField')); + + if ($isMultiSite && ($values['showSlugField'] ?? false)) { + $settingsFields[] = Field::make( + t('{name} Translation Method', ['name' => t('Slug')]), + Choice::make('slugTranslationMethod')->options(TranslationMethod::asOptions()), + )->instructions(t('How should {name} values be translated?', ['name' => t('Slug')])); + + if (($values['slugTranslationMethod'] ?? null) === TranslationMethod::Custom->value) { + $settingsFields[] = Field::make( + t('{name} Translation Key Format', ['name' => t('Slug')]), + Text::make('slugTranslationKeyFormat')->monospace(), + )->instructions(t('Template that defines the {name} field’s custom "translation key" format. Values will be copied to all sites that produce the same key.', ['name' => t('Slug')])); + } + } + + $settingsFields[] = Field::make(t('Automatic SKU Format', category: 'commerce'), Text::make('skuFormat')->monospace()) + ->instructions(t('What the unique auto-generated SKUs should look like, when a SKU field is submitted without a value. You can include tags that output properties, such as {ex1} or {ex2}', [ + 'ex1' => Html::code('{product.slug}'), + 'ex2' => Html::code('{myVariantCustomField}'), + ], category: 'commerce')); + $settingsFields[] = Field::make(t('Order Description Format', category: 'commerce'), Text::make('descriptionFormat')->monospace()) + ->instructions(t('How this product will be described on a line item in an order. You can include tags that output properties, such as {ex1} or {ex2}', [ + 'ex1' => Html::code('{product.title}'), + 'ex2' => Html::code('{myVariantCustomField}'), + ], category: 'commerce')); + $settingsFields[] = Field::make(t('Max Variants', category: 'commerce'), Number::make('maxVariants')->size(2)); + $settingsFields[] = Field::make(t('Show the Dimensions and Weight fields for products of this type', category: 'commerce'), Lightswitch::make('hasDimensions')); + $settingsFields[] = Separator::make('variant-settings-separator'); + $settingsFields[] = Field::make(t('Show the Title field for variants', category: 'commerce'), Lightswitch::make('hasVariantTitleField')); + + if ($values['hasVariantTitleField'] ?? false) { + if ($isMultiSite) { + $settingsFields[] = Field::make( + t('{name} Translation Method', ['name' => t('Title')]), + Choice::make('variantTitleTranslationMethod')->options(TranslationMethod::asOptions()), + )->instructions(t('How should {name} values be translated?', ['name' => t('Title')])); + + if (($values['variantTitleTranslationMethod'] ?? null) === TranslationMethod::Custom->value) { + $settingsFields[] = Field::make( + t('{name} Translation Key Format', ['name' => t('Title')]), + Text::make('variantTitleTranslationKeyFormat')->monospace(), + )->instructions(t('Template that defines the {name} field’s custom “translation key” format. Values will be copied to all sites that produce the same key.', ['name' => t('Title')])); + } + } + } else { + $settingsFields[] = Field::make(t('Variant Title Format', category: 'commerce'), Text::make('variantTitleFormat')->monospace()) + ->instructions(t('What the auto-generated variant titles should look like. You can include tags that output variant properties, such as {ex1} or {ex2}. All custom fields used must be set to required.', [ + 'ex1' => Html::code('{sku}'), + 'ex2' => Html::code('{myVariantsCustomField}'), + ], category: 'commerce')); + } + + $settingsFields[] = Field::make(t('Variant UI Label Format', category: 'commerce'), Text::make('variantUiLabelFormat')->monospace()) + ->instructions(t('How variants should be labeled within the control panel.', category: 'commerce')); + $settingsFields[] = Separator::make('site-settings-separator'); + $settingsFields[] = Heading::make('site-settings-heading', t('Site Settings')) + ->description(t('Choose which sites this product type should be available in, and configure the site-specific settings.', category: 'commerce')); + $settingsFields[] = Field::make(control: TableControl::make('sites') + ->keyed() + ->columns([ + 'name' => ['heading' => t('Site'), 'type' => 'heading'], + 'enabled' => ['heading' => t('Enabled'), 'type' => 'lightswitch'], + 'uriFormat' => [ + 'heading' => t('Product URI Format', category: 'commerce'), + 'type' => 'singleline', + 'info' => t('What product URIs should look like for the site.', category: 'commerce'), ], - 'variantFields' => [ - 'label' => t('Variant Fields', category: 'commerce'), - 'url' => '#variant-fields', + 'template' => [ + 'heading' => t('Template'), + 'type' => 'template', + 'options' => SelectOptions::getTemplateSuggestions(), + 'info' => t('Which template should be loaded when a product’s URL is requested.', category: 'commerce'), ], + 'enabledByDefault' => ['heading' => t('Default Status'), 'type' => 'lightswitch'], + ])); + + if (!$this->generalConfig->headlessMode) { + $settingsFields[] = Separator::make('preview-targets-separator'); + $settingsFields[] = Heading::make('preview-targets-heading', t('Preview Targets')) + ->description(t('Locations that should be available for previewing products in this product type.', category: 'commerce')); + $settingsFields[] = Field::make(control: TableControl::make('previewTargets') + ->columns([ + 'label' => ['heading' => t('Label'), 'type' => 'singleline'], + 'urlFormat' => [ + 'heading' => t('URL Format'), + 'type' => 'singleline', + 'info' => t('The URL/URI to use for this target.'), + ], + 'refresh' => ['heading' => t('Auto-refresh'), 'type' => 'lightswitch'], + ]) + ->allowAdd() + ->allowDelete() + ->allowReorder()); + } + + if ($isMultiSite) { + $settingsFields[] = Field::make( + t('Propagation Method'), + Choice::make('propagationMethod')->options([ + ['value' => 'none', 'label' => t('Only save product to the site they were created in', category: 'commerce')], + ['value' => 'siteGroup', 'label' => t('Save product to other sites in the same site group', category: 'commerce')], + ['value' => 'language', 'label' => t('Save product to other sites with the same language', category: 'commerce')], + ['value' => 'all', 'label' => t('Save product to all sites enabled for this product type', category: 'commerce')], + ['value' => 'custom', 'label' => t('Let each product choose which sites it should be saved to', category: 'commerce')], + ]), + )->instructions(t('Of the enabled sites above, which sites should products in this product type be saved to?', category: 'commerce')); + } + + return Form::make() + ->addTab(t('Settings'), $settingsFields) + ->addTab(t('Tax & Shipping', category: 'commerce'), [ + Heading::make('shipping-categories-heading', t('Available Shipping Categories', category: 'commerce')), + TemplateContent::make('shipping-categories', $this->categoriesHtml( + $productType->getShippingCategories(), + (bool) currentUser()?->can('commerce-manageShipping'), + )), + Separator::make('tax-shipping-separator'), + Heading::make('tax-categories-heading', t('Available Tax Categories', category: 'commerce')), + TemplateContent::make('tax-categories', $this->categoriesHtml( + $productType->getTaxCategories(), + (bool) currentUser()?->can('commerce-manageTaxes'), + )), ]) - ->selectedSubnavItem('settings') - ->action('commerce/product-types/save-product-type') - ->submitButtonLabel(t('Save')) - ->redirectUrl('commerce/settings/producttypes') - ->contentTemplate('commerce/settings/producttypes/_edit', [ - 'productTypeId' => $productTypeId, - 'productType' => $productType, - 'brandNewProductType' => $brandNewProductType, - 'title' => $title, - 'selectedTab' => 'productTypeSettings', - 'readOnly' => $this->readOnly, + ->addTab(t('Product Fields', category: 'commerce'), [ + Field::make(null, FieldLayoutDesigner::make('fieldLayout') + ->elementType(Product::class) + ->withCardViewDesigner()), + ]) + ->addTab(t('Variant Fields', category: 'commerce'), [ + Field::make(null, FieldLayoutDesigner::make('variant-layout.fieldLayout') + ->elementType(Variant::class) + ->withCardViewDesigner()), ]); } + /** + * @param ShippingCategory[]|TaxCategory[] $categories + * + * Category names are user-entered, so every name is run through {@see Html::encode()} + * before it's embedded — `Html::tag()`/`Html::a()` don't encode their content themselves + * (they assume the caller already has), and this content is otherwise-unsanitized HTML by + * the time {@see TemplateContent::make()} sanitizes it, so this can't rely on that alone. + */ + private function categoriesHtml(array $categories, bool $canManage): string + { + if (empty($categories)) { + return Html::tag('p', Html::encode(t('None', category: 'commerce'))); + } + + $items = implode('', array_map( + fn($category) => Html::tag('li', $canManage + ? Html::a(Html::encode(t($category->name, category: 'site')), $category->getCpEditUrl()) + : Html::encode(t($category->name, category: 'site'))), + $categories, + )); + + return Html::tag('ul', $items, ['class' => 'bullets']); + } + public function saveProductType(Request $request): ?Response { abort_unless(currentUser()?->can('manageCommerce'), 403, t('This action is not allowed for the current user.', category: 'commerce')); @@ -109,7 +515,7 @@ public function saveProductType(Request $request): ?Response $productType->enableVersioning = $request->input('enableVersioning') ?? $productType->enableVersioning; $productType->hasDimensions = (bool)$request->input('hasDimensions'); $productType->hasProductTitleField = (bool)$request->input('hasProductTitleField'); - $productType->productTitleFormat = $request->input('productTitleFormat'); + $productType->productTitleFormat = $request->input('productTitleFormat', $productType->productTitleFormat) ?? ''; $productType->productUiLabelFormat = $request->input('productUiLabelFormat'); $productType->productTitleTranslationMethod = $request->input('productTitleTranslationMethod', $productType->productTitleTranslationMethod); $productType->productTitleTranslationKeyFormat = $request->input('productTitleTranslationKeyFormat', $productType->productTitleTranslationKeyFormat); @@ -119,7 +525,7 @@ public function saveProductType(Request $request): ?Response $maxVariants = $request->input('maxVariants'); $productType->maxVariants = $maxVariants ? (int)$maxVariants : null; $productType->hasVariantTitleField = $request->input('hasVariantTitleField', false); - $productType->variantTitleFormat = $request->input('variantTitleFormat'); + $productType->variantTitleFormat = $request->input('variantTitleFormat', $productType->variantTitleFormat) ?? ''; $productType->variantUiLabelFormat = $request->input('variantUiLabelFormat'); $productType->variantTitleTranslationMethod = $request->input('variantTitleTranslationMethod', $productType->variantTitleTranslationMethod); $productType->variantTitleTranslationKeyFormat = $request->input('variantTitleTranslationKeyFormat', $productType->variantTitleTranslationKeyFormat); @@ -129,7 +535,7 @@ public function saveProductType(Request $request): ?Response $productType->isStructure = $request->input('isStructure'); $maxLevels = (int)$request->input('maxLevels'); $productType->maxLevels = $maxLevels ?: null; // zero should be null - $productType->defaultPlacement = $request->input('defaultPlacement'); + $productType->defaultPlacement = $request->input('defaultPlacement', $productType->defaultPlacement) ?? $productType->defaultPlacement; $productType->previewTargets = $request->input('previewTargets') ?: []; // Site-specific settings diff --git a/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php b/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php index d96f7dfad9..e98ef84e0a 100644 --- a/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php +++ b/src/Purchasable/FieldLayoutElements/PurchasableAvailableForPurchaseField.php @@ -45,7 +45,6 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f ]); } - #[Override] protected function settingsHtml(): ?string { return parent::settingsHtml() . FormFields::lightswitchFromConfig([ diff --git a/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php b/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php index 19abf5ea55..184383d8cc 100644 --- a/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php +++ b/src/Purchasable/FieldLayoutElements/PurchasablePromotableField.php @@ -48,7 +48,6 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f ])->toHtml(); } - #[Override] protected function settingsHtml(): ?string { return parent::settingsHtml() . FormFields::lightswitchFromConfig([ diff --git a/src/Purchasable/FieldLayoutElements/PurchasableStockField.php b/src/Purchasable/FieldLayoutElements/PurchasableStockField.php index f8941b6b97..0250243f4a 100644 --- a/src/Purchasable/FieldLayoutElements/PurchasableStockField.php +++ b/src/Purchasable/FieldLayoutElements/PurchasableStockField.php @@ -217,7 +217,6 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f Html::endTag('div'); } - #[Override] protected function settingsHtml(): ?string { $lightSwitches = FormFields::lightswitchFromConfig([ From 2eccb18f39c76ae68782ffc5cc79e0cd9d2f8c24 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 1 Sep 2026 14:04:58 +0100 Subject: [PATCH 07/32] WIP stores settings index action --- .../Controllers/Settings/StoresController.php | 82 +++++++++++++++---- 1 file changed, 67 insertions(+), 15 deletions(-) diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index efb3c11c92..3c7bb2b895 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -5,14 +5,19 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\db\Query; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Table; +use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Url; use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\CatalogPricing\CatalogPricingRules; -use CraftCms\Commerce\Database\Table; +use CraftCms\Commerce\Database\Table as DbTable; use CraftCms\Commerce\Order\Elements\Order; use CraftCms\Commerce\Payment\Currencies; +use CraftCms\Commerce\Plugin; use CraftCms\Commerce\Store\Data\Store; use CraftCms\Commerce\Store\Stores; @@ -122,7 +127,7 @@ public function saveStore(Request $request): Response $store->uid = $savedStore->uid; $store->sortOrder = $savedStore->sortOrder; } elseif (!$storeId) { - $store->sortOrder = new Query()->from(Table::STORES)->max('[[sortOrder]]') + 1; + $store->sortOrder = new Query()->from(DbTable::STORES)->max('[[sortOrder]]') + 1; } if (!$store->validate() || !$storesService->saveStore($store)) { @@ -138,14 +143,10 @@ public function saveStore(Request $request): Response return $this->asModelSuccess($store, t('Store saved.'), 'store'); } - public function storesIndex(): string + public function storesIndex(): CpScreenResponse { $stores = app(Stores::class)->getAllStores(); - $crumbs = [ - ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], - ]; - $menuItems = []; $stores->each(function(Store $s) use (&$menuItems) { $m = []; @@ -168,14 +169,65 @@ public function storesIndex(): string $menuItems[$s->handle] = $m; }); - return pageTemplate('commerce/settings/stores/index', [ - 'stores' => $stores, - 'crumbs' => $crumbs, - 'sitesStores' => app(Stores::class)->getAllSiteStores(), - 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id, - 'menuItems' => $menuItems, - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $rows = $stores->map(fn(Store $s) => [ + 'id' => $s->id, + 'name' => [ + 'label' => t($s->getName(), category: 'site'), + 'url' => Url::cpUrl('commerce/settings/stores/' . $s->id), + ], + 'handle' => $s->handle, + 'sites' => $s->getSiteNames()->join(', '), + 'currency' => $s->getCurrency()?->getCode() ?? '', + 'primary' => $s->primary ? t('Yes') : '', + 'management' => [ + 'label' => t('Store Management', category: 'commerce'), + 'items' => $menuItems[$s->handle], + ], + '_deletable' => !$s->primary, + ])->all(); + + $title = t('Stores'); + + $showNewStoreButton = !$this->readOnly && $stores->count() < count(Sites::getAllSites()); + + if ($showNewStoreButton) { + $showNewStoreButton = (Plugin::getInstance()->is(Plugin::EDITION_PRO, '=') + && $stores->count() < Plugin::EDITION_PRO_STORE_LIMIT + && app(CatalogPricingRules::class)->canUseCatalogPricingRules()) + || (Plugin::getInstance()->is(Plugin::EDITION_ENTERPRISE, '=') + && app(CatalogPricingRules::class)->canUseCatalogPricingRules()); + } + + $form = Form::make([ + Table::make('stores') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'sites', 'label' => t('Sites', category: 'commerce')], + ['key' => 'currency', 'label' => t('Currency', category: 'commerce')], + ['key' => 'primary', 'label' => t('Primary', category: 'commerce')], + ['key' => 'management', 'label' => t('Store Management', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No stores exist yet.', category: 'commerce')) + ->createAction( + $showNewStoreButton ? t('New store') : null, + $showNewStoreButton ? Url::cpUrl('commerce/settings/stores/new') : null, + ) + ->when(!$this->readOnly, fn(Table $table) => $table + ->reorderable(action([self::class, 'reorderStores'])) + ->deletable( + action([self::class, 'deleteStore']), + t('Are you sure you want to permanently delete this store and everything in it?', category: 'commerce'), + )), + ]); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($title)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext()), + ]); } public function deleteStore(Request $request): Response From b001566714355aef396e537453cfb97e20a99ffa Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Tue, 1 Sep 2026 14:22:35 +0100 Subject: [PATCH 08/32] WIP edit store --- .../Controllers/Settings/StoresController.php | 179 ++++++++++++++++-- src/Store/Stores.php | 4 +- 2 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index 3c7bb2b895..bed3760db8 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -5,8 +5,15 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\db\Query; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Enums\ControlMode; use CraftCms\Cms\Form\Form; use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\HiddenField; use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Sites; @@ -23,13 +30,24 @@ use CraftCms\Commerce\Store\Stores; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; class StoresController extends BaseSettingsController { + protected function crumbs(?string $title = null, ?string $url = null): array + { + $crumbs = parent::crumbs(t('Stores'), cp_url('commerce/settings/stores')); + + if ($title || $url) { + $crumbs[] = ['label' => $title, 'href' => $url]; + } + + return $crumbs; + } - public function editStore(?int $storeId = null): string + public function editStore(?int $storeId = null): CpScreenResponse { $storesService = app(Stores::class); @@ -49,12 +67,6 @@ public function editStore(?int $storeId = null): string $title = t('Create a new Store'); } - $crumbs = [ - ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], - ['label' => t('Settings', category: 'commerce'), 'url' => Url::url('commerce/settings')], - ['label' => t('Stores'), 'url' => Url::url('commerce/settings/stores')], - ]; - $hasOrders = $storeModel->id && Order::find() ->trashed(null) ->storeId($storeModel->id) @@ -75,18 +87,145 @@ public function editStore(?int $storeId = null): string $currencyOptions = app(Currencies::class)->getAllCurrenciesList(); - return pageTemplate('commerce/settings/stores/_edit', [ - 'brandNewStore' => $brandNewStore, - 'allowCurrencyChange' => $allowCurrencyChange, - 'title' => $title, - 'crumbs' => $crumbs, - 'store' => $storeModel, - 'currencyOptions' => $currencyOptions, - 'availableSiteOptions' => $availableSiteOptions, - 'freeOrderPaymentStrategyOptions' => $storeModel->getFreeOrderPaymentStrategyOptions(), - 'minimumTotalPriceStrategyOptions' => $storeModel->getMinimumTotalPriceStrategyOptions(), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $form = $this->buildStoreForm($storeModel, $brandNewStore, $allowCurrencyChange, $availableSiteOptions, $currencyOptions); + $values = $this->storeInitialValues($storeModel); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($brandNewStore ? null : $title)) + ->redirectUrl('commerce/settings/stores') + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext( + values: $values, + mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable, + )), + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveStore']), + ], + ]); + } + + /** + * @param list $availableSiteOptions + * @param list $currencyOptions + */ + private function buildStoreForm( + Store $storeModel, + bool $brandNewStore, + bool $allowCurrencyChange, + array $availableSiteOptions, + array $currencyOptions, + ): Form { + $currencyControl = Choice::make('currency')->options($currencyOptions); + + if (!$allowCurrencyChange) { + $currencyControl->mode(ControlMode::Disabled); + } + + $currencyField = Field::make(t('Currency', category: 'commerce'), $currencyControl)->required(); + + if (!$allowCurrencyChange) { + $currencyField->tip(t('The primary currency cannot be changed after orders are placed.', category: 'commerce')); + } + + $handle = Handle::make('handle'); + + if ($brandNewStore) { + $handle->source('name'); + } + + $storeFields = [ + $brandNewStore ? null : HiddenField::make('storeId'), + Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->required(), + Field::make(t('Handle', category: 'app'), $handle) + ->instructions(t('How you’ll refer to this store in the templates.', category: 'app')) + ->required(), + $brandNewStore + ? Field::make(t('Sites', category: 'commerce'), Choice::make('siteId')->options($availableSiteOptions)) + ->instructions(t('Every new store must be assigned to at least one site.', category: 'commerce')) + : null, + $currencyField, + $storeModel->primary + ? null + : Field::make(t('Make this the primary store', category: 'commerce'), Lightswitch::make('primary')), + ]; + + $settingsFields = [ + Field::make(t('Auto Set New Cart Addresses', category: 'commerce'), Lightswitch::make('autoSetNewCartAddresses')) + ->instructions(t('Whether the user’s primary shipping and billing addresses should be set automatically on new carts.', category: 'commerce')), + Field::make(t('Auto Set Cart Shipping Method Option', category: 'commerce'), Lightswitch::make('autoSetCartShippingMethodOption')) + ->instructions(t('Whether the first available shipping method option should be set automatically on carts.', category: 'commerce')), + Field::make(t('Auto Set Payment Source', category: 'commerce'), Lightswitch::make('autoSetPaymentSource')) + ->instructions(t('Whether the user’s primary payment source should be set automatically on new carts.', category: 'commerce')), + Field::make(t('Allow Empty Cart On Checkout', category: 'commerce'), Lightswitch::make('allowEmptyCartOnCheckout')), + Field::make(t('Allow Checkout Without Payment', category: 'commerce'), Lightswitch::make('allowCheckoutWithoutPayment')), + Field::make(t('Allow Partial Payment On Checkout', category: 'commerce'), Lightswitch::make('allowPartialPaymentOnCheckout')), + Field::make(t('Free Order Payment Strategy', category: 'commerce'), Choice::make('freeOrderPaymentStrategy') + ->options(self::choiceOptions($storeModel->getFreeOrderPaymentStrategyOptions()))) + ->instructions(t('Strategy to apply when an order is free or has a zero balance.', category: 'commerce')) + ->required(), + Field::make(t('Minimum Total Price Strategy', category: 'commerce'), Choice::make('minimumTotalPriceStrategy') + ->options(self::choiceOptions($storeModel->getMinimumTotalPriceStrategyOptions()))) + ->instructions(t('Strategy to apply when calculating the minimum order price.', category: 'commerce')) + ->required(), + Field::make(t('Require Shipping Address At Checkout', category: 'commerce'), Lightswitch::make('requireShippingAddressAtCheckout')), + Field::make(t('Require Billing Address At Checkout', category: 'commerce'), Lightswitch::make('requireBillingAddressAtCheckout')), + Field::make(t('Require Shipping Method Selection At Checkout', category: 'commerce'), Lightswitch::make('requireShippingMethodSelectionAtCheckout')), + Field::make(t('Use Billing Address For Tax', category: 'commerce'), Lightswitch::make('useBillingAddressForTax')), + Field::make(t('Validate Business Tax ID as Vat ID', category: 'commerce'), Lightswitch::make('validateOrganizationTaxIdAsVatId')), + Field::make(t('Order Reference Number Format', category: 'commerce'), Text::make('orderReferenceFormat')->monospace()) + ->instructions(t('A friendly reference number will be generated based on this format when a cart is completed and becomes an order. For example {ex1}, or {ex2}. The result of this format must be unique.', [ + 'ex1' => '2018-{number[:7]}', + 'ex2' => "{{object.dateCompleted|date('y')}}-{{ seq(object.dateCompleted|date('y'), 8) }}", + ], category: 'commerce')), + ]; + + return Form::make() + ->addTab(t('Store', category: 'commerce'), array_values(array_filter($storeFields))) + ->addTab(t('Settings', category: 'commerce'), $settingsFields); + } + + /** @return array */ + private function storeInitialValues(Store $storeModel): array + { + return [ + 'storeId' => $storeModel->id, + 'name' => $storeModel->getName(false), + 'handle' => $storeModel->handle, + 'siteId' => null, + 'currency' => $storeModel->getCurrency()?->getCode(), + 'primary' => $storeModel->primary, + 'autoSetNewCartAddresses' => $storeModel->getAutoSetNewCartAddresses(), + 'autoSetCartShippingMethodOption' => $storeModel->getAutoSetCartShippingMethodOption(), + 'autoSetPaymentSource' => $storeModel->getAutoSetPaymentSource(), + 'allowEmptyCartOnCheckout' => $storeModel->getAllowEmptyCartOnCheckout(), + 'allowCheckoutWithoutPayment' => $storeModel->getAllowCheckoutWithoutPayment(), + 'allowPartialPaymentOnCheckout' => $storeModel->getAllowPartialPaymentOnCheckout(), + 'freeOrderPaymentStrategy' => $storeModel->getFreeOrderPaymentStrategy(), + 'minimumTotalPriceStrategy' => $storeModel->getMinimumTotalPriceStrategy(), + 'requireShippingAddressAtCheckout' => $storeModel->getRequireShippingAddressAtCheckout(), + 'requireBillingAddressAtCheckout' => $storeModel->getRequireBillingAddressAtCheckout(), + 'requireShippingMethodSelectionAtCheckout' => $storeModel->getRequireShippingMethodSelectionAtCheckout(), + 'useBillingAddressForTax' => $storeModel->getUseBillingAddressForTax(), + 'validateOrganizationTaxIdAsVatId' => $storeModel->getValidateOrganizationTaxIdAsVatId(), + 'orderReferenceFormat' => $storeModel->getOrderReferenceFormat(), + ]; + } + + /** + * @param array $options Value-keyed label map, as returned by e.g. + * {@see Store::getFreeOrderPaymentStrategyOptions()}. + * @return list + */ + private static function choiceOptions(array $options): array + { + return array_map( + fn(string $value, string $label) => ['value' => $value, 'label' => $label], + array_keys($options), + $options, + ); } public function saveStore(Request $request): Response @@ -224,7 +363,7 @@ public function storesIndex(): CpScreenResponse return $this->cpScreenResponse() ->title($title) - ->crumbs($this->crumbs($title)) + ->crumbs($this->crumbs()) ->inertiaPage('Form', [ 'form' => $this->formResolver->resolve($form, new FormContext()), ]); diff --git a/src/Store/Stores.php b/src/Store/Stores.php index af84dac415..f05e8a90d6 100644 --- a/src/Store/Stores.php +++ b/src/Store/Stores.php @@ -576,10 +576,12 @@ public function getAllSiteStores(): Collection public function getSiteIdsAvailableForAssignmentToNewStores(): array { // Sites that are assigned to more than one store + // Note: COUNT(*) here, not COUNT(storeId) — the latter is an unquoted identifier inside + // raw SQL, which Postgres folds to lowercase (`storeid`), a column that doesn't exist. $storeIds = DB::table(Table::SITESTORES) ->select('storeId') ->groupBy('storeId') - ->havingRaw('COUNT(storeId) > 1') + ->havingRaw('COUNT(*) > 1') ->pluck('storeId'); return DB::table(Table::SITESTORES) From 9f117857de02f17099fb5c45e7960afddde2c193 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 2 Sep 2026 10:11:34 +0100 Subject: [PATCH 09/32] fixed importing correct classes --- src/Http/Controllers/Settings/ProductTypesController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php index ba5eee6b14..6081ca3694 100644 --- a/src/Http/Controllers/Settings/ProductTypesController.php +++ b/src/Http/Controllers/Settings/ProductTypesController.php @@ -32,8 +32,8 @@ use CraftCms\Commerce\Catalog\Elements\Variant; use CraftCms\Commerce\Catalog\ProductType\Data\ProductType; use CraftCms\Commerce\Catalog\ProductType\ProductTypes; -use CraftCms\Commerce\Shipping\Models\ShippingCategory; -use CraftCms\Commerce\Tax\Models\TaxCategory; +use CraftCms\Commerce\Shipping\Data\ShippingCategory; +use CraftCms\Commerce\Tax\Data\TaxCategory; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; From baa782ed5853032c9f0f48175e5554b4bd47e693 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 2 Sep 2026 10:11:49 +0100 Subject: [PATCH 10/32] Use html columns where required --- src/Http/Controllers/Settings/StoresController.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index bed3760db8..8b8d457898 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -17,6 +17,7 @@ use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Url; use CraftCms\Cms\View\TemplateMode; @@ -316,8 +317,8 @@ public function storesIndex(): CpScreenResponse ], 'handle' => $s->handle, 'sites' => $s->getSiteNames()->join(', '), - 'currency' => $s->getCurrency()?->getCode() ?? '', - 'primary' => $s->primary ? t('Yes') : '', + 'currency' => ['html' => Html::tag('code', Html::encode($s->getCurrency()?->getCode() ?? ''))], + 'primary' => $s->primary ? ['icon' => 'check', 'label' => t('Yes')] : '', 'management' => [ 'label' => t('Store Management', category: 'commerce'), 'items' => $menuItems[$s->handle], From 4e881d52aef49dd024b359cc81349c3d97ba6277 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 2 Sep 2026 10:12:08 +0100 Subject: [PATCH 11/32] Fixed fallbacks --- src/Store/Stores.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Store/Stores.php b/src/Store/Stores.php index f05e8a90d6..53454cf135 100644 --- a/src/Store/Stores.php +++ b/src/Store/Stores.php @@ -306,9 +306,9 @@ public function handleChangedStore(ConfigEvent $event): void $isNewStore = !$storeRecord->exists; $storeRecord->uid = $storeUid; - $storeRecord->name = $data['name']; - $storeRecord->handle = $data['handle']; - $storeRecord->primary = $data['primary']; + $storeRecord->name = $data['name'] ?? $storeRecord->name; + $storeRecord->handle = $data['handle'] ?? $storeRecord->handle; + $storeRecord->primary = $data['primary'] ?? $storeRecord->primary; $storeRecord->autoSetNewCartAddresses = ($data['autoSetNewCartAddresses'] ?? false); $storeRecord->autoSetCartShippingMethodOption = ($data['autoSetCartShippingMethodOption'] ?? false); From 7100b54b43815226b80bff81655687bbeed6fb4d Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 9 Sep 2026 14:31:35 +0100 Subject: [PATCH 12/32] Site/stores association using form components --- .../Controllers/Settings/StoresController.php | 64 ++++++++++++++----- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index 8b8d457898..f4af2de623 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Form\Controls\Choice; use CraftCms\Cms\Form\Controls\Handle; use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Table as TableControl; use CraftCms\Cms\Form\Controls\Text; use CraftCms\Cms\Form\Enums\ControlMode; use CraftCms\Cms\Form\Form; @@ -20,7 +21,6 @@ use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Url; -use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\CatalogPricing\CatalogPricingRules; use CraftCms\Commerce\Database\Table as DbTable; use CraftCms\Commerce\Order\Elements\Order; @@ -32,7 +32,6 @@ use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\cp_url; -use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; class StoresController extends BaseSettingsController @@ -396,20 +395,54 @@ public function reorderStores(Request $request): Response return $this->asSuccess(); } - public function editSiteStores(): string + public function editSiteStores(): CpScreenResponse { - $crumbs = [ - ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')], - ]; + $storesService = app(Stores::class); + $sitesStores = $storesService->getAllSiteStores(); + $primaryStoreId = $storesService->getPrimaryStore()->id; + + $storeOptions = $storesService->getAllStores()->map(fn(Store $store) => [ + 'label' => $store->getName(), + 'value' => $store->id, + ])->all(); + + $rows = []; + + foreach (Sites::getAllSites() as $site) { + $siteStore = $sitesStores->count() > 0 ? $sitesStores->firstWhere('siteId', $site->id) : null; + + $rows[$site->id] = [ + 'site' => t($site->name, category: 'site'), + 'storeId' => $siteStore->storeId ?? $primaryStoreId, + ]; + } + + $form = Form::make([ + Field::make(null, TableControl::make('siteStores') + ->columns([ + 'site' => ['type' => 'heading', 'heading' => t('Site')], + 'storeId' => ['type' => 'select', 'heading' => t('Store', category: 'commerce'), 'options' => $storeOptions], + ]) + ->keyed()), + ]); + + $values = ['siteStores' => $rows]; + $title = t('Sites'); - return pageTemplate('commerce/settings/stores/_siteStore', [ - 'crumbs' => $crumbs, - 'stores' => app(Stores::class)->getAllStores(), - 'sites' => Sites::getAllSites(), - 'sitesStores' => app(Stores::class)->getAllSiteStores(), - 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id, - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($title)) + ->redirectUrl('commerce/settings/sites') + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext( + values: $values, + mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable, + )), + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveSiteStores']), + ], + ]); } public function saveSiteStores(Request $request): Response @@ -420,7 +453,8 @@ public function saveSiteStores(Request $request): Response foreach ($sitesStores as $siteStore) { if (isset($siteStoresData[$siteStore->siteId])) { - $siteStore->storeId = $siteStoresData[$siteStore->siteId]['storeId']; + $storeId = $siteStoresData[$siteStore->siteId]['storeId']; + $siteStore->storeId = $storeId !== null && $storeId !== '' ? (int) $storeId : null; } } From 7b0b51ea078f257ef3638ef8039dcc07b3a9ca25 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 9 Sep 2026 15:01:03 +0100 Subject: [PATCH 13/32] Order field layout designer converted --- .../Settings/OrderSettingsController.php | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php index 849504734b..09fd17af74 100644 --- a/src/Http/Controllers/Settings/OrderSettingsController.php +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -4,29 +4,55 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; +use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Order\Elements\Order; use CraftCms\Commerce\Order\Orders; use Symfony\Component\HttpFoundation\Response; -use function CraftCms\Cms\pageTemplate; use function CraftCms\Cms\t; class OrderSettingsController extends BaseSettingsController { - public function edit(): string + public function edit(): CpScreenResponse { $fieldLayout = Fields::getLayoutByType(Order::class); + $title = t('Order Settings', category: 'commerce'); - return pageTemplate('commerce/settings/ordersettings/_edit', [ - 'fieldLayout' => $fieldLayout, - 'title' => t('Order Settings', category: 'commerce'), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $form = Form::make([ + Field::make(null, FieldLayoutDesigner::make('fieldLayout') + ->elementType(Order::class) + ->withCardViewDesigner()), + ]); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs($title)) + ->redirectUrl('commerce/settings/ordersettings') + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext( + values: [ + 'fieldLayout' => [ + 'id' => $fieldLayout->id, + 'uid' => $fieldLayout->uid, + ...($fieldLayout->getConfig() ?? []), + ], + ], + mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable, + )), + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + ]); } public function save(): Response From ad6957645e1d85babf17917ebb636bcf5721ec27 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 10 Sep 2026 09:35:25 +0100 Subject: [PATCH 14/32] fix cs --- src/Http/Controllers/Settings/BaseSettingsController.php | 3 +-- src/Http/Controllers/Settings/EmailsController.php | 1 - src/Http/Controllers/Settings/GatewaysController.php | 1 - src/Http/Controllers/Settings/GeneralSettingsController.php | 3 --- src/Http/Controllers/Settings/LineItemStatusesController.php | 1 - src/Http/Controllers/Settings/OrderSettingsController.php | 1 - src/Http/Controllers/Settings/OrderStatusesController.php | 1 - src/Http/Controllers/Settings/PdfsController.php | 1 - 8 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Http/Controllers/Settings/BaseSettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php index 1119ae0a40..1e71573178 100644 --- a/src/Http/Controllers/Settings/BaseSettingsController.php +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -22,8 +22,7 @@ abstract class BaseSettingsController public function __construct( protected GeneralConfig $generalConfig, protected FormResolver $formResolver, - ) - { + ) { $this->readOnly = !$generalConfig->allowAdminChanges; } diff --git a/src/Http/Controllers/Settings/EmailsController.php b/src/Http/Controllers/Settings/EmailsController.php index a47473dba3..349be3467f 100644 --- a/src/Http/Controllers/Settings/EmailsController.php +++ b/src/Http/Controllers/Settings/EmailsController.php @@ -26,7 +26,6 @@ class EmailsController extends BaseSettingsController { - public function index(): string { $emails = []; diff --git a/src/Http/Controllers/Settings/GatewaysController.php b/src/Http/Controllers/Settings/GatewaysController.php index e379c2cf6b..6ccdce3f99 100644 --- a/src/Http/Controllers/Settings/GatewaysController.php +++ b/src/Http/Controllers/Settings/GatewaysController.php @@ -19,7 +19,6 @@ class GatewaysController extends BaseSettingsController { - public function index(): string { $gateways = app(Gateways::class)->getAllGateways(); diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index ebaf7fa2e4..ecd927ed89 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -5,19 +5,16 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\commerce\Plugin; -use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Form\Controls\Combobox; use CraftCms\Cms\Form\Enums\ControlMode; use CraftCms\Cms\Form\Form; use CraftCms\Cms\Form\FormContext; -use CraftCms\Cms\Form\FormResolver; use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\Heading; use CraftCms\Cms\Form\Nodes\Separator; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Plugins; -use CraftCms\Cms\Support\Url; use Illuminate\Http\Request; use Illuminate\Support\Facades\Config; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Http/Controllers/Settings/LineItemStatusesController.php b/src/Http/Controllers/Settings/LineItemStatusesController.php index 4a9e8bcb4f..100e4b25c2 100644 --- a/src/Http/Controllers/Settings/LineItemStatusesController.php +++ b/src/Http/Controllers/Settings/LineItemStatusesController.php @@ -21,7 +21,6 @@ class LineItemStatusesController extends BaseSettingsController { - public function index(): string { $lineItemStatuses = []; diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php index 09fd17af74..9334f977bf 100644 --- a/src/Http/Controllers/Settings/OrderSettingsController.php +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -21,7 +21,6 @@ class OrderSettingsController extends BaseSettingsController { - public function edit(): CpScreenResponse { $fieldLayout = Fields::getLayoutByType(Order::class); diff --git a/src/Http/Controllers/Settings/OrderStatusesController.php b/src/Http/Controllers/Settings/OrderStatusesController.php index 6c00cd5c6b..5645509ee7 100644 --- a/src/Http/Controllers/Settings/OrderStatusesController.php +++ b/src/Http/Controllers/Settings/OrderStatusesController.php @@ -25,7 +25,6 @@ class OrderStatusesController extends BaseSettingsController { - public function index(): string { $orderStatuses = []; diff --git a/src/Http/Controllers/Settings/PdfsController.php b/src/Http/Controllers/Settings/PdfsController.php index 54e35e09da..2984014db1 100644 --- a/src/Http/Controllers/Settings/PdfsController.php +++ b/src/Http/Controllers/Settings/PdfsController.php @@ -21,7 +21,6 @@ class PdfsController extends BaseSettingsController { - public function index(): string { $pdfs = []; From 1a8778efa97fdf19712a1d729c27393971a0e705 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 10 Sep 2026 09:41:22 +0100 Subject: [PATCH 15/32] Small PHPStan fix --- src/Http/Controllers/Settings/BaseSettingsController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/Settings/BaseSettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php index 1e71573178..91c2fd3484 100644 --- a/src/Http/Controllers/Settings/BaseSettingsController.php +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -122,7 +122,7 @@ protected function crumbs(?string $title = null, ?string $url = null): array { return array_filter([ ['label' => t('Settings'), 'href' => cp_url('commerce/settings')], - $title && $url ? array_filter(['label' => $title, 'href' => $url]) : null, + $title && $url ? ['label' => $title, 'href' => $url] : null, ]); } From dc0d47d84e5ffa087d4312fa23de31fdcb768bb1 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Thu, 10 Sep 2026 09:51:19 +0100 Subject: [PATCH 16/32] Tweak plugin get instance after merge --- src/Http/Controllers/Settings/BaseSettingsController.php | 2 ++ src/Http/Controllers/Settings/GeneralSettingsController.php | 6 ++---- src/Http/Controllers/Settings/StoresController.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Http/Controllers/Settings/BaseSettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php index 91c2fd3484..b0a87b9fb6 100644 --- a/src/Http/Controllers/Settings/BaseSettingsController.php +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; +use CraftCms\Commerce\Plugin; use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; @@ -22,6 +23,7 @@ abstract class BaseSettingsController public function __construct( protected GeneralConfig $generalConfig, protected FormResolver $formResolver, + protected readonly Plugin $plugin, ) { $this->readOnly = !$generalConfig->allowAdminChanges; } diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php index ecd927ed89..24891dd54c 100644 --- a/src/Http/Controllers/Settings/GeneralSettingsController.php +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -4,7 +4,6 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use craft\commerce\Plugin; use CraftCms\Cms\Form\Controls\Combobox; use CraftCms\Cms\Form\Enums\ControlMode; use CraftCms\Cms\Form\Form; @@ -27,7 +26,7 @@ class GeneralSettingsController extends BaseSettingsController public function edit(): CpScreenResponse { - $settings = Plugin::getInstance()->getSettings(); + $settings = $this->plugin->getSettings(); $config = Config::get('craft.commerce', null); $overrideWarning = function($key) use ($config) { @@ -92,9 +91,8 @@ public function edit(): CpScreenResponse public function saveSettings(Request $request): Response|string { - $plugin = Plugin::getInstance(); $settings = $request->input('settings'); - $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings); + $pluginSettingsSaved = Plugins::savePluginSettings($this->plugin, $settings); if (!$pluginSettingsSaved) { return $this->asFailure(t('Couldn’t save settings.', category: 'commerce')); diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index f4af2de623..1c4951128c 100644 --- a/src/Http/Controllers/Settings/StoresController.php +++ b/src/Http/Controllers/Settings/StoresController.php @@ -330,10 +330,10 @@ public function storesIndex(): CpScreenResponse $showNewStoreButton = !$this->readOnly && $stores->count() < count(Sites::getAllSites()); if ($showNewStoreButton) { - $showNewStoreButton = (Plugin::getInstance()->is(Plugin::EDITION_PRO, '=') + $showNewStoreButton = ($this->plugin->is(Plugin::EDITION_PRO, '=') && $stores->count() < Plugin::EDITION_PRO_STORE_LIMIT && app(CatalogPricingRules::class)->canUseCatalogPricingRules()) - || (Plugin::getInstance()->is(Plugin::EDITION_ENTERPRISE, '=') + || ($this->plugin->is(Plugin::EDITION_ENTERPRISE, '=') && app(CatalogPricingRules::class)->canUseCatalogPricingRules()); } From 5d385ff4ca1211882c7b85ec485a7b336f9bc542 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Fri, 11 Sep 2026 13:33:47 +0100 Subject: [PATCH 17/32] Create store management namespace --- routes/actions.php | 24 ++++++++--------- routes/cp.php | 24 ++++++++--------- .../Concerns/HasStoreManagementScreen.php | 7 ++++- .../BaseStoreManagementController.php | 26 +++++++++++++++++++ .../CatalogPricingRulesController.php | 8 ++---- .../DiscountsController.php | 8 ++---- .../PaymentCurrenciesController.php | 8 ++---- .../SalesController.php | 8 ++---- .../ShippingCategoriesController.php | 8 ++---- .../ShippingMethodsController.php | 8 ++---- .../ShippingRulesController.php | 8 ++---- .../ShippingZonesController.php | 8 ++---- .../StoreManagementController.php | 9 ++----- .../TaxCategoriesController.php | 8 ++---- .../TaxRatesController.php | 8 ++---- .../TaxZonesController.php | 8 ++---- 16 files changed, 80 insertions(+), 98 deletions(-) create mode 100644 src/Http/Controllers/StoreManagement/BaseStoreManagementController.php rename src/Http/Controllers/{Settings => StoreManagement}/CatalogPricingRulesController.php (98%) rename src/Http/Controllers/{Settings => StoreManagement}/DiscountsController.php (99%) rename src/Http/Controllers/{Settings => StoreManagement}/PaymentCurrenciesController.php (94%) rename src/Http/Controllers/{Settings => StoreManagement}/SalesController.php (98%) rename src/Http/Controllers/{Settings => StoreManagement}/ShippingCategoriesController.php (97%) rename src/Http/Controllers/{Settings => StoreManagement}/ShippingMethodsController.php (96%) rename src/Http/Controllers/{Settings => StoreManagement}/ShippingRulesController.php (96%) rename src/Http/Controllers/{Settings => StoreManagement}/ShippingZonesController.php (95%) rename src/Http/Controllers/{Settings => StoreManagement}/StoreManagementController.php (96%) rename src/Http/Controllers/{Settings => StoreManagement}/TaxCategoriesController.php (97%) rename src/Http/Controllers/{Settings => StoreManagement}/TaxRatesController.php (97%) rename src/Http/Controllers/{Settings => StoreManagement}/TaxZonesController.php (95%) diff --git a/routes/actions.php b/routes/actions.php index 80f849b4b0..51fad2dd27 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -6,10 +6,10 @@ use CraftCms\Commerce\Http\Controllers\DonationsController; use CraftCms\Commerce\Http\Controllers\OrdersController; use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingRulesController; use CraftCms\Commerce\Http\Controllers\DownloadsController; use CraftCms\Commerce\Http\Controllers\EmailPreviewController; -use CraftCms\Commerce\Http\Controllers\Settings\DiscountsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\DiscountsController; use CraftCms\Commerce\Http\Controllers\Settings\EmailsController; use CraftCms\Commerce\Http\Controllers\FormulasController; use CraftCms\Commerce\Http\Controllers\Settings\GatewaysController; @@ -19,21 +19,21 @@ use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; use CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController; -use CraftCms\Commerce\Http\Controllers\Settings\PaymentCurrenciesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\PaymentCurrenciesController; use CraftCms\Commerce\Http\Controllers\PaymentSourcesController; use CraftCms\Commerce\Http\Controllers\PaymentsController; use CraftCms\Commerce\Http\Controllers\Settings\PdfsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; -use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController; -use CraftCms\Commerce\Http\Controllers\Settings\StoreManagementController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\SalesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingMethodsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\StoreManagementController; use CraftCms\Commerce\Http\Controllers\Settings\StoresController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxRatesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxZonesController; use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\UserOrdersController; diff --git a/routes/cp.php b/routes/cp.php index 6ec05f5697..bf645b6b2a 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -4,8 +4,8 @@ use CraftCms\Cms\Http\Middleware\RequireAdmin; use CraftCms\Commerce\Http\Controllers\DonationsController; use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\DiscountsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\DiscountsController; use CraftCms\Commerce\Http\Controllers\Settings\EmailsController; use CraftCms\Commerce\Http\Controllers\Settings\GatewaysController; use CraftCms\Commerce\Http\Controllers\InventoryController; @@ -15,20 +15,20 @@ use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; use CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController; -use CraftCms\Commerce\Http\Controllers\Settings\PaymentCurrenciesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\PaymentCurrenciesController; use CraftCms\Commerce\Http\Controllers\Settings\PdfsController; use CraftCms\Commerce\Http\Controllers\ProductsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; -use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController; -use CraftCms\Commerce\Http\Controllers\Settings\StoreManagementController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\SalesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingMethodsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\StoreManagementController; use CraftCms\Commerce\Http\Controllers\Settings\StoresController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxRatesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxZonesController; use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\Users\UsersController; diff --git a/src/Http/Controllers/Concerns/HasStoreManagementScreen.php b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php index 64f7ec1f5f..71f2ac61fa 100644 --- a/src/Http/Controllers/Concerns/HasStoreManagementScreen.php +++ b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php @@ -19,7 +19,12 @@ * (shipping, tax, promotions, payment currencies, etc). Every controller that needs it resolves * its own `Store` from a `storeHandle` route segment — there's no framework-level route binding * for handle-scoped resources in cms-6 (confirmed: every handle-scoped core controller does the - * same manual resolve-and-404), so this stays a plain trait rather than a shared base class. + * same manual resolve-and-404) — so `resolveStore()` stays a plain method taking `$storeHandle` + * as a parameter, not something a shared base class could inject via its constructor. + * + * This still lives as a trait, mixed into {@see \CraftCms\Commerce\Http\Controllers\StoreManagement\BaseStoreManagementController} + * rather than inlined there directly, so the per-call resolution above isn't mistaken for + * something the base class's constructor could have done instead. */ trait HasStoreManagementScreen { diff --git a/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php b/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php new file mode 100644 index 0000000000..ef6cfd34f0 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php @@ -0,0 +1,26 @@ + Date: Fri, 11 Sep 2026 14:29:13 +0100 Subject: [PATCH 18/32] Fix condition builder errors --- .../templates/settings/gateways/_edit.twig | 18 +++----------- .../store-management/discounts/_edit.twig | 24 ++++--------------- .../store-management/pricing-rules/_edit.twig | 24 ++++--------------- .../pricing-rules/_slideout.twig | 6 +---- .../shipping/shippingmethods/_edit.twig | 12 ++-------- .../shipping/shippingrules/_edit.twig | 12 ++-------- .../shipping/shippingzones/_fields.twig | 2 +- .../tax/taxzones/_fields.twig | 2 +- .../Conditions/GatewayAddressCondition.php | 11 --------- .../PostalCodeFormulaConditionRule.php | 1 - .../CatalogPricingCustomerConditionRule.php | 1 - ...CatalogPricingPurchasableConditionRule.php | 1 - .../Conditions/DiscountGroupConditionRule.php | 6 ++--- .../Conditions/HasOrdersConditionRule.php | 5 ++-- .../Settings/CatalogPricingController.php | 4 +++- .../Settings/GatewaysController.php | 9 +++++++ .../CatalogPricingRulesController.php | 7 ++++++ .../StoreManagement/DiscountsController.php | 7 ++++++ .../ShippingMethodsController.php | 7 ++++++ .../ShippingRulesController.php | 7 ++++++ .../ShippingZonesController.php | 6 ++++- .../StoreManagementController.php | 4 +++- .../StoreManagement/TaxZonesController.php | 6 ++++- .../ContainsPurchasablesConditionRule.php | 2 -- .../Conditions/CustomerConditionRule.php | 1 - .../Conditions/GatewayOrderCondition.php | 12 ---------- .../HasPurchasableConditionRule.php | 2 -- ...erCurrencyValuesAttributeConditionRule.php | 2 -- .../Conditions/VariantConditionRule.php | 1 - .../VariantProductConditionRule.php | 1 - 30 files changed, 77 insertions(+), 126 deletions(-) diff --git a/src-yii2/templates/settings/gateways/_edit.twig b/src-yii2/templates/settings/gateways/_edit.twig index 37669788b7..8431c31b1a 100644 --- a/src-yii2/templates/settings/gateways/_edit.twig +++ b/src-yii2/templates/settings/gateways/_edit.twig @@ -109,35 +109,23 @@ }) }}
- {% set orderConditionInput %} - {{ gateway.getOrderCondition().getBuilderHtml(readOnly)|raw }} - {% endset %} - {{ forms.field({ label: 'Match Order'|t('commerce'), instructions: 'Create rules that allow this gateway to match the order.'|t('commerce'), errors: gateway.getErrors('orderCondition'), - }, orderConditionInput) }} - - {% set billingAddressConditionInput %} - {{ gateway.getBillingAddressCondition().getBuilderHtml(readOnly)|raw }} - {% endset %} + }, orderConditionHtml) }} {{ forms.field({ label: 'Match Billing Address'|t('commerce'), instructions: 'Create rules that allow this gateway to match the billing address.'|t('commerce'), errors: gateway.getErrors('billingAddressCondition'), - }, billingAddressConditionInput) }} - - {% set shippingAddressConditionInput %} - {{ gateway.getShippingAddressCondition().getBuilderHtml(readOnly)|raw }} - {% endset %} + }, billingAddressConditionHtml) }} {{ forms.field({ label: 'Match Shipping Address'|t('commerce'), instructions: 'Create rules that allow this gateway to match the shipping address.'|t('commerce'), errors: gateway.getErrors('shippingAddressCondition'), - }, shippingAddressConditionInput) }} + }, shippingAddressConditionHtml) }} {% endblock %} diff --git a/src-yii2/templates/store-management/discounts/_edit.twig b/src-yii2/templates/store-management/discounts/_edit.twig index 58aa41a035..881e317ba4 100644 --- a/src-yii2/templates/store-management/discounts/_edit.twig +++ b/src-yii2/templates/store-management/discounts/_edit.twig @@ -236,45 +236,29 @@ - {% set orderConditionInput %} - {{ shippingMethod.orderCondition.getBuilderHtml()|raw }} - {% endset %} - {{ forms.field({ id: 'orderCondition', label: 'Match Order'|t('commerce'), errors: shippingMethod.getErrors('orderCondition'), instructions: 'Conditions here are matched against an order before looking through the rules. This is useful if you want to qualify a method’s availability early, or if there are common conditions to all rules for this method.'|t('commerce'), - }, orderConditionInput) }} - - {% set customerConditionInput %} - {{ shippingMethod.customerCondition.getBuilderHtml()|raw }} - {% endset %} + }, orderConditionHtml) }} {{ forms.field({ id: 'customerCondition', label: 'Match Customer'|t('commerce'), errors: shippingMethod.getErrors('customerCondition'), instructions: 'Conditions here are matched against the order’s customer before looking through the rules. This is useful if you want qualify a method’s availability early or if there are common conditions to all rules for this method.'|t('commerce'), - }, customerConditionInput) }} + }, customerConditionHtml) }} {% if shippingMethod.id %}
diff --git a/src-yii2/templates/store-management/shipping/shippingrules/_edit.twig b/src-yii2/templates/store-management/shipping/shippingrules/_edit.twig index 0470062ad2..e731dcd070 100644 --- a/src-yii2/templates/store-management/shipping/shippingrules/_edit.twig +++ b/src-yii2/templates/store-management/shipping/shippingrules/_edit.twig @@ -114,15 +114,11 @@ '; - } - } - }, - ]; - - new Craft.VueAdminTable({ - actions: [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set Default Category'), - action: 'commerce/shipping-categories/set-default-category', - param: 'storeHandle', - value: '{$storeHandle}', - allowMultiple: false - } - ] - } - ], - checkboxes: true, - columns: columns, - container: '#shipping-vue-admin-table', - deleteAction: 'commerce/shipping-categories/delete', - padded: true, - tableData: {$tableData}, - }); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a( - t('New shipping category', category: 'commerce'), - $store->getStoreSettingsUrl('shippingcategories/new'), - ['class' => 'btn submit add icon'] - )) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); + 'description' => t($shippingCategory->description, category: 'site'), + 'default' => $shippingCategory->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + '_deletable' => $shippingCategories->count() > 1 && !$shippingCategory->default, + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('shipping-categories') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Category', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping categories exist yet.', category: 'commerce')) + ->createAction(t('New shipping category', category: 'commerce'), $store->getStoreSettingsUrl('shippingcategories/new')) + ->deletable(action([self::class, 'delete'])), + ]; + + $title = t('Shipping Categories', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; if ($id) { $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById($id, $store->id); @@ -116,41 +96,192 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp } $title = $shippingCategory->id ? $shippingCategory->name : t('Create a new shipping category', category: 'commerce'); + $lockDefault = $this->lockDefault($shippingCategory, $store); - $productTypes = app(ProductTypes::class)->getAllProductTypes(); - $productTypesOptions = []; - if (!empty($productTypes)) { - $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); - } + $formatter = app(Formatter::class); + $metadataHtml = $shippingCategory->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingCategory->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingCategory->dateUpdated, 'short'), + ]) : null; - $allShippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); - $isDefaultAndOnlyCategory = $id && $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $id); + $values = $this->initialValues($shippingCategory, $store); - $metaSidebar = ''; - if ($shippingCategory->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($shippingCategory->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($shippingCategory->dateUpdated, 'short'), - ]); - } + $form = $this->formResolver->resolve( + $this->buildForm($shippingCategory, $values, $lockDefault), + new FormContext(values: $values, refreshable: true), + ); - return $this->storeManagementCpScreen($storeHandle, false) + return $this->cpScreenResponse($store) ->title($title) - ->addCrumb(t('Shipping Categories', category: 'commerce'), $store->getStoreSettingsUrl('shippingcategories')) + ->crumbs($this->crumbs($store, ...($shippingCategory->id ? [['label' => $title]] : []))) ->action('commerce/shipping-categories/save') ->redirectUrl($store->getStoreSettingsUrl('shippingcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/shipping/shippingcategories/_edit', [ - 'id' => $id, - 'shippingCategory' => $shippingCategory, - 'productTypes' => $productTypes, - 'storeHandle' => $storeHandle, - 'title' => $title, - 'productTypesOptions' => $productTypesOptions, - 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + 'metadataHtml' => $metadataHtml, ]); } + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so toggling "Default Category" can force every product type on (and disable + * further picking) without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.storeId' => ['required', 'integer'], + 'values.shippingCategoryId' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int)$values['storeId']); + abort_if($store === null, 404); + + $shippingCategoryId = $values['shippingCategoryId'] ?? null; + if ($shippingCategoryId) { + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$shippingCategoryId, $store->id); + abort_if($shippingCategory === null, 404); + } else { + $shippingCategory = new ShippingCategory(['storeId' => $store->id]); + } + + $lockDefault = $this->lockDefault($shippingCategory, $store); + + // The client only posts values for controls currently in the rendered tree, so layer + // them over this model's real defaults before re-resolving — otherwise a field that's + // about to be revealed (or the productTypes list, once `default` forces it) would fall + // back to empty instead of its actual value. + $values = array_replace($this->initialValues($shippingCategory, $store), $values); + + // `default` just flipped on (that's what triggered this round trip): the productTypes + // control is about to render disabled, so its stale posted selection — captured before + // the toggle — needs overriding to "every product type" rather than merged in as-is. + if ($values['default'] ?? false) { + $values['productTypes'] = array_column(app(ProductTypes::class)->getAllProductTypes(), 'id'); + } + + $form = $this->formResolver->resolve( + $this->buildForm($shippingCategory, $values, $lockDefault), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + private function lockDefault(ShippingCategory $shippingCategory, Store $store): bool + { + if (!$shippingCategory->id) { + return false; + } + + $allShippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + $isOnlyCategory = $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $shippingCategory->id); + + return $isOnlyCategory || $shippingCategory->default; + } + + /** @return array */ + private function initialValues(ShippingCategory $shippingCategory, Store $store): array + { + // A default category is available to every product type, whether or not it's ever + // been explicitly assigned to them — mirrors save()'s own handling below. + $productTypes = $shippingCategory->default + ? array_column(app(ProductTypes::class)->getAllProductTypes(), 'id') + : $shippingCategory->getProductTypeIds(); + + return [ + 'storeId' => $store->id, + 'shippingCategoryId' => $shippingCategory->id, + 'name' => $shippingCategory->name, + 'handle' => $shippingCategory->handle, + 'icon' => $shippingCategory->icon, + 'color' => $shippingCategory->color ?? '', + 'description' => $shippingCategory->description, + 'productTypes' => $productTypes, + 'default' => $shippingCategory->default, + 'defaultDisplay' => $shippingCategory->default, + ]; + } + + /** @param array $values */ + private function buildForm(ShippingCategory $shippingCategory, array $values, bool $lockDefault): Form + { + $isDefault = (bool)($values['default'] ?? false); + + $handle = Handle::make('handle'); + if (!$shippingCategory->id) { + $handle->source('name'); + } + + $productTypesOptions = array_values(array_map( + fn($productType) => ['label' => $productType->name, 'value' => $productType->id], + app(ProductTypes::class)->getAllProductTypes(), + )); + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($shippingCategory->id) { + $formNodes[] = HiddenField::make('shippingCategoryId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this shipping category will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you\'ll refer to this shipping category in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')); + $formNodes[] = Field::make(t('Icon', category: 'app'), IconPicker::make('icon')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), ColorSelect::make('color') + ->colors($this->colorPalette()) + ->allowTransparent() + ->blankLabel(t('No color', category: 'app'))); + + $productTypesControl = Choice::make('productTypes')->multiple()->options($productTypesOptions); + if ($isDefault) { + $productTypesControl->mode(ControlMode::Disabled); + } + + $productTypesField = Field::make(t('Available to Product Types', category: 'commerce'), $productTypesControl) + ->instructions($isDefault + ? t('The default shipping category is automatically available to all product types.', category: 'commerce') + : t('Which product types should this category be available to?', category: 'commerce')); + + if ($productTypesOptions === []) { + $productTypesField->warning( + t('There aren\'t any product types to select yet.', category: 'commerce') . ' ' . + Html::a(t('Create a product type', category: 'commerce'), 'commerce/settings/producttypes/new', ['class' => 'go']), + ); + } + + $formNodes[] = $productTypesField; + + $defaultKey = $lockDefault ? 'defaultDisplay' : 'default'; + $defaultControl = Lightswitch::make($defaultKey)->mode($lockDefault ? ControlMode::Disabled : ControlMode::Editable); + if (!$lockDefault) { + $defaultControl->reactive(); + } + + $formNodes[] = Field::make(t('Default Category', category: 'commerce'), $defaultControl) + ->instructions(t('This category will be used as the default for all purchasables in this store.', category: 'commerce')); + + if ($lockDefault) { + $formNodes[] = HiddenField::make('default'); + } + + return Form::make($formNodes); + } + public function save(Request $request): Response { $shippingCategory = new ShippingCategory(); @@ -162,7 +293,11 @@ public function save(Request $request): Response $shippingCategory->name = $request->input('name'); $shippingCategory->handle = $request->input('handle'); $shippingCategory->icon = $request->input('icon'); - $shippingCategory->color = $request->input('color'); + // '__blank__' is ColorSelect's internal sentinel for "no color" selected — it should + // never reach here (the client translates it back to '' before posting), but guard + // against it anyway for a genuinely JS-less submission. + $color = $request->input('color'); + $shippingCategory->color = ($color && $color !== '__blank__') ? $color : null; $shippingCategory->description = $request->input('description'); $shippingCategory->default = (bool)$request->input('default'); @@ -200,30 +335,16 @@ public function save(Request $request): Response public function delete(Request $request): Response { - $id = $request->input('id'); - $ids = $request->input('ids'); - - abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + abort_unless($request->expectsJson(), 400); - if ($id) { - abort_unless($request->expectsJson(), 400); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $deleteId) { - if (!app(ShippingCategories::class)->deleteShippingCategoryById((int)$deleteId)) { - $failedIds[] = $deleteId; - } - } + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing shipping category id'); - if (!empty($failedIds)) { - return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ], category: 'commerce')); + if (!app(ShippingCategories::class)->deleteShippingCategoryById((int)$id)) { + return $this->asFailure(t('Could not delete shipping category.', category: 'commerce')); } - return $this->asSuccess(t('Shipping categories deleted.', category: 'commerce')); + return $this->asSuccess(t('Shipping category deleted.', category: 'commerce')); } public function setDefaultCategory(Request $request): Response diff --git a/src/Http/Controllers/StoreManagement/ShippingZonesController.php b/src/Http/Controllers/StoreManagement/ShippingZonesController.php index c7d5ba497d..92dc1311f9 100644 --- a/src/Http/Controllers/StoreManagement/ShippingZonesController.php +++ b/src/Http/Controllers/StoreManagement/ShippingZonesController.php @@ -4,67 +4,72 @@ namespace CraftCms\Commerce\Http\Controllers\StoreManagement; -use CraftCms\Cms\Condition\ConditionBuilderRenderer; +use CraftCms\Cms\Cp\Html\ContentHtml; +use CraftCms\Cms\Form\Controls\ConditionBuilder; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Html as NewHtml; -use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\Enums\Position; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Translation\Formatter; +use CraftCms\Commerce\Address\Conditions\ZoneAddressCondition; use CraftCms\Commerce\Formula\Formulas; use CraftCms\Commerce\Shipping\Data\ShippingAddressZone; use CraftCms\Commerce\Shipping\ShippingZones; +use CraftCms\Commerce\Store\Data\Store; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\t; -readonly class ShippingZonesController extends LegacyStoreManagementController +readonly class ShippingZonesController extends BaseStoreManagementController { + protected function getSectionCrumb(Store $store): array + { + return ['label' => t('Shipping Zones', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('shippingzones')]; + } + public function index(?string $storeHandle = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id); - - $tableData = []; - foreach ($shippingZones as $shippingZone) { - $label = NewHtml::encode(t($shippingZone->name, category: 'site')); - $tableData[] = [ + $rows = app(ShippingZones::class)->getAllShippingZones($store->id) + ->map(fn(ShippingAddressZone $shippingZone) => [ 'id' => $shippingZone->id, - 'title' => NewHtml::a($label, $shippingZone->getCpEditUrl()), - 'url' => $shippingZone->getCpEditUrl(), - 'description' => NewHtml::encode(t($shippingZone->description, category: 'site')), - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a(t('New shipping zone', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); + 'name' => ['html' => Html::a(Html::encode(t($shippingZone->name, category: 'site')), $shippingZone->getCpEditUrl(), ['class' => 'cell-bold'])], + 'description' => t($shippingZone->description, category: 'site'), + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('shipping-zones') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping zones exist yet.', category: 'commerce')) + ->createAction(t('New shipping zone', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones/new')) + ->deletable(action([self::class, 'delete'])), + ]; + + $title = t('Shipping Zones', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; if ($id) { $shippingZone = app(ShippingZones::class)->getShippingZoneById($id, $store->id); @@ -75,32 +80,52 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $title = $shippingZone->id ? $shippingZone->name : t('Create a shipping zone', category: 'commerce'); - $condition = $shippingZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; + $formatter = app(Formatter::class); + $metadataHtml = $shippingZone->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingZone->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingZone->dateUpdated, 'short'), + ]) : null; - // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml(). - $conditionHtml = new ConditionBuilderRenderer($condition)->render(); + $formNodes = [ + HiddenField::make('storeId'), + ]; - $metadata = []; if ($shippingZone->id) { - $metadata = [ - t('Created at') => I18N::getFormatter()->asDatetime($shippingZone->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($shippingZone->dateUpdated, 'short'), - ]; + $formNodes[] = HiddenField::make('shippingZoneId'); } - return $this->storeManagementCpScreen($storeHandle, false) + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this shipping zone will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')) + ->instructions(t('Describe this shipping zone.', category: 'commerce')); + // Zones aren't project-config-tracked (Zone::setCondition() hardcodes forProjectConfig + // to false), so this deliberately doesn't call ->forProjectConfig() either. + $formNodes[] = Field::make(t('Address Condition'), ConditionBuilder::make('condition') + ->conditionClass(ZoneAddressCondition::class) + ->value($shippingZone->getCondition()->getConfig())); + + $values = [ + 'storeId' => $store->id, + 'shippingZoneId' => $shippingZone->id, + 'name' => $shippingZone->name, + 'description' => $shippingZone->description, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) ->title($title) - ->addCrumb(t('Shipping Zones', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones')) + ->crumbs($this->crumbs($store, ...($shippingZone->id ? [['label' => $title]] : []))) ->action('commerce/shipping-zones/save') ->redirectUrl($store->getStoreSettingsUrl('shippingzones')) - ->metaSidebarHtml(\craft\helpers\Cp::metadataHtml($metadata)) - ->contentTemplate('commerce/store-management/shipping/shippingzones/_edit', [ - 'shippingZone' => $shippingZone, - 'conditionHtml' => $conditionHtml, - 'store' => $store, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, ]); } diff --git a/src/Http/Controllers/StoreManagement/TaxCategoriesController.php b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php index e33a4aef92..46f89444a4 100644 --- a/src/Http/Controllers/StoreManagement/TaxCategoriesController.php +++ b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php @@ -5,119 +5,108 @@ namespace CraftCms\Commerce\Http\Controllers\StoreManagement; use craft\helpers\Cp; +use CraftCms\Cms\Cp\Html\ContentHtml; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\ColorSelect; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\IconPicker; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Arr; -use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Html as NewHtml; -use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\Enums\Position; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Translation\Formatter; use CraftCms\Commerce\Product\ProductType\ProductTypes; use CraftCms\Commerce\Store\Data\Store; use CraftCms\Commerce\Store\Stores; use CraftCms\Commerce\Tax\Data\TaxCategory; - use CraftCms\Commerce\Tax\TaxCategories; use CraftCms\Commerce\Tax\Taxes; -use CraftCms\Commerce\Tax\TaxRates; + use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\t; -readonly class TaxCategoriesController extends LegacyStoreManagementController +/** + * Tax categories are shared across every store (they're products' own data, not + * per-store configuration), so unlike the rest of store-management this screen's + * store-switcher is suppressed — see {@see showsStoreSwitcher()} — even though it's + * still reached through a store-handled URL. + */ +readonly class TaxCategoriesController extends BaseStoreManagementController { + protected function getSectionCrumb(Store $store): array + { + return ['label' => t('Tax Categories', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('taxcategories')]; + } + + #[\Override] + protected function showsStoreSwitcher(): bool + { + return false; + } + public function index(?string $storeHandle = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); $taxCategories = app(TaxCategories::class)->getAllTaxCategories(); + $canDelete = app(Taxes::class)->deleteTaxCategories(); - $tableData = []; - foreach ($taxCategories as $taxCategory) { - $label = NewHtml::encode(t($taxCategory->name, category: 'site')); + $rows = array_map(function(TaxCategory $taxCategory) use ($store, $taxCategories, $canDelete) { + $label = Html::encode(t($taxCategory->name, category: 'site')); $taxRates = $taxCategory->getTaxRates($store->id); - $tableData[] = [ + + return [ 'id' => $taxCategory->id, - 'title' => $label, - 'chip' => Cp::chipHtml($taxCategory, [ - 'labelHtml' => NewHtml::a($label, $taxCategory->getCpEditUrl($store->id), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $taxCategory->getCpEditUrl($store->id), + 'name' => ['html' => Cp::chipHtml($taxCategory, [ + 'labelHtml' => Html::a($label, $taxCategory->getCpEditUrl($store->id), ['class' => 'cell-bold']), + ])], 'handle' => $taxCategory->handle, - 'description' => NewHtml::encode(t($taxCategory->description, category: 'site')), - 'default' => $taxCategory->default, - '_showDelete' => $taxRates->isEmpty() && (count($taxCategories) > 1 && !$taxCategory->default), + 'description' => t($taxCategory->description, category: 'site'), + 'default' => $taxCategory->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + '_deletable' => $canDelete && $taxRates->isEmpty() && count($taxCategories) > 1 && !$taxCategory->default, ]; - } - - $buttons = app(Taxes::class)->taxCategoryActionHtml(); - if (app(Taxes::class)->createTaxCategories()) { - $buttons .= NewHtml::a(t('New tax category', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories/new'), [ - 'class' => ['btn', 'submit', 'add', 'icon'], + }, $taxCategories); + + $nodes = [ + Table::make('tax-categories') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Category', category: 'commerce')], + ]) + ->rows(array_values($rows)) + ->emptyMessage(t('No tax categories exist yet.', category: 'commerce')) + ->when( + app(Taxes::class)->createTaxCategories(), + fn(Table $table) => $table->createAction(t('New tax category', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories/new')), + ) + ->when($canDelete, fn(Table $table) => $table->deletable(action([self::class, 'delete']))), + ]; + + $title = t('Tax Categories', category: 'commerce'); + $engineButtonsHtml = app(Taxes::class)->taxCategoryActionHtml(); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->when($engineButtonsHtml !== '', fn(CpScreenResponse $screen) => $screen->additionalButtonsHtml($engineButtonsHtml)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), ]); - } - - $tableData = Json::encode($tableData); - $deleteAction = app(Taxes::class)->deleteTaxCategories() ? "'commerce/tax-categories/delete'" : 'null'; - - $js = <<
'; - } - } - }, - ]; - - var actions = [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set default category'), - action: 'commerce/tax-categories/set-default-category', - param: 'default', - value: 1, - allowMultiple: false - } - ] - } - ]; - - new Craft.VueAdminTable({ - columns: columns, - checkboxes: true, - actions: actions, - padded: true, - container: '#tax-vue-admin-table', - deleteAction: {$deleteAction}, - tableData: {$tableData}, - }); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle, hasStoreSwitcher: false) - ->additionalButtonsHtml($buttons) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - $productTypes = app(ProductTypes::class)->getAllProductTypes(); if ($id) { $taxCategory = app(TaxCategories::class)->getTaxCategoryById($id); @@ -128,38 +117,124 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $title = $taxCategory->id ? $taxCategory->name : t('Create a new tax category', category: 'commerce'); - $productTypesOptions = []; - if (!empty($productTypes)) { - $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); - } + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + $productTypesOptions = array_values(array_map( + fn($productType) => ['label' => $productType->name, 'value' => $productType->id], + $productTypes, + )); $allTaxCategoryIds = array_keys(app(TaxCategories::class)->getAllTaxCategories()); $isDefaultAndOnlyCategory = $id && count($allTaxCategoryIds) === 1 && in_array($id, $allTaxCategoryIds); $taxRates = collect(); - app(Stores::class)->getAllStores()->each(fn(Store $s) => $taxRates->push(...app(TaxRates::class)->getAllTaxRates($s->id)->all())); + if ($taxCategory->id) { + app(Stores::class)->getAllStores()->each(fn(Store $s) => $taxRates->push(...$taxCategory->getTaxRates($s->id)->all())); + } + + $formatter = app(Formatter::class); + $metadataHtml = $taxCategory->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($taxCategory->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($taxCategory->dateUpdated, 'short'), + ]) : null; + + $handle = Handle::make('handle'); + if (!$taxCategory->id) { + $handle->source('name'); + } + + $lockDefault = $isDefaultAndOnlyCategory || ($taxCategory->id && $taxCategory->default); + + $formNodes = [ + HiddenField::make('storeId'), + ]; - $metaSidebar = ''; if ($taxCategory->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($taxCategory->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($taxCategory->dateUpdated, 'short'), - ]); + $formNodes[] = HiddenField::make('taxCategoryId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this tax category will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you\'ll refer to this tax category in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Icon', category: 'app'), IconPicker::make('icon')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), ColorSelect::make('color') + ->colors($this->colorPalette()) + ->allowTransparent() + ->blankLabel(t('No color', category: 'app'))); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')); + + $productTypesField = Field::make( + t('Available to Product Types', category: 'commerce'), + Choice::make('productTypes')->multiple()->options($productTypesOptions), + )->instructions(t('Which product types should this category be available to?', category: 'commerce')); + + if ($productTypesOptions === []) { + $productTypesField->warning( + t('There aren\'t any product types to select yet.', category: 'commerce') . ' ' . + Html::a(t('Create a product type', category: 'commerce'), 'commerce/settings/producttypes/new', ['class' => 'go']), + ); + } + + $formNodes[] = $productTypesField; + + // A locked default can't be un-set here (it's the only category, or already the + // default), so the interactive control moves to a display-only path and the real + // `default` key posts via a paired HiddenField instead — a Disabled control renders + // `name=null` and submits nothing on its own. + $defaultKey = $lockDefault ? 'defaultDisplay' : 'default'; + $defaultField = Field::make( + t('Default Category', category: 'commerce'), + Lightswitch::make($defaultKey)->mode($lockDefault ? ControlMode::Disabled : ControlMode::Editable), + )->instructions(t('New products default to the first tax category available to them. If none are available, this category will be used.', category: 'commerce')); + + $formNodes[] = $defaultField; + + if ($lockDefault) { + $formNodes[] = HiddenField::make('default'); } - return $this->storeManagementCpScreen($storeHandle, false, false) + if ($taxCategory->id && $taxRates->isNotEmpty()) { + $formNodes[] = Table::make('used-by-tax-rates') + ->columns([ + ['key' => 'name', 'label' => t('Rate', category: 'commerce')], + ['key' => 'store', 'label' => t('Store', category: 'commerce')], + ]) + ->rows($taxRates->map(fn($taxRate) => [ + 'id' => $taxRate->id, + 'name' => ['html' => Html::a(Html::encode($taxRate->name), $taxRate->getCpEditUrl())], + 'store' => t($taxRate->getStore()->name, category: 'site'), + ])->values()->all()); + } + + $values = [ + 'storeId' => $store->id, + 'taxCategoryId' => $taxCategory->id, + 'name' => $taxCategory->name, + 'handle' => $taxCategory->handle, + 'icon' => $taxCategory->icon, + 'color' => $taxCategory->color ?? '', + 'description' => $taxCategory->description, + 'productTypes' => $taxCategory->getProductTypeIds(), + 'default' => $taxCategory->default, + 'defaultDisplay' => $taxCategory->default, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) ->title($title) - ->addCrumb(t('Tax Categories', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories')) + ->crumbs($this->crumbs($store, ...($taxCategory->id ? [['label' => $title]] : []))) ->action('commerce/tax-categories/save') ->redirectUrl($store->getStoreSettingsUrl('taxcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxcategories/_edit', [ - 'taxCategory' => $taxCategory, - 'productTypes' => $productTypes, - 'productTypesOptions' => $productTypesOptions, - 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, - 'taxRates' => $taxRates, - 'store' => $store, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, ]); } @@ -171,7 +246,11 @@ public function save(Request $request): Response $taxCategory->name = $request->input('name'); $taxCategory->handle = $request->input('handle'); $taxCategory->icon = $request->input('icon'); - $taxCategory->color = $request->input('color'); + // '__blank__' is ColorSelect's internal sentinel for "no color" selected — it should + // never reach here (the client translates it back to '' before posting), but guard + // against it anyway for a genuinely JS-less submission. + $color = $request->input('color'); + $taxCategory->color = ($color && $color !== '__blank__') ? $color : null; $taxCategory->description = $request->input('description'); $taxCategory->default = (bool)$request->input('default'); @@ -201,30 +280,16 @@ public function save(Request $request): Response public function delete(Request $request): Response { - $id = $request->input('id'); - $ids = $request->input('ids'); - - abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + abort_unless($request->expectsJson(), 400); - if ($id) { - abort_unless($request->expectsJson(), 400); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $deleteId) { - if (!app(TaxCategories::class)->deleteTaxCategoryById((int)$deleteId)) { - $failedIds[] = $deleteId; - } - } + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing tax category id'); - if (!empty($failedIds)) { - return $this->asFailure(t('Could not delete {count, number} tax {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ], category: 'commerce')); + if (!app(TaxCategories::class)->deleteTaxCategoryById((int)$id)) { + return $this->asFailure(t('Could not delete tax category.', category: 'commerce')); } - return $this->asSuccess(t('Tax categories deleted.', category: 'commerce')); + return $this->asSuccess(t('Tax category deleted.', category: 'commerce')); } public function setDefaultCategory(Request $request): Response diff --git a/src/Http/Controllers/StoreManagement/TaxZonesController.php b/src/Http/Controllers/StoreManagement/TaxZonesController.php index 40ff78bd65..a019969646 100644 --- a/src/Http/Controllers/StoreManagement/TaxZonesController.php +++ b/src/Http/Controllers/StoreManagement/TaxZonesController.php @@ -4,15 +4,21 @@ namespace CraftCms\Commerce\Http\Controllers\StoreManagement; -use craft\helpers\Cp; -use CraftCms\Cms\Condition\ConditionBuilderRenderer; +use CraftCms\Cms\Cp\Html\ContentHtml; +use CraftCms\Cms\Form\Controls\ConditionBuilder; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\Support\Facades\HtmlStack; -use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Html as NewHtml; -use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\Enums\Position; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Translation\Formatter; +use CraftCms\Commerce\Address\Conditions\ZoneAddressCondition; use CraftCms\Commerce\Formula\Formulas; +use CraftCms\Commerce\Store\Data\Store; use CraftCms\Commerce\Tax\Data\TaxAddressZone; use CraftCms\Commerce\Tax\TaxZones; @@ -20,61 +26,53 @@ use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\t; -readonly class TaxZonesController extends LegacyStoreManagementController +readonly class TaxZonesController extends BaseStoreManagementController { + protected function getSectionCrumb(Store $store): array + { + return ['label' => t('Tax Zones', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('taxzones')]; + } + public function index(?string $storeHandle = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $taxZones = app(TaxZones::class)->getAllTaxZones($store->id); - - $tableData = []; - foreach ($taxZones as $taxZone) { - $label = NewHtml::encode(t($taxZone->name, category: 'site')); - $tableData[] = [ + $rows = app(TaxZones::class)->getAllTaxZones($store->id) + ->map(fn(TaxAddressZone $taxZone) => [ 'id' => $taxZone->id, - 'title' => NewHtml::a($label, $taxZone->getCpEditUrl()), - 'url' => $taxZone->getCpEditUrl(), - 'description' => NewHtml::encode(t($taxZone->description, category: 'site')), - 'default' => $taxZone->default, - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<'; - } - } - }, -]; - -new Craft.VueAdminTable({ - columns: columns, - container: '#tax-vue-admin-table', - deleteAction: 'commerce/tax-zones/delete', - tableData: {$tableData}, - }); -JS; - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a(t('New tax zone', category: 'commerce'), $store->getStoreSettingsUrl('taxzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); + 'name' => ['html' => Html::a(Html::encode(t($taxZone->name, category: 'site')), $taxZone->getCpEditUrl(), ['class' => 'cell-bold'])], + 'description' => t($taxZone->description, category: 'site'), + 'default' => $taxZone->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('tax-zones') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Zone', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No tax zones exist yet.', category: 'commerce')) + ->createAction(t('New tax zone', category: 'commerce'), $store->getStoreSettingsUrl('taxzones/new')) + ->deletable(action([self::class, 'delete'])), + ]; + + $title = t('Tax Zones', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; if ($id) { $taxZone = app(TaxZones::class)->getTaxZoneById($id, $store->id); @@ -85,33 +83,58 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $title = $taxZone->id ? $taxZone->name : t('Create a tax zone', category: 'commerce'); - $condition = $taxZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; + $defaultLabel = $store->getUseBillingAddressForTax() + ? t('Default to this tax zone when no billing address is set', category: 'commerce') + : t('Default to this tax zone when no shipping address is set', category: 'commerce'); + + $formatter = app(Formatter::class); + $metadataHtml = $taxZone->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($taxZone->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($taxZone->dateUpdated, 'short'), + ]) : null; - // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml(). - $conditionHtml = new ConditionBuilderRenderer($condition)->render(); + $formNodes = [ + HiddenField::make('storeId'), + ]; - $metaSidebar = ''; if ($taxZone->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($taxZone->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($taxZone->dateUpdated, 'short'), - ]); + $formNodes[] = HiddenField::make('taxZoneId'); } - return $this->storeManagementCpScreen($storeHandle, false) + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this tax zone will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')) + ->instructions(t('Describe this tax zone.', category: 'commerce')); + $formNodes[] = Field::make($defaultLabel, Lightswitch::make('default')); + // Zones aren't project-config-tracked (Zone::setCondition() hardcodes forProjectConfig + // to false), so this deliberately doesn't call ->forProjectConfig() either. + $formNodes[] = Field::make(t('Address Condition'), ConditionBuilder::make('condition') + ->conditionClass(ZoneAddressCondition::class) + ->value($taxZone->getCondition()->getConfig())); + + $values = [ + 'storeId' => $store->id, + 'taxZoneId' => $taxZone->id, + 'name' => $taxZone->name, + 'description' => $taxZone->description, + 'default' => $taxZone->default, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) ->title($title) - ->addCrumb(t('Tax Zones', category: 'commerce'), $store->getStoreSettingsUrl('taxzones')) - ->selectedSubnavItem('store-management') + ->crumbs($this->crumbs($store, ...($taxZone->id ? [['label' => $title]] : []))) ->action('commerce/tax-zones/save') ->redirectUrl($store->getStoreSettingsUrl('taxzones')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxzones/_edit', [ - 'taxZone' => $taxZone, - 'store' => $store, - 'conditionHtml' => $conditionHtml, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, ]); } From 6889ef4a7e40542da0b30c639fbb98a89f56f984 Mon Sep 17 00:00:00 2001 From: Nathaniel Hammond Date: Wed, 16 Sep 2026 08:38:22 +0100 Subject: [PATCH 31/32] Tax rates controller conversion --- routes/actions.php | 1 + .../StoreManagement/TaxRatesController.php | 389 +++++++++++------- 2 files changed, 236 insertions(+), 154 deletions(-) diff --git a/routes/actions.php b/routes/actions.php index 8f20e0cccf..6354804e98 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -145,6 +145,7 @@ Route::post('tax-rates/save', [TaxRatesController::class, 'save']); Route::post('tax-rates/delete', [TaxRatesController::class, 'delete']); Route::post('tax-rates/update-status', [TaxRatesController::class, 'updateStatus']); + Route::post('tax-rates/render-form', [TaxRatesController::class, 'renderForm']); }); Route::middleware('can:commerce-managePromotions')->group(function () { diff --git a/src/Http/Controllers/StoreManagement/TaxRatesController.php b/src/Http/Controllers/StoreManagement/TaxRatesController.php index c65cb899f7..690e0d2db0 100644 --- a/src/Http/Controllers/StoreManagement/TaxRatesController.php +++ b/src/Http/Controllers/StoreManagement/TaxRatesController.php @@ -5,123 +5,96 @@ namespace CraftCms\Commerce\Http\Controllers\StoreManagement; use craft\helpers\Cp; +use CraftCms\Cms\Cp\Html\ContentHtml; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Number; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Html as NewHtml; -use CraftCms\Cms\Support\Json; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Translation\Formatter; use CraftCms\Cms\Translation\Locale; -use CraftCms\Cms\View\Enums\Position; -use CraftCms\Commerce\Helpers\Cp as CommerceCp; -use CraftCms\Commerce\Helpers\Localization; +use CraftCms\Commerce\Store\Data\Store; +use CraftCms\Commerce\Store\Stores; +use CraftCms\Commerce\Tax\Data\TaxAddressZone; +use CraftCms\Commerce\Tax\Data\TaxCategory; use CraftCms\Commerce\Tax\Data\TaxRate; +use CraftCms\Commerce\Helpers\Localization; use CraftCms\Commerce\Tax\Models\TaxRate as TaxRateRecord; use CraftCms\Commerce\Tax\TaxCategories; use CraftCms\Commerce\Tax\Taxes; use CraftCms\Commerce\Tax\TaxRates; - use CraftCms\Commerce\Tax\TaxZones; + +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; use function CraftCms\Cms\t; -readonly class TaxRatesController extends LegacyStoreManagementController +readonly class TaxRatesController extends BaseStoreManagementController { + protected function getSectionCrumb(Store $store): array + { + return ['label' => t('Tax Rates', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('taxrates')]; + } + public function index(?string $storeHandle = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; $taxRates = app(TaxRates::class)->getAllTaxRates($store->id); - // Preload all zone and category data for listing. - app(TaxZones::class)->getAllTaxZones($store->id); - app(TaxCategories::class)->getAllTaxCategories(); - - $tableData = []; - foreach ($taxRates as $taxRate) { - $label = NewHtml::encode(t($taxRate->name, category: 'site')); - $tableData[] = [ + $rows = $taxRates + ->map(fn(TaxRate $taxRate) => [ 'id' => $taxRate->id, - 'status' => $taxRate->enabled, - 'title' => NewHtml::a($label, $taxRate->getCpEditUrl()), - 'url' => $taxRate->getCpEditUrl(), + 'name' => ['html' => Html::a(Html::encode(t($taxRate->name, category: 'site')), $taxRate->getCpEditUrl(), ['class' => 'cell-bold'])], 'rate' => $taxRate->getRateAsPercent(), - 'included' => $taxRate->include, - 'removeIncluded' => $taxRate->removeIncluded, - 'vat' => $taxRate->hasTaxIdValidators(), - 'zone' => $taxRate->getIsEverywhere() ? t('Everywhere', category: 'commerce') : ($taxRate->getTaxZone() ? NewHtml::encode($taxRate->getTaxZone()->name) : ''), - 'category' => $taxRate->getTaxCategory() ? Cp::chipHtml($taxRate->getTaxCategory()) : '', - ]; - } - - $buttonsHtml = app(Taxes::class)->taxRateActionHtml(); - - if (app(Taxes::class)->createTaxRates()) { - $buttonsHtml .= NewHtml::a(t('New tax rate', category: 'commerce'), "commerce/store-management/$storeHandle/taxrates/new", [ - 'class' => 'btn submit add icon', + 'included' => $taxRate->include ? ['icon' => 'check', 'label' => t('Yes')] : '', + 'removeIncluded' => $taxRate->removeIncluded ? ['icon' => 'check', 'label' => t('Yes')] : '', + 'zone' => $taxRate->getIsEverywhere() ? t('Everywhere', category: 'commerce') : t($taxRate->getTaxZone()->name, category: 'site'), + 'category' => $taxRate->getTaxCategory() ? ['html' => Cp::chipHtml($taxRate->getTaxCategory())] : '', + 'enabled' => $taxRate->enabled ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('tax-rates') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'rate', 'label' => t('Rate', category: 'commerce')], + ['key' => 'included', 'label' => t('Include in price?', category: 'commerce')], + ['key' => 'removeIncluded', 'label' => t('Remove from price?', category: 'commerce')], + ['key' => 'zone', 'label' => t('Tax Zone', category: 'commerce')], + ['key' => 'category', 'label' => t('Tax Category', category: 'commerce')], + ['key' => 'enabled', 'label' => t('Enabled?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No tax rates exist yet.', category: 'commerce')) + ->when( + app(Taxes::class)->createTaxRates(), + fn(Table $table) => $table->createAction(t('New tax rate', category: 'commerce'), $store->getStoreSettingsUrl('taxrates/new')), + ) + ->when($this->canDeleteTaxRates(), fn(Table $table) => $table->deletable(action([self::class, 'delete']))), + ]; + + $title = t('Tax Rates', category: 'commerce'); + $engineButtonsHtml = app(Taxes::class)->taxRateActionHtml(); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->when($engineButtonsHtml !== '', fn(CpScreenResponse $screen) => $screen->additionalButtonsHtml($engineButtonsHtml)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), ]); - } - - $tableData = Json::encode($tableData, JSON_UNESCAPED_UNICODE); - $deleteAction = app(Taxes::class)->deleteTaxRates() ? 'commerce/tax-rates/delete' : null; - - $js = <<'; - } - } }, - { name: 'removeIncluded', title: Craft.t('commerce', 'Remove from price?'), callback: function(value) { - if (value) { - return ''; - } - } }, - { name: 'zone', title: Craft.t('commerce', 'Tax Zone') }, - { name: 'category', title: Craft.t('commerce', 'Tax Category') } -]; - -var actions = [ - { - label: Craft.t('commerce', 'Set status'), - actions: [ - { - label: Craft.t('commerce', 'Enabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'enabled', - status: 'enabled' - }, - { - label: Craft.t('commerce', 'Disabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'disabled', - status: 'disabled' - } - ] - } -]; - -new Craft.VueAdminTable({ - columns: columns, - actions: actions, - checkboxes: true, - container: '#taxrate-vue-admin-table', - deleteAction: '{$deleteAction}', - tableData: {$tableData}, -}); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml($buttonsHtml) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'taxrate-vue-admin-table'])); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse @@ -129,9 +102,6 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp abort_unless(app(Taxes::class)->viewTaxRates(), 403, 'Tax engine does not permit you to perform this action'); $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - $percentSymbol = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - if ($id) { $taxRate = app(TaxRates::class)->getTaxRateById($id, $store->id); @@ -142,72 +112,185 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $title = $taxRate->id ? $taxRate->name : t('Create a new tax rate', category: 'commerce'); - $variables = compact('taxRate', 'store', 'storeHandle', 'percentSymbol'); + $formatter = app(Formatter::class); + $metadataHtml = $taxRate->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($taxRate->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($taxRate->dateUpdated, 'short'), + ]) : null; - $taxZone = null; - if ($taxRate->taxZoneId) { - $taxZone = app(TaxZones::class)->getTaxZoneById($taxRate->taxZoneId, $store->id); - } + $values = $this->initialValues($taxRate, $store); + + $form = $this->formResolver->resolve( + $this->buildForm($taxRate, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($taxRate->id ? [['label' => $title]] : []))) + ->action('commerce/tax-rates/save') + ->redirectUrl($store->getStoreSettingsUrl('taxrates')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + 'metadataHtml' => $metadataHtml, + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so switching the taxable subject, toggling "Included in price?", or checking a + * tax ID validator can reveal or hide the fields that depend on them without a full page + * reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.storeId' => ['required', 'integer'], + 'values.taxRateId' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int)$values['storeId']); + abort_if($store === null, 404); - $taxCategory = null; - if ($taxRate->taxCategoryId) { - $taxCategory = app(TaxCategories::class)->getTaxCategoryById($taxRate->taxCategoryId); + $taxRateId = $values['taxRateId'] ?? null; + if ($taxRateId) { + $taxRate = app(TaxRates::class)->getTaxRateById((int)$taxRateId, $store->id); + abort_if($taxRate === null, 404); + } else { + $taxRate = new TaxRate(['storeId' => $store->id]); } - $variables['taxZoneField'] = CommerceCp::taxZoneFieldHtml([ - 'label' => t('Tax Zone', category: 'commerce'), - 'instructions' => t('Select a tax zone. If empty, this rate will match anywhere.', category: 'commerce'), - 'id' => 'taxZoneId', - 'name' => 'taxZoneId', - 'value' => $taxZone, - 'errors' => $taxRate->getErrors('taxZoneId'), - 'required' => false, - 'limit' => 1, + $values = array_replace($this->initialValues($taxRate, $store), $values); + + $form = $this->formResolver->resolve( + $this->buildForm($taxRate, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + /** @return array */ + private function initialValues(TaxRate $taxRate, Store $store): array + { + return [ 'storeId' => $store->id, - 'storeHandle' => $storeHandle, - ]); + 'taxRateId' => $taxRate->id, + 'name' => $taxRate->name, + 'code' => $taxRate->code, + 'taxable' => $taxRate->taxable, + 'taxZoneId' => $taxRate->taxZoneId, + // `taxCategoryId` is required, so a Choice control that never fires a real + // change event (e.g. the sole option in a brand-new tax rate's dropdown, never + // explicitly picked) would otherwise post null — a native `