From 9d41645e037e142c8b63cb172c645cc5882cd5f7 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Mon, 27 Jul 2026 16:26:05 +0200 Subject: [PATCH 01/44] [Sylius 2] Update composer dependencies to Sylius 2.x / Symfony 7 / PHP 8.2 Bumps sylius/sylius to ~2.2.0, php to ^8.2, drops the obsolete doctrine/dbal ^3.0 conflict and the abandoned friendsofsymfony/jsrouting-bundle (incompatible with Symfony 7), and aligns dev tooling (phpstan, phpspec, phpunit, psalm, sylius-labs/coding-standard, sylius/test-application) with current Sylius 2.x versions. --- .gitignore | 4 +- bin/create_console_symlink.php | 53 ++++++++++++++ composer.json | 125 +++++++++++++++++++++++---------- 3 files changed, 143 insertions(+), 39 deletions(-) create mode 100644 bin/create_console_symlink.php diff --git a/.gitignore b/.gitignore index 2fba55ec9..da7068c99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /bin/* !/bin/.gitkeep +!/bin/create_console_symlink.php /vendor/ /node_modules/ @@ -8,8 +9,9 @@ /etc/build/* !/etc/build/.gitkeep -/tests/Application/yarn.lock +/var/ /web/media /.phpunit.result.cache +/.php-version diff --git a/bin/create_console_symlink.php b/bin/create_console_symlink.php new file mode 100644 index 000000000..9dbf03cb1 --- /dev/null +++ b/bin/create_console_symlink.php @@ -0,0 +1,53 @@ + {$targetRelativeFromBin}"); +} + +@chmod($linkPath, 0755); +info("Created symlink: bin/console -> {$targetRelativeFromBin}"); diff --git a/composer.json b/composer.json index 4047626fd..247a78695 100644 --- a/composer.json +++ b/composer.json @@ -19,59 +19,108 @@ } ], "require": { - "php": "^8.0", - - "sylius/sylius": "~1.10.11 || ~1.11.2", - "friendsofsymfony/jsrouting-bundle": "^2.2" + "php": "^8.2", + "sylius/sylius": "~2.2.0" }, "require-dev": { - "behat/behat": "^3.6", - "behat/mink-selenium2-driver": "^1.4", - "dmore/behat-chrome-extension": "^1.3", - "dmore/chrome-mink-driver": "^2.7", - "friends-of-behat/mink": "^1.8", - "friends-of-behat/mink-browserkit-driver": "^1.3", - "friends-of-behat/mink-extension": "^2.5", + "behat/behat": "^3.16", + "behat/mink": "^1.13", + "behat/mink-selenium2-driver": "^1.7", + "dbrekelmans/bdi": "^1.4", + "dmore/behat-chrome-extension": "^1.4", + "dmore/chrome-mink-driver": "^2.9", + "friends-of-behat/mink-browserkit-driver": "^1.6", + "friends-of-behat/mink-debug-extension": "^2.1", + "friends-of-behat/mink-extension": "^2.7", "friends-of-behat/page-object-extension": "^0.3", - "friends-of-behat/suite-settings-extension": "^1.0", - "friends-of-behat/symfony-extension": "^2.1", - "friends-of-behat/variadic-extension": "^1.3", - "friends-of-behat/mink-debug-extension": "^2.0", - "friendsofsymfony/oauth-server-bundle": "^1.6 || >2.0.0-alpha.0 ^2.0@dev", - "phpspec/phpspec": "^7.0", - "phpstan/phpstan": "0.12.96", - "phpstan/phpstan-webmozart-assert": "0.12.12", - "phpunit/phpunit": "^9.5", + "friends-of-behat/suite-settings-extension": "^1.1", + "friends-of-behat/symfony-extension": "^2.6", + "friends-of-behat/variadic-extension": "^1.6", + "phpspec/phpspec": "^8.0", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^10.5", "polishsymfonycommunity/symfony-mocker-container": "^1.0", - "se/selenium-server-standalone": "^2.52", - "sylius-labs/coding-standard": "^3.0", - "symfony/debug-bundle": "^4.4 || ^5.4", - "symfony/dotenv": "^4.4 || ^5.4", - "symfony/web-profiler-bundle": "^4.4 || ^5.4" - }, - "conflict": { - "doctrine/dbal": "^3.0" + "psalm/plugin-symfony": "^5.2", + "rector/rector": "^1.0", + "sylius-labs/coding-standard": "^4.4", + "sylius/sylius-rector": "^2.0", + "sylius/test-application": "^2.0.0@alpha", + "symfony/debug-bundle": "^7.1", + "symfony/dotenv": "^7.1", + "symfony/web-profiler-bundle": "^7.1", + "symfony/webpack-encore-bundle": "^2.2", + "vimeo/psalm": "^6.13" }, "prefer-stable": true, + "config": { + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": false, + "php-http/discovery": false, + "symfony/flex": true, + "symfony/runtime": true + } + }, "autoload": { "psr-4": { - "Sylius\\AdminOrderCreationPlugin\\": "src/", - "Tests\\Sylius\\AdminOrderCreationPlugin\\": "tests/" + "Sylius\\AdminOrderCreationPlugin\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Sylius\\AdminOrderCreationPlugin\\": ["tests/", "tests/TestApplication/src/"] } }, "scripts": { - "analyse": [ - "@composer validate --strict", - "vendor/bin/phpstan analyse -c phpstan.neon -l max src/", - "vendor/bin/ecs check src/ spec/" + "database-reset": [ + "vendor/bin/console doctrine:database:drop --force --if-exists", + "vendor/bin/console doctrine:database:create", + "vendor/bin/console doctrine:migration:migrate -n", + "vendor/bin/console sylius:fixtures:load -n" + ], + "frontend-clear": [ + "cd vendor/sylius/test-application && yarn install && yarn build", + "vendor/bin/console assets:install" + ], + "test-app-init": [ + "@database-reset", + "@frontend-clear" ], - "fix": [ - "vendor/bin/ecs check src/ spec/ --fix" + "ecs": "ecs", + "phpstan": "phpstan analyse", + "psalm": "psalm --no-cache", + "phpunit": "phpunit", + "phpspec": "phpspec run", + "behat": "behat --strict -vvv --no-interaction || behat --strict -vvv --no-interaction --rerun", + "suite": [ + "@ecs", + "@phpstan", + "@psalm", + "@phpunit", + "@phpspec", + "@behat" + ], + "auto-scripts": { + "security-checker security:check": "script" + }, + "post-install-cmd": [ + "@create-console-symlink" + ], + "post-update-cmd": [ + "@create-console-symlink" + ], + "create-console-symlink": [ + "@php bin/create_console_symlink.php" ] }, "extra": { "branch-alias": { - "dev-master": "1.1-dev" - } + "dev-master": "2.0-dev" + }, + "symfony": { + "require": "^7.3" + }, + "public-dir": "vendor/sylius/test-application/public" } } From 31520ec335ed4dd929deba87ecbbeb5e37700350 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Mon, 27 Jul 2026 16:26:33 +0200 Subject: [PATCH 02/44] [Sylius 2] Move src/Resources/* to flat config/, templates/, translations/ Aligns with the Sylius 2.x plugin layout: config/app/*.yml -> config/*.yaml, views/* -> templates/* (snake_case), translations/*.yml -> translations/*.yaml. Overrides Bundle::getPath() to point at the plugin root so Symfony resolves the new top-level directories instead of the legacy Resources/ convention, and updates every internal template/config path reference accordingly (controllers, routing, DI extension, README install/copy instructions). --- README.md | 36 ++++++++--- .../config/app/ajax.yml => config/ajax.yaml | 8 +-- .../app/config.yml => config/config.yaml | 4 +- .../app/routing.yml => config/routing.yaml | 17 +++--- {src/Resources/config => config}/services.xml | 4 +- .../services/reorder_processing.xml | 4 +- docs/legacy_installation.md | 61 ------------------- src/Controller/OrderCreateAction.php | 4 +- src/Controller/OrderPreviewAction.php | 4 +- .../SelectNewOrderCustomerAction.php | 2 +- .../SyliusAdminOrderCreationExtension.php | 4 +- src/SyliusAdminOrderCreationPlugin.php | 5 ++ .../Order/Show/Summary/_item.html.twig | 0 .../Order/Show/Summary/_totals.html.twig | 0 .../Order/Show/_payment.html.twig | 0 .../emails/order_created.html.twig | 0 .../order}/create.html.twig | 17 +++--- .../order/create}/_breadcrumb.html.twig | 0 .../order/create}/_form.html.twig | 0 .../order/item_collection_theme.html.twig | 0 .../order}/preview.html.twig | 4 +- .../order/select_customer.html.twig | 32 ++++------ .../order/select_customer_theme.html.twig | 0 .../order/show}/_item.html.twig | 0 .../order/show}/_summary.html.twig | 0 .../messages.de.yaml | 0 .../messages.en.yaml | 0 .../messages.fr.yaml | 0 .../messages.ru.yaml | 0 .../validators.de.yaml | 0 .../validators.en.yaml | 0 .../validators.fr.yaml | 0 .../validators.ru.yaml | 0 33 files changed, 77 insertions(+), 129 deletions(-) rename src/Resources/config/app/ajax.yml => config/ajax.yaml (87%) rename src/Resources/config/app/config.yml => config/config.yaml (85%) rename src/Resources/config/app/routing.yml => config/routing.yaml (85%) rename {src/Resources/config => config}/services.xml (96%) rename {src/Resources/config => config}/services/reorder_processing.xml (87%) delete mode 100644 docs/legacy_installation.md rename {src/Resources/views => templates/bundles}/SyliusAdminBundle/Order/Show/Summary/_item.html.twig (100%) rename {src/Resources/views => templates/bundles}/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig (100%) rename {src/Resources/views => templates/bundles}/SyliusAdminBundle/Order/Show/_payment.html.twig (100%) rename src/Resources/views/Emails/orderCreated.html.twig => templates/emails/order_created.html.twig (100%) rename {src/Resources/views/Order => templates/order}/create.html.twig (85%) rename {src/Resources/views/Order/Create => templates/order/create}/_breadcrumb.html.twig (100%) rename {src/Resources/views/Order/Create => templates/order/create}/_form.html.twig (100%) rename src/Resources/views/Order/itemCollectionTheme.html.twig => templates/order/item_collection_theme.html.twig (100%) rename {src/Resources/views/Order => templates/order}/preview.html.twig (97%) rename src/Resources/views/Order/selectCustomer.html.twig => templates/order/select_customer.html.twig (76%) rename src/Resources/views/Order/selectCustomerTheme.html.twig => templates/order/select_customer_theme.html.twig (100%) rename {src/Resources/views/Order/Show => templates/order/show}/_item.html.twig (100%) rename {src/Resources/views/Order/Show => templates/order/show}/_summary.html.twig (100%) rename src/Resources/translations/messages.de.yml => translations/messages.de.yaml (100%) rename src/Resources/translations/messages.en.yml => translations/messages.en.yaml (100%) rename src/Resources/translations/messages.fr.yml => translations/messages.fr.yaml (100%) rename src/Resources/translations/messages.ru.yml => translations/messages.ru.yaml (100%) rename src/Resources/translations/validators.de.yml => translations/validators.de.yaml (100%) rename src/Resources/translations/validators.en.yml => translations/validators.en.yaml (100%) rename src/Resources/translations/validators.fr.yml => translations/validators.fr.yaml (100%) rename src/Resources/translations/validators.ru.yml => translations/validators.ru.yaml (100%) diff --git a/README.md b/README.md index f35172096..a1fae6b70 100644 --- a/README.md +++ b/README.md @@ -34,28 +34,44 @@ After creating an Order via Admin panel, this new Order is listed like any other ## Installation -#### Beware! - -> This installation instruction assumes that you're using Symfony Flex. If you don't, take a look at the -[legacy installation instruction](docs/legacy_installation.md). However, we strongly encourage you to use -Symfony Flex, it's much quicker! :) +Requires Sylius `~2.2.0` and PHP `^8.2`. 1. Require plugin with composer: ```bash composer require sylius/admin-order-creation-plugin ``` - + > Remember to allow community recipes with `composer config extra.symfony.allow-contrib true` or during plugin installation process -2. Copy Sylius templates overridden in plugin to your templates directory (e.g `templates/bundles/`): +2. Register the bundle in `config/bundles.php`: + + ```php + Sylius\AdminOrderCreationPlugin\SyliusAdminOrderCreationPlugin::class => ['all' => true], + ``` + +3. Import plugin configuration in `config/packages/sylius_admin_order_creation_plugin.yaml`: + + ```yaml + imports: + - { resource: "@SyliusAdminOrderCreationPlugin/config/config.yaml" } + ``` + +4. Import plugin routes in `config/routes/sylius_admin_order_creation_plugin.yaml`: + + ```yaml + sylius_admin_order_creation_plugin: + resource: "@SyliusAdminOrderCreationPlugin/config/routing.yaml" + ``` + +5. Copy Sylius templates overridden in plugin to your templates directory (e.g `templates/bundles/`): ```bash mkdir -p templates/bundles/SyliusAdminBundle/ - cp -R vendor/sylius/admin-order-creation-plugin/src/Resources/views/SyliusAdminBundle/* templates/bundles/SyliusAdminBundle/ + cp -R vendor/sylius/admin-order-creation-plugin/templates/bundles/SyliusAdminBundle/* templates/bundles/SyliusAdminBundle/ ``` -3. Override repositories +6. Override repositories 1. Create repository classes ```bash @@ -118,7 +134,7 @@ Symfony Flex, it's much quicker! :) ## Extension points Admin Order Creation Plugin makes it possible to add custom discount during order creation - thus some of Order -Show templates need to be replaced with those placed in `Resources/views` package. +Show templates need to be replaced with those placed in the `templates/bundles/SyliusAdminBundle` directory. Payment link generation and sending process is based on logic placed in the PaymentLinkCreationListener class. Thus, it can be easily replaced with suitable implementation. diff --git a/src/Resources/config/app/ajax.yml b/config/ajax.yaml similarity index 87% rename from src/Resources/config/app/ajax.yml rename to config/ajax.yaml index 22c3e21cb..49fd825b5 100644 --- a/src/Resources/config/app/ajax.yml +++ b/config/ajax.yaml @@ -2,7 +2,7 @@ sylius_admin_order_creation_ajax_customer: path: /customer-search methods: [GET] defaults: - _controller: sylius.controller.customer:indexAction + _controller: sylius.controller.customer::indexAction _format: json _sylius: permission: true @@ -14,7 +14,7 @@ sylius_admin_order_creation_ajax_customer_by_email: path: /customer-search-by-email methods: [GET] defaults: - _controller: sylius.controller.customer:indexAction + _controller: sylius.controller.customer::indexAction _format: json _sylius: permission: true @@ -26,7 +26,7 @@ sylius_admin_order_creation_ajax_product_variants_by_phrase_and_channel: path: /product-variant-search/{channelCode} methods: [GET] defaults: - _controller: sylius.controller.product_variant:indexAction + _controller: sylius.controller.product_variant::indexAction _format: json _sylius: permission: true @@ -42,7 +42,7 @@ sylius_admin_order_creation_ajax_product_variant_by_codes: path: /product-variant-search-by-code methods: [GET] defaults: - _controller: sylius.controller.product_variant:indexAction + _controller: sylius.controller.product_variant::indexAction _format: json _sylius: permission: true diff --git a/src/Resources/config/app/config.yml b/config/config.yaml similarity index 85% rename from src/Resources/config/app/config.yml rename to config/config.yaml index 8964c6e19..ccdd0c101 100644 --- a/src/Resources/config/app/config.yml +++ b/config/config.yaml @@ -1,5 +1,5 @@ imports: - - { resource: '@SyliusAdminOrderCreationPlugin/Resources/config/services.xml' } + - { resource: '@SyliusAdminOrderCreationPlugin/config/services.xml' } sylius_grid: grids: @@ -29,4 +29,4 @@ sylius_mailer: emails: order_created_in_admin_panel: subject: 'sylius_admin_order_creation.email.order_created.subject' - template: '@SyliusAdminOrderCreationPlugin/Emails/orderCreated.html.twig' + template: '@SyliusAdminOrderCreationPlugin/emails/order_created.html.twig' diff --git a/src/Resources/config/app/routing.yml b/config/routing.yaml similarity index 85% rename from src/Resources/config/app/routing.yml rename to config/routing.yaml index a235552ba..a5a4a32e3 100644 --- a/src/Resources/config/app/routing.yml +++ b/config/routing.yaml @@ -18,12 +18,12 @@ sylius_admin_order_creation_order_create: options: expose: true defaults: - _controller: sylius.controller.order:createAction + _controller: sylius.controller.order::createAction _sylius: event: admin_create section: admin permission: true - template: '@SyliusAdminOrderCreationPlugin/Order/create.html.twig' + template: '@SyliusAdminOrderCreationPlugin/order/create.html.twig' form: type: Sylius\AdminOrderCreationPlugin\Form\Type\NewOrderType factory: @@ -50,12 +50,12 @@ sylius_admin_order_creation_order_create_from_preview: options: expose: true defaults: - _controller: sylius.controller.order:createAction + _controller: sylius.controller.order::createAction _sylius: event: admin_create section: admin permission: true - template: '@SyliusAdminOrderCreationPlugin/Order/preview.html.twig' + template: '@SyliusAdminOrderCreationPlugin/order/preview.html.twig' form: type: Sylius\AdminOrderCreationPlugin\Form\Type\NewOrderType factory: @@ -78,12 +78,12 @@ sylius_admin_order_creation_reorder: options: expose: true defaults: - _controller: sylius.controller.order:createAction + _controller: sylius.controller.order::createAction _sylius: event: admin_create section: admin permission: true - template: '@SyliusAdminOrderCreationPlugin/Order/create.html.twig' + template: '@SyliusAdminOrderCreationPlugin/order/create.html.twig' form: type: Sylius\AdminOrderCreationPlugin\Form\Type\NewOrderType factory: @@ -92,7 +92,4 @@ sylius_admin_order_creation_reorder: sylius_admin_order_creation_ajax: prefix: admin/ajax - resource: "@SyliusAdminOrderCreationPlugin/Resources/config/app/ajax.yml" - -fos_js_routing: - resource: "@FOSJsRoutingBundle/Resources/config/routing/routing.xml" + resource: "@SyliusAdminOrderCreationPlugin/config/ajax.yaml" diff --git a/src/Resources/config/services.xml b/config/services.xml similarity index 96% rename from src/Resources/config/services.xml rename to config/services.xml index 5e3f1bce4..1387826a7 100644 --- a/src/Resources/config/services.xml +++ b/config/services.xml @@ -32,7 +32,7 @@ - + @@ -89,7 +89,7 @@ - + diff --git a/src/Resources/config/services/reorder_processing.xml b/config/services/reorder_processing.xml similarity index 87% rename from src/Resources/config/services/reorder_processing.xml rename to config/services/reorder_processing.xml index 9eddea77d..29ceadf3e 100644 --- a/src/Resources/config/services/reorder_processing.xml +++ b/config/services/reorder_processing.xml @@ -22,8 +22,8 @@ - - + + diff --git a/docs/legacy_installation.md b/docs/legacy_installation.md deleted file mode 100644 index 2b9443770..000000000 --- a/docs/legacy_installation.md +++ /dev/null @@ -1,61 +0,0 @@ -### Legacy installation (without Symfony Flex) - -1. Require plugin with composer: - - ```bash - composer require sylius/admin-order-creation-plugin - ``` - -2. Import configuration to `app/config/config.yml`: - - ```yaml - imports: - - { resource: "@SyliusAdminOrderCreationPlugin/Resources/config/app/config.yml" } - ``` - -3. Import routing to `app/config/routing.yml`: - - ```yaml - sylius_admin_order_creation: - resource: "@SyliusAdminOrderCreationPlugin/Resources/config/app/routing.yml" - ``` - -4. Add plugin class to your `AppKernel`: - - ```php - $bundles = [ - new \FOS\JsRoutingBundle\FOSJsRoutingBundle(), - new \Sylius\AdminOrderCreationPlugin\SyliusAdminOrderCreationPlugin(), - ]; - ``` - -5. Copy Sylius templates overridden in plugin to your templates directory (e.g `app/Resources/SyliusAdminBundle/views/`): - - ```bash - mkdir -p app/Resources/SyliusAdminBundle/views/ - cp -R vendor/sylius/admin-order-creation-plugin/src/Resources/views/SyliusAdminBundle/* app/Resources/SyliusAdminBundle/views/ - ``` - -6. Override repositories - - As shown [here](tests/Application/Doctrine/ORM) - and [here](tests/Application/config/packages/_sylius.yaml). - -7. Copy plugin migrations to your migrations directory (e.g. `src/Migrations`) and apply them to your database: - - ```bash - cp -R vendor/sylius/admin-order-creation-plugin/migrations/* src/Migrations - bin/console doctrine:migrations:migrate - ``` - -8. Install `FOSJsRoutingBundle` assets: - - ```bash - bin/console assets:install --symlink web - ``` - -8. Clear cache: - - ```bash - bin/console cache:clear - ``` diff --git a/src/Controller/OrderCreateAction.php b/src/Controller/OrderCreateAction.php index bd43332ce..ed72a3a27 100644 --- a/src/Controller/OrderCreateAction.php +++ b/src/Controller/OrderCreateAction.php @@ -25,7 +25,7 @@ final class OrderCreateAction public function __construct( OrderFactoryInterface $orderFactory, FormFactoryInterface $formFactory, - Environment $twig + Environment $twig, ) { $this->orderFactory = $orderFactory; $this->formFactory = $formFactory; @@ -42,7 +42,7 @@ public function __invoke(Request $request): Response $form = $this->formFactory->create(NewOrderType::class, $order); $form->handleRequest($request); - return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/Order/create.html.twig', [ + return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/order/create.html.twig', [ 'form' => $form->createView(), ])); } diff --git a/src/Controller/OrderPreviewAction.php b/src/Controller/OrderPreviewAction.php index 5f60575f8..a62c993ee 100644 --- a/src/Controller/OrderPreviewAction.php +++ b/src/Controller/OrderPreviewAction.php @@ -30,7 +30,7 @@ public function __construct( OrderFactoryInterface $orderFactory, FormFactoryInterface $formFactory, OrderProcessorInterface $orderProcessor, - Environment $twig + Environment $twig, ) { $this->orderFactory = $orderFactory; $this->formFactory = $formFactory; @@ -49,7 +49,7 @@ public function __invoke(Request $request): Response $order = $form->handleRequest($request)->getData(); $this->orderProcessor->process($order); - return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/Order/preview.html.twig', [ + return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/order/preview.html.twig', [ 'form' => $form->createView(), ])); } diff --git a/src/Controller/SelectNewOrderCustomerAction.php b/src/Controller/SelectNewOrderCustomerAction.php index 188f18bbe..9e6e9c1e0 100644 --- a/src/Controller/SelectNewOrderCustomerAction.php +++ b/src/Controller/SelectNewOrderCustomerAction.php @@ -30,7 +30,7 @@ public function __invoke(Request $request): Response $selectCustomerForm = $this->formFactory->create(NewOrderCustomerSelectType::class); $createCustomerForm = $this->formFactory->create(NewOrderCustomerCreateType::class); - return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/Order/selectCustomer.html.twig', [ + return new Response($this->twig->render('@SyliusAdminOrderCreationPlugin/order/select_customer.html.twig', [ 'selectCustomerForm' => $selectCustomerForm->createView(), 'createCustomerForm' => $createCustomerForm->createView(), ])); diff --git a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php index 4f77fad5a..c64643d9a 100644 --- a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php +++ b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php @@ -18,7 +18,7 @@ final class SyliusAdminOrderCreationExtension extends Extension implements Prepe public function load(array $config, ContainerBuilder $container): void { $this->processConfiguration($this->getConfiguration([], $container), $config); - $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); + $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); } public function prepend(ContainerBuilder $container): void @@ -33,7 +33,7 @@ protected function getMigrationsNamespace(): string protected function getMigrationsDirectory(): string { - return '@SyliusAdminOrderCreationPlugin/Migrations'; + return '@SyliusAdminOrderCreationPlugin/src/Migrations'; } protected function getNamespacesOfMigrationsExecutedBefore(): array diff --git a/src/SyliusAdminOrderCreationPlugin.php b/src/SyliusAdminOrderCreationPlugin.php index 48973d09f..6fb06b2cb 100644 --- a/src/SyliusAdminOrderCreationPlugin.php +++ b/src/SyliusAdminOrderCreationPlugin.php @@ -13,6 +13,11 @@ final class SyliusAdminOrderCreationPlugin extends Bundle { use SyliusPluginTrait; + public function getPath(): string + { + return \dirname(__DIR__); + } + public function build(ContainerBuilder $container): void { $container->addCompilerPass(new RegisterReorderProcessorsPass()); diff --git a/src/Resources/views/SyliusAdminBundle/Order/Show/Summary/_item.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig similarity index 100% rename from src/Resources/views/SyliusAdminBundle/Order/Show/Summary/_item.html.twig rename to templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig diff --git a/src/Resources/views/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig similarity index 100% rename from src/Resources/views/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig rename to templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig diff --git a/src/Resources/views/SyliusAdminBundle/Order/Show/_payment.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig similarity index 100% rename from src/Resources/views/SyliusAdminBundle/Order/Show/_payment.html.twig rename to templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig diff --git a/src/Resources/views/Emails/orderCreated.html.twig b/templates/emails/order_created.html.twig similarity index 100% rename from src/Resources/views/Emails/orderCreated.html.twig rename to templates/emails/order_created.html.twig diff --git a/src/Resources/views/Order/create.html.twig b/templates/order/create.html.twig similarity index 85% rename from src/Resources/views/Order/create.html.twig rename to templates/order/create.html.twig index 02275723c..c26c2782f 100644 --- a/src/Resources/views/Order/create.html.twig +++ b/templates/order/create.html.twig @@ -2,7 +2,7 @@ {% block title %}{{ 'sylius.ui.new_order'|trans }} {{ parent() }}{% endblock %} -{% form_theme form '@SyliusAdminOrderCreationPlugin/Order/itemCollectionTheme.html.twig' %} +{% form_theme form '@SyliusAdminOrderCreationPlugin/order/item_collection_theme.html.twig' %} {% if order is defined %} {% set customer_id = order.customer.id %} @@ -19,18 +19,19 @@
{{ 'sylius.ui.new_order'|trans }}
- {% include '@SyliusAdminOrderCreationPlugin/Order/Create/_breadcrumb.html.twig' %} + {% include '@SyliusAdminOrderCreationPlugin/order/create/_breadcrumb.html.twig' %} - {% include '@SyliusAdminOrderCreationPlugin/Order/Create/_form.html.twig' with { + {% include '@SyliusAdminOrderCreationPlugin/order/create/_form.html.twig' with { 'action': path('sylius_admin_order_creation_order_preview', {'customerId': customer_id, 'channelCode': channel_code}) } %} {% endblock %} {% block javascripts %} {{ parent() }} - - + - + - -{% endblock %} diff --git a/templates/order/select_customer_theme.html.twig b/templates/order/select_customer_theme.html.twig deleted file mode 100644 index 44b7b6d30..000000000 --- a/templates/order/select_customer_theme.html.twig +++ /dev/null @@ -1,8 +0,0 @@ -{% extends '@SyliusAdmin/Form/theme.html.twig' %} - -{% block sylius_customer_autocomplete_choice_row %} - {{ form_row(form, { - 'remote_url': path('sylius_admin_order_creation_ajax_customer'), - 'load_edit_url': path('sylius_admin_order_creation_ajax_customer_by_email') - }) }} -{% endblock %} diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 4dbb17792..44fb09443 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -8,14 +8,20 @@ sylius_admin_order_creation: order_price: 'Order price' ui: add_discount: 'Add discount' + add_item: 'Add item' + add_payment: 'Add payment' + add_shipment: 'Add shipment' confirm: 'Confirm' create_new: 'Create new' customer_email: 'Customer email cannot be empty' customer_selection: 'Customer selection' + existing_customer: 'Existing customer' item_discount: 'Item discount' new_customer_email: 'New customer email' no_customer_selected: "You haven't selected a customer" + no_discount: 'No discount applied' order_discount: 'Order discount' order_preview: 'Order preview' + pay: 'Pay' reorder: 'Reorder' shipping_methods_selection_requirement: 'You need to add some items and shipping address to select from eligible shipping method' diff --git a/translations/validators.de.yaml b/translations/validators.de.yaml index d1af7ea58..8c1d8c003 100644 --- a/translations/validators.de.yaml +++ b/translations/validators.de.yaml @@ -1,3 +1,4 @@ sylius_admin_order_creation: order_discount: 'Der Rabatt kann nicht unter 0 liegen' customer_email: 'Die E-Mail Adresse des Kunden darf nicht leer sein' + no_customer_selected: 'Sie haben keinen Kunden ausgewählt' diff --git a/translations/validators.en.yaml b/translations/validators.en.yaml index 4cf738657..5acdad121 100644 --- a/translations/validators.en.yaml +++ b/translations/validators.en.yaml @@ -1,3 +1,4 @@ sylius_admin_order_creation: order_discount: 'Discount cannot be below 0' customer_email: 'Customer email cannot be empty' + no_customer_selected: "You haven't selected a customer" diff --git a/translations/validators.fr.yaml b/translations/validators.fr.yaml index e7425c39d..6b6dcd7b6 100644 --- a/translations/validators.fr.yaml +++ b/translations/validators.fr.yaml @@ -1,3 +1,4 @@ sylius_admin_order_creation: order_discount: La réduction ne peut pas être inférieure à zéro customer_email: L'email du client ne peut pas être vide + no_customer_selected: Vous n'avez sélectionné aucun client From 98c2fafd2724ec211bf703bc3f499b93b4f05d3e Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Tue, 1 Sep 2026 09:20:40 +0200 Subject: [PATCH 19/44] Migrate order-creation form to a Live Component on Bootstrap/Tabler Rebuilds the item/shipment/payment/adjustment collections as a Symfony UX Live Component (OrderFormComponent) using LiveCollectionType, so rows can be added/removed without a full page reload. Product variant selection moves to a channel-scoped UX Autocomplete type; shipping method choices are now filtered to eligible methods via ShippingMethodChoiceType's native `subject` option, computed from a throwaway order built from the live form state. This retires the old jQuery/Semantic-UI collection widgets and the AJAX endpoint (ProvideAvailableShippingMethodsAction / ajax.yaml / AvailableShippingMethodsListProvider) and preparator (NewOrderPreparator) they existed to serve, all superseded by the Live Component doing this work in-process. --- config/ajax.yaml | 60 -------- config/routing.yaml | 12 +- config/services.xml | 26 ++-- spec/Preparator/NewOrderPreparatorSpec.php | 67 --------- ...ailableShippingMethodsListProviderSpec.php | 38 ------ .../ProvideAvailableShippingMethodsAction.php | 40 ------ src/Form/Type/AdjustmentType.php | 1 + src/Form/Type/NewOrderType.php | 56 ++++++-- src/Form/Type/OrderItemType.php | 19 +-- ...roductVariantInChannelAutocompleteType.php | 56 ++++++++ src/Form/Type/ShipmentType.php | 20 ++- src/Preparator/NewOrderPreparator.php | 53 ------- src/Preparator/OrderPreparatorInterface.php | 13 -- .../AvailableShippingMethodsListProvider.php | 31 ----- src/Twig/Component/OrderFormComponent.php | 76 +++++++++++ templates/order/create.html.twig | 127 +++++------------ templates/order/create/_breadcrumb.html.twig | 10 -- templates/order/create/_form.html.twig | 52 ------- .../create/_order_form_component.html.twig | 129 ++++++++++++++++++ .../order/item_collection_theme.html.twig | 56 ++++++-- 20 files changed, 437 insertions(+), 505 deletions(-) delete mode 100644 config/ajax.yaml delete mode 100644 spec/Preparator/NewOrderPreparatorSpec.php delete mode 100644 spec/Provider/AvailableShippingMethodsListProviderSpec.php delete mode 100644 src/Controller/ProvideAvailableShippingMethodsAction.php create mode 100644 src/Form/Type/ProductVariantInChannelAutocompleteType.php delete mode 100644 src/Preparator/NewOrderPreparator.php delete mode 100644 src/Preparator/OrderPreparatorInterface.php delete mode 100644 src/Provider/AvailableShippingMethodsListProvider.php create mode 100644 src/Twig/Component/OrderFormComponent.php delete mode 100644 templates/order/create/_breadcrumb.html.twig delete mode 100644 templates/order/create/_form.html.twig create mode 100644 templates/order/create/_order_form_component.html.twig diff --git a/config/ajax.yaml b/config/ajax.yaml deleted file mode 100644 index ee343d57f..000000000 --- a/config/ajax.yaml +++ /dev/null @@ -1,60 +0,0 @@ -sylius_admin_order_creation_ajax_customer: - path: /customer-search - methods: [GET] - defaults: - _controller: sylius.controller.customer::indexAction - _format: json - _sylius: - permission: true - repository: - method: findByEmailPart - arguments: $email - -sylius_admin_order_creation_ajax_customer_by_email: - path: /customer-search-by-email - methods: [GET] - defaults: - _controller: sylius.controller.customer::indexAction - _format: json - _sylius: - permission: true - repository: - method: findBy - arguments: [email: $email] - -sylius_admin_order_creation_ajax_product_variants_by_phrase_and_channel: - path: /product-variant-search/{channelCode} - methods: [GET] - defaults: - _controller: sylius.controller.product_variant::indexAction - _format: json - _sylius: - permission: true - serialization_groups: [Autocomplete] - repository: - method: findByPhraseAndChannelCode - arguments: - phrase: $phrase - channelCode: $channelCode - locale: expr:service('sylius.context.locale').getLocaleCode() - -sylius_admin_order_creation_ajax_product_variant_by_codes: - path: /product-variant-search-by-code - methods: [GET] - defaults: - _controller: sylius.controller.product_variant::indexAction - _format: json - _sylius: - permission: true - serialization_groups: [Autocomplete] - repository: - method: findOneByCode - arguments: $code - -sylius_admin_order_creation_ajax_provide_available_shipping_methods: - path: /admin/orders/available-shipping-methods/{customerId}/{channelCode}/{shipmentNumber} - methods: [GET] - defaults: - _controller: Webgriffe\SyliusAdminOrderCreationPlugin\Controller\ProvideAvailableShippingMethodsAction - options: - expose: true diff --git a/config/routing.yaml b/config/routing.yaml index 4f1a2d60c..c3d74f5fd 100644 --- a/config/routing.yaml +++ b/config/routing.yaml @@ -27,7 +27,7 @@ sylius_admin_order_creation_order_create: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Sylius\\AdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] requirements: customerId: \d+ @@ -41,7 +41,7 @@ sylius_admin_order_creation_order_preview: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Sylius\\AdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] sylius_admin_order_creation_order_create_from_preview: @@ -59,7 +59,7 @@ sylius_admin_order_creation_order_create_from_preview: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Sylius\\AdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] sylius_admin_order_creation_order_create_back: @@ -87,9 +87,5 @@ sylius_admin_order_creation_reorder: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Sylius\\AdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createFromExistingOrder'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createFromExistingOrder'] arguments: ["expr:service('sylius.repository.order').find($id)"] - -sylius_admin_order_creation_ajax: - prefix: admin/ajax - resource: "@WebgriffeSyliusAdminOrderCreationPlugin/config/ajax.yaml" diff --git a/config/services.xml b/config/services.xml index 085c00fa9..4156d6674 100644 --- a/config/services.xml +++ b/config/services.xml @@ -11,6 +11,7 @@ + @@ -26,17 +27,18 @@ - - - + + - - - + + %sylius.model.customer.class% + + - - + + %sylius.model.product_variant.class% + @@ -115,7 +117,13 @@ - + + + + + + + diff --git a/spec/Preparator/NewOrderPreparatorSpec.php b/spec/Preparator/NewOrderPreparatorSpec.php deleted file mode 100644 index 60c214ac9..000000000 --- a/spec/Preparator/NewOrderPreparatorSpec.php +++ /dev/null @@ -1,67 +0,0 @@ -beConstructedWith($orderFactory, $formFactory, $orderProcessor); - } - - function it_is_order_preparator(): void - { - $this->shouldImplement(OrderPreparatorInterface::class); - } - - function it_prepares_new_order_based_on_request_data( - OrderFactoryInterface $orderFactory, - FormFactoryInterface $formFactory, - OrderProcessorInterface $orderProcessor, - Request $request, - OrderInterface $order, - OrderInterface $orderWithData, - FormInterface $form, - ): void { - $request->attributes = new ParameterBag([ - 'customerId' => '1', - 'channelCode' => 'WEB-US', - ]); - - $orderFactory->createForCustomerAndChannel('1', 'WEB-US')->willReturn($order); - $formFactory->create(NewOrderType::class, $order)->willReturn($form); - - $form->handleRequest($request)->willReturn($form); - $form->getData()->willReturn($orderWithData); - - $orderProcessor->process($orderWithData)->shouldBeCalled(); - - $this->prepareFromRequest($request)->shouldReturn($orderWithData); - } - - function it_throws_exception_if_there_is_no_customer_email_specified_in_request(Request $request): void - { - $request->attributes = new ParameterBag([]); - - $this - ->shouldThrow(\InvalidArgumentException::class) - ->during('prepareFromRequest', [$request]) - ; - } -} diff --git a/spec/Provider/AvailableShippingMethodsListProviderSpec.php b/spec/Provider/AvailableShippingMethodsListProviderSpec.php deleted file mode 100644 index b32707eb3..000000000 --- a/spec/Provider/AvailableShippingMethodsListProviderSpec.php +++ /dev/null @@ -1,38 +0,0 @@ -beConstructedWith($shippingMethodsResolver); - } - - function it_provides_supported_shipping_methods_list_for_order_shipment( - ShippingMethodsResolverInterface $shippingMethodsResolver, - ShipmentInterface $shipment, - ShippingMethodInterface $freeShippingMethod, - ShippingMethodInterface $dhlShippingMethod, - ) { - $shippingMethodsResolver->getSupportedMethods($shipment)->willReturn([ - $freeShippingMethod, - $dhlShippingMethod, - ]); - - $freeShippingMethod->getCode()->willReturn('FREE'); - $freeShippingMethod->getName()->willReturn('Free'); - - $dhlShippingMethod->getCode()->willReturn('DHL'); - $dhlShippingMethod->getName()->willReturn('DHL'); - - $this($shipment)->shouldReturn(['FREE' => 'Free', 'DHL' => 'DHL']); - } -} diff --git a/src/Controller/ProvideAvailableShippingMethodsAction.php b/src/Controller/ProvideAvailableShippingMethodsAction.php deleted file mode 100644 index da2220dc9..000000000 --- a/src/Controller/ProvideAvailableShippingMethodsAction.php +++ /dev/null @@ -1,40 +0,0 @@ -orderPreparator = $orderPreparator; - $this->availableShippingMethodsListProvider = $availableShippingMethodsListProvider; - } - - public function __invoke(Request $request): Response - { - $order = $this->orderPreparator->prepareFromRequest($request); - $shipment = $order->getShipments()->get((int) $request->attributes->get('shipmentNumber')); - - if ($shipment === null) { - return new JsonResponse([]); - } - - return new JsonResponse($this->availableShippingMethodsListProvider->__invoke($shipment)); - } -} diff --git a/src/Form/Type/AdjustmentType.php b/src/Form/Type/AdjustmentType.php index 591339670..1025ee20d 100644 --- a/src/Form/Type/AdjustmentType.php +++ b/src/Form/Type/AdjustmentType.php @@ -23,6 +23,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->add('amount', MoneyType::class, [ 'label' => $options['label'], 'currency' => $options['currency'], + 'empty_data' => 0, 'constraints' => [ new Range(['min' => 0, 'minMessage' => 'sylius_admin_order_creation.order_discount', 'groups' => ['sylius']]), ], diff --git a/src/Form/Type/NewOrderType.php b/src/Form/Type/NewOrderType.php index 4b3398bee..e1549bcee 100644 --- a/src/Form/Type/NewOrderType.php +++ b/src/Form/Type/NewOrderType.php @@ -9,10 +9,12 @@ use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType; use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\OrderInterface; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Sylius\Component\Shipping\Model\ShippingSubjectInterface; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; final class NewOrderType extends AbstractResourceType { @@ -31,15 +33,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'sylius.ui.billing_address', 'required' => false, ]) - ->add('payments', CollectionType::class, [ + ->add('payments', LiveCollectionType::class, [ 'entry_type' => PaymentType::class, 'label' => 'sylius.ui.payments', 'allow_add' => true, 'allow_delete' => true, 'by_reference' => false, ]) - ->add('shipments', CollectionType::class, [ + ->add('shipments', LiveCollectionType::class, [ 'entry_type' => ShipmentType::class, + 'entry_options' => [ + 'subject' => $options['shipmentChoicesSubject'], + ], 'label' => 'sylius.ui.shipments', 'allow_add' => true, 'allow_delete' => true, @@ -55,17 +60,18 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $event ->getForm() - ->add('items', CollectionType::class, [ + ->add('items', LiveCollectionType::class, [ 'label' => false, 'entry_type' => OrderItemType::class, 'entry_options' => [ 'currency' => $order->getCurrencyCode(), + 'channelCode' => $channel->getCode(), ], 'allow_add' => true, 'allow_delete' => true, 'by_reference' => false, ]) - ->add('adjustments', CollectionType::class, [ + ->add('adjustments', LiveCollectionType::class, [ 'label' => false, 'entry_type' => AdjustmentType::class, 'entry_options' => [ @@ -76,15 +82,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'allow_add' => true, 'allow_delete' => true, 'by_reference' => false, - 'button_add_label' => 'sylius_admin_order_creation.ui.add_discount', + 'button_add_options' => [ + 'label' => 'sylius_admin_order_creation.ui.add_discount', + ], ]) ->add('localeCode', LocaleCodeChoiceType::class, [ - 'label' => false, + 'label' => 'sylius.ui.locale', 'choices' => $channel->getLocales(), 'empty_data' => $order->getLocaleCode(), ]) ->add('currencyCode', CurrencyCodeChoiceType::class, [ - 'label' => false, + 'label' => 'sylius.ui.currency', 'choices' => $channel->getCurrencies(), 'empty_data' => $order->getCurrencyCode(), ]) @@ -93,7 +101,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { $orderData = $event->getData(); - if (isset($orderData['shippingAddress']) && $this->isBillingAddressEmpty($orderData)) { + if ($this->isShippingAddressComplete($orderData) && $this->isBillingAddressEmpty($orderData)) { $orderData['billingAddress'] = $orderData['shippingAddress']; $event->setData($orderData); @@ -102,6 +110,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ; } + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('shipmentChoicesSubject', null); + $resolver->setAllowedTypes('shipmentChoicesSubject', ['null', ShippingSubjectInterface::class]); + } + public function getBlockPrefix(): string { return 'sylius_admin_order_creation_new_order'; @@ -122,4 +138,26 @@ private function isBillingAddressEmpty(array $orderData): bool $orderData['billingAddress']['postcode'] === '' ; } + + /** + * The order creation page re-renders live as the admin types (via the order-form Live Component), so + * this form's PRE_SUBMIT listener runs on every keystroke-triggered re-render, not just on the final + * submit. Only copying the shipping address into an empty billing address once shipping is itself + * fully filled in prevents a half-typed shipping address from being copied over field-by-field, which + * would otherwise permanently block the rest of the copy (billing would no longer read as "empty"). + */ + private function isShippingAddressComplete(array $orderData): bool + { + if (!isset($orderData['shippingAddress'])) { + return false; + } + + foreach (['firstName', 'lastName', 'street', 'countryCode', 'city', 'postcode'] as $field) { + if (($orderData['shippingAddress'][$field] ?? '') === '') { + return false; + } + } + + return true; + } } diff --git a/src/Form/Type/OrderItemType.php b/src/Form/Type/OrderItemType.php index 57efc0083..d320df377 100644 --- a/src/Form/Type/OrderItemType.php +++ b/src/Form/Type/OrderItemType.php @@ -5,14 +5,13 @@ namespace Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type; use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType; -use Sylius\Bundle\ResourceBundle\Form\Type\ResourceAutocompleteChoiceType; use Symfony\Component\Form\DataMapperInterface; -use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; final class OrderItemType extends AbstractResourceType { @@ -37,16 +36,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'sylius.ui.quantity', 'empty_data' => 1, ]) - ->add('variant', ResourceAutocompleteChoiceType::class, [ + ->add('variant', ProductVariantInChannelAutocompleteType::class, [ 'label' => 'sylius.ui.variant', - 'choice_name' => 'descriptor', - 'choice_value' => 'code', - 'resource' => 'sylius.product_variant', + 'extra_options' => [ + 'channel_code' => $options['channelCode'], + 'choice_label' => 'descriptor', + ], ]) ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void { $event ->getForm() - ->add('adjustments', CollectionType::class, [ + ->add('adjustments', LiveCollectionType::class, [ 'label' => false, 'entry_type' => AdjustmentType::class, 'entry_options' => [ @@ -57,7 +57,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'allow_add' => true, 'allow_delete' => true, 'by_reference' => false, - 'button_add_label' => 'sylius_admin_order_creation.ui.add_discount', + 'button_add_options' => [ + 'label' => 'sylius_admin_order_creation.ui.add_discount', + ], ]) ; }) @@ -78,6 +80,7 @@ public function configureOptions(OptionsResolver $resolver): void parent::configureOptions($resolver); $resolver->setRequired('currency'); + $resolver->setRequired('channelCode'); } /** diff --git a/src/Form/Type/ProductVariantInChannelAutocompleteType.php b/src/Form/Type/ProductVariantInChannelAutocompleteType.php new file mode 100644 index 000000000..73e2380df --- /dev/null +++ b/src/Form/Type/ProductVariantInChannelAutocompleteType.php @@ -0,0 +1,56 @@ +setDefaults([ + 'class' => $this->productVariantClass, + 'filter_query' => function (Options $options): ?callable { + $channelCode = $options['extra_options']['channel_code'] ?? null; + + if ($channelCode === null) { + return null; + } + + return function (QueryBuilder $queryBuilder, string $query, EntityRepository $repository) use ($channelCode): void { + $queryBuilder + ->innerJoin('entity.channelPricings', 'channelPricing') + ->andWhere('channelPricing.channelCode = :channelPricingChannelCode') + ->setParameter('channelPricingChannelCode', $channelCode) + ; + }; + }, + ]); + } + + public function getBlockPrefix(): string + { + return 'webgriffe_sylius_admin_order_creation_product_variant_in_channel_autocomplete'; + } + + public function getParent(): string + { + return TranslatableAutocompleteType::class; + } +} diff --git a/src/Form/Type/ShipmentType.php b/src/Form/Type/ShipmentType.php index 12c3809af..a141270d3 100644 --- a/src/Form/Type/ShipmentType.php +++ b/src/Form/Type/ShipmentType.php @@ -6,16 +6,32 @@ use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType; use Sylius\Bundle\ShippingBundle\Form\Type\ShippingMethodChoiceType; +use Sylius\Component\Shipping\Model\ShippingSubjectInterface; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; final class ShipmentType extends AbstractResourceType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('method', ShippingMethodChoiceType::class, [ + $methodOptions = [ 'required' => true, 'label' => 'sylius.form.checkout.shipping_method', - ]); + ]; + + if ($options['subject'] !== null) { + $methodOptions['subject'] = $options['subject']; + } + + $builder->add('method', ShippingMethodChoiceType::class, $methodOptions); + } + + public function configureOptions(OptionsResolver $resolver): void + { + parent::configureOptions($resolver); + + $resolver->setDefault('subject', null); + $resolver->setAllowedTypes('subject', ['null', ShippingSubjectInterface::class]); } /** diff --git a/src/Preparator/NewOrderPreparator.php b/src/Preparator/NewOrderPreparator.php deleted file mode 100644 index 6a264d7dd..000000000 --- a/src/Preparator/NewOrderPreparator.php +++ /dev/null @@ -1,53 +0,0 @@ -orderFactory = $orderFactory; - $this->formFactory = $formFactory; - $this->orderProcessor = $orderProcessor; - } - - public function prepareFromRequest(Request $request): OrderInterface - { - Assert::true($request->attributes->has('customerId')); - $customerEmail = $request->attributes->get('customerId'); - - Assert::true($request->attributes->has('channelCode')); - $channelCode = $request->attributes->get('channelCode'); - - $order = $this->orderFactory->createForCustomerAndChannel($customerEmail, $channelCode); - $form = $this->formFactory->create(NewOrderType::class, $order); - - /** @var OrderInterface $order */ - $order = $form->handleRequest($request)->getData(); - $this->orderProcessor->process($order); - - return $order; - } -} diff --git a/src/Preparator/OrderPreparatorInterface.php b/src/Preparator/OrderPreparatorInterface.php deleted file mode 100644 index 80c1aae83..000000000 --- a/src/Preparator/OrderPreparatorInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -shippingMethodsResolver = $shippingMethodsResolver; - } - - public function __invoke(ShipmentInterface $shipment): array - { - $shippingMethods = $this->shippingMethodsResolver->getSupportedMethods($shipment); - $shippingMethodsList = []; - - foreach ($shippingMethods as $shippingMethod) { - $shippingMethodsList[$shippingMethod->getCode()] = $shippingMethod->getName(); - } - - return $shippingMethodsList; - } -} diff --git a/src/Twig/Component/OrderFormComponent.php b/src/Twig/Component/OrderFormComponent.php new file mode 100644 index 000000000..4b39c353f --- /dev/null +++ b/src/Twig/Component/OrderFormComponent.php @@ -0,0 +1,76 @@ +formFactory->create(NewOrderType::class, $this->createOrder(), [ + 'shipmentChoicesSubject' => $this->computeShipmentChoicesSubject(), + ]); + } + + private function createOrder(): OrderInterface + { + return $this->orderFactory->createForCustomerAndChannel($this->customerId, $this->channelCode); + } + + /** + * Builds a throwaway order from the current (live, not-yet-final) form values so that the + * shipment's "method" field can be restricted to the shipping methods actually eligible for + * the items/address entered so far, mirroring Sylius' own checkout behaviour. + */ + private function computeShipmentChoicesSubject(): ?ShipmentInterface + { + if ($this->formValues === []) { + return null; + } + + try { + $order = $this->createOrder(); + $this->formFactory->create(NewOrderType::class, $order)->submit($this->formValues); + $this->orderProcessor->process($order); + } catch (\Throwable) { + return null; + } + + $shipment = $order->getShipments()->first(); + + return $shipment instanceof ShipmentInterface ? $shipment : null; + } +} diff --git a/templates/order/create.html.twig b/templates/order/create.html.twig index 052d7ff1b..8bb887624 100644 --- a/templates/order/create.html.twig +++ b/templates/order/create.html.twig @@ -1,8 +1,6 @@ -{% extends '@SyliusAdmin/layout.html.twig' %} +{% extends '@SyliusAdmin/shared/layout/base.html.twig' %} -{% block title %}{{ 'sylius.ui.new_order'|trans }} {{ parent() }}{% endblock %} - -{% form_theme form '@WebgriffeSyliusAdminOrderCreationPlugin/order/item_collection_theme.html.twig' %} +{% from '@SyliusAdmin/shared/helper/breadcrumbs.html.twig' import breadcrumbs %} {% if order is defined %} {% set customer_id = order.customer.id %} @@ -12,95 +10,40 @@ {% set channel_code = app.request.attributes.get('channelCode') %} {% endif %} -{% block content %} -
-
-

- -
{{ 'sylius.ui.new_order'|trans }}
-

- {% include '@WebgriffeSyliusAdminOrderCreationPlugin/order/create/_breadcrumb.html.twig' %} -
-
- {% include '@WebgriffeSyliusAdminOrderCreationPlugin/order/create/_form.html.twig' with { - 'action': path('sylius_admin_order_creation_order_preview', {'customerId': customer_id, 'channelCode': channel_code}) - } %} -{% endblock %} - -{% block javascripts %} - {{ parent() }} - - + {% include '@SyliusAdmin/shared/crud/common/content/footer.html.twig' %} + {% endblock %} diff --git a/templates/order/create/_breadcrumb.html.twig b/templates/order/create/_breadcrumb.html.twig deleted file mode 100644 index c2e087795..000000000 --- a/templates/order/create/_breadcrumb.html.twig +++ /dev/null @@ -1,10 +0,0 @@ -{% import '@SyliusAdmin/Macro/breadcrumb.html.twig' as breadcrumb %} - -{% set breadcrumbs = [ - { label: 'sylius.ui.administration'|trans, url: path('sylius_admin_dashboard') }, - { label: 'sylius.ui.orders'|trans, url: path('sylius_admin_order_index') }, - { label: 'sylius.ui.new'|trans } -] -%} - -{{ breadcrumb.crumble(breadcrumbs) }} diff --git a/templates/order/create/_form.html.twig b/templates/order/create/_form.html.twig deleted file mode 100644 index 82515cbb9..000000000 --- a/templates/order/create/_form.html.twig +++ /dev/null @@ -1,52 +0,0 @@ -
- {{ form_start(form, {'action': action, 'attr': {'class': 'ui loadable form', 'novalidate': 'novalidate'}}) }} - -
-
- {{ 'sylius.ui.items'|trans }} -
-
- {{ form_row(form.items) }} -
- -
- {{ 'sylius.ui.shipping_address'|trans }} & {{ 'sylius.ui.billing_address'|trans }} -
-
-
-
{{ form_row(form.shippingAddress) }}
-
{{ form_row(form.billingAddress) }}
-
-
- -
- {{ 'sylius.ui.locale'|trans }} & {{ 'sylius.ui.currency'|trans }} -
-
-
-
{{ form_row(form.localeCode) }}
-
{{ form_row(form.currencyCode) }}
-
-
- -
- {{ 'sylius.ui.shipments'|trans }} & {{ 'sylius.ui.payments'|trans }} -
-
-
-
-
{{ 'sylius_admin_order_creation.ui.shipping_methods_selection_requirement'|trans }}
- {{ form_row(form.shipments) }} -
-
- {{ form_row(form.payments) }} -
-
-
-
- - {% include '@SyliusUi/Form/Buttons/_create.html.twig' with {'paths': {'cancel': path('sylius_admin_order_index')}} %} - - {{ form_row(form._token) }} - {{ form_end(form, {'render_rest': false}) }} -
diff --git a/templates/order/create/_order_form_component.html.twig b/templates/order/create/_order_form_component.html.twig new file mode 100644 index 000000000..1e963194d --- /dev/null +++ b/templates/order/create/_order_form_component.html.twig @@ -0,0 +1,129 @@ +{% form_theme form '@WebgriffeSyliusAdminOrderCreationPlugin/order/item_collection_theme.html.twig' %} + +{% macro address_fields(address) %} +
{{ form_row(address.firstName) }}
+
{{ form_row(address.lastName) }}
+
{{ form_row(address.phoneNumber) }}
+
{{ form_row(address.company) }}
+
{{ form_row(address.countryCode) }}
+
+ {% if address.provinceCode is defined %}{{ form_row(address.provinceCode) }}{% endif %} + {% if address.provinceName is defined %}{{ form_row(address.provinceName) }}{% endif %} +
+
{{ form_row(address.street) }}
+
{{ form_row(address.city) }}
+
{{ form_row(address.postcode) }}
+{% endmacro %} + +
+ {{ form_start(form, { + 'action': path('sylius_admin_order_creation_order_preview', {'customerId': customerId, 'channelCode': channelCode}), + 'attr': {'novalidate': 'novalidate'}, + }) }} + {{ form_errors(form) }} + + + +
+
+
+
+
+ {{ form_row(form.items, {'label': false, 'skip_add_button': true}) }} +
+ {{ form_row(form.items.vars.button_add, {'label': 'sylius_admin_order_creation.ui.add_item'|trans, 'attr': {'class': 'btn btn-outline-primary'}}) }} +
+
+
+ +
+
+
+
+
{{ 'sylius.ui.shipping_address'|trans }}
+
+ {{ _self.address_fields(form.shippingAddress) }} +
+
+
+
+
+
{{ 'sylius.ui.billing_address'|trans }}
+
+ {{ _self.address_fields(form.billingAddress) }} +
+
+
+
+
+ +
+
+
+
{{ form_row(form.localeCode) }}
+
{{ form_row(form.currencyCode) }}
+
+
+
+ +
+
+
+
+
{{ 'sylius.ui.shipments'|trans }}
+
+
{{ 'sylius_admin_order_creation.ui.shipping_methods_selection_requirement'|trans }}
+ {{ form_row(form.shipments, {'label': false, 'skip_add_button': true}) }} + {{ form_row(form.shipments.vars.button_add, {'label': 'sylius_admin_order_creation.ui.add_shipment'|trans, 'attr': {'class': 'btn btn-outline-primary'}}) }} +
+
+
+
+
+
{{ 'sylius.ui.payments'|trans }}
+
+ {{ form_row(form.payments, {'label': false, 'skip_add_button': true}) }} + {% if form.payments|length == 0 %} + {{ form_row(form.payments.vars.button_add, {'label': 'sylius_admin_order_creation.ui.add_payment'|trans, 'attr': {'class': 'btn btn-outline-primary'}}) }} + {% endif %} +
+
+
+
+
+ +
+
+
+ {{ form_row(form.adjustments, {'label': false, 'skip_add_button': true}) }} + {{ form_row(form.adjustments.vars.button_add, {'attr': {'class': 'btn btn-outline-primary'}}) }} +
+
+
+
+ + {{ form_row(form.promotionCoupon) }} + +
+ {{ 'sylius.ui.cancel'|trans }} + +
+ + {{ form_end(form, {'render_rest': false}) }} +
diff --git a/templates/order/item_collection_theme.html.twig b/templates/order/item_collection_theme.html.twig index f2dc5e303..b01347473 100644 --- a/templates/order/item_collection_theme.html.twig +++ b/templates/order/item_collection_theme.html.twig @@ -1,15 +1,45 @@ -{% extends '@SyliusAdmin/Form/theme.html.twig' %} - -{% block _sylius_admin_order_creation_new_order_items_entry_widget %} - {% spaceless %} -
- {{ form_row(form.quantity, {'attr': {'class' : 'item-quantity'}}) }} - {{ form_row(form.variant, { - 'remote_url': path('sylius_admin_order_creation_ajax_product_variants_by_phrase_and_channel', {'channelCode': form.parent.parent.vars.value.channel.code}), - 'remote_criteria_type': 'contains', - 'remote_criteria_name': 'phrase', - 'load_edit_url': path('sylius_admin_order_creation_ajax_product_variant_by_codes') - }) }} +{% extends '@SyliusAdmin/shared/form_theme.html.twig' %} + +{% block _sylius_admin_order_creation_new_order_items_entry_row %} +
+
+
{{ form_row(form.variant) }}
+
{{ form_row(form.quantity) }}
+
{{ form_widget(button_delete, {'label': 'sylius.ui.delete'|trans, 'attr': {'class': 'btn btn-outline-danger'}}) }}
+
+
+
+ {{ form_row(form.adjustments, {'label': false, 'skip_add_button': true}) }} + {{ form_row(form.adjustments.vars.button_add, {'attr': {'class': 'btn btn-outline-primary'}}) }} +
- {% endspaceless %} +
+{% endblock %} + +{% block _sylius_admin_order_creation_new_order_order_item_adjustments_entry_row %} +
+
{{ form_row(form.amount) }}
+ {{ form_widget(button_delete, {'label': 'sylius.ui.delete'|trans, 'attr': {'class': 'btn btn-outline-danger'}}) }} +
+{% endblock %} + +{% block _sylius_admin_order_creation_new_order_adjustments_entry_row %} +
+
{{ form_row(form.amount) }}
+ {{ form_widget(button_delete, {'label': 'sylius.ui.delete'|trans, 'attr': {'class': 'btn btn-outline-danger'}}) }} +
+{% endblock %} + +{% block _sylius_admin_order_creation_new_order_shipments_entry_row %} +
+
{{ form_row(form.method) }}
+ {{ form_widget(button_delete, {'label': 'sylius.ui.delete'|trans, 'attr': {'class': 'btn btn-outline-danger'}}) }} +
+{% endblock %} + +{% block _sylius_admin_order_creation_new_order_payments_entry_row %} +
+
{{ form_row(form.method) }}
+ {{ form_widget(button_delete, {'label': 'sylius.ui.delete'|trans, 'attr': {'class': 'btn btn-outline-danger'}}) }} +
{% endblock %} From 9184bbfc3754f70b0620eb2b369aa1ef78a82107 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Tue, 1 Sep 2026 09:20:56 +0200 Subject: [PATCH 20/44] Migrate order-preview page to a Live Component on Bootstrap/Tabler Moves the preview form's content into a dedicated Live Component (OrderPreviewFormComponent, a thin subclass reusing OrderFormComponent's logic under a different name/template) so order- and item-level discounts can be added/edited in place before confirming. Money formatting in this template drops the locale argument to sylius_format_money(): app.user is null during a Live Component's AJAX sub-request even though it's populated on the full page load, so relying on app.user.localeCode here would break mid-edit. --- .../Component/OrderPreviewFormComponent.php | 15 ++ templates/order/preview.html.twig | 206 ++++-------------- .../_order_preview_form_component.html.twig | 170 +++++++++++++++ .../preview/item_collection_theme.html.twig | 42 ++++ 4 files changed, 268 insertions(+), 165 deletions(-) create mode 100644 src/Twig/Component/OrderPreviewFormComponent.php create mode 100644 templates/order/preview/_order_preview_form_component.html.twig create mode 100644 templates/order/preview/item_collection_theme.html.twig diff --git a/src/Twig/Component/OrderPreviewFormComponent.php b/src/Twig/Component/OrderPreviewFormComponent.php new file mode 100644 index 000000000..7839d8806 --- /dev/null +++ b/src/Twig/Component/OrderPreviewFormComponent.php @@ -0,0 +1,15 @@ + -
-

- -
- {{ 'sylius_admin_order_creation.ui.order_preview'|trans }} -
-
-
- {{ order.currencyCode }} -
-
- {% include [('@SyliusAdmin/Order/Label/State' ~ '/' ~ order.state ~ '.html.twig'), '@SyliusUi/Label/_default.html.twig'] with {'value': ('sylius.ui.' ~ order.state)|trans} %} -
-
- {{ flags.fromLocaleCode(order.localeCode) }}{{ order.localeCode|locale_name }} -
-
- {{ 'sylius.ui.purchased_from'|trans }} - {{ order.channel }} -
+{% extends '@SyliusAdmin/shared/layout/base.html.twig' %} + +{% from '@SyliusAdmin/shared/helper/breadcrumbs.html.twig' import breadcrumbs %} +{% from '@SyliusAdmin/order/macro/order_state_label.html.twig' import label as orderStateLabel %} + +{% set order = form.vars.data %} +{% set customerId = app.request.attributes.get('customerId') %} +{% set channelCode = app.request.attributes.get('channelCode') %} + +{% block title %}{{ 'sylius_admin_order_creation.ui.order_preview'|trans }} {{ parent() }}{% endblock %} + +{% block body %} + {% include '@SyliusAdmin/shared/crud/common/sidebar.html.twig' %} + {% include '@SyliusAdmin/shared/crud/common/navbar.html.twig' %} + +
+ {% include '@SyliusAdmin/shared/crud/common/content/flashes.html.twig' %} + +

-
-
-
-
- {{ form_errors(form) }} -
-
-
-
-
- {% include '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/_summary.html.twig' with {'form': form.items} %} -
-
-
-
-
- {{ order.customer.fullName }} -
- {{ 'sylius.ui.customer_since'|trans }} {{ order.customer.createdAt|format_date }}. -
-
- - {% if order.customer.phoneNumber is not empty %} -
- - - {{ order.customer.phoneNumber }} - -
- {% endif %} - {% if order.customerIp is defined and order.customerIp is not empty %} -
- - - {{ order.customerIp }} - -
- {% endif %} -
-

- {{ 'sylius.ui.shipping_address'|trans }} -

-
- {% include '@SyliusAdmin/Common/_address.html.twig' with {'address': order.shippingAddress} %} -
-

- {{ 'sylius.ui.billing_address'|trans }} -

-
- {% include '@SyliusAdmin/Common/_address.html.twig' with {'address': order.billingAddress} %} -
-
-
-
-
-
-
- {{ form_row(form.promotionCoupon) }} - {{ form_row(form.shippingAddress) }} - {{ form_row(form.billingAddress) }} - {{ form_row(form.payments) }} - {{ form_row(form.shipments) }} -
- {{ form_row(form.adjustments) }} -
-
-
-
-
-
- {% if order.hasPayments %} -

{{ 'sylius.ui.payments'|trans }}

-
- {% for payment in order.payments %} - {% include '@SyliusAdmin/Order/Show/_payment.html.twig' %} - {% endfor %} -
- {% endif %}
-
-
- {% if order.hasShipments %} -

{{ 'sylius.ui.shipments'|trans }}

-
- {% for shipment in order.shipments %} - {% include '@SyliusAdmin/Order/Show/_shipment.html.twig' %} - {% endfor %} -
- {% endif %} + +
+
+ {{ component('webgriffe_sylius_admin_order_creation:order_preview_form', { + customerId: customerId, + channelCode: channelCode, + form: form, + }) }}
-
-
- {{ form_rest(form) }} -
- {{ form_end(form) }} -
- -
- - + {% include '@SyliusAdmin/shared/crud/common/content/footer.html.twig' %}
{% endblock %} - -{% block javascripts %} - {{ parent() }} - -{% endblock %} diff --git a/templates/order/preview/_order_preview_form_component.html.twig b/templates/order/preview/_order_preview_form_component.html.twig new file mode 100644 index 000000000..ed4c2bba3 --- /dev/null +++ b/templates/order/preview/_order_preview_form_component.html.twig @@ -0,0 +1,170 @@ +{% from '@SyliusAdmin/shared/helper/address.html.twig' import address %} +{% from '@SyliusAdmin/order/macro/order_payment_state_label.html.twig' import label as paymentStateLabel %} +{% from '@SyliusAdmin/order/macro/order_shipping_state_label.html.twig' import label as shippingStateLabel %} + +{% form_theme form + '@SyliusAdmin/shared/form_theme.html.twig' + '@WebgriffeSyliusAdminOrderCreationPlugin/order/item_collection_theme.html.twig' + '@WebgriffeSyliusAdminOrderCreationPlugin/order/preview/item_collection_theme.html.twig' +%} + +{% set order = form.vars.data %} + +
+ {{ form_start(form, {'attr': {'novalidate': 'novalidate', 'id': form.vars.id}}) }} + + {{ form_errors(form) }} + +
+
+
+
{{ 'sylius.ui.items'|trans }}
+
+ + + + + + + + + + + + {{ form_row(form.items, {'label': false, 'skip_add_button': true}) }} + + + + + + + + + + + +
{{ 'sylius.ui.order_item_product'|trans }}{{ 'sylius.ui.unit_price'|trans }}{{ 'sylius.ui.item_discount'|trans }}{{ 'sylius.ui.quantity'|trans }}{{ 'sylius.ui.total'|trans }}
{{ 'sylius.ui.shipping_total'|trans }}{{ order.shippingTotal|sylius_format_money(order.currencyCode) }}
{{ 'sylius.ui.total'|trans }}{{ order.total|sylius_format_money(order.currencyCode) }}
+
+
+ +
+
{{ 'sylius_admin_order_creation.ui.order_discount'|trans }}
+
+ {{ form_row(form.adjustments, {'label': false, 'skip_add_button': true}) }} + {{ form_row(form.adjustments.vars.button_add, { + 'label': 'sylius_admin_order_creation.ui.add_discount'|trans, + 'attr': {'class': 'btn btn-outline-primary'}, + }) }} +
+
+ +
+
+
+
{{ 'sylius.ui.payments'|trans }}
+
+ {% if order.hasPayments %} +
+ + + {% for payment in order.payments %} + + + + + + {% endfor %} + +
{{ payment.method.name }}{{ paymentStateLabel(payment.state) }}{{ payment.amount|sylius_format_money(order.currencyCode) }}
+
+ {% else %} + {{ 'sylius.ui.no_payments'|trans }} + {% endif %} +
+
+
+
+
+
{{ 'sylius.ui.shipments'|trans }}
+
+ {% if order.hasShipments %} +
+ + + {% for shipment in order.shipments %} + + + + + {% endfor %} + +
+ {{ shipment.method.name }} +
{{ ux_icon('tabler:world') }} {{ shipment.method.zone }}
+
{{ shippingStateLabel(shipment.state) }}
+
+ {% else %} + {{ 'sylius.ui.there_are_no_shipments_to_display'|trans }} + {% endif %} +
+
+
+
+
+ +
+
+
{{ 'sylius.ui.customer'|trans }}
+
+
{{ 'sylius.ui.name'|trans }}:
+
{{ order.customer.fullName }}
+
{{ 'sylius.ui.email'|trans }}:
+ + {% if order.customer.phoneNumber is not empty %} +
{{ 'sylius.form.address.phone_number'|trans }}:
+
{{ order.customer.phoneNumber }}
+ {% endif %} +
+
+ +
+
{{ 'sylius.ui.shipping_address'|trans }}
+
+ {% if order.shippingAddress is not null %}{{ address(order.shippingAddress) }}{% endif %} +
+
+ +
+
{{ 'sylius.ui.billing_address'|trans }}
+
+ {% if order.billingAddress is not null %}{{ address(order.billingAddress) }}{% endif %} +
+
+
+
+ +
+ {{ form_rest(form) }} +
+ + {{ form_end(form, {'render_rest': false}) }} + +
+ + +
+
diff --git a/templates/order/preview/item_collection_theme.html.twig b/templates/order/preview/item_collection_theme.html.twig new file mode 100644 index 000000000..222d2fdbe --- /dev/null +++ b/templates/order/preview/item_collection_theme.html.twig @@ -0,0 +1,42 @@ +{% block _sylius_admin_order_creation_new_order_items_entry_row %} + {% set item = form.vars.data %} + {% set variant = item.variant %} + {% set product = variant.product %} + + + +
+
+ {% if product.imagesByType('thumbnail') is not empty %} + {{ product.name }} + {% elseif product.images.first %} + {{ product.name }} + {% else %} + {{ product.name }} + {% endif %} +
+
+
{{ item.productName }}
+
{{ variant.code }}
+
{{ item.variantName }}
+
+
+ + {{ item.unitPrice|sylius_format_money(item.order.currencyCode) }} + + {{ form_row(form.adjustments, {'label': false, 'skip_add_button': true}) }} + {{ form_row(form.adjustments.vars.button_add, { + 'label': 'sylius_admin_order_creation.ui.add_discount'|trans, + 'attr': {'class': 'btn btn-sm btn-outline-primary'}, + }) }} + + {{ item.quantity }} + {{ item.total|sylius_format_money(item.order.currencyCode) }} + + + + {{ form_widget(form.variant) }} + {{ form_widget(form.quantity) }} + + +{% endblock %} From d295993ec4011c7c2caecb5ef224107453beb88d Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Tue, 1 Sep 2026 09:21:14 +0200 Subject: [PATCH 21/44] Rebuild Order Show page customizations with Sylius 2 Twig Hooks Replaces the removed @SyliusAdmin template-override mechanism (templates/bundles/..., templates/order/show/_item.html.twig, _summary.html.twig) with hook templates: a "Pay" action on the payment row, an order-discount summary line, and item-row overrides for unit discount / discounted unit price / subtotal. The item-row overrides matter beyond display: core's own templates only sum adjustments of Sylius's built-in promotion types, so a manually-entered per-item discount (our own 'order_item_discount' adjustment type) rendered as if no discount had been applied at all. Aligning our type with core's promotion types instead was rejected - core's order processor clears and recalculates promotion-typed adjustments on every OrderPreviewAction::process() call, which would wipe a manual discount that isn't backed by an actual promotion rule. Also gives OrderShowPage's two plugin-specific assertions (hasPaymentLink, hasNoPaymentBlock) [data-test-*] selectors matching the new hook markup; core's own ShowPage already covers the rest for Tabler. --- config/config.yaml | 1 + config/twig_hooks/order_show.yaml | 22 ++++++ .../Order/Show/Summary/_item.html.twig | 52 ------------- .../Order/Show/Summary/_totals.html.twig | 73 ------------------- .../Order/Show/_payment.html.twig | 44 ----------- templates/order/show/_item.html.twig | 43 ----------- templates/order/show/_summary.html.twig | 29 -------- .../body/discounted_unit_price.html.twig | 17 +++++ .../sections/items/body/subtotal.html.twig | 17 +++++ .../items/body/unit_discount.html.twig | 12 +++ .../actions/pay_via_payment_link.html.twig | 7 ++ .../sections/summary/order_discount.html.twig | 11 +++ tests/Behat/Page/Admin/OrderShowPage.php | 12 ++- 13 files changed, 95 insertions(+), 245 deletions(-) create mode 100644 config/twig_hooks/order_show.yaml delete mode 100644 templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig delete mode 100644 templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig delete mode 100644 templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig delete mode 100644 templates/order/show/_item.html.twig delete mode 100644 templates/order/show/_summary.html.twig create mode 100644 templates/order/show/sections/items/body/discounted_unit_price.html.twig create mode 100644 templates/order/show/sections/items/body/subtotal.html.twig create mode 100644 templates/order/show/sections/items/body/unit_discount.html.twig create mode 100644 templates/order/show/sections/payments/item/actions/pay_via_payment_link.html.twig create mode 100644 templates/order/show/sections/summary/order_discount.html.twig diff --git a/config/config.yaml b/config/config.yaml index b9b3656f0..96ec9f49d 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,5 +1,6 @@ imports: - { resource: '@WebgriffeSyliusAdminOrderCreationPlugin/config/services.xml' } + - { resource: 'twig_hooks/**/*.yaml' } sylius_grid: grids: diff --git a/config/twig_hooks/order_show.yaml b/config/twig_hooks/order_show.yaml new file mode 100644 index 000000000..8769953ee --- /dev/null +++ b/config/twig_hooks/order_show.yaml @@ -0,0 +1,22 @@ +sylius_twig_hooks: + hooks: + 'sylius_admin.order.show.content.sections.payments.item.actions': + pay_via_payment_link: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/sections/payments/item/actions/pay_via_payment_link.html.twig' + priority: 150 + + 'sylius_admin.order.show.content.sections.summary': + order_discount: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/sections/summary/order_discount.html.twig' + priority: -100 + + 'sylius_admin.order.show.content.sections.items.body': + unit_discount: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/sections/items/body/unit_discount.html.twig' + priority: 600 + discounted_unit_price: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/sections/items/body/discounted_unit_price.html.twig' + priority: 400 + subtotal: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/show/sections/items/body/subtotal.html.twig' + priority: 200 diff --git a/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig deleted file mode 100644 index fe177a554..000000000 --- a/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_item.html.twig +++ /dev/null @@ -1,52 +0,0 @@ -{% import "@SyliusAdmin/Common/Macro/money.html.twig" as money %} - -{% set orderPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT') %} -{% set unitPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_UNIT_PROMOTION_ADJUSTMENT') %} -{% set shippingAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::SHIPPING_ADJUSTMENT') %} -{% set taxAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::TAX_ADJUSTMENT') %} -{% set orderItemDiscountAdjustment = constant('Sylius\\AdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_ITEM_DISCOUNT_ADJUSTMENT') %} - -{% set variant = item.variant %} -{% set product = variant.product %} - -{% set unitDiscount = item.units.first.adjustmentsTotal(unitPromotionAdjustment) + item.getAdjustmentsTotalRecursively(orderItemDiscountAdjustment) / item.quantity %} -{% set discountedUnitPrice = item.fullDiscountedUnitPrice + item.getAdjustmentsTotalRecursively(orderItemDiscountAdjustment) / item.quantity %} -{% set subtotal = item.quantity * (item.unitPrice + item.units.first.adjustmentsTotal(unitPromotionAdjustment) + item.units.first.adjustmentsTotal(orderPromotionAdjustment)) + item.getAdjustmentsTotalRecursively(orderItemDiscountAdjustment) %} - -{% set taxIncluded = sylius_admin_order_unit_tax_included(item) %} -{% set taxExcluded = sylius_admin_order_unit_tax_excluded(item) %} - - - - {% include '@SyliusAdmin/Product/_info.html.twig' %} - - - {{ money.format(item.unitPrice, order.currencyCode) }} - - - {{ money.format(unitDiscount, order.currencyCode) }} - - - {{ money.format(item.units.first.adjustmentsTotal(orderPromotionAdjustment), order.currencyCode) }} - - - {{ money.format(discountedUnitPrice, order.currencyCode) }} - - - {{ item.quantity }} - - - {{ money.format(subtotal, order.currencyCode) }} - - -
{{ money.format(taxExcluded, order.currencyCode) }}
-
-
{{ money.format(taxIncluded, order.currencyCode) }} -
- ({{ 'sylius.ui.included_in_price'|trans }}) -
- - - {{ money.format(item.total, order.currencyCode) }} - - diff --git a/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig deleted file mode 100644 index ab2a43d97..000000000 --- a/templates/bundles/SyliusAdminBundle/Order/Show/Summary/_totals.html.twig +++ /dev/null @@ -1,73 +0,0 @@ -{% import "@SyliusAdmin/Common/Macro/money.html.twig" as money %} - -{% set orderPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT') %} -{% set orderShippingPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_SHIPPING_PROMOTION_ADJUSTMENT') %} -{% set itemPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_ITEM_PROMOTION_ADJUSTMENT') %} -{% set shippingAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::SHIPPING_ADJUSTMENT') %} -{% set taxAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::TAX_ADJUSTMENT') %} -{% set orderDiscountAdjustment = constant('Sylius\\AdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_DISCOUNT_ADJUSTMENT') %} - -{% set orderShippingPromotions = sylius_aggregate_adjustments(order.getAdjustmentsRecursively(orderShippingPromotionAdjustment)) %} - - - - - {{ 'sylius.ui.tax_total'|trans }}: - {{ money.format(order.taxTotal, order.currencyCode) }} - - - {{ 'sylius.ui.items_total'|trans }}: - {{ money.format(order.itemsTotal, order.currencyCode) }} - - - - - {% if not order.adjustments(shippingAdjustment).isEmpty() %} -
-
{{ 'sylius.ui.shipping'|trans }}:
- {% for adjustment in order.adjustments(shippingAdjustment) %} -
-
{{ money.format(adjustment.amount, order.currencyCode) }}
-
-
- {{ adjustment.label }}: -
-
-
- {% endfor %} -
- {% else %} -

{{ 'sylius.ui.no_shipping_charges'|trans }}

- {% endif %} - - {% if not orderShippingPromotions is empty %} - -
-
{{ 'sylius.ui.shipping_discount'|trans }}:
- {% for label, amount in orderShippingPromotions %} -
-
- {{ money.format(amount, order.currencyCode) }} -
-
- {% endfor %} -
- - {% endif %} - - {{ 'sylius.ui.shipping_total'|trans }}: - {{ money.format(order.shippingTotal, order.currencyCode) }} - - - - - {{ 'sylius_admin_order_creation.ui.order_discount'|trans }}: - {{ money.format(order.getAdjustmentsTotalRecursively(orderDiscountAdjustment), order.currencyCode) }} - - - - - {{ 'sylius.ui.order_total'|trans }}: - {{ money.format(order.total, order.currencyCode) }} - - diff --git a/templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig b/templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig deleted file mode 100644 index 72e0eaa99..000000000 --- a/templates/bundles/SyliusAdminBundle/Order/Show/_payment.html.twig +++ /dev/null @@ -1,44 +0,0 @@ -{% import "@SyliusAdmin/Common/Macro/money.html.twig" as money %} -{% import '@SyliusUi/Macro/labels.html.twig' as label %} - -
-
- {% include '@SyliusAdmin/Common/Label/paymentState.html.twig' with {'data': payment.state} %} -
- -
-
- {{ payment.method }} -
-
- {{ money.format(payment.amount, payment.order.currencyCode) }} -
-
- {% if sm_can(payment, 'complete', 'sylius_payment') %} -
-
- - - -
-
- {% if payment.details['payment-link'] is defined %} - - {% endif %} - {% endif %} - {% if sm_can(payment, 'refund', 'sylius_payment') %} -
-
- - - -
-
- {% endif %} -
diff --git a/templates/order/show/_item.html.twig b/templates/order/show/_item.html.twig deleted file mode 100644 index b1e5cd819..000000000 --- a/templates/order/show/_item.html.twig +++ /dev/null @@ -1,43 +0,0 @@ -{% import "@SyliusAdmin/Common/Macro/money.html.twig" as money %} - -{% set orderPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT') %} -{% set itemPromotionAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_ITEM_PROMOTION_ADJUSTMENT') %} -{% set orderItemDiscountAdjustment = constant('Sylius\\AdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_ITEM_DISCOUNT_ADJUSTMENT') %} -{% set shippingAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::SHIPPING_ADJUSTMENT') %} -{% set taxAdjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::TAX_ADJUSTMENT') %} - -{% set variant = item.variant %} -{% set product = variant.product %} - - - - {% include '@SyliusAdmin/Product/_info.html.twig' %} - - - {{ money.format(item.unitPrice, order.currencyCode) }} - - - {{ money.format(item.discountedUnitPrice, order.currencyCode) }} - - - {{ item.quantity }} - - - {{ money.format(item.subtotal, order.currencyCode) }} - - - {% set itemDiscountTotal = item.getAdjustmentsTotalRecursively(orderPromotionAdjustment) + item.getAdjustmentsTotalRecursively(orderItemDiscountAdjustment) %} - {{ money.format(itemDiscountTotal, order.currencyCode) }} - - - {{ money.format(item.taxTotal, order.currencyCode) }} - - - {{ money.format(item.total, order.currencyCode) }} - - - - - {{ form_row(form.adjustments) }} - - diff --git a/templates/order/show/_summary.html.twig b/templates/order/show/_summary.html.twig deleted file mode 100644 index cbcfcea4f..000000000 --- a/templates/order/show/_summary.html.twig +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - {% for item_form in form.children %} - {% include '@SyliusAdmin/Order/Show/Summary/_item.html.twig' with {'item': order.items.get(loop.index0)} %} - - - - - {% endfor %} - - - {% include '@SyliusAdmin/Order/Show/Summary/_totals.html.twig' %} - -
{{ 'sylius.ui.order_item_product'|trans }}{{ 'sylius.ui.unit_price'|trans }}{{ 'sylius.ui.item_discount'|trans }}{{ 'sylius.ui.distributed_order_discount'|trans }}{{ 'sylius.ui.discounted_unit_price'|trans }}{{ 'sylius.ui.quantity'|trans }}{{ 'sylius.ui.subtotal'|trans }}{{ 'sylius.ui.tax'|trans }}{{ 'sylius.ui.total'|trans }}
- {{ form_row(item_form.adjustments) }} -
diff --git a/templates/order/show/sections/items/body/discounted_unit_price.html.twig b/templates/order/show/sections/items/body/discounted_unit_price.html.twig new file mode 100644 index 000000000..cf2ae8929 --- /dev/null +++ b/templates/order/show/sections/items/body/discounted_unit_price.html.twig @@ -0,0 +1,17 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{% set unit_promotion_adjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_UNIT_PROMOTION_ADJUSTMENT') %} +{% set order_promotion_adjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT') %} +{% set item_discount_adjustment = constant('Webgriffe\\SyliusAdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_ITEM_DISCOUNT_ADJUSTMENT') %} + +{% set order = hookable_metadata.context.resource %} +{% set item = hookable_metadata.context.item %} +{% set discountedUnitPrice = item.unitPrice + + item.units.first.getAdjustmentsTotal(unit_promotion_adjustment) + + item.units.first.getAdjustmentsTotal(order_promotion_adjustment) + + (item.getAdjustmentsTotal(item_discount_adjustment) // item.quantity) +%} + + + {{ money.format(discountedUnitPrice, order.currencyCode) }} + diff --git a/templates/order/show/sections/items/body/subtotal.html.twig b/templates/order/show/sections/items/body/subtotal.html.twig new file mode 100644 index 000000000..c4c70ea44 --- /dev/null +++ b/templates/order/show/sections/items/body/subtotal.html.twig @@ -0,0 +1,17 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{% set order_promotion_adjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_PROMOTION_ADJUSTMENT') %} +{% set unit_promotion_adjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_UNIT_PROMOTION_ADJUSTMENT') %} +{% set item_discount_adjustment = constant('Webgriffe\\SyliusAdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_ITEM_DISCOUNT_ADJUSTMENT') %} + +{% set order = hookable_metadata.context.resource %} +{% set item = hookable_metadata.context.item %} +{% set aggregated_unit_promotion_adjustments = item.getAdjustmentsTotalRecursively(unit_promotion_adjustment) + + item.getAdjustmentsTotalRecursively(order_promotion_adjustment) + + item.getAdjustmentsTotal(item_discount_adjustment) +%} +{% set subtotal = (item.unitPrice * item.quantity) + aggregated_unit_promotion_adjustments %} + + + {{ money.format(subtotal, order.currencyCode) }} + diff --git a/templates/order/show/sections/items/body/unit_discount.html.twig b/templates/order/show/sections/items/body/unit_discount.html.twig new file mode 100644 index 000000000..8e2f55cbb --- /dev/null +++ b/templates/order/show/sections/items/body/unit_discount.html.twig @@ -0,0 +1,12 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{% set unit_promotion_adjustment = constant('Sylius\\Component\\Core\\Model\\AdjustmentInterface::ORDER_UNIT_PROMOTION_ADJUSTMENT') %} +{% set item_discount_adjustment = constant('Webgriffe\\SyliusAdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_ITEM_DISCOUNT_ADJUSTMENT') %} + +{% set order = hookable_metadata.context.resource %} +{% set item = hookable_metadata.context.item %} +{% set unitDiscount = item.units.first.getAdjustmentsTotal(unit_promotion_adjustment) + (item.getAdjustmentsTotal(item_discount_adjustment) // item.quantity) %} + + + {{ money.format(unitDiscount, order.currencyCode) }} + diff --git a/templates/order/show/sections/payments/item/actions/pay_via_payment_link.html.twig b/templates/order/show/sections/payments/item/actions/pay_via_payment_link.html.twig new file mode 100644 index 000000000..a33f95888 --- /dev/null +++ b/templates/order/show/sections/payments/item/actions/pay_via_payment_link.html.twig @@ -0,0 +1,7 @@ +{% set payment = hookable_metadata.context.payment %} + +{% if payment.details['payment-link'] is defined %} + + {{ ux_icon('tabler:credit-card') }} {{ 'sylius_admin_order_creation.ui.pay'|trans }} + +{% endif %} diff --git a/templates/order/show/sections/summary/order_discount.html.twig b/templates/order/show/sections/summary/order_discount.html.twig new file mode 100644 index 000000000..fedee44bb --- /dev/null +++ b/templates/order/show/sections/summary/order_discount.html.twig @@ -0,0 +1,11 @@ +{% import '@SyliusAdmin/shared/helper/money.html.twig' as money %} + +{% set order = hookable_metadata.context.resource %} +{% set orderDiscountTotal = order.getAdjustmentsTotalRecursively(constant('Webgriffe\\SyliusAdminOrderCreationPlugin\\Form\\Type\\AdjustmentType::ORDER_DISCOUNT_ADJUSTMENT')) %} + +{% if orderDiscountTotal != 0 %} + + {{ 'sylius_admin_order_creation.ui.order_discount'|trans }}: + {{ money.format(orderDiscountTotal, order.currencyCode) }} + +{% endif %} diff --git a/tests/Behat/Page/Admin/OrderShowPage.php b/tests/Behat/Page/Admin/OrderShowPage.php index ff35d533b..89f30ccca 100644 --- a/tests/Behat/Page/Admin/OrderShowPage.php +++ b/tests/Behat/Page/Admin/OrderShowPage.php @@ -10,20 +10,24 @@ final class OrderShowPage extends ShowPage implements OrderShowPageInterface { public function hasPaymentLink(): bool { - $lastPayment = $this->getElement('payments')->find('css', '.item:last-child'); + $lastPayment = $this->getElement('payments')->find('css', '[data-test-payment]:last-child'); - return null !== $lastPayment->find('css', '#payment-link'); + if (null === $lastPayment) { + return false; + } + + return null !== $lastPayment->find('css', '[data-test-pay-via-payment-link]'); } public function hasNoPaymentBlock(): bool { - return null !== $this->getElement('no-payments'); + return null !== $this->getDocument()->find('css', $this->getDefinedElements()['no-payments']); } protected function getDefinedElements(): array { return array_merge(parent::getDefinedElements(), [ - 'no-payments' => '#no-payments', + 'no-payments' => '[data-test-no-payments]', ]); } } From 8056bb656d5d6d0f5b43924eb62ee7d3d815ac7d Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Tue, 1 Sep 2026 09:21:35 +0200 Subject: [PATCH 22/44] Rewrite Behat suite for the Sylius 2 admin UI Replaces every selector tied to the old Semantic UI/jQuery collection widgets with Bootstrap/TomSelect/data-test-attribute equivalents, and swaps the plugin's custom AutoCompleteSelector for core's own Sylius\Behat\Service\Helper\AutocompleteHelperInterface. Core's getSelectedItems() turned out to always resolve to the first outside the form, since Symfony's form component rejects unrecognized POST keys by default ("This form should not contain extra fields"). --- config/services.xml | 1 + ...creating_order_with_online_payment.feature | 16 ++++++- .../PaymentLinkCreationListenerSpec.php | 45 ++++++++++++++++++- .../PaymentLinkCreationListener.php | 24 +++++++++- src/Form/Type/NewOrderType.php | 10 ++++- .../_order_preview_form_component.html.twig | 6 +++ .../Context/Admin/ManagingOrdersContext.php | 8 ++++ tests/Behat/Page/Admin/OrderPreviewPage.php | 5 +++ .../Page/Admin/OrderPreviewPageInterface.php | 2 + translations/messages.en.yaml | 1 + 10 files changed, 113 insertions(+), 5 deletions(-) diff --git a/config/services.xml b/config/services.xml index 4156d6674..d407fed27 100644 --- a/config/services.xml +++ b/config/services.xml @@ -100,6 +100,7 @@ + diff --git a/features/creating_order/creating_order_with_online_payment.feature b/features/creating_order/creating_order_with_online_payment.feature index 3baf19676..b52a50c2f 100644 --- a/features/creating_order/creating_order_with_online_payment.feature +++ b/features/creating_order/creating_order_with_online_payment.feature @@ -20,8 +20,22 @@ Feature: Creating order with online payment And I specify this order shipping address as "Ankh Morpork", "Frost Alley", "90210", "United States" for "Jon Snow" And I select "Free" shipping method And I select "Paypal" payment method - And I place and confirm this order + And I place this order + And I want to send a payment link email to the customer + And I confirm this order Then I should be notified that order has been successfully created And there should be a payment link displayed next to order's payment And there should be a payment link sent to "jon.snow@the-wall.com" And there should be one not paid nor shipped order with channel "United States" for "jon.snow@the-wall.com" in the registry + + @ui @javascript @email + Scenario: Not sending a payment link email by default + When I create a new order for "jon.snow@the-wall.com" and channel "United States" + And I add "Stark Coat" to this order + And I specify this order shipping address as "Ankh Morpork", "Frost Alley", "90210", "United States" for "Jon Snow" + And I select "Free" shipping method + And I select "Paypal" payment method + And I place and confirm this order + Then I should be notified that order has been successfully created + And there should be a payment link displayed next to order's payment + And there should be no payment link sent to "jon.snow@the-wall.com" diff --git a/spec/EventListener/PaymentLinkCreationListenerSpec.php b/spec/EventListener/PaymentLinkCreationListenerSpec.php index 1d8ede822..3b0f68a26 100644 --- a/spec/EventListener/PaymentLinkCreationListenerSpec.php +++ b/spec/EventListener/PaymentLinkCreationListenerSpec.php @@ -13,6 +13,9 @@ use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Component\EventDispatcher\GenericEvent; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType; use Webgriffe\SyliusAdminOrderCreationPlugin\Provider\PaymentTokenProviderInterface; use Webgriffe\SyliusAdminOrderCreationPlugin\Sender\OrderPaymentLinkSenderInterface; @@ -22,14 +25,16 @@ function let( PaymentTokenProviderInterface $paymentTokenProvider, ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, ) { - $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender); + $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack); } - function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it( + function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_when_checkbox_is_checked( PaymentTokenProviderInterface $paymentTokenProvider, ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, TokenInterface $token, GenericEvent $event, OrderInterface $order, @@ -47,6 +52,10 @@ function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it( $paymentTokenProvider->getPaymentToken($payment)->willReturn($token); $token->getAfterUrl()->willReturn('http://url-to-pay.com'); + $requestStack->getCurrentRequest()->willReturn(new Request([], [ + NewOrderType::BLOCK_PREFIX => ['sendPaymentLinkEmail' => '1'], + ])); + $payment->setDetails(['payment-link' => 'http://url-to-pay.com'])->shouldBeCalled(); $orderPaymentLinkSender->sendPaymentLink($order)->shouldBeCalled(); @@ -55,6 +64,38 @@ function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it( $this->setPaymentLink($event); } + function it_does_not_send_the_email_when_the_checkbox_is_not_checked( + PaymentTokenProviderInterface $paymentTokenProvider, + ObjectManager $orderManager, + OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, + TokenInterface $token, + GenericEvent $event, + OrderInterface $order, + PaymentInterface $payment, + PaymentMethodInterface $paymentMethod, + GatewayConfigInterface $gatewayConfig, + ) { + $event->getSubject()->willReturn($order); + $order->getLastPayment(PaymentInterface::STATE_NEW)->willReturn($payment); + + $payment->getMethod()->willReturn($paymentMethod); + $paymentMethod->getGatewayConfig()->willReturn($gatewayConfig); + $gatewayConfig->getGatewayName()->willReturn('paypal_express_checkout'); + + $paymentTokenProvider->getPaymentToken($payment)->willReturn($token); + $token->getAfterUrl()->willReturn('http://url-to-pay.com'); + + $requestStack->getCurrentRequest()->willReturn(new Request([], [])); + + $payment->setDetails(['payment-link' => 'http://url-to-pay.com'])->shouldBeCalled(); + $orderPaymentLinkSender->sendPaymentLink($order)->shouldNotBeCalled(); + + $orderManager->flush()->shouldBeCalled(); + + $this->setPaymentLink($event); + } + function it_throws_exception_if_event_subject_is_not_payment(GenericEvent $event) { $event->getSubject()->willReturn('badObject'); diff --git a/src/EventListener/PaymentLinkCreationListener.php b/src/EventListener/PaymentLinkCreationListener.php index 7cad3fd3e..1172b03a4 100644 --- a/src/EventListener/PaymentLinkCreationListener.php +++ b/src/EventListener/PaymentLinkCreationListener.php @@ -10,6 +10,8 @@ use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; use Symfony\Component\EventDispatcher\GenericEvent; +use Symfony\Component\HttpFoundation\RequestStack; +use Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType; use Webgriffe\SyliusAdminOrderCreationPlugin\Provider\PaymentTokenProviderInterface; use Webgriffe\SyliusAdminOrderCreationPlugin\Sender\OrderPaymentLinkSenderInterface; use Webmozart\Assert\Assert; @@ -25,14 +27,19 @@ final class PaymentLinkCreationListener /** @var OrderPaymentLinkSenderInterface */ private $orderPaymentLinkSender; + /** @var RequestStack */ + private $requestStack; + public function __construct( PaymentTokenProviderInterface $paymentTokenProvider, ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, ) { $this->paymentTokenProvider = $paymentTokenProvider; $this->orderManager = $orderManager; $this->orderPaymentLinkSender = $orderPaymentLinkSender; + $this->requestStack = $requestStack; } public function setPaymentLink(GenericEvent $event): void @@ -58,7 +65,22 @@ public function setPaymentLink(GenericEvent $event): void $token = $this->paymentTokenProvider->getPaymentToken($payment); $payment->setDetails(['payment-link' => $token->getAfterUrl()]); - $this->orderPaymentLinkSender->sendPaymentLink($order); + if ($this->shouldSendPaymentLinkEmail()) { + $this->orderPaymentLinkSender->sendPaymentLink($order); + } + $this->orderManager->flush(); } + + private function shouldSendPaymentLinkEmail(): bool + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return false; + } + + $formData = $request->request->all(NewOrderType::BLOCK_PREFIX); + + return (bool) ($formData['sendPaymentLinkEmail'] ?? false); + } } diff --git a/src/Form/Type/NewOrderType.php b/src/Form/Type/NewOrderType.php index e1549bcee..056a35f6c 100644 --- a/src/Form/Type/NewOrderType.php +++ b/src/Form/Type/NewOrderType.php @@ -10,6 +10,7 @@ use Sylius\Component\Core\Model\ChannelInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Shipping\Model\ShippingSubjectInterface; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -18,6 +19,8 @@ final class NewOrderType extends AbstractResourceType { + public const BLOCK_PREFIX = 'sylius_admin_order_creation_new_order'; + public function buildForm(FormBuilderInterface $builder, array $options): void { $builder @@ -40,6 +43,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'allow_delete' => true, 'by_reference' => false, ]) + ->add('sendPaymentLinkEmail', CheckboxType::class, [ + 'mapped' => false, + 'required' => false, + 'label' => 'sylius_admin_order_creation.ui.send_payment_link_email', + ]) ->add('shipments', LiveCollectionType::class, [ 'entry_type' => ShipmentType::class, 'entry_options' => [ @@ -120,7 +128,7 @@ public function configureOptions(OptionsResolver $resolver): void public function getBlockPrefix(): string { - return 'sylius_admin_order_creation_new_order'; + return self::BLOCK_PREFIX; } private function isBillingAddressEmpty(array $orderData): bool diff --git a/templates/order/preview/_order_preview_form_component.html.twig b/templates/order/preview/_order_preview_form_component.html.twig index ed4c2bba3..6758354f7 100644 --- a/templates/order/preview/_order_preview_form_component.html.twig +++ b/templates/order/preview/_order_preview_form_component.html.twig @@ -80,6 +80,12 @@ {% else %} {{ 'sylius.ui.no_payments'|trans }} {% endif %} +
+ {{ form_widget(form.sendPaymentLinkEmail, { + 'attr': {'class': 'form-check-input'}, + }) }} + {{ form_label(form.sendPaymentLinkEmail, null, {'label_attr': {'class': 'form-check-label'}}) }} +
diff --git a/tests/Behat/Context/Admin/ManagingOrdersContext.php b/tests/Behat/Context/Admin/ManagingOrdersContext.php index 1ca8a7f58..b7d01bb09 100644 --- a/tests/Behat/Context/Admin/ManagingOrdersContext.php +++ b/tests/Behat/Context/Admin/ManagingOrdersContext.php @@ -270,6 +270,14 @@ public function confirmThisOrder(): void $this->orderPreviewPage->confirm(); } + /** + * @When I want to send a payment link email to the customer + */ + public function iWantToSendAPaymentLinkEmailToTheCustomer(): void + { + $this->orderPreviewPage->checkSendPaymentLinkEmail(); + } + /** * @When I go back to the order creation */ diff --git a/tests/Behat/Page/Admin/OrderPreviewPage.php b/tests/Behat/Page/Admin/OrderPreviewPage.php index b125334f7..ad503fd6e 100644 --- a/tests/Behat/Page/Admin/OrderPreviewPage.php +++ b/tests/Behat/Page/Admin/OrderPreviewPage.php @@ -101,6 +101,11 @@ public function lowerItemWithProductPriceBy(string $productCode, string $discoun $row->fillField('Item discount', $discount); } + public function checkSendPaymentLinkEmail(): void + { + $this->getDocument()->checkField('Send a payment link to the customer via email'); + } + public function confirm(): void { $this->getDocument()->pressButton('Confirm'); diff --git a/tests/Behat/Page/Admin/OrderPreviewPageInterface.php b/tests/Behat/Page/Admin/OrderPreviewPageInterface.php index 29bb7f46a..838829385 100644 --- a/tests/Behat/Page/Admin/OrderPreviewPageInterface.php +++ b/tests/Behat/Page/Admin/OrderPreviewPageInterface.php @@ -28,6 +28,8 @@ public function lowerOrderPriceBy(string $discount): void; public function lowerItemWithProductPriceBy(string $productCode, string $discount): void; + public function checkSendPaymentLinkEmail(): void; + public function confirm(): void; public function goBack(): void; diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 44fb09443..63a4bf3d8 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -24,4 +24,5 @@ sylius_admin_order_creation: order_preview: 'Order preview' pay: 'Pay' reorder: 'Reorder' + send_payment_link_email: 'Send a payment link to the customer via email' shipping_methods_selection_requirement: 'You need to add some items and shipping address to select from eligible shipping method' From 2a2fe157be19631b1a96c0c4c57f06ecf39d4edb Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Wed, 2 Sep 2026 16:27:28 +0200 Subject: [PATCH 30/44] Fix order preview losing prices and shipment on any Live Component action Live Component actions (add discount, add item, ...) only re-submit raw form values onto a freshly instantiated order, so nothing recalculated derived data (unit prices, totals, shipping cost), and the shipment choice-list eligibility check could invalidate an already-selected method, silently dropping the whole shipment from the order. Co-Authored-By: Claude Sonnet 5 --- config/services.xml | 2 + psalm-baseline.xml | 3 ++ src/Twig/Component/OrderFormComponent.php | 50 ++++++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/config/services.xml b/config/services.xml index d407fed27..4bb6bad02 100644 --- a/config/services.xml +++ b/config/services.xml @@ -122,12 +122,14 @@ +
+ diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4b2791bab..ed4190726 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -260,6 +260,9 @@ + + + diff --git a/src/Twig/Component/OrderFormComponent.php b/src/Twig/Component/OrderFormComponent.php index 4b39c353f..163799f1c 100644 --- a/src/Twig/Component/OrderFormComponent.php +++ b/src/Twig/Component/OrderFormComponent.php @@ -7,10 +7,12 @@ use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\ShipmentInterface; use Sylius\Component\Order\Processor\OrderProcessorInterface; +use Sylius\Component\Shipping\Resolver\ShippingMethodsResolverInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Attribute\PreReRender; use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\LiveCollectionTrait; use Webgriffe\SyliusAdminOrderCreationPlugin\Factory\OrderFactoryInterface; @@ -35,6 +37,7 @@ public function __construct( private readonly OrderFactoryInterface $orderFactory, private readonly FormFactoryInterface $formFactory, private readonly OrderProcessorInterface $orderProcessor, + private readonly ShippingMethodsResolverInterface $shippingMethodsResolver, ) { } @@ -45,6 +48,32 @@ protected function instantiateForm(): FormInterface ]); } + /** + * Live Component actions (add/remove an item, a discount, a shipment...) only re-submit the + * raw form values onto a freshly instantiated order - nothing in Live Component itself + * recalculates derived data (unit prices, shipping cost, shipment/unit associations). Without + * this, every such action would render the order as if it had just been created empty: all + * prices at $0, shipments dropped, etc. This runs after Live Component's own form submission + * (which happens at the default #[PreReRender] priority of 0), never on the initial render. + */ + #[PreReRender(priority: -10)] + public function reprocessOrder(): void + { + $order = $this->getForm()->getData(); + + if (!$order instanceof OrderInterface) { + return; + } + + try { + $this->orderProcessor->process($order); + } catch (\Throwable) { + // Items freshly added on the create page have no variant selected yet, which the + // pricing processor can't handle - leave the order as submitted and let it settle + // once the user picks a variant and this hook runs again. + } + } + private function createOrder(): OrderInterface { return $this->orderFactory->createForCustomerAndChannel($this->customerId, $this->channelCode); @@ -54,6 +83,14 @@ private function createOrder(): OrderInterface * Builds a throwaway order from the current (live, not-yet-final) form values so that the * shipment's "method" field can be restricted to the shipping methods actually eligible for * the items/address entered so far, mirroring Sylius' own checkout behaviour. + * + * The eligibility restriction is only ever meant to guide a *new* selection. Never let it + * invalidate a method the admin already picked: zone matching on this throwaway, not-yet-fully + * submitted order can be momentarily narrower than on the final order (e.g. while the address + * form is only partially filled in across requests), and passing a subject whose eligible-method + * list excludes the already-chosen method makes Symfony's ChoiceType treat that submitted value + * as invalid - which silently drops the entire shipment from the collection instead of just + * rejecting the method field. */ private function computeShipmentChoicesSubject(): ?ShipmentInterface { @@ -71,6 +108,17 @@ private function computeShipmentChoicesSubject(): ?ShipmentInterface $shipment = $order->getShipments()->first(); - return $shipment instanceof ShipmentInterface ? $shipment : null; + if (!$shipment instanceof ShipmentInterface) { + return null; + } + + if ( + !$this->shippingMethodsResolver->supports($shipment) || + !in_array($shipment->getMethod(), $this->shippingMethodsResolver->getSupportedMethods($shipment), true) + ) { + return null; + } + + return $shipment; } } From ad90613f1310e168e8b700547accb405f932c881 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 08:57:36 +0200 Subject: [PATCH 31/44] Make the offline payment gateway names configurable PaymentLinkCreationListener hardcoded the single gateway name 'offline' to decide when to skip payment-link generation, forcing host applications to fully decorate the listener just to recognize their own offline gateways (e.g. bank transfer). Expose it as plugin configuration (sylius_admin_order_creation_plugin.offline_gateway_names, defaulting to ['offline']) instead. --- config/services.xml | 1 + .../PaymentLinkCreationListenerSpec.php | 34 ++++++++++++++++++- src/DependencyInjection/Configuration.php | 18 +++++++++- .../SyliusAdminOrderCreationExtension.php | 4 ++- .../PaymentLinkCreationListener.php | 10 +++++- .../DependencyInjection/ConfigurationTest.php | 28 +++++++++++++++ 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/DependencyInjection/ConfigurationTest.php diff --git a/config/services.xml b/config/services.xml index 4bb6bad02..4aee1f04d 100644 --- a/config/services.xml +++ b/config/services.xml @@ -101,6 +101,7 @@ + %sylius_admin_order_creation_plugin.offline_gateway_names%
diff --git a/spec/EventListener/PaymentLinkCreationListenerSpec.php b/spec/EventListener/PaymentLinkCreationListenerSpec.php index 3b0f68a26..332853746 100644 --- a/spec/EventListener/PaymentLinkCreationListenerSpec.php +++ b/spec/EventListener/PaymentLinkCreationListenerSpec.php @@ -27,7 +27,7 @@ function let( OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, ) { - $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack); + $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack, ['offline']); } function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_when_checkbox_is_checked( @@ -135,4 +135,36 @@ function it_does_nothing_if_order_payment_gateway_is_offline( $this->setPaymentLink($event); } + + function it_does_nothing_if_order_payment_gateway_is_one_of_the_configured_offline_gateway_names( + PaymentTokenProviderInterface $paymentTokenProvider, + ObjectManager $orderManager, + OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, + Payum $payum, + GenericEvent $event, + OrderInterface $order, + PaymentInterface $payment, + PaymentMethodInterface $paymentMethod, + GatewayConfigInterface $gatewayConfig, + ) { + $this->beConstructedWith( + $paymentTokenProvider, + $orderManager, + $orderPaymentLinkSender, + $requestStack, + ['offline', 'bank_transfer'], + ); + + $event->getSubject()->willReturn($order); + $order->getLastPayment(PaymentInterface::STATE_NEW)->willReturn($payment); + + $payment->getMethod()->willReturn($paymentMethod); + $paymentMethod->getGatewayConfig()->willReturn($gatewayConfig); + $gatewayConfig->getGatewayName()->willReturn('bank_transfer'); + + $payum->getTokenFactory()->shouldNotBeCalled(); + + $this->setPaymentLink($event); + } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a05617357..a26f3d491 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,13 +4,29 @@ namespace Webgriffe\SyliusAdminOrderCreationPlugin\DependencyInjection; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; +use Webmozart\Assert\Assert; final class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder(): TreeBuilder { - return new TreeBuilder('sylius_admin_order_creation_plugin'); + $treeBuilder = new TreeBuilder('sylius_admin_order_creation_plugin'); + $rootNode = $treeBuilder->getRootNode(); + Assert::isInstanceOf($rootNode, ArrayNodeDefinition::class); + + $rootNode + ->children() + ->arrayNode('offline_gateway_names') + ->info('Payment gateway names for which no payment link is generated after an order is created from the admin panel.') + ->scalarPrototype()->end() + ->defaultValue(['offline']) + ->end() + ->end() + ; + + return $treeBuilder; } } diff --git a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php index 80d917589..987a04244 100644 --- a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php +++ b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php @@ -17,7 +17,9 @@ final class SyliusAdminOrderCreationExtension extends Extension implements Prepe public function load(array $config, ContainerBuilder $container): void { - $this->processConfiguration($this->getConfiguration([], $container), $config); + $config = $this->processConfiguration($this->getConfiguration([], $container), $config); + $container->setParameter('sylius_admin_order_creation_plugin.offline_gateway_names', $config['offline_gateway_names']); + $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); } diff --git a/src/EventListener/PaymentLinkCreationListener.php b/src/EventListener/PaymentLinkCreationListener.php index 1172b03a4..dcbc70ffc 100644 --- a/src/EventListener/PaymentLinkCreationListener.php +++ b/src/EventListener/PaymentLinkCreationListener.php @@ -30,16 +30,24 @@ final class PaymentLinkCreationListener /** @var RequestStack */ private $requestStack; + /** @var list */ + private $offlineGatewayNames; + + /** + * @param list $offlineGatewayNames + */ public function __construct( PaymentTokenProviderInterface $paymentTokenProvider, ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + array $offlineGatewayNames, ) { $this->paymentTokenProvider = $paymentTokenProvider; $this->orderManager = $orderManager; $this->orderPaymentLinkSender = $orderPaymentLinkSender; $this->requestStack = $requestStack; + $this->offlineGatewayNames = $offlineGatewayNames; } public function setPaymentLink(GenericEvent $event): void @@ -58,7 +66,7 @@ public function setPaymentLink(GenericEvent $event): void /** @var GatewayConfigInterface $gatewayConfig */ $gatewayConfig = $paymentMethod->getGatewayConfig(); - if ('offline' === $gatewayConfig->getGatewayName()) { + if (\in_array($gatewayConfig->getGatewayName(), $this->offlineGatewayNames, true)) { return; } diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php new file mode 100644 index 000000000..76ae4bd47 --- /dev/null +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,28 @@ +processConfiguration(new Configuration(), []); + + self::assertSame(['offline'], $processedConfiguration['offline_gateway_names']); + } + + public function testItAllowsConfiguringCustomOfflineGatewayNames(): void + { + $processedConfiguration = (new Processor())->processConfiguration(new Configuration(), [ + ['offline_gateway_names' => ['offline', 'bank_transfer']], + ]); + + self::assertSame(['offline', 'bank_transfer'], $processedConfiguration['offline_gateway_names']); + } +} From d22c94a5d5fb709032fb2a1ca4b970287e763018 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 08:58:44 +0200 Subject: [PATCH 32/44] Allow disabling payment-link generation via configuration Host applications that don't want the plugin's payment-link/email behavior had no way to opt out short of writing a compiler pass that removes the PaymentLinkCreationListener service entirely. Add a payment_link_generation_enabled configuration flag (default true) so this is a one-line config change instead. --- config/services.xml | 1 + .../PaymentLinkCreationListenerSpec.php | 29 ++++++++++++++++++- src/DependencyInjection/Configuration.php | 4 +++ .../SyliusAdminOrderCreationExtension.php | 1 + .../PaymentLinkCreationListener.php | 9 ++++++ .../DependencyInjection/ConfigurationTest.php | 16 ++++++++++ 6 files changed, 59 insertions(+), 1 deletion(-) diff --git a/config/services.xml b/config/services.xml index 4aee1f04d..07731e335 100644 --- a/config/services.xml +++ b/config/services.xml @@ -102,6 +102,7 @@ %sylius_admin_order_creation_plugin.offline_gateway_names% + %sylius_admin_order_creation_plugin.payment_link_generation_enabled%
diff --git a/spec/EventListener/PaymentLinkCreationListenerSpec.php b/spec/EventListener/PaymentLinkCreationListenerSpec.php index 332853746..3590c47e4 100644 --- a/spec/EventListener/PaymentLinkCreationListenerSpec.php +++ b/spec/EventListener/PaymentLinkCreationListenerSpec.php @@ -27,7 +27,7 @@ function let( OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, ) { - $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack, ['offline']); + $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack, ['offline'], true); } function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_when_checkbox_is_checked( @@ -154,6 +154,7 @@ function it_does_nothing_if_order_payment_gateway_is_one_of_the_configured_offli $orderPaymentLinkSender, $requestStack, ['offline', 'bank_transfer'], + true, ); $event->getSubject()->willReturn($order); @@ -167,4 +168,30 @@ function it_does_nothing_if_order_payment_gateway_is_one_of_the_configured_offli $this->setPaymentLink($event); } + + function it_does_nothing_when_payment_link_generation_is_disabled( + PaymentTokenProviderInterface $paymentTokenProvider, + ObjectManager $orderManager, + OrderPaymentLinkSenderInterface $orderPaymentLinkSender, + RequestStack $requestStack, + Payum $payum, + GenericEvent $event, + OrderInterface $order, + ) { + $this->beConstructedWith( + $paymentTokenProvider, + $orderManager, + $orderPaymentLinkSender, + $requestStack, + ['offline'], + false, + ); + + $event->getSubject()->willReturn($order); + + $order->getLastPayment(PaymentInterface::STATE_NEW)->shouldNotBeCalled(); + $payum->getTokenFactory()->shouldNotBeCalled(); + + $this->setPaymentLink($event); + } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a26f3d491..8aa3527ba 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -24,6 +24,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarPrototype()->end() ->defaultValue(['offline']) ->end() + ->booleanNode('payment_link_generation_enabled') + ->info('Whether to generate (and optionally send) a payment link after an order is created from the admin panel.') + ->defaultTrue() + ->end() ->end() ; diff --git a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php index 987a04244..1cc3a1771 100644 --- a/src/DependencyInjection/SyliusAdminOrderCreationExtension.php +++ b/src/DependencyInjection/SyliusAdminOrderCreationExtension.php @@ -19,6 +19,7 @@ public function load(array $config, ContainerBuilder $container): void { $config = $this->processConfiguration($this->getConfiguration([], $container), $config); $container->setParameter('sylius_admin_order_creation_plugin.offline_gateway_names', $config['offline_gateway_names']); + $container->setParameter('sylius_admin_order_creation_plugin.payment_link_generation_enabled', $config['payment_link_generation_enabled']); $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); } diff --git a/src/EventListener/PaymentLinkCreationListener.php b/src/EventListener/PaymentLinkCreationListener.php index dcbc70ffc..246c13396 100644 --- a/src/EventListener/PaymentLinkCreationListener.php +++ b/src/EventListener/PaymentLinkCreationListener.php @@ -33,6 +33,9 @@ final class PaymentLinkCreationListener /** @var list */ private $offlineGatewayNames; + /** @var bool */ + private $enabled; + /** * @param list $offlineGatewayNames */ @@ -42,16 +45,22 @@ public function __construct( OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, array $offlineGatewayNames, + bool $enabled, ) { $this->paymentTokenProvider = $paymentTokenProvider; $this->orderManager = $orderManager; $this->orderPaymentLinkSender = $orderPaymentLinkSender; $this->requestStack = $requestStack; $this->offlineGatewayNames = $offlineGatewayNames; + $this->enabled = $enabled; } public function setPaymentLink(GenericEvent $event): void { + if (!$this->enabled) { + return; + } + /** @var OrderInterface $order */ $order = $event->getSubject(); Assert::isInstanceOf($order, OrderInterface::class); diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 76ae4bd47..35b62aa9f 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -25,4 +25,20 @@ public function testItAllowsConfiguringCustomOfflineGatewayNames(): void self::assertSame(['offline', 'bank_transfer'], $processedConfiguration['offline_gateway_names']); } + + public function testItDefaultsPaymentLinkGenerationToEnabled(): void + { + $processedConfiguration = (new Processor())->processConfiguration(new Configuration(), []); + + self::assertTrue($processedConfiguration['payment_link_generation_enabled']); + } + + public function testItAllowsDisablingPaymentLinkGeneration(): void + { + $processedConfiguration = (new Processor())->processConfiguration(new Configuration(), [ + ['payment_link_generation_enabled' => false], + ]); + + self::assertFalse($processedConfiguration['payment_link_generation_enabled']); + } } From d30e77edc678f787eed97380121940e001458460 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:07:49 +0200 Subject: [PATCH 33/44] Fix the plugin's DI extension never being registered/loaded The bundle never overrode Bundle::getContainerExtension(), and Symfony's default naming convention (which derives the expected extension class name from the bundle class name) didn't match the actual SyliusAdminOrderCreationExtension class name. As a result the extension's load()/prepend() were never invoked by the container compiler, silently making Configuration.php dead code and the two parameters introduced in the previous commits undefined at runtime (discovered via the new integration test, which failed to boot the container until this was fixed). --- src/DependencyInjection/Configuration.php | 5 ----- src/WebgriffeSyliusAdminOrderCreationPlugin.php | 8 ++++++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 8aa3527ba..e031ad226 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,10 +4,8 @@ namespace Webgriffe\SyliusAdminOrderCreationPlugin\DependencyInjection; -use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; -use Webmozart\Assert\Assert; final class Configuration implements ConfigurationInterface { @@ -15,7 +13,6 @@ public function getConfigTreeBuilder(): TreeBuilder { $treeBuilder = new TreeBuilder('sylius_admin_order_creation_plugin'); $rootNode = $treeBuilder->getRootNode(); - Assert::isInstanceOf($rootNode, ArrayNodeDefinition::class); $rootNode ->children() @@ -27,8 +24,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('payment_link_generation_enabled') ->info('Whether to generate (and optionally send) a payment link after an order is created from the admin panel.') ->defaultTrue() - ->end() - ->end() ; return $treeBuilder; diff --git a/src/WebgriffeSyliusAdminOrderCreationPlugin.php b/src/WebgriffeSyliusAdminOrderCreationPlugin.php index d3c1614f6..603f20c35 100644 --- a/src/WebgriffeSyliusAdminOrderCreationPlugin.php +++ b/src/WebgriffeSyliusAdminOrderCreationPlugin.php @@ -6,8 +6,10 @@ use Sylius\Bundle\CoreBundle\Application\SyliusPluginTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; use Symfony\Component\HttpKernel\Bundle\Bundle; use Webgriffe\SyliusAdminOrderCreationPlugin\DependencyInjection\Compiler\RegisterReorderProcessorsPass; +use Webgriffe\SyliusAdminOrderCreationPlugin\DependencyInjection\SyliusAdminOrderCreationExtension; final class WebgriffeSyliusAdminOrderCreationPlugin extends Bundle { @@ -22,4 +24,10 @@ public function build(ContainerBuilder $container): void { $container->addCompilerPass(new RegisterReorderProcessorsPass()); } + + #[\Override] + public function getContainerExtension(): ?ExtensionInterface + { + return new SyliusAdminOrderCreationExtension(); + } } From 0fc9d314b58ec177b3171748324f7f6e3c89aaf3 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:08:32 +0200 Subject: [PATCH 34/44] Reference OrderFactoryInterface instead of the concrete class in routing The order creation/preview/reorder routes resolved their _sylius.factory via expr:service() against the concrete OrderFactory class name, bypassing the OrderFactoryInterface seam that decorating host applications are expected to use. Alias the interface to the service and point routing.yaml at it instead, so a host app's decorator is picked up wherever the plugin resolves the factory. --- config/routing.yaml | 8 +++---- config/services.xml | 2 ++ .../OrderFactoryServiceWiringTest.php | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/DependencyInjection/OrderFactoryServiceWiringTest.php diff --git a/config/routing.yaml b/config/routing.yaml index c3d74f5fd..3372742a1 100644 --- a/config/routing.yaml +++ b/config/routing.yaml @@ -27,7 +27,7 @@ sylius_admin_order_creation_order_create: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactoryInterface")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] requirements: customerId: \d+ @@ -41,7 +41,7 @@ sylius_admin_order_creation_order_preview: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactoryInterface")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] sylius_admin_order_creation_order_create_from_preview: @@ -59,7 +59,7 @@ sylius_admin_order_creation_order_create_from_preview: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createForCustomerAndChannel'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactoryInterface")', 'createForCustomerAndChannel'] arguments: [$customerId, $channelCode] sylius_admin_order_creation_order_create_back: @@ -87,5 +87,5 @@ sylius_admin_order_creation_reorder: form: type: Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType factory: - method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactory")', 'createFromExistingOrder'] + method: ['expr:service("Webgriffe\\SyliusAdminOrderCreationPlugin\\Factory\\OrderFactoryInterface")', 'createFromExistingOrder'] arguments: ["expr:service('sylius.repository.order').find($id)"] diff --git a/config/services.xml b/config/services.xml index 07731e335..1676d1108 100644 --- a/config/services.xml +++ b/config/services.xml @@ -89,6 +89,8 @@
+ + diff --git a/tests/Integration/DependencyInjection/OrderFactoryServiceWiringTest.php b/tests/Integration/DependencyInjection/OrderFactoryServiceWiringTest.php new file mode 100644 index 000000000..d9b54e9a5 --- /dev/null +++ b/tests/Integration/DependencyInjection/OrderFactoryServiceWiringTest.php @@ -0,0 +1,23 @@ +getContainer(); + + $orderFactory = $container->get(OrderFactoryInterface::class); + + self::assertInstanceOf(OrderFactoryInterface::class, $orderFactory); + } +} From 1b7c3207edfd4192c4fd6cce3ed68f981a96099f Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:11:14 +0200 Subject: [PATCH 35/44] Dispatch a plugin-owned OrderCreatedByAdminEvent after order creation Host applications wanting to hook into "an order was just created from the admin panel" (e.g. to notify the placing agent, or to build an anti-double-submission guard) had nothing to listen to but Sylius core's generic, untyped sylius.order.post_admin_create GenericEvent - forcing them to duplicate/replace whole listeners to get a stable extension point. OrderCreationListener now also dispatches a typed OrderCreatedByAdminEvent carrying the created order, so this becomes a plain #[AsEventListener] on a plugin-owned event class. --- config/services.xml | 2 ++ psalm-baseline.xml | 5 ++++ .../OrderCreationListenerSpec.php | 26 +++++++++++++++++-- src/Event/OrderCreatedByAdminEvent.php | 24 +++++++++++++++++ src/EventListener/OrderCreationListener.php | 21 +++++++++++++-- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 src/Event/OrderCreatedByAdminEvent.php diff --git a/config/services.xml b/config/services.xml index 1676d1108..82b7820c1 100644 --- a/config/services.xml +++ b/config/services.xml @@ -94,8 +94,10 @@ + + diff --git a/psalm-baseline.xml b/psalm-baseline.xml index ed4190726..b9cda5a21 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -59,6 +59,11 @@ + + + + + diff --git a/spec/EventListener/OrderCreationListenerSpec.php b/spec/EventListener/OrderCreationListenerSpec.php index 9a6217f6b..25c16e7a3 100644 --- a/spec/EventListener/OrderCreationListenerSpec.php +++ b/spec/EventListener/OrderCreationListenerSpec.php @@ -5,19 +5,23 @@ namespace spec\Webgriffe\SyliusAdminOrderCreationPlugin\EventListener; use PhpSpec\ObjectBehavior; +use Prophecy\Argument; use Sylius\Abstraction\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\OrderCheckoutTransitions; use Sylius\Component\Order\Processor\OrderProcessorInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreatedByAdminEvent; final class OrderCreationListenerSpec extends ObjectBehavior { function let( OrderProcessorInterface $orderProcessor, StateMachineInterface $stateMachine, + EventDispatcherInterface $eventDispatcher, ) { - $this->beConstructedWith($orderProcessor, $stateMachine); + $this->beConstructedWith($orderProcessor, $stateMachine, $eventDispatcher); } function it_processes_order_before_creation( @@ -86,7 +90,7 @@ function it_completes_order_without_shipping_before_creation( function it_throws_exception_if_event_subject_is_not_order(GenericEvent $event) { - $event->getSubject()->willReturn('badObject', 'badObject'); + $event->getSubject()->willReturn('badObject', 'badObject', 'badObject'); $this ->shouldThrow(\InvalidArgumentException::class) @@ -95,5 +99,23 @@ function it_throws_exception_if_event_subject_is_not_order(GenericEvent $event) $this ->shouldThrow(\InvalidArgumentException::class) ->during('completeOrderBeforeCreation', [$event]); + + $this + ->shouldThrow(\InvalidArgumentException::class) + ->during('dispatchOrderCreatedEvent', [$event]); + } + + function it_dispatches_an_order_created_by_admin_event_after_order_creation( + EventDispatcherInterface $eventDispatcher, + GenericEvent $event, + OrderInterface $order, + ) { + $event->getSubject()->willReturn($order); + + $eventDispatcher->dispatch(Argument::that(function (OrderCreatedByAdminEvent $dispatchedEvent) use ($order) { + return $dispatchedEvent->getOrder() === $order->getWrappedObject(); + }))->shouldBeCalled(); + + $this->dispatchOrderCreatedEvent($event); } } diff --git a/src/Event/OrderCreatedByAdminEvent.php b/src/Event/OrderCreatedByAdminEvent.php new file mode 100644 index 000000000..2ae319d4d --- /dev/null +++ b/src/Event/OrderCreatedByAdminEvent.php @@ -0,0 +1,24 @@ +order = $order; + } + + public function getOrder(): OrderInterface + { + return $this->order; + } +} diff --git a/src/EventListener/OrderCreationListener.php b/src/EventListener/OrderCreationListener.php index 43d2229ac..d0acf6fe5 100644 --- a/src/EventListener/OrderCreationListener.php +++ b/src/EventListener/OrderCreationListener.php @@ -8,7 +8,9 @@ use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\OrderCheckoutTransitions; use Sylius\Component\Order\Processor\OrderProcessorInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreatedByAdminEvent; use Webmozart\Assert\Assert; final class OrderCreationListener @@ -19,10 +21,17 @@ final class OrderCreationListener /** @var StateMachineInterface */ private $stateMachine; - public function __construct(OrderProcessorInterface $orderProcessor, StateMachineInterface $stateMachine) - { + /** @var EventDispatcherInterface */ + private $eventDispatcher; + + public function __construct( + OrderProcessorInterface $orderProcessor, + StateMachineInterface $stateMachine, + EventDispatcherInterface $eventDispatcher, + ) { $this->orderProcessor = $orderProcessor; $this->stateMachine = $stateMachine; + $this->eventDispatcher = $eventDispatcher; } public function processOrderBeforeCreation(GenericEvent $event): void @@ -48,4 +57,12 @@ public function completeOrderBeforeCreation(GenericEvent $event): void } $this->stateMachine->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE); } + + public function dispatchOrderCreatedEvent(GenericEvent $event): void + { + $order = $event->getSubject(); + Assert::isInstanceOf($order, OrderInterface::class); + + $this->eventDispatcher->dispatch(new OrderCreatedByAdminEvent($order)); + } } From 3acef11b4c5ed27a0fb84b65810f3ef2669392b3 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:12:36 +0200 Subject: [PATCH 36/44] Rewrite the README's Extension points section to match Sylius 2 code The section still described the pre-migration (Sylius 1.x) template override mechanism (templates/bundles/SyliusAdminBundle) instead of the Twig Hooks now used for the Order Show sections, didn't mention the plugin's own configuration/events, and implied the AdjustmentType constants were open for extension when the class is final. Document what's actually configurable/hookable today, and where the plugin's Twig/UI migration to Sylius 2 still has ground to cover (order creation/preview/select-customer pages). --- README.md | 60 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2a22d0b5f..dcd259250 100644 --- a/README.md +++ b/README.md @@ -133,22 +133,68 @@ Requires Sylius `~2.2.0` and PHP `^8.2`. ## Extension points -Admin Order Creation Plugin makes it possible to add custom discount during order creation - thus some of Order -Show templates need to be replaced with those placed in the `templates/bundles/SyliusAdminBundle` directory. +### Configuration -Payment link generation and sending process is based on logic placed in the PaymentLinkCreationListener class. Thus, it can -be easily replaced with suitable implementation. +The plugin exposes semantic configuration under `sylius_admin_order_creation_plugin`: -Adjustments set is not closed and strictly defined - adding custom adjustment means defining a new constant in the -AdjustmentType class. +```yaml +sylius_admin_order_creation_plugin: + # Gateway names for which no payment link is generated after an order is created from the admin panel. + offline_gateway_names: ['offline'] + # Whether to generate (and optionally send) a payment link at all after an order is created from the admin panel. + payment_link_generation_enabled: true +``` + +### Events + +The plugin dispatches its own `Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreatedByAdminEvent` (carrying +the created `OrderInterface`) right after an order is created from the admin panel, in addition to the generic +Sylius core `sylius.order.pre_admin_create` / `sylius.order.post_admin_create` events the plugin itself listens to. +Listen to it with a plain `#[AsEventListener]` to hook side effects (notifications, audit logging, custom +guards, ...) without having to decorate or replace any of the plugin's own listeners. + +### Payment link generation + +Payment link generation and sending is based on logic placed in the `PaymentLinkCreationListener` class. It can be +turned off entirely via the `payment_link_generation_enabled` configuration flag, or replaced altogether by +decorating/replacing the service for more advanced needs. + +### Order Show templates (Twig Hooks) + +Order Show template sections related to this plugin (discount rows, payment-link action, ...) are registered as +[Twig Hooks](https://docs.sylius.com/the-book/customization/twig-hooks) in `config/twig_hooks/order_show.yaml`. +Override or add your own hookable template at the same hook name (with a different priority) to customize them - +see that file for the exact hook names in use. + +The order creation, preview and select-customer pages are not yet migrated to Twig Hooks and are still overridable +only the classic Symfony way, by placing a template at the same bundle-relative path under your own +`templates/bundles/WebgriffeSyliusAdminOrderCreationPlugin/` directory (see `templates/order/` in this repository +for the paths to override). This is part of the still-ongoing Sylius 2 UI migration mentioned below. + +### Adjustments + +The set of order/item adjustment types is not closed - adding a custom adjustment means defining a new constant on +your own adjustment-type class; `AdjustmentType`'s own constants (`ORDER_DISCOUNT_ADJUSTMENT`, +`ORDER_ITEM_DISCOUNT_ADJUSTMENT`) are not extensible themselves, since the class is `final`. + +### Reorder processing Significant part of Reorder Processing is inspired by official Sylius [Customer Reorder Plugin](https://github.com/Sylius/CustomerReorderPlugin/). In case of the need for more processors, just add new class implementing `ReorderProcessor` interface, declare it in `reorder_processing.xml` file and match it with a proper tag. +### Forms + Admin Order Creation process is based on Symfony Forms. To find out more about Symfony Forms extension possibilities, check out -[Symfony Docs](https://symfony.com/doc/current/form/create_form_type_extension.html). +[Symfony Docs](https://symfony.com/doc/current/form/create_form_type_extension.html). + +### Factory + +`Factory\OrderFactoryInterface` is aliased as a service, and routes resolve their order factory through that alias +rather than the concrete `OrderFactory` class, so a host application can decorate `OrderFactoryInterface` and have +its decorator picked up wherever the plugin creates an order (e.g. to attach the placing administrator to the +order, or to reuse an in-progress order from session storage). ## Development From fcd549675a9472bfcf17a155d51a15d063a23792 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:41:15 +0200 Subject: [PATCH 37/44] Add two more generic extension-point events OrderCreatedByAdminEvent alone only covers the very end of the creation flow. Add two more plugin-owned events at other points a host application is likely to need a hook, generalized from real needs observed in a downstream project rather than tied to it: - OrderCreationInitializedEvent, dispatched by OrderFactory whenever an order is initialized for admin creation or reorder (both createForCustomerAndChannel and createFromExistingOrder). Lets a host guard/veto or enrich the order before it's shown/processed, without having to replace the controllers or factory outright. - PaymentLinkGeneratedEvent, dispatched by PaymentLinkCreationListener right after a payment link is generated, decoupled from the "send email" step, for side effects (notifications, audit, CRM sync, ...) that shouldn't be tied to the email toggle. --- config/services.xml | 2 ++ psalm-baseline.xml | 10 ++++++++ .../PaymentLinkCreationListenerSpec.php | 16 ++++++++++++- spec/Factory/OrderFactorySpec.php | 14 +++++++++++ src/Event/OrderCreationInitializedEvent.php | 24 +++++++++++++++++++ src/Event/PaymentLinkGeneratedEvent.php | 24 +++++++++++++++++++ .../PaymentLinkCreationListener.php | 9 +++++++ src/Factory/OrderFactory.php | 11 +++++++++ 8 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/Event/OrderCreationInitializedEvent.php create mode 100644 src/Event/PaymentLinkGeneratedEvent.php diff --git a/config/services.xml b/config/services.xml index 82b7820c1..12a517cd2 100644 --- a/config/services.xml +++ b/config/services.xml @@ -87,6 +87,7 @@ + @@ -105,6 +106,7 @@ + %sylius_admin_order_creation_plugin.offline_gateway_names% %sylius_admin_order_creation_plugin.payment_link_generation_enabled% diff --git a/psalm-baseline.xml b/psalm-baseline.xml index b9cda5a21..1e5de1b10 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -64,6 +64,16 @@ + + + + + + + + + + diff --git a/spec/EventListener/PaymentLinkCreationListenerSpec.php b/spec/EventListener/PaymentLinkCreationListenerSpec.php index 3590c47e4..85fe2eb9e 100644 --- a/spec/EventListener/PaymentLinkCreationListenerSpec.php +++ b/spec/EventListener/PaymentLinkCreationListenerSpec.php @@ -8,13 +8,16 @@ use Payum\Core\Payum; use Payum\Core\Security\TokenInterface; use PhpSpec\ObjectBehavior; +use Prophecy\Argument; use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\PaymentLinkGeneratedEvent; use Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType; use Webgriffe\SyliusAdminOrderCreationPlugin\Provider\PaymentTokenProviderInterface; use Webgriffe\SyliusAdminOrderCreationPlugin\Sender\OrderPaymentLinkSenderInterface; @@ -26,8 +29,9 @@ function let( ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, ) { - $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack, ['offline'], true); + $this->beConstructedWith($paymentTokenProvider, $orderManager, $orderPaymentLinkSender, $requestStack, $eventDispatcher, ['offline'], true); } function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_when_checkbox_is_checked( @@ -35,6 +39,7 @@ function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_whe ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, TokenInterface $token, GenericEvent $event, OrderInterface $order, @@ -57,6 +62,9 @@ function it_sets_after_url_from_token_of_last_order_new_payment_and_sends_it_whe ])); $payment->setDetails(['payment-link' => 'http://url-to-pay.com'])->shouldBeCalled(); + $eventDispatcher->dispatch(Argument::that(function (PaymentLinkGeneratedEvent $dispatchedEvent) use ($payment) { + return $dispatchedEvent->getPayment() === $payment->getWrappedObject(); + }))->shouldBeCalled(); $orderPaymentLinkSender->sendPaymentLink($order)->shouldBeCalled(); $orderManager->flush()->shouldBeCalled(); @@ -69,6 +77,7 @@ function it_does_not_send_the_email_when_the_checkbox_is_not_checked( ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, TokenInterface $token, GenericEvent $event, OrderInterface $order, @@ -89,6 +98,7 @@ function it_does_not_send_the_email_when_the_checkbox_is_not_checked( $requestStack->getCurrentRequest()->willReturn(new Request([], [])); $payment->setDetails(['payment-link' => 'http://url-to-pay.com'])->shouldBeCalled(); + $eventDispatcher->dispatch(Argument::type(PaymentLinkGeneratedEvent::class))->willReturn(new PaymentLinkGeneratedEvent($payment->getWrappedObject())); $orderPaymentLinkSender->sendPaymentLink($order)->shouldNotBeCalled(); $orderManager->flush()->shouldBeCalled(); @@ -141,6 +151,7 @@ function it_does_nothing_if_order_payment_gateway_is_one_of_the_configured_offli ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, Payum $payum, GenericEvent $event, OrderInterface $order, @@ -153,6 +164,7 @@ function it_does_nothing_if_order_payment_gateway_is_one_of_the_configured_offli $orderManager, $orderPaymentLinkSender, $requestStack, + $eventDispatcher, ['offline', 'bank_transfer'], true, ); @@ -174,6 +186,7 @@ function it_does_nothing_when_payment_link_generation_is_disabled( ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, Payum $payum, GenericEvent $event, OrderInterface $order, @@ -183,6 +196,7 @@ function it_does_nothing_when_payment_link_generation_is_disabled( $orderManager, $orderPaymentLinkSender, $requestStack, + $eventDispatcher, ['offline'], false, ); diff --git a/spec/Factory/OrderFactorySpec.php b/spec/Factory/OrderFactorySpec.php index 6b24f733b..2ef253f5a 100644 --- a/spec/Factory/OrderFactorySpec.php +++ b/spec/Factory/OrderFactorySpec.php @@ -14,6 +14,8 @@ use Sylius\Component\Currency\Model\CurrencyInterface; use Sylius\Component\Locale\Model\LocaleInterface; use Sylius\Component\Resource\Factory\FactoryInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreationInitializedEvent; use Webgriffe\SyliusAdminOrderCreationPlugin\Factory\OrderFactoryInterface; use Webgriffe\SyliusAdminOrderCreationPlugin\ReorderProcessing\ReorderProcessor; @@ -24,12 +26,14 @@ function let( CustomerRepositoryInterface $customerRepository, ChannelRepositoryInterface $channelRepository, ReorderProcessor $reorderProcessor, + EventDispatcherInterface $eventDispatcher, ) { $this->beConstructedWith( $baseOrderFactory, $customerRepository, $channelRepository, $reorderProcessor, + $eventDispatcher, ); } @@ -49,6 +53,7 @@ function it_creates_order_for_customer_with_default_channel_locale_and_currency( FactoryInterface $baseOrderFactory, CustomerRepositoryInterface $customerRepository, ChannelRepositoryInterface $channelRepository, + EventDispatcherInterface $eventDispatcher, OrderInterface $order, CustomerInterface $customer, ChannelInterface $channel, @@ -71,6 +76,10 @@ function it_creates_order_for_customer_with_default_channel_locale_and_currency( $order->setCurrencyCode('USD')->shouldBeCalled(); $order->setLocaleCode('en_US')->shouldBeCalled(); + $eventDispatcher->dispatch(Argument::that(function (OrderCreationInitializedEvent $event) use ($order) { + return $event->getOrder() === $order->getWrappedObject(); + }))->shouldBeCalled(); + $this ->createForCustomerAndChannel('1', 'WEB-US') ->shouldReturn($order) @@ -149,6 +158,7 @@ function it_throws_an_exception_if_there_is_no_default_locale( function it_creates_reorder_from_an_existing_order( FactoryInterface $baseOrderFactory, ReorderProcessor $reorderProcessor, + EventDispatcherInterface $eventDispatcher, OrderInterface $order, OrderInterface $reorder, ): void { @@ -156,6 +166,10 @@ function it_creates_reorder_from_an_existing_order( $reorderProcessor->process($order, $reorder)->shouldBeCalled(); + $eventDispatcher->dispatch(Argument::that(function (OrderCreationInitializedEvent $event) use ($reorder) { + return $event->getOrder() === $reorder->getWrappedObject(); + }))->shouldBeCalled(); + $this->createFromExistingOrder($order); } } diff --git a/src/Event/OrderCreationInitializedEvent.php b/src/Event/OrderCreationInitializedEvent.php new file mode 100644 index 000000000..29b26ed8f --- /dev/null +++ b/src/Event/OrderCreationInitializedEvent.php @@ -0,0 +1,24 @@ +order = $order; + } + + public function getOrder(): OrderInterface + { + return $this->order; + } +} diff --git a/src/Event/PaymentLinkGeneratedEvent.php b/src/Event/PaymentLinkGeneratedEvent.php new file mode 100644 index 000000000..4d30dfb26 --- /dev/null +++ b/src/Event/PaymentLinkGeneratedEvent.php @@ -0,0 +1,24 @@ +payment = $payment; + } + + public function getPayment(): PaymentInterface + { + return $this->payment; + } +} diff --git a/src/EventListener/PaymentLinkCreationListener.php b/src/EventListener/PaymentLinkCreationListener.php index 246c13396..b3bf379fc 100644 --- a/src/EventListener/PaymentLinkCreationListener.php +++ b/src/EventListener/PaymentLinkCreationListener.php @@ -9,8 +9,10 @@ use Sylius\Component\Core\Model\PaymentInterface; use Sylius\Component\Core\Model\PaymentMethodInterface; use Sylius\Component\Payment\Model\GatewayConfigInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; use Symfony\Component\HttpFoundation\RequestStack; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\PaymentLinkGeneratedEvent; use Webgriffe\SyliusAdminOrderCreationPlugin\Form\Type\NewOrderType; use Webgriffe\SyliusAdminOrderCreationPlugin\Provider\PaymentTokenProviderInterface; use Webgriffe\SyliusAdminOrderCreationPlugin\Sender\OrderPaymentLinkSenderInterface; @@ -30,6 +32,9 @@ final class PaymentLinkCreationListener /** @var RequestStack */ private $requestStack; + /** @var EventDispatcherInterface */ + private $eventDispatcher; + /** @var list */ private $offlineGatewayNames; @@ -44,6 +49,7 @@ public function __construct( ObjectManager $orderManager, OrderPaymentLinkSenderInterface $orderPaymentLinkSender, RequestStack $requestStack, + EventDispatcherInterface $eventDispatcher, array $offlineGatewayNames, bool $enabled, ) { @@ -51,6 +57,7 @@ public function __construct( $this->orderManager = $orderManager; $this->orderPaymentLinkSender = $orderPaymentLinkSender; $this->requestStack = $requestStack; + $this->eventDispatcher = $eventDispatcher; $this->offlineGatewayNames = $offlineGatewayNames; $this->enabled = $enabled; } @@ -82,6 +89,8 @@ public function setPaymentLink(GenericEvent $event): void $token = $this->paymentTokenProvider->getPaymentToken($payment); $payment->setDetails(['payment-link' => $token->getAfterUrl()]); + $this->eventDispatcher->dispatch(new PaymentLinkGeneratedEvent($payment)); + if ($this->shouldSendPaymentLinkEmail()) { $this->orderPaymentLinkSender->sendPaymentLink($order); } diff --git a/src/Factory/OrderFactory.php b/src/Factory/OrderFactory.php index 3f9dbed95..21a6bac19 100644 --- a/src/Factory/OrderFactory.php +++ b/src/Factory/OrderFactory.php @@ -12,6 +12,8 @@ use Sylius\Component\Currency\Model\CurrencyInterface; use Sylius\Component\Locale\Model\LocaleInterface; use Sylius\Component\Resource\Factory\FactoryInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreationInitializedEvent; use Webgriffe\SyliusAdminOrderCreationPlugin\ReorderProcessing\ReorderProcessor; use Webmozart\Assert\Assert; @@ -29,17 +31,22 @@ final class OrderFactory implements OrderFactoryInterface /** @var ReorderProcessor */ private $reorderProcessor; + /** @var EventDispatcherInterface */ + private $eventDispatcher; + public function __construct( FactoryInterface $baseOrderFactory, CustomerRepositoryInterface $customerRepository, ChannelRepositoryInterface $channelRepository, ReorderProcessor $reorderProcessor, + EventDispatcherInterface $eventDispatcher, ) { $this->baseOrderFactory = $baseOrderFactory; $this->customerRepository = $customerRepository; $this->channelRepository = $channelRepository; $this->reorderProcessor = $reorderProcessor; + $this->eventDispatcher = $eventDispatcher; } public function createNew(): OrderInterface @@ -77,6 +84,8 @@ public function createForCustomerAndChannel(string $customerId, string $channelC Assert::isInstanceOf($defaultLocale, LocaleInterface::class); $order->setLocaleCode($defaultLocale->getCode()); + $this->eventDispatcher->dispatch(new OrderCreationInitializedEvent($order)); + return $order; } @@ -87,6 +96,8 @@ public function createFromExistingOrder(OrderInterface $order): OrderInterface $this->reorderProcessor->process($order, $reorder); + $this->eventDispatcher->dispatch(new OrderCreationInitializedEvent($reorder)); + return $reorder; } } From 9471ba16987dca2aa7a72d2a8894d6caeab06e79 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 09:41:29 +0200 Subject: [PATCH 38/44] Document the two new extension-point events in the README Keep the Events subsection in sync with the OrderCreationInitializedEvent and PaymentLinkGeneratedEvent additions. --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index dcd259250..092490d0d 100644 --- a/README.md +++ b/README.md @@ -147,11 +147,19 @@ sylius_admin_order_creation_plugin: ### Events -The plugin dispatches its own `Webgriffe\SyliusAdminOrderCreationPlugin\Event\OrderCreatedByAdminEvent` (carrying -the created `OrderInterface`) right after an order is created from the admin panel, in addition to the generic -Sylius core `sylius.order.pre_admin_create` / `sylius.order.post_admin_create` events the plugin itself listens to. -Listen to it with a plain `#[AsEventListener]` to hook side effects (notifications, audit logging, custom -guards, ...) without having to decorate or replace any of the plugin's own listeners. +In addition to the generic Sylius core `sylius.order.pre_admin_create` / `sylius.order.post_admin_create` events the +plugin itself listens to, it dispatches its own typed events at the points a host application is most likely to +need a hook. Listen to any of them with a plain `#[AsEventListener]` instead of decorating or replacing the +plugin's own services: + +- `Event\OrderCreationInitializedEvent`, dispatched by `OrderFactory` whenever an order is initialized for admin + creation or reorder (carries the `OrderInterface`). Use it to guard/veto (e.g. throw an `AccessDeniedException`) + or enrich the order before it's shown or further processed. +- `Event\OrderCreatedByAdminEvent`, dispatched right after an order is created from the admin panel (carries the + created `OrderInterface`). Use it for side effects that only make sense once the order actually exists + (notifications, audit logging, ...). +- `Event\PaymentLinkGeneratedEvent`, dispatched right after a payment link is generated for a payment (carries the + `PaymentInterface`), independently of whether the "send by email" checkbox was ticked. ### Payment link generation From 0636f36e6190fa0136811d24e292482b9f81b294 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 11:39:38 +0200 Subject: [PATCH 39/44] Migrate the order creation page body to a Twig Hook The create.html.twig page rendered the order-form Live Component inline, forcing a host application to copy the whole template to change anything in the page body. Replace that with a sylius_admin_order_creation.order.create.content hook (single "form" hookable, unchanged content) so a host can add a sibling hookable or disable/replace it without owning the page shell. --- config/twig_hooks/order_create.yaml | 6 ++++++ templates/order/create.html.twig | 8 ++++---- templates/order/create/sections/form.html.twig | 5 +++++ 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 config/twig_hooks/order_create.yaml create mode 100644 templates/order/create/sections/form.html.twig diff --git a/config/twig_hooks/order_create.yaml b/config/twig_hooks/order_create.yaml new file mode 100644 index 000000000..9a35face8 --- /dev/null +++ b/config/twig_hooks/order_create.yaml @@ -0,0 +1,6 @@ +sylius_twig_hooks: + hooks: + 'sylius_admin_order_creation.order.create.content': + form: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/create/sections/form.html.twig' + priority: 0 diff --git a/templates/order/create.html.twig b/templates/order/create.html.twig index 8bb887624..396a684fc 100644 --- a/templates/order/create.html.twig +++ b/templates/order/create.html.twig @@ -36,11 +36,11 @@
- {{ component('webgriffe_sylius_admin_order_creation:order_form', { - customerId: customer_id, - channelCode: channel_code, + {% hook 'sylius_admin_order_creation.order.create.content' with { + customer_id: customer_id, + channel_code: channel_code, form: form, - }) }} + } %}
diff --git a/templates/order/create/sections/form.html.twig b/templates/order/create/sections/form.html.twig new file mode 100644 index 000000000..e30eb097d --- /dev/null +++ b/templates/order/create/sections/form.html.twig @@ -0,0 +1,5 @@ +{{ component('webgriffe_sylius_admin_order_creation:order_form', { + customerId: hookable_metadata.context.customer_id, + channelCode: hookable_metadata.context.channel_code, + form: hookable_metadata.context.form, +}) }} From d61713f6bf7087d398dd52b175a4979ed14fb5f4 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 11:39:42 +0200 Subject: [PATCH 40/44] Migrate the order preview page body to a Twig Hook Same rationale as the create page: the preview-form Live Component was embedded inline in preview.html.twig. Replace it with a sylius_admin_order_creation.order.preview.content hook (single "form" hookable, unchanged content). --- config/twig_hooks/order_preview.yaml | 6 ++++++ templates/order/preview.html.twig | 8 ++++---- templates/order/preview/sections/form.html.twig | 5 +++++ 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 config/twig_hooks/order_preview.yaml create mode 100644 templates/order/preview/sections/form.html.twig diff --git a/config/twig_hooks/order_preview.yaml b/config/twig_hooks/order_preview.yaml new file mode 100644 index 000000000..d407aa532 --- /dev/null +++ b/config/twig_hooks/order_preview.yaml @@ -0,0 +1,6 @@ +sylius_twig_hooks: + hooks: + 'sylius_admin_order_creation.order.preview.content': + form: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/preview/sections/form.html.twig' + priority: 0 diff --git a/templates/order/preview.html.twig b/templates/order/preview.html.twig index d651a8ae4..c41d8e549 100644 --- a/templates/order/preview.html.twig +++ b/templates/order/preview.html.twig @@ -38,11 +38,11 @@
- {{ component('webgriffe_sylius_admin_order_creation:order_preview_form', { - customerId: customerId, - channelCode: channelCode, + {% hook 'sylius_admin_order_creation.order.preview.content' with { + customer_id: customerId, + channel_code: channelCode, form: form, - }) }} + } %}
diff --git a/templates/order/preview/sections/form.html.twig b/templates/order/preview/sections/form.html.twig new file mode 100644 index 000000000..4ae209564 --- /dev/null +++ b/templates/order/preview/sections/form.html.twig @@ -0,0 +1,5 @@ +{{ component('webgriffe_sylius_admin_order_creation:order_preview_form', { + customerId: hookable_metadata.context.customer_id, + channelCode: hookable_metadata.context.channel_code, + form: hookable_metadata.context.form, +}) }} From c7369fdc968985e394a5f8840ffe8c886c4f39b6 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 11:39:48 +0200 Subject: [PATCH 41/44] Migrate the select-customer page cards to Twig Hooks select_customer.html.twig hardcoded both the "existing customer" and "create new customer" cards inline, so a host application wanting to drop the create-new-customer path (as mtdistribuzione did, by copying the whole template) had no lighter-weight option. Split each card into its own hookable (existing_customer, new_customer) registered under sylius_admin_order_creation.order.select_customer.content, so it can now be done with `enabled: false` in the host's own twig_hooks config. --- config/twig_hooks/order_select_customer.yaml | 9 +++++ templates/order/select_customer.html.twig | 40 ++----------------- .../sections/existing_customer.html.twig | 19 +++++++++ .../sections/new_customer.html.twig | 19 +++++++++ 4 files changed, 51 insertions(+), 36 deletions(-) create mode 100644 config/twig_hooks/order_select_customer.yaml create mode 100644 templates/order/select_customer/sections/existing_customer.html.twig create mode 100644 templates/order/select_customer/sections/new_customer.html.twig diff --git a/config/twig_hooks/order_select_customer.yaml b/config/twig_hooks/order_select_customer.yaml new file mode 100644 index 000000000..73f8fee85 --- /dev/null +++ b/config/twig_hooks/order_select_customer.yaml @@ -0,0 +1,9 @@ +sylius_twig_hooks: + hooks: + 'sylius_admin_order_creation.order.select_customer.content': + existing_customer: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/select_customer/sections/existing_customer.html.twig' + priority: 100 + new_customer: + template: '@WebgriffeSyliusAdminOrderCreationPlugin/order/select_customer/sections/new_customer.html.twig' + priority: 0 diff --git a/templates/order/select_customer.html.twig b/templates/order/select_customer.html.twig index 0e0bb3404..80def4b74 100644 --- a/templates/order/select_customer.html.twig +++ b/templates/order/select_customer.html.twig @@ -2,9 +2,6 @@ {% from '@SyliusAdmin/shared/helper/breadcrumbs.html.twig' import breadcrumbs %} -{% form_theme selectCustomerForm '@SyliusAdmin/shared/form_theme.html.twig' %} -{% form_theme createCustomerForm '@SyliusAdmin/shared/form_theme.html.twig' %} - {% block title %}{{ 'sylius.ui.new_order'|trans }} {{ parent() }}{% endblock %} {% block body %} @@ -33,39 +30,10 @@
-
-
-
-
{{ 'sylius_admin_order_creation.ui.existing_customer'|trans }}
-
-
- {{ form_start(selectCustomerForm, {'method': 'GET'}) }} - {{ form_row(selectCustomerForm.customer) }} - {{ form_row(selectCustomerForm.channel) }} - - {{ form_end(selectCustomerForm) }} -
-
-
- -
-
-
-
{{ 'sylius_admin_order_creation.ui.create_new'|trans }}
-
-
- {{ form_start(createCustomerForm, {'method': 'GET'}) }} - {{ form_row(createCustomerForm.customerEmail) }} - {{ form_row(createCustomerForm.channel) }} - - {{ form_end(createCustomerForm) }} -
-
-
+ {% hook 'sylius_admin_order_creation.order.select_customer.content' with { + select_customer_form: selectCustomerForm, + create_customer_form: createCustomerForm, + } %}
diff --git a/templates/order/select_customer/sections/existing_customer.html.twig b/templates/order/select_customer/sections/existing_customer.html.twig new file mode 100644 index 000000000..8e7727858 --- /dev/null +++ b/templates/order/select_customer/sections/existing_customer.html.twig @@ -0,0 +1,19 @@ +{% set selectCustomerForm = hookable_metadata.context.select_customer_form %} +{% form_theme selectCustomerForm '@SyliusAdmin/shared/form_theme.html.twig' %} + +
+
+
+
{{ 'sylius_admin_order_creation.ui.existing_customer'|trans }}
+
+
+ {{ form_start(selectCustomerForm, {'method': 'GET'}) }} + {{ form_row(selectCustomerForm.customer) }} + {{ form_row(selectCustomerForm.channel) }} + + {{ form_end(selectCustomerForm) }} +
+
+
diff --git a/templates/order/select_customer/sections/new_customer.html.twig b/templates/order/select_customer/sections/new_customer.html.twig new file mode 100644 index 000000000..7cae7a0e9 --- /dev/null +++ b/templates/order/select_customer/sections/new_customer.html.twig @@ -0,0 +1,19 @@ +{% set createCustomerForm = hookable_metadata.context.create_customer_form %} +{% form_theme createCustomerForm '@SyliusAdmin/shared/form_theme.html.twig' %} + +
+
+
+
{{ 'sylius_admin_order_creation.ui.create_new'|trans }}
+
+
+ {{ form_start(createCustomerForm, {'method': 'GET'}) }} + {{ form_row(createCustomerForm.customerEmail) }} + {{ form_row(createCustomerForm.channel) }} + + {{ form_end(createCustomerForm) }} +
+
+
From 9026a459f65eb2ab14ae34e8156efa9e61fd8b43 Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 11:39:54 +0200 Subject: [PATCH 42/44] Update README/AGENTS.md for the completed Twig Hooks migration Document the new order create/preview/select-customer hooks alongside the existing Order Show ones, and correct AGENTS.md's outdated note that the Twig/UI layer was still mid-migration and templates targeted removed Sylius 1.x paths - verified during this work that none of the plugin's @SyliusAdmin/... references are actually broken, and the migration is now complete. --- AGENTS.md | 2 +- README.md | 31 ++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f117ad8d..bc118d807 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,4 +91,4 @@ All code must be in English — class names, method names, variable names, comme - After any change to the plugin's namespace, class map, or `composer.json` autoload section, run `composer dump-autoload` **inside the Docker container** too, not just locally — the container's autoload map is what the running app actually uses. ### Git -- This plugin is mid-migration from Sylius 1.x to Sylius 2.x (branch `sylius-2`). Infrastructure (composer deps, DI/config layout, state machine, test harness, Behat, CI) is migrated. The Twig/UI layer is not: templates still target removed Sylius 1.x paths (`@SyliusAdmin/layout.html.twig`, Semantic UI markup) and need rewriting against Sylius 2's Bootstrap/Tabler admin UI and Twig Hooks system for the Order Show page override. +- This plugin was migrated from Sylius 1.x to Sylius 2.x (branch `sylius-2`): infrastructure (composer deps, DI/config layout, state machine, test harness, Behat, CI) and the Twig/UI layer (Bootstrap/Tabler admin UI, all templates registered as Twig Hooks under `config/twig_hooks/`) are both done. See the README's "Extension points" section for the hook names in use. diff --git a/README.md b/README.md index 092490d0d..1319a3757 100644 --- a/README.md +++ b/README.md @@ -167,17 +167,26 @@ Payment link generation and sending is based on logic placed in the `PaymentLink turned off entirely via the `payment_link_generation_enabled` configuration flag, or replaced altogether by decorating/replacing the service for more advanced needs. -### Order Show templates (Twig Hooks) - -Order Show template sections related to this plugin (discount rows, payment-link action, ...) are registered as -[Twig Hooks](https://docs.sylius.com/the-book/customization/twig-hooks) in `config/twig_hooks/order_show.yaml`. -Override or add your own hookable template at the same hook name (with a different priority) to customize them - -see that file for the exact hook names in use. - -The order creation, preview and select-customer pages are not yet migrated to Twig Hooks and are still overridable -only the classic Symfony way, by placing a template at the same bundle-relative path under your own -`templates/bundles/WebgriffeSyliusAdminOrderCreationPlugin/` directory (see `templates/order/` in this repository -for the paths to override). This is part of the still-ongoing Sylius 2 UI migration mentioned below. +### Templates (Twig Hooks) + +All the plugin's admin pages are registered as [Twig Hooks](https://docs.sylius.com/the-book/customization/twig-hooks). +Override or add your own hookable template at the same hook name (with a different priority, or `enabled: false` to +remove a default one) instead of copying the whole page template. See the corresponding `config/twig_hooks/*.yaml` +file for the exact hook names in use: + +- `config/twig_hooks/order_show.yaml` - Order Show sections related to this plugin (discount rows, payment-link + action, ...), hooked into Sylius core's own `sylius_admin.order.show...` hook tree. +- `config/twig_hooks/order_create.yaml` / `order_preview.yaml` - the `sylius_admin_order_creation.order.create.content` + / `...order.preview.content` hooks, each with a single `form` hookable wrapping the order creation/preview Live + Component. Order creation and preview aren't Sylius resource CRUD routes, so unlike Order Show these hooks are + defined by the plugin itself rather than plugged into a pre-existing Sylius hook tree. +- `config/twig_hooks/order_select_customer.yaml` - the `sylius_admin_order_creation.order.select_customer.content` + hook, with two independent hookables: `existing_customer` and `new_customer` (one card each). Disable + `new_customer` (`enabled: false`) if your application always creates orders for existing customers, for example. + +The page shells (`templates/order/create.html.twig`, `preview.html.twig`, `select_customer.html.twig`) still extend +`@SyliusAdmin/shared/layout/base.html.twig` and include the standard sidebar/navbar/flashes/footer by hand, since +these are plain controller-rendered pages, not Sylius resource CRUD routes with their own generic hookable layout. ### Adjustments From 6d480d6508e4aa8ce874680f49244bbece2adc4c Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 12:43:03 +0200 Subject: [PATCH 43/44] Validate the order form before advancing from create to preview step Previously OrderPreviewAction skipped form validation entirely, so an incomplete order (e.g. missing shipping address) always moved on to the preview step with no errors shown. --- .../validating_order_before_preview.feature | 20 +++++++++++++++++++ src/Controller/OrderPreviewAction.php | 13 +++++++++++- .../Context/Admin/ManagingOrdersContext.php | 16 +++++++++++++++ .../Element/Admin/OrderCreateFormElement.php | 10 ++++++++++ .../Admin/OrderCreateFormElementInterface.php | 4 ++++ 5 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 features/creating_order/validating_order_before_preview.feature diff --git a/features/creating_order/validating_order_before_preview.feature b/features/creating_order/validating_order_before_preview.feature new file mode 100644 index 000000000..02b3d0e4e --- /dev/null +++ b/features/creating_order/validating_order_before_preview.feature @@ -0,0 +1,20 @@ +@admin_order_creation_managing_orders @ui @javascript +Feature: Validating the order before previewing it + In order to avoid placing an incomplete order + As an Administrator + I want the order creation form to be validated before I see its preview + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "Stark Coat" priced at "$100.00" + And the store ships everywhere for free + And the store allows paying with "Cash on Delivery" + And there is a customer account "jon.snow@the-wall.com" + And I am logged in as an administrator + + Scenario: Trying to preview an order without a shipping address + When I create a new order for "jon.snow@the-wall.com" and channel "United States" + And I add "Stark Coat" to this order + And I place this order + Then I should still be on the order creation form + And I should see a validation error diff --git a/src/Controller/OrderPreviewAction.php b/src/Controller/OrderPreviewAction.php index 042ac8618..325b4f3f7 100644 --- a/src/Controller/OrderPreviewAction.php +++ b/src/Controller/OrderPreviewAction.php @@ -46,7 +46,18 @@ public function __invoke(Request $request): Response $order = $this->orderFactory->createForCustomerAndChannel($customerId, $channelCode); $form = $this->formFactory->create(NewOrderType::class, $order); - $order = $form->handleRequest($request)->getData(); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + return new Response( + $this->twig->render('@WebgriffeSyliusAdminOrderCreationPlugin/order/create.html.twig', [ + 'form' => $form->createView(), + ]), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + $order = $form->getData(); $this->orderProcessor->process($order); return new Response($this->twig->render('@WebgriffeSyliusAdminOrderCreationPlugin/order/preview.html.twig', [ diff --git a/tests/Behat/Context/Admin/ManagingOrdersContext.php b/tests/Behat/Context/Admin/ManagingOrdersContext.php index b7d01bb09..b8201d941 100644 --- a/tests/Behat/Context/Admin/ManagingOrdersContext.php +++ b/tests/Behat/Context/Admin/ManagingOrdersContext.php @@ -541,6 +541,22 @@ public function shouldBeAbleToConfirmOrderCreation(): void Assert::true($this->orderPreviewPage->hasConfirmButton()); } + /** + * @Then I should see a validation error + */ + public function iShouldSeeAValidationError(): void + { + Assert::true($this->orderCreateFormElement->hasValidationErrors()); + } + + /** + * @Then I should still be on the order creation form + */ + public function iShouldStillBeOnTheOrderCreationForm(): void + { + Assert::true($this->orderCreateFormElement->isDisplayed()); + } + /** * @Then the product named :productName should not be in the items list */ diff --git a/tests/Behat/Element/Admin/OrderCreateFormElement.php b/tests/Behat/Element/Admin/OrderCreateFormElement.php index 8d3a37e0c..dca5e6317 100644 --- a/tests/Behat/Element/Admin/OrderCreateFormElement.php +++ b/tests/Behat/Element/Admin/OrderCreateFormElement.php @@ -175,6 +175,16 @@ public function isAddPaymentButtonVisible(): bool return $addPaymentButton !== null && $addPaymentButton->isVisible(); } + public function hasValidationErrors(): bool + { + return $this->getDocument()->has('css', '.invalid-feedback'); + } + + public function isDisplayed(): bool + { + return $this->getDocument()->findButton('Order preview') !== null; + } + private function fillAddressData(NodeElement $addressForm, AddressInterface $address): void { $countryCode = $address->getCountryCode(); diff --git a/tests/Behat/Element/Admin/OrderCreateFormElementInterface.php b/tests/Behat/Element/Admin/OrderCreateFormElementInterface.php index c84fb78f9..32e23d94d 100644 --- a/tests/Behat/Element/Admin/OrderCreateFormElementInterface.php +++ b/tests/Behat/Element/Admin/OrderCreateFormElementInterface.php @@ -44,4 +44,8 @@ public function placeOrder(): void; public function getShippingMethodsValidationMessage(): string; public function isAddPaymentButtonVisible(): bool; + + public function hasValidationErrors(): bool; + + public function isDisplayed(): bool; } From e0ace8fcf11dda2d141da0fe91a06f4f98553b2e Mon Sep 17 00:00:00 2001 From: Luca Gallinari Date: Thu, 3 Sep 2026 12:43:12 +0200 Subject: [PATCH 44/44] Show validation errors for fields hidden on the order preview page The preview form only visibly renders items, adjustments and the send- payment-link checkbox; every other field (addresses, payments, shipments, locale, currency) is dumped via form_rest inside a display:none block, so their validation errors existed in the DOM but were never visible when confirming an order with invalid data. --- .../_order_preview_form_component.html.twig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/templates/order/preview/_order_preview_form_component.html.twig b/templates/order/preview/_order_preview_form_component.html.twig index 6758354f7..506c9f7dc 100644 --- a/templates/order/preview/_order_preview_form_component.html.twig +++ b/templates/order/preview/_order_preview_form_component.html.twig @@ -10,10 +10,24 @@ {% set order = form.vars.data %} +{% macro nested_errors(field) %} + {{ form_errors(field) }} + {% for child in field %} + {{ _self.nested_errors(child) }} + {% endfor %} +{% endmacro %} +
{{ form_start(form, {'attr': {'novalidate': 'novalidate', 'id': form.vars.id}}) }} {{ form_errors(form) }} + {{ _self.nested_errors(form.promotionCoupon) }} + {{ _self.nested_errors(form.shippingAddress) }} + {{ _self.nested_errors(form.billingAddress) }} + {{ _self.nested_errors(form.payments) }} + {{ _self.nested_errors(form.shipments) }} + {{ _self.nested_errors(form.localeCode) }} + {{ _self.nested_errors(form.currencyCode) }}