Merge branch '2.1' into filter-out-disabled-products-from-get-by-code-item-ednpoint

# Conflicts:
#	UPGRADE-2.1.md
This commit is contained in:
Tomasz Kaliński 2026-06-01 14:30:30 +02:00
commit 76fa984cc2
113 changed files with 2069 additions and 221 deletions

View file

@ -1,5 +1,34 @@
# CHANGELOG FOR `2.1.X`
## v2.1.14 (2026-04-10)
#### Details
- [#18579](https://github.com/Sylius/Sylius/pull/18579) Fix problem with empty taxon product index ([@tomkalon](https://github.com/tomkalon))
- [#18933](https://github.com/Sylius/Sylius/pull/18933) fix: add default filter to breadcrumbs configuration title to prevent ScalarDataBag exception ([@camilleislasse](https://github.com/camilleislasse))
- [#18943](https://github.com/Sylius/Sylius/pull/18943) Fix build after APIPlatform 4.3.2 release ([@TheMilek](https://github.com/TheMilek))
- [#18941](https://github.com/Sylius/Sylius/pull/18941) Add payment enabled in channel validation ([@marekrzytki](https://github.com/marekrzytki))
- [#18926](https://github.com/Sylius/Sylius/pull/18926) [BUGFIX] Allow updating provinceName when provinceCode is null in API ([@rust-le](https://github.com/rust-le))
- [#18953](https://github.com/Sylius/Sylius/pull/18953) [BUGFIX] Make mailer services public to allow usage in state machine callbacks ([@rust-le](https://github.com/rust-le))
- [#18961](https://github.com/Sylius/Sylius/pull/18961) Resolve flash alert correctly without breaking translation ([@TheMilek](https://github.com/TheMilek))
- [#18940](https://github.com/Sylius/Sylius/pull/18940) Restore missing page titles for admin resource show pages ([@marekrzytki](https://github.com/marekrzytki))
- [#18922](https://github.com/Sylius/Sylius/pull/18922) Unified name translations handling in admin grid index ([@pbalcerzak](https://github.com/pbalcerzak))
- [#18965](https://github.com/Sylius/Sylius/pull/18965) Allow choosing different payment method with skip payment step when it gets disabled ([@TheMilek](https://github.com/TheMilek))
- [#18969](https://github.com/Sylius/Sylius/pull/18969) Add missing status code mapping for OrderItemNotFoundException ([@marekrzytki](https://github.com/marekrzytki))
- [#18970](https://github.com/Sylius/Sylius/pull/18970) Add email validation constraints to cart update ([@marekrzytki](https://github.com/marekrzytki))
- [#18972](https://github.com/Sylius/Sylius/pull/18972) Fix admin templates ([@loic425](https://github.com/loic425))
- [#18974](https://github.com/Sylius/Sylius/pull/18974) Unify Tests directory with tests ([@TheMilek](https://github.com/TheMilek))
## v2.1.13 (2026-03-18)
#### Details
- [#18904](https://github.com/Sylius/Sylius/pull/18904) [BUGFIX] remove redundant `object` from PHPDoc union types ([@rust-le](https://github.com/rust-le))
- [#18899](https://github.com/Sylius/Sylius/pull/18899) [CS][DX] Refactor
- [#18895](https://github.com/Sylius/Sylius/pull/18895) [Admin] Fix product taxon grid `enabled` field always showing `true` ([@serhiilabs](https://github.com/serhiilabs))
- [#18911](https://github.com/Sylius/Sylius/pull/18911) [BUGFIX] fix build errors ([@rust-le](https://github.com/rust-le))
- [#18920](https://github.com/Sylius/Sylius/pull/18920) Telemetry improvements 2.1 ([@TheMilek](https://github.com/TheMilek))
## v2.1.12 (2026-03-09)
#### Details

View file

@ -1,4 +1,4 @@
# UPGRADE FROM `2.1.11` TO `2.1.12`
# UPGRADE FROM `2.1.13` TO `2.1.14`
## Shop API
@ -9,26 +9,362 @@
If you decorate this service, make sure your decorator also implements
`ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface` and proxies the `applyToItem` method.
## Redirect behavior changes
### Admin resource translations
`CurrencySwitchController`, `ImpersonateUserController` and `StorageBasedLocaleSwitcher` no longer use the
HTTP `Referer` header for redirects. Instead, they use the `RouterInterface` to generate a redirect URL based on
the `_sylius.redirect` route attribute (defaulting to `sylius_shop_homepage`).
Translations for admin-managed resources (such as product options, attributes, associations) are now resolved using the current admin locale instead of the default `%locale%` parameter.
If your application relied on the Referer-based redirect behavior (e.g., to return the user to the page they came from
after switching currency or locale), you can customize the redirect target by overriding the route definition:
Previously, translation-aware query builders relied on a static `%locale%` parameter, which could lead to inconsistencies when the admin user was working in a different language than the application default.
This has been updated to dynamically resolve the locale from the admin context.
### Before
```yaml
sylius_grid:
grids:
sylius_admin_product_association_type:
driver:
name: doctrine/orm
options:
class: "%sylius.model.product_association_type.class%"
repository:
method: createListQueryBuilder
arguments: ["%locale%"]
```
### After
```yaml
sylius_grid:
grids:
sylius_admin_product_association_type:
driver:
name: doctrine/orm
options:
class: "%sylius.model.product_association_type.class%"
repository:
method: createListQueryBuilder
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
```
### Restore missing admin resource show title
A missing page title has been restored to the admin resource show pages (e.g. product, order, customer, shipment, catalog promotion).
Added separated show routes for each resource with dedicated show page templates.
### Order payment method eligibility check
The `Sylius\Bundle\CoreBundle\Validator\Constraints\OrderPaymentMethodEligibilityValidator` and
`Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentMethodEligibilityValidator` now checks also if
payment method is assigned to the channel of the order.
# UPGRADE FROM `2.1.12` TO `2.1.13`
### Telemetry improvements
Telemetry has been improved with per-query database timeouts to prevent slow queries from blocking admin panel
requests. Timeouts are applied automatically using platform-specific mechanisms (MySQL, MariaDB, PostgreSQL).
The global query timeout (in milliseconds, default: 60000, minimum: 1000) is **configurable** via environment variable:
```dotenv
SYLIUS_TELEMETRY_QUERY_TIMEOUT=30000
```
Additionally, telemetry collection is now rate-limited — it will be skipped if it was already triggered within
the last hour, preventing redundant data collection on rapid admin page loads.
# UPGRADE FROM `2.1.11` TO `2.1.12`
This is a **security release** addressing multiple vulnerabilities. Updating is strongly recommended.
## Security fixes
### [API] DQL Injection via API Order Filters (Critical)
An unauthenticated DQL injection vulnerability has been fixed in the following API order filters:
- `Sylius\Bundle\ApiBundle\Filter\Doctrine\ProductPriceOrderFilter`
- `Sylius\Bundle\ApiBundle\Filter\Doctrine\TranslationOrderNameAndLocaleFilter`
Previously, user-supplied sort direction values (e.g. `order[price]`, `order[translation.name]`) were passed directly
into DQL `ORDER BY` clauses without validation, allowing an attacker to inject arbitrary DQL expressions.
Both filters now define an `ALLOWED_DIRECTIONS` whitelist (`['asc', 'desc']`) and validate the input against it
before applying it to the query. Invalid values are silently ignored.
**Changes in `ProductPriceOrderFilter`:**
```diff
final class ProductPriceOrderFilter extends AbstractContextAwareFilter
{
+ private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
+
protected function filterProperty(/* ... */)
{
// ...
+ $direction = strtolower($value['price']);
+ if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
+ return;
+ }
// ...
- ->orderBy('channelPricing.price', $value['price'])
+ ->orderBy('channelPricing.price', $direction)
}
}
```
**Changes in `TranslationOrderNameAndLocaleFilter`:**
```diff
final class TranslationOrderNameAndLocaleFilter extends AbstractContextAwareFilter
{
+ private const ALLOWED_DIRECTIONS = ['asc', 'desc'];
+
protected function filterProperty(/* ... */)
{
// ...
- $direction = $value['translation.name'];
+ $direction = strtolower($value['translation.name']);
+ if (!in_array($direction, self::ALLOWED_DIRECTIONS, true)) {
+ return;
+ }
// ...
- ->orderBy('translation.name', $value['translation.name'])
+ ->orderBy('translation.name', $direction)
}
}
```
**No action required** — this fix is applied automatically upon updating. If you have extended or overridden either
of these filters, verify that your custom implementation also validates the sort direction.
### [Promotion] Race condition on promotion usage limit (High)
A race condition has been fixed where concurrent orders could exceed a promotion's usage limit. When multiple checkouts
completed simultaneously, the non-atomic read-then-write of `Promotion::$used` allowed the counter to be incremented
beyond `usageLimit`.
1. A new class has been introduced:
`Sylius\Bundle\CoreBundle\Doctrine\ORM\Promotion\Modifier\AtomicOrderPromotionsUsageModifier`
This class implements `Sylius\Component\Core\Promotion\Modifier\OrderPromotionsUsageModifierInterface` and uses
atomic SQL statements (`UPDATE ... WHERE used < usage_limit` and `SELECT ... FOR UPDATE`) to enforce promotion
and coupon usage limits at the database level, preventing race conditions.
Its constructor accepts a `Doctrine\DBAL\Connection`:
```php
public function __construct(Connection $connection)
```
2. The new service **decorates** the existing `sylius.promotion_usage_modifier` service:
```xml
<service
id="sylius.promotion_usage_modifier.atomic"
class="Sylius\Bundle\CoreBundle\Doctrine\ORM\Promotion\Modifier\AtomicOrderPromotionsUsageModifier"
decorates="sylius.promotion_usage_modifier"
>
<argument type="service" id="doctrine.dbal.default_connection" />
</service>
```
If you have overridden or decorated the `sylius.promotion_usage_modifier` service, review your customizations
to ensure compatibility with the new decorator chain.
3. A new exception class has been introduced:
`Sylius\Component\Core\Promotion\Exception\PromotionUsageLimitReachedException`
This exception extends `Doctrine\ORM\OptimisticLockException` and is thrown when a promotion or coupon usage
limit has been reached during checkout. It provides two named constructors:
```php
PromotionUsageLimitReachedException::withPromotionCode(string $code): self
PromotionUsageLimitReachedException::withCouponCode(string $code): self
```
If you have custom error handling around the checkout completion workflow (e.g. in state machine callbacks
or event listeners), you may want to catch this exception to display an appropriate message to the customer.
### [Admin] XSS in taxon tree and autocompletes (High)
A stored XSS vulnerability has been fixed in the admin panel's taxon tree and autocomplete components. Taxon names
containing malicious HTML/JavaScript were rendered without escaping.
**Affected files:**
- `Sylius\Bundle\AdminBundle\Resources\assets\controllers\ProductTaxonTreeController.js` — taxon name is now escaped
via `textContent` before insertion into the DOM template.
- `Sylius\Bundle\AdminBundle\Resources\assets\controllers\TaxonTreeController.js` — uses `textContent` assignment
instead of string interpolation with `replaceAll('__TAXON_NAME__', name)`.
- New file `Sylius\Bundle\AdminBundle\Resources\assets\scripts\autocomplete-xss-protection.js` — intercepts
`autocomplete:pre-connect` events and escapes label fields before rendering.
**No action required** — this fix is applied automatically upon updating. If you have custom JavaScript that renders
taxon names or autocomplete labels using `innerHTML` or string templates, review your code for similar XSS vectors.
### [Shop] XSS in taxon breadcrumbs (High)
A stored XSS vulnerability has been fixed in the shop breadcrumbs template. Breadcrumb labels were rendered using
Twig's `|raw` filter, allowing injected HTML in taxon or order names to execute in the browser.
**Changes in `shared/breadcrumbs.html.twig`:**
```diff
- <a class="link-reset" href="{{ item.path }}">{{ item.label|raw }}</a>
+ <a class="link-reset" href="{{ item.path }}">{{ item.label }}</a>
...
- <span class="text-body-tertiary text-break">{{ item.label|raw }}</span>
+ <span class="text-body-tertiary text-break">{{ item.label }}</span>
```
If you have overridden the `shared/breadcrumbs.html.twig` template, ensure you are not using the `|raw` filter
on user-controllable label values.
### [Shop] XSS in ApiLoginController (Medium)
A DOM-based XSS vulnerability has been fixed in `ApiLoginController.js`. The server error message was inserted
using `innerHTML`, allowing malicious content in the response to execute JavaScript.
```diff
- errorElement.innerHTML = response.message;
+ errorElement.textContent = response.message;
```
**No action required** — this fix is applied automatically upon updating.
### [Shop] IDOR in checkout address LiveComponent (High)
An Insecure Direct Object Reference (IDOR) vulnerability has been fixed in the checkout address LiveComponent.
The `addressFieldUpdated()` method in `Checkout\Address\FormComponent` used `$this->addressRepository->find($addressId)`,
allowing any authenticated customer to load another customer's address by manipulating the `#[LiveArg]` value.
The fix replaces `find()` with `findOneByCustomer()` to scope the lookup to the current customer:
```diff
- $address = $this->addressRepository->find($addressId);
+ $customer = $this->customerContext->getCustomer();
+ if (!$customer instanceof CustomerInterface) {
+ return;
+ }
+ $address = $this->addressRepository->findOneByCustomer((string) $addressId, $customer);
+ if (null === $address) {
+ return;
+ }
```
Additionally, `SummaryComponent::refreshCart()` and `WidgetComponent::refreshCart()` no longer accept an external
`$cartId` argument — they use the internally held cart reference instead, preventing cart ID manipulation.
**No action required** — this fix is applied automatically upon updating. If you have extended or overridden
`Checkout\Address\FormComponent`, `Cart\SummaryComponent`, or `Cart\WidgetComponent`, review your customizations
to ensure they do not expose similar IDOR vectors.
### [API] Cart authorization bypass (High)
An authorization bypass has been fixed in `AddItemToCartHandler`. Previously, any authenticated user could add items
to another user's cart by providing a different cart token in the API request.
The handler now validates that the current user has access to the cart before modifying it:
```diff
final readonly class AddItemToCartHandler
{
public function __construct(
// ...
+ private ?UserContextInterface $userContext = null,
) {
}
public function __invoke(AddItemToCart $addItemToCart): OrderInterface
{
// ...
+ $this->assertCartAccessible($cart);
// ...
}
+
+ private function assertCartAccessible(OrderInterface $cart): void
+ {
+ // Validates current user matches cart owner
+ // Guest carts remain accessible
+ // Throws NotFoundHttpException if access denied
+ }
}
```
The `UserContextInterface` service is now injected into the handler via `command_handlers.xml`.
**No action required** — this fix is applied automatically upon updating. If you have overridden the
`AddItemToCartHandler` or its service definition, ensure the new `UserContextInterface` argument is included.
### Open Redirect vulnerability (Medium)
An open redirect vulnerability has been fixed in `CurrencySwitchController`, `ImpersonateUserController` and
`StorageBasedLocaleSwitcher`. These controllers no longer use the HTTP `Referer` header for redirects. Instead,
they use the `RouterInterface` to generate a redirect URL based on the `_sylius.redirect` route attribute
(defaulting to `sylius_shop_homepage`).
A new trait has been added: `Sylius\Bundle\ShopBundle\Controller\RedirectTrait`.
**Affected classes:**
- `Sylius\Bundle\ShopBundle\Controller\CurrencySwitchController`
- `Sylius\Bundle\AdminBundle\Controller\ImpersonateUserController`
- `Sylius\Bundle\ShopBundle\Locale\StorageBasedLocaleSwitcher`
If your application relied on the Referer-based redirect behavior, you can customize the redirect target
by overriding the route definition:
```yaml
# config/routes/sylius_shop.yaml
sylius_shop_switch_currency:
path: /{_locale}/switch-currency/{code}
methods: [GET]
defaults:
_controller: sylius.controller.shop.currency_switch:switchAction
_sylius:
redirect: sylius_shop_homepage # or any route name you prefer
redirect: sylius_shop_homepage
```
## Other changes
### Fix too frequent/long requests to GUS
The `Sylius\Bundle\AdminBundle\Controller\NotificationController` has been updated to reduce the frequency and
duration of outbound requests to GUS.
1. The constructor of `NotificationController` has been modified:
```diff
public function __construct(
private ClientInterface $client,
private MessageFactory $messageFactory,
string $hubUri,
private string $environment,
+ private CacheItemPoolInterface $cache,
)
```
The `cache.app` service is injected as the new argument.
2. Responses are now **cached for 24 hours** (`TTL = 86400s`) using PSR-6 `CacheItemPoolInterface`.
Subsequent calls to `getVersionAction()` return the cached result without making an HTTP request.
3. HTTP request timeouts have been added:
```diff
-$hubResponse = $this->client->send($hubRequest, ['verify' => false]);
+$hubResponse = $this->client->send($hubRequest, [
+ 'verify' => false,
+ 'timeout' => 2,
+ 'connect_timeout' => 1,
+]);
```
If you have overridden the `sylius.controller.admin.notification` service or its arguments, update your
configuration to include the new `CacheItemPoolInterface` argument.
# UPGRADE FROM `2.1.8` TO `2.1.9`
## Telemetry

View file

@ -1,7 +1,7 @@
{
"name": "sylius/sylius",
"type": "library",
"version": "v2.1.13-dev",
"version": "v2.1.15-dev",
"description": "E-Commerce platform for PHP, based on Symfony framework.",
"homepage": "https://sylius.com",
"license": "MIT",

View file

@ -0,0 +1,23 @@
admin_basic_create:
path: /admin/basic/create
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: admin/basic/create.html.twig
admin_basic_update:
path: /admin/basic/update
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: admin/basic/update.html.twig
admin_basic_index:
path: /admin/basic/index
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: admin/basic/index.html.twig
admin_basic_show:
path: /admin/basic/show
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: admin/basic/show.html.twig

View file

@ -0,0 +1,47 @@
@paying_for_order
Feature: Preventing placing an order with a payment method not available in channel
In order to have my order shipped without issues
As a Visitor
I want to be prevented from placing an order with a payment method not available in my channel
Background:
Given the store operates on a single channel in the "United States" named "US Web Store"
And the store has a product "Ubi T-Shirt" priced at "$19.99"
And the store ships everywhere for Free
And the store allows paying "Bank Transfer"
And I am a logged in customer
@api @ui
Scenario: Being prevented from placing an order with a payment method that is not available in my channel
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded selecting "Bank Transfer" payment method
And the payment method "Bank Transfer" has been disabled in "US Web Store" channel
When I try to confirm my order
Then I should be informed that this payment method has been disabled
And I should not see the thank you page
@api @ui
Scenario: Being prevented from placing an order with a payment method that is disabled
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded selecting "Bank Transfer" payment method
But the payment method "Bank Transfer" is disabled
When I try to confirm my order
Then I should be informed that this payment method has been disabled
And I should not see the thank you page
@api @ui
Scenario: Being prevented from placing an order with a payment method that is disabled and not available in my channel
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded selecting "Bank Transfer" payment method
But the payment method "Bank Transfer" is disabled
And the payment method "Bank Transfer" has been disabled in "US Web Store" channel
When I try to confirm my order
Then I should be informed that this payment method has been disabled
And I should not see the thank you page
@api @ui
Scenario: Being able to place an order with enabled payment method in my channel
Given I added product "Ubi T-Shirt" to the cart
And I have proceeded selecting "Bank Transfer" payment method
When I try to confirm my order
Then I should see the thank you page

View file

@ -45,3 +45,35 @@ Feature: Skipping payment step when only one payment method is available
When I complete addressing step with email "guest@example.com" and "United States" based billing address
And I complete the shipping step with the first shipping method
Then I should be on the checkout payment step
@api @ui
Scenario: Being able to recover after the auto-selected payment method gets disabled
Given the payment method "Bank transfer" is disabled
And the store allows paying with "Offline"
And I added product "Guards! Guards!" to the cart
And I complete addressing step with email "guest@example.com" and "United States" based billing address
And I complete the shipping step with the first shipping method
And the payment method "Offline" is disabled
And the payment method "Bank transfer" is enabled
And I have tried to confirm my order
And I have been informed that this payment method has been disabled
When I go back to payment step of the checkout
And I choose "Bank transfer" payment method
And I confirm my order
Then I should see the thank you page
@api @ui
Scenario: Being able to recover after the auto-selected payment method gets removed from channel
Given the payment method "Bank transfer" is disabled
And the store allows paying with "Offline"
And I added product "Guards! Guards!" to the cart
And I complete addressing step with email "guest@example.com" and "United States" based billing address
And I complete the shipping step with the first shipping method
And the payment method "Offline" has been disabled in "United States" channel
And the payment method "Bank transfer" is enabled
And I have tried to confirm my order
And I have been informed that this payment method has been disabled
When I go back to payment step of the checkout
And I choose "Bank transfer" payment method
And I confirm my order
Then I should see the thank you page

View file

@ -110,6 +110,7 @@ final class CheckoutContext implements Context
}
#[When('the customer is at the checkout payment step')]
#[When('I go back to payment step of the checkout')]
public function theCustomerIsAtTheCheckoutPaymentStep(): void
{
// Intentionally left blank, as this is a UI-specific action.
@ -443,9 +444,8 @@ final class CheckoutContext implements Context
$this->sharedStorage->set('order', $this->orderRepository->findOneByNumber($this->sharedStorage->get('order_number')));
}
/**
* @When I try to confirm my order
*/
#[Given('I have tried to confirm my order')]
#[When('I try to confirm my order')]
public function iTryToConfirmMyOrder(): void
{
$response = $this->completeOrder();
@ -1151,9 +1151,8 @@ final class CheckoutContext implements Context
$this->hasProvinceNameInAddress($provinceName, $addressType);
}
/**
* @Then /^I should be informed that (this payment method) has been disabled$/
*/
#[Given('/^I have been informed that (this payment method) has been disabled$/')]
#[Then('/^I should be informed that (this payment method) has been disabled$/')]
public function iShouldBeInformedThatThisPaymentMethodHasBeenDisabled(PaymentMethodInterface $paymentMethod): void
{
$response = $this->client->getLastResponse();

View file

@ -123,6 +123,15 @@ final readonly class PaymentContext implements Context
$this->paymentMethodManager->flush();
}
#[Given('the payment method :paymentMethod is enabled')]
#[Given('the payment method :paymentMethod gets enabled')]
public function theStoreHasAPaymentMethodEnabled(PaymentMethodInterface $paymentMethod): void
{
$paymentMethod->enable();
$this->paymentMethodManager->flush();
}
/**
* @Given /^(it) has instructions "([^"]+)"$/
*/
@ -154,6 +163,16 @@ final readonly class PaymentContext implements Context
$this->paymentMethodManager->flush();
}
/**
* @Given the payment method :paymentMethod has been disabled in :channel channel
*/
public function theStoreHasDisabledPaymentMethodInChannel(PaymentMethodInterface $paymentMethod, ChannelInterface $channel): void
{
$paymentMethod->removeChannel($channel);
$this->paymentMethodManager->flush();
}
/**
* @Given the store allows paying with :paymentMethodName in :channel channel
*/

View file

@ -15,6 +15,7 @@ namespace Sylius\Behat\Context\Ui\Shop\Checkout;
use Behat\Behat\Context\Context;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Step\Given;
use Behat\Step\Then;
use Behat\Step\When;
use Doctrine\ORM\EntityManagerInterface;
@ -100,6 +101,7 @@ final readonly class CheckoutCompleteContext implements Context
* @Given the customer confirmed the order
* @When I try to confirm my order
*/
#[Given('I have tried to confirm my order')]
#[When('I confirm my order')]
public function iConfirmMyOrder(): void
{
@ -325,9 +327,8 @@ final readonly class CheckoutCompleteContext implements Context
);
}
/**
* @Then /^I should be informed that (this payment method) has been disabled$/
*/
#[Given('/^I have been informed that (this payment method) has been disabled$/')]
#[Then('/^I should be informed that (this payment method) has been disabled$/')]
public function iShouldBeInformedThatThisPaymentMethodHasBeenDisabled(PaymentMethodInterface $paymentMethod): void
{
Assert::same(

View file

@ -7,7 +7,7 @@ sylius_grid:
class: "%sylius.model.product_variant.class%"
repository:
method: createInventoryListQueryBuilder
arguments: ["%locale%"]
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
sorting:
name: asc
fields:

View file

@ -7,7 +7,7 @@ sylius_grid:
class: "%sylius.model.payment_method.class%"
repository:
method: createListQueryBuilder
arguments: ["%locale%"]
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
sorting:
position: asc
fields:

View file

@ -7,7 +7,7 @@ sylius_grid:
class: "%sylius.model.product_association_type.class%"
repository:
method: createListQueryBuilder
arguments: ["%locale%"]
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
sorting:
code: asc
fields:

View file

@ -7,7 +7,7 @@ sylius_grid:
class: "%sylius.model.product_option.class%"
repository:
method: createListQueryBuilder
arguments: ["%locale%"]
arguments: ["expr:service('sylius.context.locale').getLocaleCode()"]
sorting:
position: asc
fields:

View file

@ -11,6 +11,16 @@ sylius_admin_catalog_promotion:
permission: true
type: sylius.resource
sylius_admin_catalog_promotion_show:
path: /catalog-promotions/{id}
methods: [GET]
defaults:
_controller: sylius.controller.catalog_promotion::showAction
_sylius:
section: admin
permission: true
template: "@SyliusAdmin/catalog_promotion/show.html.twig"
sylius_admin_catalog_promotion_product_variant_index:
path: /catalog-promotions/{id}/variants
methods: [GET]

View file

@ -11,6 +11,16 @@ sylius_admin_customer:
permission: true
type: sylius.resource
sylius_admin_customer_show:
path: /customers/{id}
methods: [GET]
defaults:
_controller: sylius.controller.customer::showAction
_sylius:
section: admin
permission: true
template: "@SyliusAdmin/customer/show.html.twig"
sylius_admin_customer_order_index:
path: /customers/{id}/orders
methods: [GET]

View file

@ -22,7 +22,7 @@ sylius_admin_order_show:
arguments:
- '$id'
permission: true
template: "@SyliusAdmin/shared/crud/show.html.twig"
template: "@SyliusAdmin/order/show.html.twig"
sylius_admin_order_history:
path: /orders/{id}/history
@ -50,7 +50,7 @@ sylius_admin_order_update:
arguments:
- '$id'
permission: true
template: "@SyliusAdmin/shared/crud/update.html.twig"
template: "@SyliusAdmin/order/update.html.twig"
form:
type: Sylius\Bundle\AdminBundle\Form\Type\OrderType
options:

View file

@ -9,6 +9,16 @@ sylius_admin_product:
type: Sylius\Bundle\AdminBundle\Form\Type\ProductType
permission: true
type: sylius.resource
sylius_admin_product_show:
path: /products/{id}
methods: [GET]
defaults:
_controller: sylius.controller.product::showAction
_sylius:
section: admin
permission: true
template: "@SyliusAdmin/product/show.html.twig"
sylius_admin_product_create_simple:
path: /products/new/simple

View file

@ -7,6 +7,16 @@ sylius_admin_shipment:
grid: sylius_admin_shipment
permission: true
type: sylius.resource
sylius_admin_shipment_show:
path: /shipments/{id}
methods: [GET]
defaults:
_controller: sylius.controller.shipment::showAction
_sylius:
section: admin
permission: true
template: "@SyliusAdmin/shipment/show.html.twig"
sylius_admin_shipment_ship:
path: /shipments/{id}/ship

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.catalog_promotion'|trans ~ ' ' ~ catalog_promotion.name ~ ' | '}} {{ parent() }}{% endblock %}
{% block body %}
{% hook 'show' with { _prefixes: prefixes, resource, metadata, configuration } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'show#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'show#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.customer'|trans ~' '~ customer.email ~ ' | '}} {{ parent() }}{% endblock %}
{% block body %}
{% hook 'show' with { _prefixes: prefixes, resource, metadata, configuration } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'show#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'show#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -1,6 +1,18 @@
<div>
<div>
<strong>{{ data.product.name }}</strong>
{% if data.product.name is not null %}
<strong>{{ data.product.name }}</strong>
{% else %}
<span class="text-danger" {{ sylius_test_html_attribute('missing-translation') }} >
{{ ux_icon('tabler:exclamation-circle') }} <i>{{ 'sylius.ui.missing_translation'|trans }}</i>
</span>
{% endif %}
</div>
<div class="text-muted small">{{ data.name }}</div>
{% if data.name is not null %}
<div class="text-muted small">{{ data.name }}</div>
{% else %}
<span class="text-danger" {{ sylius_test_html_attribute('missing-translation') }} >
{{ ux_icon('tabler:exclamation-circle') }} <i>{{ 'sylius.ui.missing_translation'|trans }}</i>
</span>
{% endif %}
</div>

View file

@ -6,7 +6,7 @@
] %}
{% block title %}
{{ 'sylius.ui.history'|trans }} | {{ parent() }}
{{ 'sylius.ui.order'|trans ~ ' #' ~ order.number ~ ' - ' ~ 'sylius.ui.order_history'|trans ~ ' | ' }}{{ parent() }}
{% endblock %}
{% block body %}

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.order'|trans ~' #'~ order.number ~ ' | '}} {{ parent() }}{% endblock %}
{% block body %}
{% hook 'show' with { _prefixes: prefixes, resource, metadata, configuration } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'show#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'show#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.edit_order'|trans ~' #'~ order.number ~ ' | '}} {{ parent() }}{% endblock %}
{% block body %}
{% hook 'update' with { _prefixes: prefixes, resource, metadata, configuration, form } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'update#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'update#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.product'|trans ~ ' ' ~ product.name ~ ' | '}} {{ parent() }}{% endblock %}
{% block body %}
{% hook 'show' with { _prefixes: prefixes, resource, metadata, configuration } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'show#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'show#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -1,12 +1,15 @@
{% extends '@SyliusAdmin/shared/components/confirmation_modal.html.twig' %}
{% import '@SyliusAdmin/shared/helper/button.html.twig' as button %}
{% props id = null, path = null, label = 'sylius.ui.delete', icon = 'tabler:trash-x', disabled = false, form_attr = null, type = 'delete' %}
{% set modal_id = 'delete-modal-' ~ id %}
{% props id = null, path = null, label = 'sylius.ui.delete', icon = 'tabler:trash-x', disabled = false, form_attr = null, type = 'delete', modal_id = null, icon_only = true %}
{% if modal_id is null %}
{% set modal_id = 'delete-modal-' ~ id %}
{% endif %}
{% block trigger %}
<div data-bs-toggle="tooltip" data-bs-title="{{ label|trans }}">
{{ button.default({icon: icon, icon_class: 'icon text-danger', icon_only: true, disabled: disabled, attr: 'data-bs-toggle=modal data-bs-target=#' ~ modal_id }) }}
{{ button.default({icon: icon, icon_class: 'icon text-danger', icon_only: icon_only, text: label|trans, disabled: disabled, attr: 'data-bs-toggle=modal data-bs-target=#' ~ modal_id }) }}
</div>
{% endblock %}

View file

@ -1,25 +1,27 @@
{% if form is not defined %}
{% set form = hookable_metadata.context.form %}
{% set form = hookable_metadata.context.form|default(null) %}
{% endif %}
{% if resource is not defined %}
{% set resource = hookable_metadata.context.resource|default(null) %}
{% endif %}
{% form_theme form '@SyliusAdmin/shared/form_theme.html.twig' %}
{% if form is not null %}
{% form_theme form '@SyliusAdmin/shared/form_theme.html.twig' %}
<div class="container-xl" {% if attributes is defined %} {{ attributes }} {% endif %}>
{{ form_start(form, {'attr': {'novalidate': 'novalidate', 'id': form.vars.id}}) }}
<div class="card-body">
{% if hookable_metadata.configuration.method is defined %}
<input type="hidden" name="_method" value="{{ hookable_metadata.configuration.method }}"/>
{% elseif form.vars.data.id|default(null) is not null %}
<input type="hidden" name="_method" value="PUT"/>
{% endif %}
{{ form_errors(form) }}
{{ form_widget(form._token) }}
<div class="container-xl" {% if attributes is defined %} {{ attributes }} {% endif %}>
{{ form_start(form, {'attr': {'novalidate': 'novalidate', 'id': form.vars.id}}) }}
<div class="card-body">
{% if hookable_metadata.configuration.method is defined %}
<input type="hidden" name="_method" value="{{ hookable_metadata.configuration.method }}"/>
{% elseif form.vars.data.id|default(null) is not null %}
<input type="hidden" name="_method" value="PUT"/>
{% endif %}
{{ form_errors(form) }}
{{ form_widget(form._token) }}
{% hook 'form' with { form, resource } %}
{% hook 'form' with { form, resource } %}
</div>
{{ form_end(form, {render_rest: hookable_metadata.configuration.render_rest|default(false)}) }}
</div>
{{ form_end(form, {render_rest: hookable_metadata.configuration.render_rest|default(false)}) }}
</div>
{% endif %}

View file

@ -1,4 +1,4 @@
{% set resources = hookable_metadata.context.resources %}
{% set resources = hookable_metadata.context.resources|default(null) %}
<div class="col-12 col-md-auto ms-auto d-print-none">
<div class="btn-list">

View file

@ -1,14 +1,20 @@
{% import '@SyliusAdmin/shared/helper/button.html.twig' as button %}
{% set configuration = hookable_metadata.context.configuration %}
{% set configuration = hookable_metadata.context.configuration|default(null) %}
{% set fallback_url = app.request.headers.get('referer')|default(sylius_generate_redirect_path(path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
))) %}
{% if app.request.headers.has('referer') %}
{% set fallback_url = app.request.headers.get('referer') %}
{% else %}
{% set fallback_url = configuration is not null ? sylius_generate_redirect_path(path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
)): null %}
{% endif %}
{{ button.cancel(sylius_test_form_attribute('cancel-changes-button')|merge({
text: 'sylius.ui.back'|trans,
class: 'btn',
fallback_url: fallback_url
})) }}
{% if fallback_url is not null %}
{{ button.cancel(sylius_test_form_attribute('cancel-changes-button')|merge({
text: 'sylius.ui.back'|trans,
class: 'btn',
fallback_url: fallback_url
})) }}
{% endif %}

View file

@ -1,5 +1,5 @@
{% import '@SyliusAdmin/shared/helper/button.html.twig' as button %}
{% set form = hookable_metadata.context.form %}
{% set form = hookable_metadata.context.form|default(null) %}
{{ button.primary({ text: 'sylius.ui.create'|trans, type: 'submit', form: form.vars.id }) }}
{{ button.primary({ text: 'sylius.ui.create'|trans, type: 'submit', form: form.vars.id|default(null) }) }}

View file

@ -4,6 +4,6 @@
sylius_test_form_attribute('update-changes-button')|merge({
text: 'sylius.ui.update'|trans,
type: 'submit',
form: hookable_metadata.context.form.vars.id
form: hookable_metadata.context.form.vars.id|default(null)
})
) }}

View file

@ -4,7 +4,7 @@
{% set metadata = hookable_metadata.context.metadata %}
{% set header = hookable_metadata.configuration.title|default(metadata.applicationName~'.ui.'~metadata.pluralName) %}
{% else %}
{% set header = hookable_metadata.configuration.title %}
{% set header = hookable_metadata.configuration.title|default(null) %}
{% endif %}
{% set icon = hookable_metadata.configuration.icon|default(null) %}

View file

@ -4,18 +4,28 @@
{% set metadata = hookable_metadata.context.metadata %}
{% set resource_name = hookable_metadata.configuration.resource_name|default(metadata.applicationName~'.ui.'~metadata.pluralName) %}
{% else %}
{% set resource_name = hookable_metadata.configuration.resource_name %}
{% set resource_name = hookable_metadata.configuration.resource_name|default(null) %}
{% endif %}
{% set configuration = hookable_metadata.context.configuration %}
{% set index_url = path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
)
%}
{% set configuration = hookable_metadata.context.configuration|default(null) %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_name, url: index_url, active: false },
{ name: 'sylius.ui.new'|trans, active: true}
]) }}
{% if configuration is not null %}
{% set index_url = path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
)
%}
{% endif %}
{% if index_url is defined and resource_name is not null %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_name, url: index_url, active: false },
{ name: 'sylius.ui.new'|trans, active: true}
]) }}
{% else %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: 'sylius.ui.new'|trans, active: true}
]) }}
{% endif %}

View file

@ -7,8 +7,8 @@
{% set singular_name = hookable_metadata.configuration.resource_name|default(metadata.applicationName ~ '.ui.' ~ _context.metadata.name) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% else %}
{% set singular_name = hookable_metadata.configuration.resource_name %}
{% set header = hookable_metadata.configuration.header %}
{% set singular_name = hookable_metadata.configuration.resource_name|default(null) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% endif %}
<div class="col-12 col-md-6">

View file

@ -1,9 +1,9 @@
{% import '@SyliusAdmin/shared/helper/table.html.twig' as table %}
{% import '@SyliusAdmin/shared/helper/pagination.html.twig' as pagination %}
{% set resources = hookable_metadata.context.resources %}
{% set data = resources.data %}
{% set definition = resources.definition %}
{% set resources = hookable_metadata.context.resources|default(null) %}
{% set data = resources.data|default([]) %}
{% set definition = resources.definition|default(null) %}
{% if data|length > 0 %}
<div class="card">

View file

@ -1,12 +1,12 @@
{% from "@SyliusAdmin/shared/helper/accordion.html.twig" import accordion %}
{% import "@SyliusAdmin/shared/helper/button.html.twig" as button %}
{% set resources = hookable_metadata.context.resources %}
{% set resources = hookable_metadata.context.resources|default(null) %}
{% set path = path(app.request.attributes.get('_route'), app.request.attributes.all('_route_params')) %}
{% set are_criteria_set = app.request.query.has('criteria') %}
{% if resources.definition.enabledFilters is not empty %}
{% if resources.definition.enabledFilters|default([]) is not empty %}
<div class="position-relative z-1 bg-white mb-5">
{% set content %}
<div class="mb-3">

View file

@ -1,6 +1,6 @@
{% set resources = hookable_metadata.context.resources %}
{% set resources = hookable_metadata.context.resources|default(null) %}
{% if resources.data|length == 0 %}
{% if resources.data is defined and resources.data|length == 0 %}
<div class="card">
<div class="empty">
{% hook 'no_results' %}

View file

@ -6,10 +6,10 @@
{% if title_from_context is not null or resource_metadata is not null %}
{% set title = title_from_context|default(resource_metadata.applicationName~'.ui.'~resource_metadata.pluralName) %}
{% else %}
{% set title = hookable_metadata.configuration.title %}
{% set title = hookable_metadata.configuration.title|default(null) %}
{% endif %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', 'url': path('sylius_admin_dashboard'), 'active': false },
{ name: title, 'active': true },
{ name: title|default('sylius.ui.index'|trans), 'active': true },
]) }}

View file

@ -1,14 +1,16 @@
{% from '@SyliusAdmin/shared/helper/breadcrumbs.html.twig' import breadcrumbs %}
{% set configuration = hookable_metadata.context.configuration %}
{% set resource = hookable_metadata.context.resource %}
{% set metadata = hookable_metadata.context.metadata %}
{% set configuration = hookable_metadata.context.configuration|default(null) %}
{% set resource = hookable_metadata.context.resource|default(null) %}
{% set metadata = hookable_metadata.context.metadata|default(null) %}
{% set resource_index = metadata.applicationName~'.ui.'~metadata.pluralName %}
{% set index_url = path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
) %}
{% if configuration and metadata %}
{% set resource_index = metadata.applicationName~'.ui.'~metadata.pluralName %}
{% set index_url = path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
) %}
{% endif %}
{% set rendered_field_prefix = hookable_metadata.configuration.rendered_field_prefix|default(null) %}
{% set field_name = hookable_metadata.configuration.rendered_field|default('') %}
@ -19,18 +21,26 @@
: {}
%}
{% set resource_show_name = rendered_field|default(resource.translations[app.locale].name|default(resource.code|default(resource.id))) %}
{% set resource_show_name = rendered_field|default(resource.translations[app.locale].name|default(resource.code|default(resource.id|default(null)))) %}
{% if action is defined and action.name is defined %}
{% set show_url = path(configuration.getRouteName('show'), { id: resource.id }) %}
{% set resource_show = { name: resource_show_name, url: show_url} %}
{% else %}
{% set resource_show = { name: resource_show_name, active: true} %}
{% set resource_show = { name: resource_show_name|default('sylius.ui.show'|trans), active: true} %}
{% endif %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_index, url: index_url, active: false },
resource_show,
action
]) }}
{% if resource_index is defined and index_url is defined %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_index, url: index_url, active: false },
resource_show,
action
]) }}
{% else %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
resource_show,
action
]) }}
{% endif %}

View file

@ -7,8 +7,8 @@
{% set singular_name = hookable_metadata.configuration.resource_name|default(metadata.applicationName ~ '.ui.' ~ _context.metadata.name) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% else %}
{% set singular_name = hookable_metadata.configuration.resource_name %}
{% set header = hookable_metadata.configuration.header %}
{% set singular_name = hookable_metadata.configuration.resource_name|default(null) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% endif %}
<div class="col-12 col-md-6">

View file

@ -1,32 +1,44 @@
{% from '@SyliusAdmin/shared/helper/breadcrumbs.html.twig' import breadcrumbs %}
{% set configuration = hookable_metadata.context.configuration %}
{% set resource = hookable_metadata.context.resource %}
{% set metadata = hookable_metadata.context.metadata %}
{% set configuration = hookable_metadata.context.configuration|default(null) %}
{% set resource = hookable_metadata.context.resource is defined ? hookable_metadata.context.resource : null %}
{% set metadata = hookable_metadata.context.metadata|default(null) %}
{% set resource_index = metadata.applicationName~'.ui.'~metadata.pluralName %}
{% if metadata is not null %}
{% set resource_index = metadata.applicationName~'.ui.'~metadata.pluralName %}
{% endif %}
{% if configuration is not null %}
{% set index_url = path(
configuration.vars.index.route.name|default(configuration.getRouteName('index')),
configuration.vars.index.route.parameters|default(configuration.vars.route.parameters|default({}))
) %}
{% endif %}
{% set rendered_field_prefix = hookable_metadata.configuration.rendered_field_prefix|default(null) %}
{% set field_name = hookable_metadata.configuration.rendered_field|default('') %}
{% set rendered_field = rendered_field_prefix ~ (attribute(resource, field_name) is defined ? attribute(resource, field_name) : null) %}
{% set resource_edit_name = rendered_field|default(resource.translations[app.locale].name|default(resource.code|default(resource.id))) %}
{% set show_route_name = configuration.vars.index.route.name|default(configuration.getRouteName('show')) %}
{% set resource_edit_name = rendered_field|default(resource.translations[app.locale].name|default(resource.code|default(resource.id|default(null)))) %}
{% set show_route_name = configuration.vars.index.route.name|default(configuration.getRouteName('show')|default(null)) %}
{% if sylius_route_exists(show_route_name) %}
{% if show_route_name is not null and sylius_route_exists(show_route_name) %}
{% set show_url = path(show_route_name, { id: resource.id }) %}
{% set resource_edit = { name: resource_edit_name, url: show_url} %}
{% else %}
{% set resource_edit = { name: resource_edit_name, active: true} %}
{% endif %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_index, url: index_url, active: false },
resource_edit,
{ name: 'sylius.ui.edit'|trans, active: false }
]) }}
{% if resource_index is defined %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: resource_index, url: index_url, active: false },
resource_edit,
{ name: 'sylius.ui.edit'|trans, active: false }
]) }}
{% else %}
{{ breadcrumbs([
{ name: 'sylius.ui.dashboard', url: path('sylius_admin_dashboard'), active: false },
{ name: 'sylius.ui.edit'|trans, active: false }
]) }}
{% endif %}

View file

@ -1,7 +1,7 @@
{% set configuration = hookable_metadata.context.configuration %}
{% set show_route_name = configuration.vars.index.route.name|default(configuration.getRouteName('show')) %}
{% set configuration = hookable_metadata.context.configuration|default(null) %}
{% set show_route_name = configuration.vars.index.route.name|default(configuration.getRouteName('show')|default(null)) %}
{% if sylius_route_exists(show_route_name) %}
{% if show_route_name is not null and sylius_route_exists(show_route_name) %}
{% set resource_id = hookable_metadata.context.resource.id %}
{% set resource_name = hookable_metadata.context.metadata.name %}

View file

@ -7,8 +7,8 @@
{% set singular_name = hookable_metadata.configuration.resource_name|default(metadata.applicationName ~ '.ui.' ~ _context.metadata.name) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% else %}
{% set singular_name = hookable_metadata.configuration.resource_name %}
{% set header = hookable_metadata.configuration.header %}
{% set singular_name = hookable_metadata.configuration.resource_name|default(null) %}
{% set header = hookable_metadata.configuration.header|default(null) %}
{% endif %}
<div class="col-12 col-md-6">

View file

@ -1,3 +1,7 @@
{% if data is not null %}
<span class="fw-medium">{{ data }}</span>
<div class="fw-medium">{{ data }}</div>
{% else %}
<span class="text-danger" {{ sylius_test_html_attribute('missing-translation-paragraph') }}>
{{ ux_icon('tabler:exclamation-circle') }} <i>{{ 'sylius.ui.missing_translation'|trans }}</i>
</span>
{% endif %}

View file

@ -24,8 +24,8 @@
{% macro row(grid, definition, row) %}
{% import '@SyliusAdmin/shared/helper/dropdown.html.twig' as dropdown %}
<tr class="item" {{ sylius_test_html_attribute('row') }} {{ sylius_test_html_attribute('resource-id', row.id) }}>
{% if definition.actionGroups.bulk is defined and definition.getEnabledActions('bulk')|length > 0 %}
<tr class="item" {{ sylius_test_html_attribute('row') }} {{ sylius_test_html_attribute('resource-id', row.id|default(null)) }}>
{% if definition.actionGroups.bulk is defined and definition.getEnabledActions('bulk')|length > 0 and row.id is defined %}
<td class="text-center"><input data-check-all-group="index" class="form-check-input" type="checkbox" value="{{ row.id }}" /></td>
{% endif %}
{% for field in definition.enabledFields|sylius_sort_by('position') %}

View file

@ -0,0 +1,25 @@
{% extends '@SyliusAdmin/shared/layout/base.html.twig' %}
{% set prefixes = configuration.vars.hook_prefix is defined ? [configuration.vars.hook_prefix] %}
{% set prefixes = prefixes|default({})|merge([
'sylius_admin.%resource_name%'|replace({'%resource_name%': resource_name|default(metadata.name)}),
'sylius_admin.common'
]) %}
{% set header = metadata.applicationName ~ '.ui.' ~ metadata.pluralName %}
{% block title %}{{ 'sylius.ui.shipment'|trans ~' '~ shipment.id ~ ' | ' }}{{ parent() }}{% endblock %}
{% block body %}
{% hook 'show' with { _prefixes: prefixes, resource, metadata, configuration } %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
{% hook 'show#stylesheets' with { _prefixes: prefixes } %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{% hook 'show#javascripts' with { _prefixes: prefixes } %}
{% endblock %}

View file

@ -28,7 +28,7 @@ final readonly class AddressMapper implements AddressMapperInterface
$currentAddress->setPostcode($targetAddress->getPostcode());
$currentAddress->setPhoneNumber($targetAddress->getPhoneNumber());
if (null !== $targetAddress->getProvinceCode()) {
if (null !== $targetAddress->getProvinceCode() || null !== $targetAddress->getProvinceName()) {
$currentAddress->setProvinceCode($targetAddress->getProvinceCode());
$currentAddress->setProvinceName($targetAddress->getProvinceName());
}

View file

@ -60,6 +60,7 @@ api_platform:
Sylius\Component\Payment\Exception\PaymentRequestNotFoundException: 404
Sylius\Bundle\ApiBundle\Exception\PaymentNotFoundException: 404
Sylius\Bundle\ApiBundle\Exception\PaymentMethodNotFoundException: 404
Sylius\Bundle\ApiBundle\Exception\OrderItemNotFoundException: 404
Sylius\Bundle\ApiBundle\Exception\StateMachineTransitionFailedException: 422
Sylius\Bundle\ApiBundle\Exception\TranslationInDefaultLocaleCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\TranslationLocaleMismatchException: 422

View file

@ -28,6 +28,22 @@
<constraint name="Sylius\Bundle\ApiBundle\Validator\Constraints\UpdateCartEmailNotAllowed">
<option name="groups">sylius</option>
</constraint>
<property name="email">
<constraint name="Length">
<option name="max">254</option>
<option name="maxMessage">sylius.customer.email.max</option>
<option name="groups">
<value>sylius</value>
</option>
</constraint>
<constraint name="Email">
<option name="message">sylius.customer.email.invalid</option>
<option name="mode">strict</option>
<option name="groups">
<value>sylius</value>
</option>
</constraint>
</property>
<property name="billingAddress">
<constraint name="Valid" />
</property>

View file

@ -50,6 +50,7 @@ final class TranslatableDenormalizer implements DenormalizerInterface, Denormali
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return
is_array($data) &&
Request::METHOD_POST === ($context[ContextKeys::HTTP_REQUEST_METHOD_TYPE] ?? null) &&
!isset($context[self::getAlreadyCalledKey($type)]) &&
is_a($type, TranslatableInterface::class, true)

View file

@ -16,6 +16,7 @@ namespace Sylius\Bundle\ApiBundle\Validator\Constraints;
use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
@ -39,12 +40,18 @@ final class OrderPaymentMethodEligibilityValidator extends ConstraintValidator
$order = $this->orderRepository->findOneBy(['tokenValue' => $value->orderTokenValue]);
Assert::notNull($order);
$channel = $order->getChannel();
Assert::notNull($channel);
/** @var PaymentInterface $payment */
foreach ($order->getPayments() as $payment) {
if (!$payment->getMethod()->isEnabled()) {
/** @var PaymentMethodInterface $paymentMethod */
$paymentMethod = $payment->getMethod();
if (!$paymentMethod->isEnabled() || !$paymentMethod->hasChannel($channel)) {
$this->context->addViolation(
$constraint->message,
['%paymentMethodName%' => $payment->getMethod()->getName()],
['%paymentMethodName%' => $paymentMethod->getName()],
);
}
}

View file

@ -11,7 +11,7 @@
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Tests\Doctrine\ORM\Filter;
namespace Tests\Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface;

View file

@ -11,7 +11,7 @@
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Tests\Doctrine\ORM\Filter;
namespace Tests\Sylius\Bundle\ApiBundle\Doctrine\ORM\Filter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\EntityManagerInterface;

View file

@ -75,7 +75,7 @@ final class AddressMapperTest extends TestCase
$targetAddressMock->expects(self::once())->method('getPostcode')->willReturn('00000');
$targetAddressMock->expects(self::once())->method('getPhoneNumber')->willReturn('123456789');
$targetAddressMock->expects(self::once())->method('getProvinceCode')->willReturn(null);
$targetAddressMock->expects(self::never())->method('getProvinceName')->willReturn('east');
$targetAddressMock->expects(self::once())->method('getProvinceName')->willReturn(null);
$currentAddressMock->expects(self::once())->method('setFirstName')->with('John');
$currentAddressMock->expects(self::once())->method('setLastName')->with('Doe');
$currentAddressMock->expects(self::once())->method('setCompany')->with('CocaCola');
@ -84,8 +84,39 @@ final class AddressMapperTest extends TestCase
$currentAddressMock->expects(self::once())->method('setCity')->with('New York');
$currentAddressMock->expects(self::once())->method('setPostcode')->with('00000');
$currentAddressMock->expects(self::once())->method('setPhoneNumber')->with('123456789');
$currentAddressMock->expects(self::never())->method('setProvinceCode')->with('999');
$currentAddressMock->expects(self::never())->method('setProvinceName')->with('east');
$currentAddressMock->expects(self::never())->method('setProvinceCode');
$currentAddressMock->expects(self::never())->method('setProvinceName');
self::assertSame($currentAddressMock, $this->addressMapper->mapExisting($currentAddressMock, $targetAddressMock));
}
public function testUpdatesAnAddressWithProvinceNameOnly(): void
{
/** @var AddressInterface|MockObject $currentAddressMock */
$currentAddressMock = $this->createMock(AddressInterface::class);
/** @var AddressInterface|MockObject $targetAddressMock */
$targetAddressMock = $this->createMock(AddressInterface::class);
$targetAddressMock->expects(self::once())->method('getFirstName')->willReturn('John');
$targetAddressMock->expects(self::once())->method('getLastName')->willReturn('Doe');
$targetAddressMock->expects(self::once())->method('getCompany')->willReturn('CocaCola');
$targetAddressMock->expects(self::once())->method('getStreet')->willReturn('Green Avenue');
$targetAddressMock->expects(self::once())->method('getCountryCode')->willReturn('US');
$targetAddressMock->expects(self::once())->method('getCity')->willReturn('New York');
$targetAddressMock->expects(self::once())->method('getPostcode')->willReturn('00000');
$targetAddressMock->expects(self::once())->method('getPhoneNumber')->willReturn('123456789');
$targetAddressMock->expects(self::atLeastOnce())->method('getProvinceCode')->willReturn(null);
$targetAddressMock->expects(self::atLeastOnce())->method('getProvinceName')->willReturn('East');
$currentAddressMock->expects(self::once())->method('setFirstName')->with('John');
$currentAddressMock->expects(self::once())->method('setLastName')->with('Doe');
$currentAddressMock->expects(self::once())->method('setCompany')->with('CocaCola');
$currentAddressMock->expects(self::once())->method('setStreet')->with('Green Avenue');
$currentAddressMock->expects(self::once())->method('setCountryCode')->with('US');
$currentAddressMock->expects(self::once())->method('setCity')->with('New York');
$currentAddressMock->expects(self::once())->method('setPostcode')->with('00000');
$currentAddressMock->expects(self::once())->method('setPhoneNumber')->with('123456789');
$currentAddressMock->expects(self::once())->method('setProvinceCode')->with(null);
$currentAddressMock->expects(self::once())->method('setProvinceName')->with('East');
self::assertSame($currentAddressMock, $this->addressMapper->mapExisting($currentAddressMock, $targetAddressMock));
}

View file

@ -40,6 +40,17 @@ final class TranslatableDenormalizerTest extends TestCase
public function testOnlySupportsTranslatableResource(): void
{
self::assertFalse(
$this->translatableDenormalizer->supportsDenormalization(
'iri-string',
TranslatableInterface::class,
null,
[
ContextKeys::HTTP_REQUEST_METHOD_TYPE => 'POST',
],
),
);
self::assertFalse(
$this->translatableDenormalizer->supportsDenormalization(
[],

View file

@ -20,6 +20,7 @@ use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Bundle\ApiBundle\Command\Checkout\UpdateCart;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentMethodEligibility;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderPaymentMethodEligibilityValidator;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
@ -79,6 +80,8 @@ final class OrderPaymentMethodEligibilityValidatorTest extends TestCase
$paymentMethodMock = $this->createMock(PaymentMethodInterface::class);
/** @var ExecutionContextInterface|MockObject $executionContextMock */
$executionContextMock = $this->createMock(ExecutionContextInterface::class);
/** @var ChannelInterface|MockObject $channelMock */
$channelMock = $this->createMock(ChannelInterface::class);
$this->orderPaymentMethodEligibilityValidator->initialize($executionContextMock);
@ -91,13 +94,14 @@ final class OrderPaymentMethodEligibilityValidatorTest extends TestCase
->with(['tokenValue' => 'token'])
->willReturn($orderMock);
$orderMock->method('getChannel')->willReturn($channelMock);
$orderMock
->expects(self::once())
->method('getPayments')
->willReturn(new ArrayCollection([$paymentMock]));
$paymentMock
->expects($this->exactly(2))
->method('getMethod')
->willReturn($paymentMethodMock);
@ -122,7 +126,7 @@ final class OrderPaymentMethodEligibilityValidatorTest extends TestCase
$this->orderPaymentMethodEligibilityValidator->validate($value, $constraint);
}
public function testDoesNotAddViolationIfPaymentIsAvailable(): void
public function testAddsViolationIfPaymentIsNotAvailableInChannel(): void
{
/** @var OrderInterface|MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
@ -132,6 +136,8 @@ final class OrderPaymentMethodEligibilityValidatorTest extends TestCase
$paymentMethodMock = $this->createMock(PaymentMethodInterface::class);
/** @var ExecutionContextInterface|MockObject $executionContextMock */
$executionContextMock = $this->createMock(ExecutionContextInterface::class);
/** @var ChannelInterface|MockObject $channelMock */
$channelMock = $this->createMock(ChannelInterface::class);
$this->orderPaymentMethodEligibilityValidator->initialize($executionContextMock);
@ -144,20 +150,69 @@ final class OrderPaymentMethodEligibilityValidatorTest extends TestCase
->with(['tokenValue' => 'token'])
->willReturn($orderMock);
$orderMock->method('getChannel')->willReturn($channelMock);
$orderMock
->expects(self::once())
->method('getPayments')
->willReturn(new ArrayCollection([$paymentMock]));
$paymentMock
->expects(self::once())
->method('getMethod')
->willReturn($paymentMethodMock);
$paymentMethodMock
$paymentMethodMock->method('isEnabled')->willReturn(true);
$paymentMethodMock->method('hasChannel')->with($channelMock)->willReturn(false);
$paymentMethodMock->method('getName')->willReturn('bank transfer');
$executionContextMock
->expects(self::once())
->method('isEnabled')
->willReturn(true);
->method('addViolation')
->with(
'sylius.order.payment_method_eligibility',
['%paymentMethodName%' => 'bank transfer'],
);
$this->orderPaymentMethodEligibilityValidator->validate($value, $constraint);
}
public function testDoesNotAddViolationIfPaymentIsAvailable(): void
{
/** @var OrderInterface|MockObject $orderMock */
$orderMock = $this->createMock(OrderInterface::class);
/** @var PaymentInterface|MockObject $paymentMock */
$paymentMock = $this->createMock(PaymentInterface::class);
/** @var PaymentMethodInterface|MockObject $paymentMethodMock */
$paymentMethodMock = $this->createMock(PaymentMethodInterface::class);
/** @var ExecutionContextInterface|MockObject $executionContextMock */
$executionContextMock = $this->createMock(ExecutionContextInterface::class);
/** @var ChannelInterface|MockObject $channelMock */
$channelMock = $this->createMock(ChannelInterface::class);
$this->orderPaymentMethodEligibilityValidator->initialize($executionContextMock);
$constraint = new OrderPaymentMethodEligibility();
$value = new CompleteOrder(orderTokenValue: 'token');
$this->orderRepository
->expects(self::once())
->method('findOneBy')
->with(['tokenValue' => 'token'])
->willReturn($orderMock);
$orderMock->method('getChannel')->willReturn($channelMock);
$orderMock
->expects(self::once())
->method('getPayments')
->willReturn(new ArrayCollection([$paymentMock]));
$paymentMock
->method('getMethod')
->willReturn($paymentMethodMock);
$paymentMethodMock->method('isEnabled')->willReturn(true);
$paymentMethodMock->method('hasChannel')->with($channelMock)->willReturn(true);
$executionContextMock
->expects(self::never())

View file

@ -11,12 +11,12 @@
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Tests\Validator\Constraints;
namespace Tests\Sylius\Bundle\ApiBundle\Validator\Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Command\OrderTokenValueAwareInterface;
use Sylius\Bundle\ApiBundle\Command\Checkout\CompleteOrder;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderProductEligibility;
use Sylius\Bundle\ApiBundle\Validator\Constraints\OrderProductEligibilityValidator;
use Sylius\Component\Core\Model\ChannelInterface;
@ -48,8 +48,7 @@ final class OrderProductEligibilityValidatorTest extends TestCase
$orderToken = 'ORDER_TOKEN';
$variantName = 'Variant 1';
$command = $this->createMock(OrderTokenValueAwareInterface::class);
$command->method('getOrderTokenValue')->willReturn($orderToken);
$command = new CompleteOrder($orderToken);
$variant = $this->createMock(ProductVariantInterface::class);
$variant->method('isEnabled')->willReturn(false);
@ -86,8 +85,7 @@ final class OrderProductEligibilityValidatorTest extends TestCase
$orderToken = 'ORDER_TOKEN';
$productName = 'Product 2';
$command = $this->createMock(OrderTokenValueAwareInterface::class);
$command->method('getOrderTokenValue')->willReturn($orderToken);
$command = new CompleteOrder($orderToken);
$variant = $this->createMock(ProductVariantInterface::class);
$variant->method('isEnabled')->willReturn(true);
@ -124,8 +122,7 @@ final class OrderProductEligibilityValidatorTest extends TestCase
$orderToken = 'ORDER_TOKEN';
$productName = 'Product 3';
$command = $this->createMock(OrderTokenValueAwareInterface::class);
$command->method('getOrderTokenValue')->willReturn($orderToken);
$command = new CompleteOrder($orderToken);
$variant = $this->createMock(ProductVariantInterface::class);
$variant->method('isEnabled')->willReturn(true);
@ -161,8 +158,7 @@ final class OrderProductEligibilityValidatorTest extends TestCase
{
$orderToken = 'ORDER_TOKEN';
$command = $this->createMock(OrderTokenValueAwareInterface::class);
$command->method('getOrderTokenValue')->willReturn($orderToken);
$command = new CompleteOrder($orderToken);
$variant = $this->createMock(ProductVariantInterface::class);
$variant->method('isEnabled')->willReturn(true);

View file

@ -159,6 +159,7 @@ final class Configuration implements ConfigurationInterface
->booleanNode('plugins')->defaultTrue()->end()
->scalarNode('salt')->defaultNull()->end()
->scalarNode('url')->defaultValue('https://prism.sylius.com/telemetry')->end()
->integerNode('query_timeout')->defaultValue(60000)->min(1000)->end()
->end()
->end()
->end()

View file

@ -219,7 +219,7 @@ final class SyliusCoreExtension extends AbstractResourceExtension implements Pre
}
/**
* @param array{enabled: bool, business: bool, technical: bool, plugins: bool, salt: string|null, url: string} $telemetryConfig
* @param array{enabled: bool, business: bool, technical: bool, plugins: bool, salt: string|null, url: string, query_timeout: int} $telemetryConfig
*/
private function configureTelemetry(array $telemetryConfig, ContainerBuilder $container, XmlFileLoader $loader): void
{
@ -228,6 +228,12 @@ final class SyliusCoreExtension extends AbstractResourceExtension implements Pre
$container->setParameter('sylius_core.telemetry.enabled', $telemetryConfig['enabled']);
$container->setParameter('sylius_core.telemetry.salt', $telemetrySalt);
$container->setParameter('sylius_core.telemetry.url', $telemetryConfig['url']);
$queryTimeout = $telemetryConfig['query_timeout'];
$envQueryTimeout = $_ENV['SYLIUS_TELEMETRY_QUERY_TIMEOUT'] ?? $_SERVER['SYLIUS_TELEMETRY_QUERY_TIMEOUT'] ?? getenv('SYLIUS_TELEMETRY_QUERY_TIMEOUT');
if ($envQueryTimeout !== false) {
$queryTimeout = max(1000, (int) $envQueryTimeout);
}
$container->setParameter('sylius_core.telemetry.query_timeout', $queryTimeout);
$businessEnabled = $this->isTelemetryCategoryEnabled($telemetryConfig['business'], 'SYLIUS_TELEMETRY_BUSINESS');
$technicalEnabled = $this->isTelemetryCategoryEnabled($telemetryConfig['technical'], 'SYLIUS_TELEMETRY_TECHNICAL');

View file

@ -31,6 +31,7 @@ class PaymentMethodRepository extends BasePaymentMethodRepository implements Pay
public function createListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->addSelect('translation')
->leftJoin('o.gatewayConfig', 'gatewayConfig')
->leftJoin('o.translations', 'translation', 'WITH', 'translation.locale = :locale')
->setParameter('locale', $locale)

View file

@ -30,7 +30,7 @@ class ProductTaxonRepository extends EntityRepository implements ProductTaxonRep
{
return $this->createQueryBuilder('o')
->innerJoin('o.product', 'product')
->innerJoin('product.translations', 'translations', Join::WITH, 'translations.locale = :locale')
->leftJoin('product.translations', 'translations', Join::WITH, 'translations.locale = :locale')
->andWhere('o.taxon = :taxonId')
->setParameter('taxonId', $taxonId)
->setParameter('locale', $locale)

View file

@ -33,7 +33,10 @@ class ProductVariantRepository extends BaseProductVariantRepository implements P
public function createInventoryListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->addSelect('translation', 'product', 'productTranslation')
->leftJoin('o.translations', 'translation', 'WITH', 'translation.locale = :locale')
->innerJoin('o.product', 'product')
->leftJoin('product.translations', 'productTranslation', 'WITH', 'productTranslation.locale = :locale')
->andWhere('o.tracked = :tracked')
->setParameter('locale', $locale)
->setParameter('tracked', true)

View file

@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\EventListener\Workflow\OrderCheckout;
use Sylius\Component\Core\Checker\OrderPaymentMethodSelectionAvailabilityCheckerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\OrderCheckoutStates;
use Symfony\Component\Workflow\Event\GuardEvent;
use Webmozart\Assert\Assert;
final readonly class BlockSelectPaymentFromPaymentSkippedListener
{
public function __construct(
private OrderPaymentMethodSelectionAvailabilityCheckerInterface $orderPaymentMethodSelectionAvailabilityChecker,
) {
}
public function __invoke(GuardEvent $event): void
{
if (!$event->getMarking()->has(OrderCheckoutStates::STATE_PAYMENT_SKIPPED)) {
return;
}
$order = $event->getSubject();
Assert::isInstanceOf($order, OrderInterface::class);
if (!$this->orderPaymentMethodSelectionAvailabilityChecker->isPaymentMethodSelectionAvailable($order)) {
$event->setBlocked(true, 'Payment method selection is not allowed for an order with zero total.');
}
}
}

View file

@ -26,12 +26,18 @@ winzou_state_machine:
from: [shipping_selected, shipping_skipped]
to: payment_skipped
select_payment:
from: [payment_selected, shipping_skipped, shipping_selected]
from: [payment_selected, payment_skipped, shipping_skipped, shipping_selected]
to: payment_selected
complete:
from: [payment_selected, payment_skipped]
to: completed
callbacks:
guard:
sylius_block_select_payment_from_payment_skipped:
on: ["select_payment"]
from: ["payment_skipped"]
do: ["@sylius.checker.order_payment_method_selection_availability", "isPaymentMethodSelectionAvailable"]
args: ["object"]
after:
sylius_process_cart:
on: ["select_shipping", "address", "select_payment", "skip_shipping", "skip_payment"]

View file

@ -45,6 +45,7 @@ framework:
!php/const Sylius\Component\Core\OrderCheckoutTransitions::TRANSITION_SELECT_PAYMENT:
from:
- !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_PAYMENT_SELECTED
- !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_PAYMENT_SKIPPED
- !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_SHIPPING_SKIPPED
- !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_SHIPPING_SELECTED
to: !php/const Sylius\Component\Core\OrderCheckoutStates::STATE_PAYMENT_SELECTED

View file

@ -27,6 +27,9 @@
</service>
<service id="Sylius\Component\Core\Checker\OrderPaymentMethodSelectionRequirementCheckerInterface" alias="sylius.checker.order_payment_method_selection_requirement" />
<service id="sylius.checker.order_payment_method_selection_availability" class="Sylius\Component\Core\Checker\OrderPaymentMethodSelectionAvailabilityChecker" public="true" />
<service id="Sylius\Component\Core\Checker\OrderPaymentMethodSelectionAvailabilityCheckerInterface" alias="sylius.checker.order_payment_method_selection_availability" />
<service id="sylius.checker.cli_context" class="Sylius\Component\Core\Checker\CLIContextChecker">
<argument type="service" id="request_stack" />
</service>

View file

@ -50,5 +50,10 @@
<argument type="service" id="sylius.state_resolver.order_shipping" />
<tag name="kernel.event_listener" event="workflow.sylius_order_checkout.completed.complete" priority="100"/>
</service>
<service id="sylius.listener.workflow.order_checkout.block_select_payment_from_payment_skipped" class="Sylius\Bundle\CoreBundle\EventListener\Workflow\OrderCheckout\BlockSelectPaymentFromPaymentSkippedListener">
<argument type="service" id="sylius.checker.order_payment_method_selection_availability" />
<tag name="kernel.event_listener" event="workflow.sylius_order_checkout.guard.select_payment" />
</service>
</services>
</container>

View file

@ -27,25 +27,25 @@
<tag name="kernel.event_listener" event="sylius.customer.post_register" method="sendUserRegistrationEmail" />
</service>
<service id="sylius.mailer.contact_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ContactEmailManager">
<service id="sylius.mailer.contact_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ContactEmailManager" public="true">
<argument type="service" id="sylius.email_sender" />
</service>
<service id="Sylius\Bundle\CoreBundle\Mailer\ContactEmailManagerInterface" alias="sylius.mailer.contact_email_manager" />
<service id="Sylius\Bundle\CoreBundle\Mailer\ContactEmailManagerInterface" alias="sylius.mailer.contact_email_manager" public="true" />
<service id="sylius.mailer.order_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\OrderEmailManager">
<service id="sylius.mailer.order_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\OrderEmailManager" public="true">
<argument type="service" id="sylius.email_sender" />
</service>
<service id="Sylius\Bundle\CoreBundle\Mailer\OrderEmailManagerInterface" alias="sylius.mailer.order_email_manager" />
<service id="Sylius\Bundle\CoreBundle\Mailer\OrderEmailManagerInterface" alias="sylius.mailer.order_email_manager" public="true" />
<service id="sylius.mailer.shipment_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ShipmentEmailManager">
<service id="sylius.mailer.shipment_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ShipmentEmailManager" public="true">
<argument type="service" id="sylius.email_sender" />
</service>
<service id="Sylius\Bundle\CoreBundle\Mailer\ShipmentEmailManagerInterface" alias="sylius.mailer.shipment_email_manager" />
<service id="Sylius\Bundle\CoreBundle\Mailer\ShipmentEmailManagerInterface" alias="sylius.mailer.shipment_email_manager" public="true" />
<service id="sylius.mailer.reset_password_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ResetPasswordEmailManager">
<service id="sylius.mailer.reset_password_email_manager" class="Sylius\Bundle\CoreBundle\Mailer\ResetPasswordEmailManager" public="true">
<argument type="service" id="sylius.email_sender" />
</service>
<service id="Sylius\Bundle\CoreBundle\Mailer\ResetPasswordEmailManagerInterface" alias="sylius.mailer.reset_password_email_manager" />
<service id="Sylius\Bundle\CoreBundle\Mailer\ResetPasswordEmailManagerInterface" alias="sylius.mailer.reset_password_email_manager" public="true" />
<service
id="sylius.mailer.account_registration_email_manager"

View file

@ -13,6 +13,13 @@
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service
id="sylius.telemetry.query.timeout_runner"
class="Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner"
>
<argument>%sylius_core.telemetry.query_timeout%</argument>
</service>
<service
id="sylius.telemetry.installation_id_generator"
class="Sylius\Component\Core\Telemetry\Generator\InstallationIdGenerator"
@ -82,6 +89,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<argument type="string">%locale%</argument>
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -92,6 +100,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -101,6 +110,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -110,6 +120,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -119,6 +130,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -128,6 +140,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -137,6 +150,7 @@
lazy="Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface"
>
<argument type="service" id="doctrine.dbal.default_connection" />
<argument type="service" id="sylius.telemetry.query.timeout_runner" />
<tag name="sylius.telemetry.business_data_provider" />
</service>
@ -183,6 +197,7 @@
lazy="true"
>
<argument type="service" id="sylius.telemetry.send_manager" />
<argument type="service" id="sylius.telemetry.cache" />
<argument>%sylius.security.api_admin_route%</argument>
<tag name="kernel.event_listener" event="kernel.terminate" method="onAdminAccess" priority="-1024" />
</service>

View file

@ -40,15 +40,15 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
final class SyliusCoreBundle extends AbstractResourceBundle
{
public const VERSION = '2.1.13-DEV';
public const VERSION = '2.1.15-DEV';
public const VERSION_ID = '20113';
public const VERSION_ID = '20115';
public const MAJOR_VERSION = '2';
public const MINOR_VERSION = '1';
public const RELEASE_VERSION = '13';
public const RELEASE_VERSION = '15';
public const EXTRA_VERSION = 'DEV';

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Telemetry\EventListener;
use Sylius\Component\Core\Telemetry\Cache\TelemetryCacheInterface;
use Sylius\Component\Core\Telemetry\TelemetrySendManagerInterface;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
@ -21,6 +22,7 @@ class TelemetryListener
{
public function __construct(
private TelemetrySendManagerInterface $telemetrySendManager,
private TelemetryCacheInterface $telemetryCache,
private string $adminApiPrefix,
) {
}
@ -33,7 +35,12 @@ class TelemetryListener
return;
}
if ($this->telemetryCache->wasRecentlyTriggered()) {
return;
}
try {
$this->telemetryCache->markAsRecentlyTriggered();
$this->telemetrySendManager->sendIfNeeded($request);
} catch (\Throwable) {
}

View file

@ -15,29 +15,34 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CountriesData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
/** @internal */
final class CountriesDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
{
try {
$channelsWithCountries = $this->connection->fetchAllAssociative(
$channelsWithCountries = $this->queryTimeoutRunner->fetchAllAssociative(
$this->connection,
'SELECT c.id as channel_id, co.code as country_code
FROM sylius_channel c
LEFT JOIN sylius_channel_countries cc ON cc.channel_id = c.id
LEFT JOIN sylius_country co ON co.id = cc.country_id AND co.enabled = 1
WHERE c.enabled = 1',
LEFT JOIN sylius_country co ON co.id = cc.country_id AND co.enabled = true
WHERE c.enabled = true',
);
$allCountries = $this->connection->fetchFirstColumn(
'SELECT code FROM sylius_country WHERE enabled = 1',
$allCountries = $this->queryTimeoutRunner->fetchFirstColumn(
$this->connection,
'SELECT code FROM sylius_country WHERE enabled = true',
);
$countriesByChannel = [];

View file

@ -15,20 +15,26 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CurrenciesData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
/** @internal */
final class CurrenciesDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
{
try {
$currencies = $this->connection->fetchFirstColumn('SELECT code FROM sylius_currency');
$currencies = $this->queryTimeoutRunner->fetchFirstColumn(
$this->connection,
'SELECT code FROM sylius_currency',
);
return new CurrenciesData($currencies);
} catch (\Throwable) {

View file

@ -15,6 +15,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\LocalesData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
@ -23,6 +24,7 @@ final class LocalesDataProvider implements DataProviderInterface
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
private string $defaultLocale,
) {
}
@ -30,11 +32,15 @@ final class LocalesDataProvider implements DataProviderInterface
public function provide(): TelemetryDataInterface
{
try {
$locales = $this->connection->fetchFirstColumn('SELECT code FROM sylius_locale');
$channelDefaultLocales = $this->connection->fetchFirstColumn(
$locales = $this->queryTimeoutRunner->fetchFirstColumn(
$this->connection,
'SELECT code FROM sylius_locale',
);
$channelDefaultLocales = $this->queryTimeoutRunner->fetchFirstColumn(
$this->connection,
'SELECT DISTINCT l.code FROM sylius_locale l
INNER JOIN sylius_channel c ON c.default_locale_id = l.id
WHERE c.enabled = 1',
WHERE c.enabled = true',
);
return new LocalesData($locales, $channelDefaultLocales, $this->defaultLocale);

View file

@ -15,6 +15,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\MetricsCountsData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\OrderCheckoutStates;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
@ -23,21 +24,24 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
/** @internal */
final class MetricsCountsDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
{
try {
$counts = $this->connection->fetchAssociative(
$counts = $this->queryTimeoutRunner->fetchAssociative(
$this->connection,
'SELECT
(SELECT COUNT(id) FROM sylius_customer) as customers_count,
(SELECT COUNT(id) FROM sylius_product) as products_count,
(SELECT COUNT(id) FROM sylius_product_variant) as product_variants_count,
(SELECT COUNT(id) FROM sylius_product_variant WHERE shipping_required = 0) as virtual_product_variants_count,
(SELECT COUNT(id) FROM sylius_product_variant WHERE shipping_required = false) as virtual_product_variants_count,
(SELECT COUNT(id) FROM sylius_order WHERE checkout_state = :completedState) as orders_count,
(SELECT COUNT(id) FROM sylius_channel WHERE enabled = 1) as channels_count',
(SELECT COUNT(id) FROM sylius_channel WHERE enabled = true) as channels_count',
[
'completedState' => OrderCheckoutStates::STATE_COMPLETED,
],

View file

@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrderMetricsData;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrdersBusinessData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\OrderCheckoutStates;
use Sylius\Component\Core\OrderPaymentStates;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
@ -25,8 +26,10 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
/** @internal */
final class OrdersBusinessDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
@ -46,7 +49,8 @@ final class OrdersBusinessDataProvider implements DataProviderInterface
{
$oneMonthAgo = (new \DateTimeImmutable('-1 month'))->format('Y-m-d H:i:s');
return $this->connection->fetchAllAssociative(
return $this->queryTimeoutRunner->fetchAllAssociative(
$this->connection,
'SELECT
o.currency_code,
COUNT(o.id) as order_count,

View file

@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentMethodsData;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentProviderData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
@ -23,14 +24,17 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
/** @internal */
final class PaymentMethodsDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
{
try {
$results = $this->connection->fetchAllAssociative(
$results = $this->queryTimeoutRunner->fetchAllAssociative(
$this->connection,
'SELECT pm.code, gc.factory_name, pm.is_enabled, COUNT(p.id) as payments_count
FROM sylius_payment_method pm
JOIN sylius_gateway_config gc ON pm.gateway_config_id = gc.id

View file

@ -16,6 +16,7 @@ namespace Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingMethodsData;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingProviderData;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
use Sylius\Component\Core\Telemetry\DataProvider\DataProviderInterface;
use Sylius\Component\Core\Telemetry\DTO\TelemetryDataInterface;
use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
@ -23,14 +24,17 @@ use Sylius\Component\Core\Telemetry\Mapper\ValueRangeMapper;
/** @internal */
final class ShippingMethodsDataProvider implements DataProviderInterface
{
public function __construct(private Connection $connection)
{
public function __construct(
private Connection $connection,
private TimeoutRunner $queryTimeoutRunner,
) {
}
public function provide(): TelemetryDataInterface
{
try {
$results = $this->connection->fetchAllAssociative(
$results = $this->queryTimeoutRunner->fetchAllAssociative(
$this->connection,
'SELECT sm.code, sm.calculator, sm.is_enabled, COUNT(s.id) as shipments_count
FROM sylius_shipping_method sm
LEFT JOIN sylius_shipment s ON s.method_id = sm.id

View file

@ -0,0 +1,122 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Telemetry\Query;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
/** @internal */
final class TimeoutRunner
{
public function __construct(
private int $timeoutMs = 60000,
) {
}
/**
* @param array<string, mixed> $params
*
* @return list<array<string, mixed>>
*/
public function fetchAllAssociative(Connection $connection, string $sql, array $params = []): array
{
$sql = $this->applyTimeout($connection, $sql);
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchAllAssociative($sql, $params));
}
/**
* @param array<string, mixed> $params
*
* @return array<string, mixed>|false
*/
public function fetchAssociative(Connection $connection, string $sql, array $params = [])
{
$sql = $this->applyTimeout($connection, $sql);
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchAssociative($sql, $params));
}
/**
* @param array<string, mixed> $params
*
* @return list<mixed>
*/
public function fetchFirstColumn(Connection $connection, string $sql, array $params = []): array
{
$sql = $this->applyTimeout($connection, $sql);
return $this->executeInTimeoutContext($connection, fn () => $connection->fetchFirstColumn($sql, $params));
}
private function applyTimeout(Connection $connection, string $sql): string
{
$platform = $connection->getDatabasePlatform();
// MariaDB: SET STATEMENT max_statement_time=X FOR SELECT ... (per-statement, seconds)
if ($platform instanceof MariaDBPlatform) {
$timeoutSeconds = $this->timeoutMs / 1000;
return sprintf('SET STATEMENT max_statement_time=%s FOR %s', $timeoutSeconds, $sql);
}
// MySQL: SELECT /*+ MAX_EXECUTION_TIME(X) */ ... (optimizer hint, milliseconds)
if ($platform instanceof AbstractMySQLPlatform) {
return preg_replace(
'/^\s*SELECT\b/i',
sprintf('SELECT /*+ MAX_EXECUTION_TIME(%d) */', $this->timeoutMs),
$sql,
1,
);
}
// PostgreSQL: no per-query mechanism, use SET LOCAL in transaction
// Handled in executeInTimeoutContext()
return $sql;
}
/**
* @template T
*
* @param callable(): T $callback
*
* @return T
*/
private function executeInTimeoutContext(Connection $connection, callable $callback)
{
$platform = $connection->getDatabasePlatform();
// PostgreSQL: SET LOCAL only works within a transaction and resets automatically on COMMIT/ROLLBACK
if ($platform instanceof PostgreSQLPlatform) {
$connection->beginTransaction();
try {
$connection->executeStatement(sprintf("SET LOCAL statement_timeout = '%d'", $this->timeoutMs));
$result = $callback();
$connection->commit();
return $result;
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
}
}
// MySQL/MariaDB: timeout is embedded in the SQL itself, just execute
return $callback();
}
}

View file

@ -14,6 +14,8 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Validator\Constraints;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
@ -31,10 +33,21 @@ final class OrderPaymentMethodEligibilityValidator extends ConstraintValidator
/** @var OrderPaymentMethodEligibility $constraint */
Assert::isInstanceOf($constraint, OrderPaymentMethodEligibility::class);
$channel = $value->getChannel();
Assert::notNull($channel);
$payments = $value->getPayments();
/** @var PaymentInterface $payment */
foreach ($payments as $payment) {
if (!$payment->getMethod()->isEnabled()) {
/** @var ?PaymentMethodInterface $paymentMethod */
$paymentMethod = $payment->getMethod();
if (null === $paymentMethod) {
continue;
}
if (!$payment->getMethod()->isEnabled() || !$paymentMethod->hasChannel($channel)) {
$this->context->addViolation(
$constraint->message,
['%paymentMethodName%' => $payment->getMethod()->getName()],

View file

@ -248,6 +248,7 @@ final class ConfigurationTest extends TestCase
'technical' => true,
'plugins' => true,
'url' => 'https://prism.sylius.com/telemetry',
'query_timeout' => 60000,
],
],
'telemetry',
@ -272,6 +273,7 @@ final class ConfigurationTest extends TestCase
'technical' => true,
'plugins' => true,
'url' => 'https://prism.sylius.com/telemetry',
'query_timeout' => 60000,
],
],
'telemetry',

View file

@ -0,0 +1,101 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\EventListener\Workflow\OrderCheckout;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\EventListener\Workflow\OrderCheckout\BlockSelectPaymentFromPaymentSkippedListener;
use Sylius\Component\Core\Checker\OrderPaymentMethodSelectionAvailabilityCheckerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\OrderCheckoutStates;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\Transition;
final class BlockSelectPaymentFromPaymentSkippedListenerTest extends TestCase
{
private MockObject&OrderPaymentMethodSelectionAvailabilityCheckerInterface $availabilityChecker;
private BlockSelectPaymentFromPaymentSkippedListener $listener;
protected function setUp(): void
{
$this->availabilityChecker = $this->createMock(OrderPaymentMethodSelectionAvailabilityCheckerInterface::class);
$this->listener = new BlockSelectPaymentFromPaymentSkippedListener($this->availabilityChecker);
}
public function testItDoesNothingWhenMarkingIsNotPaymentSkipped(): void
{
$order = $this->createMock(OrderInterface::class);
$event = $this->createGuardEvent($order, ['shipping_selected' => 1]);
$this->availabilityChecker->expects($this->never())->method('isPaymentMethodSelectionAvailable');
($this->listener)($event);
$this->assertFalse($event->isBlocked());
}
public function testItBlocksTransitionWhenSelectingPaymentIsNotAvailable(): void
{
$order = $this->createMock(OrderInterface::class);
$event = $this->createGuardEvent($order, [OrderCheckoutStates::STATE_PAYMENT_SKIPPED => 1]);
$this->availabilityChecker
->expects($this->once())
->method('isPaymentMethodSelectionAvailable')
->with($order)
->willReturn(false)
;
($this->listener)($event);
$this->assertTrue($event->isBlocked());
}
public function testItDoesNotBlockTransitionWhenSelectingPaymentIsAvailable(): void
{
$order = $this->createMock(OrderInterface::class);
$event = $this->createGuardEvent($order, [OrderCheckoutStates::STATE_PAYMENT_SKIPPED => 1]);
$this->availabilityChecker
->expects($this->once())
->method('isPaymentMethodSelectionAvailable')
->with($order)
->willReturn(true)
;
($this->listener)($event);
$this->assertFalse($event->isBlocked());
}
public function testItThrowsAnExceptionOnNonSupportedSubject(): void
{
$event = $this->createGuardEvent(new \stdClass(), [OrderCheckoutStates::STATE_PAYMENT_SKIPPED => 1]);
$this->expectException(\InvalidArgumentException::class);
($this->listener)($event);
}
private function createGuardEvent(object $subject, array $places): GuardEvent
{
return new GuardEvent(
$subject,
new Marking($places),
new Transition('select_payment', OrderCheckoutStates::STATE_PAYMENT_SKIPPED, 'payment_selected'),
);
}
}

View file

@ -15,6 +15,7 @@ namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\EventListener;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\EventListener\TelemetryListener;
use Sylius\Component\Core\Telemetry\Cache\TelemetryCacheInterface;
use Sylius\Component\Core\Telemetry\TelemetrySendManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -25,16 +26,20 @@ final class TelemetryListenerTest extends TestCase
{
private TelemetrySendManagerInterface $telemetrySendManager;
private TelemetryCacheInterface $telemetryCache;
private TelemetryListener $listener;
protected function setUp(): void
{
$this->telemetrySendManager = $this->createMock(TelemetrySendManagerInterface::class);
$this->listener = new TelemetryListener($this->telemetrySendManager, '/api/v2/admin');
$this->telemetryCache = $this->createMock(TelemetryCacheInterface::class);
$this->listener = new TelemetryListener($this->telemetrySendManager, $this->telemetryCache, '/api/v2/admin');
}
public function test_it_does_nothing_when_not_admin_route(): void
{
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
$event = $this->createEvent('sylius_shop_homepage', '/');
@ -44,6 +49,7 @@ final class TelemetryListenerTest extends TestCase
public function test_it_does_nothing_when_route_is_null_and_path_not_admin(): void
{
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
$event = $this->createEvent(null, '/shop/products');
@ -53,6 +59,8 @@ final class TelemetryListenerTest extends TestCase
public function test_it_calls_telemetry_send_manager_on_admin_dashboard_visit(): void
{
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
$event = $this->createEvent('sylius_admin_dashboard', '/admin');
@ -62,6 +70,8 @@ final class TelemetryListenerTest extends TestCase
public function test_it_calls_telemetry_send_manager_on_admin_order_index(): void
{
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
$event = $this->createEvent('sylius_admin_order_index', '/admin/orders');
@ -71,6 +81,8 @@ final class TelemetryListenerTest extends TestCase
public function test_it_calls_telemetry_send_manager_on_admin_api_route(): void
{
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
$this->telemetrySendManager->expects($this->once())->method('sendIfNeeded');
$event = $this->createEvent('api_orders_admin_get_collection', '/api/v2/admin/orders');
@ -80,6 +92,7 @@ final class TelemetryListenerTest extends TestCase
public function test_it_does_not_trigger_on_shop_api_route(): void
{
$this->telemetryCache->expects($this->never())->method('wasRecentlyTriggered');
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
$event = $this->createEvent('api_orders_shop_get_collection', '/api/v2/shop/orders');
@ -87,8 +100,21 @@ final class TelemetryListenerTest extends TestCase
$this->listener->onAdminAccess($event);
}
public function test_it_skips_when_telemetry_was_recently_triggered(): void
{
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(true);
$this->telemetryCache->expects($this->never())->method('markAsRecentlyTriggered');
$this->telemetrySendManager->expects($this->never())->method('sendIfNeeded');
$event = $this->createEvent('sylius_admin_dashboard', '/admin');
$this->listener->onAdminAccess($event);
}
public function test_it_handles_exceptions_silently(): void
{
$this->telemetryCache->expects($this->once())->method('wasRecentlyTriggered')->willReturn(false);
$this->telemetryCache->expects($this->once())->method('markAsRecentlyTriggered');
$this->telemetrySendManager->method('sendIfNeeded')->willThrowException(new \RuntimeException('Test exception'));
$event = $this->createEvent('sylius_admin_dashboard', '/admin');

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CountriesData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\CountriesDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class CountriesDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class CountriesDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new CountriesDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new CountriesDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_countries_from_enabled_channels_with_enabled_countries(): void
@ -35,8 +38,8 @@ final class CountriesDataProviderTest extends TestCase
$this->connection->expects(self::once())
->method('fetchAllAssociative')
->with(self::logicalAnd(
self::stringContains('co.enabled = 1'),
self::stringContains('c.enabled = 1'),
self::stringContains('co.enabled = true'),
self::stringContains('c.enabled = true'),
))
->willReturn([
['channel_id' => 1, 'country_code' => 'US'],
@ -46,7 +49,7 @@ final class CountriesDataProviderTest extends TestCase
]);
$this->connection->expects(self::once())
->method('fetchFirstColumn')
->with(self::stringContains('WHERE enabled = 1'))
->with(self::stringContains('WHERE enabled = true'))
->willReturn(['US', 'CA', 'DE', 'FR', 'PL', 'GB']);
$data = $this->provider->provide();

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\CurrenciesData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\CurrenciesDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class CurrenciesDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class CurrenciesDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new CurrenciesDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new CurrenciesDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_currency_codes(): void

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\LocalesData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\LocalesDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class LocalesDataProviderTest extends TestCase
{
@ -29,7 +31,8 @@ final class LocalesDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new LocalesDataProvider($this->connection, self::DEFAULT_LOCALE);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new LocalesDataProvider($this->connection, new TimeoutRunner(), self::DEFAULT_LOCALE);
}
public function test_it_provides_locales_channel_defaults_from_enabled_channels(): void
@ -40,7 +43,7 @@ final class LocalesDataProviderTest extends TestCase
if (str_contains($sql, 'sylius_locale') && !str_contains($sql, 'sylius_channel')) {
return ['en_US', 'pl_PL', 'de_DE'];
}
self::assertStringContainsString('c.enabled = 1', $sql);
self::assertStringContainsString('c.enabled = true', $sql);
return ['en_US', 'de_DE'];
});

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\MetricsCountsData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\MetricsCountsDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class MetricsCountsDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class MetricsCountsDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new MetricsCountsDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new MetricsCountsDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_all_counts(): void

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\OrdersBusinessData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\OrdersBusinessDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class OrdersBusinessDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class OrdersBusinessDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new OrdersBusinessDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new OrdersBusinessDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_all_order_metrics_for_single_currency(): void
@ -55,19 +58,18 @@ final class OrdersBusinessDataProviderTest extends TestCase
public function test_it_provides_all_order_metrics_for_multiple_currencies(): void
{
// Values in minor units
$this->connection->method('fetchAllAssociative')->willReturn([
[
'currency_code' => 'USD',
'order_count' => 100,
'gmv' => 3000000, // 30,000 in minor units, AOV = 300
'gmv' => 3000000,
'avg_items' => 3.0,
'avg_units' => 4.5,
],
[
'currency_code' => 'EUR',
'order_count' => 50,
'gmv' => 1500000, // 15,000 in minor units, AOV = 300
'gmv' => 1500000,
'avg_items' => 2.5,
'avg_units' => 3.5,
],
@ -114,12 +116,11 @@ final class OrdersBusinessDataProviderTest extends TestCase
public function test_it_rounds_averages_correctly(): void
{
// Values in minor units - 1,500 GMV, 10 orders = 150 AOV
$this->connection->method('fetchAllAssociative')->willReturn([
[
'currency_code' => 'USD',
'order_count' => 10,
'gmv' => 150000, // 1,500 in minor units
'gmv' => 150000,
'avg_items' => 2.4,
'avg_units' => 3.6,
],

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\PaymentMethodsData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\PaymentMethodsDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class PaymentMethodsDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class PaymentMethodsDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new PaymentMethodsDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new PaymentMethodsDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_payment_providers_assigned_to_channel(): void

View file

@ -14,9 +14,11 @@ declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Provider\Business;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\DTO\Business\ShippingMethodsData;
use Sylius\Bundle\CoreBundle\Telemetry\Provider\Business\ShippingMethodsDataProvider;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class ShippingMethodsDataProviderTest extends TestCase
{
@ -27,7 +29,8 @@ final class ShippingMethodsDataProviderTest extends TestCase
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->provider = new ShippingMethodsDataProvider($this->connection);
$this->connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$this->provider = new ShippingMethodsDataProvider($this->connection, new TimeoutRunner());
}
public function test_it_provides_non_archived_shipping_providers_assigned_to_channel(): void

View file

@ -0,0 +1,169 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Bundle\CoreBundle\Telemetry\Query;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\CoreBundle\Telemetry\Query\TimeoutRunner;
final class TimeoutRunnerTest extends TestCase
{
public function test_mysql_applies_optimizer_hint(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$connection->expects(self::once())
->method('fetchAllAssociative')
->with(
self::matchesRegularExpression('/SELECT\s+\/\*\+\s+MAX_EXECUTION_TIME\(5000\)\s+\*\//'),
[],
)
->willReturn([['id' => 1]]);
$runner = new TimeoutRunner(5000);
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
self::assertSame([['id' => 1]], $result);
}
public function test_mariadb_applies_set_statement(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(MariaDBPlatform::class));
$connection->expects(self::once())
->method('fetchAllAssociative')
->with(
'SET STATEMENT max_statement_time=5 FOR SELECT id FROM sylius_order',
[],
)
->willReturn([['id' => 1]]);
$runner = new TimeoutRunner(5000);
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
self::assertSame([['id' => 1]], $result);
}
public function test_postgresql_uses_transaction_with_set_local(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(PostgreSQLPlatform::class));
$connection->expects(self::once())->method('beginTransaction');
$connection->expects(self::once())
->method('executeStatement')
->with("SET LOCAL statement_timeout = '5000'");
$connection->expects(self::once())
->method('fetchAllAssociative')
->with('SELECT id FROM sylius_order', [])
->willReturn([['id' => 1]]);
$connection->expects(self::once())->method('commit');
$connection->expects(self::never())->method('rollBack');
$runner = new TimeoutRunner(5000);
$result = $runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
self::assertSame([['id' => 1]], $result);
}
public function test_postgresql_rolls_back_on_exception(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(PostgreSQLPlatform::class));
$connection->expects(self::once())->method('beginTransaction');
$connection->expects(self::once())->method('executeStatement');
$connection->expects(self::once())
->method('fetchAllAssociative')
->willThrowException(new \RuntimeException('query timeout'));
$connection->expects(self::never())->method('commit');
$connection->expects(self::once())->method('rollBack');
$runner = new TimeoutRunner(5000);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('query timeout');
$runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
}
public function test_default_timeout_is_60_seconds(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$connection->expects(self::once())
->method('fetchAllAssociative')
->with(
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(60000\)/'),
[],
)
->willReturn([]);
$runner = new TimeoutRunner();
$runner->fetchAllAssociative($connection, 'SELECT id FROM sylius_order');
}
public function test_fetch_associative_applies_timeout(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$connection->expects(self::once())
->method('fetchAssociative')
->with(
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(3000\)/'),
[],
)
->willReturn(['count' => 42]);
$runner = new TimeoutRunner(3000);
$result = $runner->fetchAssociative($connection, 'SELECT COUNT(*) as count FROM sylius_order');
self::assertSame(['count' => 42], $result);
}
public function test_fetch_first_column_applies_timeout(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(AbstractMySQLPlatform::class));
$connection->expects(self::once())
->method('fetchFirstColumn')
->with(
self::matchesRegularExpression('/MAX_EXECUTION_TIME\(3000\)/'),
[],
)
->willReturn(['USD', 'EUR']);
$runner = new TimeoutRunner(3000);
$result = $runner->fetchFirstColumn($connection, 'SELECT code FROM sylius_currency');
self::assertSame(['USD', 'EUR'], $result);
}
public function test_mariadb_timeout_converts_to_seconds(): void
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($this->createMock(MariaDBPlatform::class));
$connection->expects(self::once())
->method('fetchAllAssociative')
->with(
'SET STATEMENT max_statement_time=2.5 FOR SELECT 1',
[],
)
->willReturn([]);
$runner = new TimeoutRunner(2500);
$runner->fetchAllAssociative($connection, 'SELECT 1');
}
}

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\ProductBundle\Doctrine\ORM;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
use Sylius\Component\Product\Model\ProductAssociationTypeInterface;
@ -28,8 +29,8 @@ class ProductAssociationTypeRepository extends EntityRepository implements Produ
public function createListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->innerJoin('o.translations', 'translation')
->andWhere('translation.locale = :locale')
->addSelect('translation')
->leftJoin('o.translations', 'translation', Join::WITH, 'translation.locale = :locale')
->setParameter('locale', $locale)
;
}

View file

@ -29,7 +29,8 @@ class ProductAttributeRepository extends EntityRepository implements ProductAttr
public function createListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->innerJoin('o.translations', 'translation', Join::WITH, 'translation.locale = :locale')
->addSelect('translation')
->leftJoin('o.translations', 'translation', Join::WITH, 'translation.locale = :locale')
->setParameter('locale', $locale)
;
}

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\ProductBundle\Doctrine\ORM;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ResourceBundle\Doctrine\ORM\EntityRepository;
use Sylius\Component\Product\Model\ProductOptionInterface;
@ -28,8 +29,8 @@ class ProductOptionRepository extends EntityRepository implements ProductOptionR
public function createListQueryBuilder(string $locale): QueryBuilder
{
return $this->createQueryBuilder('o')
->innerJoin('o.translations', 'translation')
->andWhere('translation.locale = :locale')
->addSelect('translation')
->leftJoin('o.translations', 'translation', Join::WITH, 'translation.locale = :locale')
->setParameter('locale', $locale)
;
}

View file

@ -2,11 +2,9 @@
{% for type in ['success', 'error', 'info', 'warning'] %}
{% for flash in app.session.flashbag.get(type) %}
{% set header = 'sylius.ui.'~type %}
{% if type == 'error' %}
{% set type = 'danger' %}
{% endif %}
{% set alert_class = type == 'error' ? 'danger' : type %}
<div class="alert alert-{{ type }} my-2" role="alert" data-test-sylius-flash-message>
<div class="alert alert-{{ alert_class }} my-2" role="alert" data-test-sylius-flash-message>
<div class="d-flex justify-content-between">
<div class="fw-bold">{{ header|trans }}</div>
<span class="close" data-bs-dismiss="alert" aria-label="Close">

View file

@ -11,7 +11,7 @@
declare(strict_types=1);
namespace Sylius\Bundle\TaxonomyBundle\Tests\Validator\Constraints;
namespace Tests\Sylius\Bundle\TaxonomyBundle\Validator\Constraints;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

View file

@ -425,6 +425,7 @@ sylius:
in: 'In'
inactive: 'Inactive'
included_in_price: 'Included in price'
index: 'Index'
index_metadata: 'Metadata'
index_of_all_configured_locales: 'Index of all configured locales'
info: 'Info'

View file

@ -422,6 +422,7 @@ sylius:
in: Dans
inactive: Inactif
included_in_price: Inclus dans le prix
index: Index
index_metadata: Méta-données
index_of_all_configured_locales: Index de tous les paramètres régionaux configurés
info: Info

View file

@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Checker;
use Sylius\Component\Core\Model\OrderInterface;
final class OrderPaymentMethodSelectionAvailabilityChecker implements OrderPaymentMethodSelectionAvailabilityCheckerInterface
{
public function isPaymentMethodSelectionAvailable(OrderInterface $order): bool
{
return $order->getTotal() > 0;
}
}

View file

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Component\Core\Checker;
use Sylius\Component\Core\Model\OrderInterface;
interface OrderPaymentMethodSelectionAvailabilityCheckerInterface
{
public function isPaymentMethodSelectionAvailable(OrderInterface $order): bool;
}

Some files were not shown because too many files have changed in this diff Show more