Add possibility to remove option values from Product Option (#17048)

| Q               | A
|-----------------|-----
| Branch?         | 2.0 <!-- see the comment below -->
| New feature?    | a bit
| BC breaks?      | yes
| License         | MIT

<!--
 - Bug fixes must be submitted against the 1.13 branch
 - Features and deprecations must be submitted against the 1.14 branch
- Features, removing deprecations and BC breaks must be submitted
against the 2.0 branch
 - Make sure that the correct base branch is set

To be sure you are not breaking any Backward Compatibilities, check the
documentation:

https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->
![Screenshot 2024-09-27 at 17 41
46](https://github.com/user-attachments/assets/076b858f-1b28-4adf-9d40-2d27cab3f867)
This commit is contained in:
Grzegorz Sadowski 2024-10-01 13:17:03 +02:00 committed by GitHub
commit 3ec2396737
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 677 additions and 54 deletions

View file

@ -5,7 +5,7 @@ Feature: Managing option values of a product option
I want to be able to edit a product option and its option values
Background:
Given the store is available in "English (United States)"
Given the store operates on a single channel in "United States"
And the store has a product option "T-Shirt size" with a code "t_shirt_size"
And this product option has the "S" option value with code "OV1"
And this product option has also the "M" option value with code "OV2"
@ -19,21 +19,34 @@ Feature: Managing option values of a product option
Then I should be notified that it has been successfully edited
And this product option should have the "L" option value
@api @todo-ui @javascript
@api @ui @mink:chromedriver
Scenario: Removing an option value from an existing product option
Given this product option has also the "L" option value with code "OV3"
When I want to modify the "T-Shirt size" product option
And I delete the "L" option value of this product option
And I delete the "OV3" option value of this product option
And I save my changes
Then I should be notified that it has been successfully edited
And this product option should not have the "L" option value
@api @todo-ui @javascript
@api @ui @mink:chromedriver
Scenario: Removing and adding a new option value to an existing product option
When I want to modify the "T-Shirt size" product option
And I delete the "M" option value of this product option
And I delete the "OV2" option value of this product option
And I add the "L" option value identified by "OV3"
And I save my changes
Then I should be notified that it has been successfully edited
And this product option should not have the "M" option value
And this product option should have the "L" option value
@api @ui @mink:chromedriver
Scenario: Removing product option value that is in use by product variant
Given the store has a "Car" configurable product
And this product has option "Model" with values "Sedan", "Kombi" and "Cabrio"
And this product has "Car-Variant-1" variant priced at "$20.00" configured with "Sedan" option value
And this product has "Car-Variant-2" variant priced at "$25.00" configured with "Kombi" option value
And this product has "Car-Variant-3" variant priced at "$50.00" configured with "Cabrio" option value
When I want to modify the "Model" product option
And I delete the "Sedan" option value of this product option
And I try to save my changes
Then I should be notified that it is in use
And product option "Model" should still have the "Sedan" option value

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Behat\Context\Api\Admin;
use ApiPlatform\Api\IriConverterInterface;
use Behat\Behat\Context\Context;
use Sylius\Behat\Client\ApiClientInterface;
use Sylius\Behat\Client\ResponseCheckerInterface;
@ -20,6 +21,7 @@ use Sylius\Behat\Context\Api\Admin\Helper\ValidationTrait;
use Sylius\Behat\Context\Api\Resources;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Product\Model\ProductOptionInterface;
use Sylius\Component\Product\Model\ProductOptionValueInterface;
use Webmozart\Assert\Assert;
final class ManagingProductOptionsContext implements Context
@ -30,6 +32,7 @@ final class ManagingProductOptionsContext implements Context
private ApiClientInterface $client,
private ResponseCheckerInterface $responseChecker,
private SharedStorageInterface $sharedStorage,
private IriConverterInterface $iriConverter,
) {
}
@ -113,11 +116,12 @@ final class ManagingProductOptionsContext implements Context
}
/**
* @When I delete the :value option value of this product option
* @When I delete the :optionValue option value of this product option
*/
public function iDeleteTheOptionValueOfThisProductOption(string $value): void
public function iDeleteTheOptionValueOfThisProductOption(ProductOptionValueInterface $optionValue): void
{
$this->client->removeSubResourceObject('values', $value, 'value');
$optionValueIri = $this->iriConverter->getIriFromResource($optionValue);
$this->client->removeSubResourceObject('values', $optionValueIri, 'value');
}
/**
@ -228,6 +232,7 @@ final class ManagingProductOptionsContext implements Context
/**
* @Then /^(product option "[^"]+") should have the "([^"]+)" option value$/
* @Then /^(product option "[^"]+") should still have the "([^"]+)" option value$/
* @Then /^(this product option) should have the "([^"]*)" option value$/
*/
public function productOptionShouldHaveTheOptionValue(
@ -296,6 +301,17 @@ final class ManagingProductOptionsContext implements Context
);
}
/**
* @Then I should be notified that it is in use
*/
public function iShouldBeNotifiedThatItIsInUse(): void
{
Assert::contains(
$this->responseChecker->getError($this->client->getLastResponse()),
'Cannot delete, the product option value is in use.',
);
}
/**
* @Then I should be notified that it has been successfully created
*/

View file

@ -143,7 +143,7 @@ final readonly class ManagingProductOptionsContext implements Context
*/
public function iDeleteTheOptionValueWithCodeAndValue(string $value): void
{
// TODO: Implement deleting option value
$this->formElement->removeOptionValue($value);
}
/**
@ -253,6 +253,7 @@ final readonly class ManagingProductOptionsContext implements Context
/**
* @Then /^(this product option) should have the "([^"]*)" option value$/
* @Then /^(product option "[^"]+") should have the "([^"]*)" option value$/
* @Then /^(product option "[^"]+") should still have the "([^"]*)" option value$/
* @Then /^(this product option) should have the "([^"]*)" option value in ("([^"]+)" locale)$/
*/
public function thisProductOptionShouldHaveTheOptionValue(

View file

@ -28,6 +28,17 @@ class FormElement extends BaseFormElement implements FormElementInterface
$this->getElement('name', ['%locale_code%' => $localeCode])->setValue($name);
}
public function removeOptionValue(string $optionValue): void
{
$optionValues = $this->getDocument()->findAll('css', '[data-test-option-value]');
foreach ($optionValues as $optionValueElement) {
if ($optionValueElement->has('css', sprintf('input[value="%s"]', $optionValue))) {
$optionValueElement->find('css', '[data-test-delete-option-value]')->click();
$this->waitForFormUpdate();
}
}
}
public function addOptionValue(string $code, string $localeCode, string $value): void
{
$this->getElement('add_option_value')->press();
@ -56,6 +67,7 @@ class FormElement extends BaseFormElement implements FormElementInterface
'add_option_value' => '[data-test-add-option-value]',
'apply_to_all' => '[data-test-option-value="%value_code%"] [data-test-option-value-locale="%locale_code%"] [data-test-apply-to-all]',
'code' => '[data-test-code]',
'delete_option_value' => '[data-test-delete-option-value]',
'form' => '[data-live-name-value="sylius_admin:product_option:form"]',
'last_option_value' => '[data-test-option-values] [data-test-option-value]:last-child',
'name' => '#sylius_admin_product_option_translations_%locale_code%_name',

View file

@ -126,6 +126,7 @@
<argument type="service" id="sylius.behat.api_platform_client.admin" />
<argument type="service" id="Sylius\Behat\Client\ResponseCheckerInterface" />
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="api_platform.iri_converter" />
</service>
<service id="sylius.behat.context.api.admin.managing_products" class="Sylius\Behat\Context\Api\Admin\ManagingProductsContext">

View file

@ -9,12 +9,16 @@ default:
- sylius.behat.context.transform.lexical
- sylius.behat.context.transform.locale
- sylius.behat.context.transform.product
- sylius.behat.context.transform.product_option
- sylius.behat.context.transform.product_option_value
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.locale
- sylius.behat.context.setup.product_option
- sylius.behat.context.setup.admin_api_security
- sylius.behat.context.setup.product
- sylius.behat.context.api.admin.managing_product_options
- sylius.behat.context.api.admin.response

View file

@ -10,12 +10,16 @@ default:
- sylius.behat.context.transform.lexical
- sylius.behat.context.transform.locale
- sylius.behat.context.transform.product
- sylius.behat.context.transform.product_option
- sylius.behat.context.transform.product_option_value
- sylius.behat.context.transform.shared_storage
- sylius.behat.context.setup.channel
- sylius.behat.context.setup.locale
- sylius.behat.context.setup.product_option
- sylius.behat.context.setup.admin_security
- sylius.behat.context.setup.product
- sylius.behat.context.ui.admin.managing_product_options
- sylius.behat.context.ui.admin.notification

View file

@ -0,0 +1,65 @@
<?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\AdminBundle\EventListener;
use Sylius\Bundle\CoreBundle\Provider\FlashBagProvider;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Resource\ResourceActions;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final readonly class ResourceDeleteExceptionListener
{
public function __construct(
private UrlGeneratorInterface $router,
private RequestStack $requestStack,
) {
}
public function onResourceDeleteException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof ResourceDeleteException) {
return;
}
FlashBagProvider::getFlashBag($this->requestStack)->add('error', [
'message' => 'sylius.resource.delete_error',
'parameters' => ['%resource%' => $exception->getResourceName()],
]);
$eventRequest = $event->getRequest();
$requestAttributes = $eventRequest->attributes;
$originalRoute = $requestAttributes->get('_route', '');
$referrer = $eventRequest->headers->get('referer');
if (null !== $referrer) {
$event->setResponse(new RedirectResponse($referrer));
return;
}
$event->setResponse($this->createRedirectResponse($originalRoute, ResourceActions::INDEX));
}
private function createRedirectResponse(string $originalRoute, string $targetAction): RedirectResponse
{
$redirectRoute = str_replace(ResourceActions::DELETE, $targetAction, $originalRoute);
return new RedirectResponse($this->router->generate($redirectRoute));
}
}

View file

@ -14,31 +14,12 @@ declare(strict_types=1);
namespace Sylius\Bundle\AdminBundle\EventListener;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\CoreBundle\Provider\FlashBagProvider;
use Sylius\Resource\ResourceActions;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final readonly class ResourceDeleteSubscriber implements EventSubscriberInterface
final readonly class ResourceDeleteListener
{
public function __construct(
private UrlGeneratorInterface $router,
private RequestStack $requestStack,
) {
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => 'onResourceDelete',
];
}
public function onResourceDelete(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
@ -67,19 +48,7 @@ final readonly class ResourceDeleteSubscriber implements EventSubscriberInterfac
return;
}
FlashBagProvider::getFlashBag($this->requestStack)->add('error', [
'message' => 'sylius.resource.delete_error',
'parameters' => ['%resource%' => $resourceName],
]);
$referrer = $eventRequest->headers->get('referer');
if (null !== $referrer) {
$event->setResponse(new RedirectResponse($referrer));
return;
}
$event->setResponse($this->createRedirectResponse($originalRoute, ResourceActions::INDEX));
throw new ResourceDeleteException($resourceName);
}
private function getResourceNameFromRoute(string $route): string
@ -92,13 +61,6 @@ final readonly class ResourceDeleteSubscriber implements EventSubscriberInterfac
return trim(implode(' ', $routeArrayWithoutPrefixes));
}
private function createRedirectResponse(string $originalRoute, string $targetAction): RedirectResponse
{
$redirectRoute = str_replace(ResourceActions::DELETE, $targetAction, $originalRoute);
return new RedirectResponse($this->router->generate($redirectRoute));
}
private function isMethodDelete(Request $request): bool
{
return Request::METHOD_DELETE === $request->getMethod();
@ -109,6 +71,7 @@ final readonly class ResourceDeleteSubscriber implements EventSubscriberInterfac
return str_starts_with($route, 'sylius');
}
/** @param array<string, mixed> $syliusParameters */
private function isAdminSection(array $syliusParameters): bool
{
return array_key_exists('section', $syliusParameters) && 'admin' === $syliusParameters['section'];

View file

@ -40,3 +40,5 @@ twig_hooks:
template: '@SyliusAdmin/product_option/form/sections/values/value/code.html.twig'
translations:
template: '@SyliusAdmin/product_option/form/sections/values/value/translations.html.twig'
button_delete:
template: '@SyliusAdmin/product_option/form/sections/values/value/button_delete.html.twig'

View file

@ -42,3 +42,5 @@ twig_hooks:
template: '@SyliusAdmin/product_option/form/sections/values/value/code.html.twig'
translations:
template: '@SyliusAdmin/product_option/form/sections/values/value/translations.html.twig'
button_delete:
template: '@SyliusAdmin/product_option/form/sections/values/value/button_delete.html.twig'

View file

@ -26,10 +26,14 @@
<tag name="kernel.event_listener" event="sylius.locale.pre_delete" method="preDelete" />
</service>
<service id="sylius.event_subscriber.resource_delete" class="Sylius\Bundle\AdminBundle\EventListener\ResourceDeleteSubscriber">
<service id="sylius.event_listener.resource_delete_exception" class="Sylius\Bundle\AdminBundle\EventListener\ResourceDeleteExceptionListener">
<argument type="service" id="router" />
<argument type="service" id="request_stack" />
<tag name="kernel.event_subscriber" event="kernel.exception" />
<tag name="kernel.event_listener" event="kernel.exception" method="onResourceDeleteException" />
</service>
<service id="sylius.event_listener.resource_delete" class="Sylius\Bundle\AdminBundle\EventListener\ResourceDeleteListener">
<tag name="kernel.event_listener" event="kernel.exception" method="onResourceDelete" />
</service>
<service id="sylius.event_subscriber.admin_cache_control_subscriber" class="Sylius\Bundle\AdminBundle\EventListener\AdminSectionCacheControlSubscriber">

View file

@ -0,0 +1,90 @@
<?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 spec\Sylius\Bundle\AdminBundle\EventListener;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ResourceDeleteExceptionListenerSpec extends ObjectBehavior
{
function let(UrlGeneratorInterface $router, RequestStack $requestStack): void
{
$this->beConstructedWith($router, $requestStack);
}
function it_does_nothing_if_exception_is_not_resource_delete_exception(
KernelInterface $kernel,
ForeignKeyConstraintViolationException $exception,
): void {
$event = new ExceptionEvent($kernel->getWrappedObject(), new Request(), HttpKernelInterface::MAIN_REQUEST, $exception->getWrappedObject());
$this->onResourceDeleteException($event)->shouldReturn(null);
}
function it_redirects_to_referer_if_present_and_adds_flash_message(
KernelInterface $kernel,
Request $request,
RequestStack $requestStack,
FlashBagInterface $flashBag,
UrlGeneratorInterface $router,
SessionInterface $session,
): void {
$request->attributes = new ParameterBag(['_route' => 'sylius_admin_product_delete']);
$request->headers = new HeaderBag(['referer' => '/admin/product/index']);
$exception = new ResourceDeleteException('Product');
$event = new ExceptionEvent($kernel->getWrappedObject(), $request->getWrappedObject(), HttpKernelInterface::MAIN_REQUEST, $exception);
$requestStack->getSession()->willReturn($session);
$session->getBag('flashes')->willReturn($flashBag);
$flashBag->add('error', ['message' => 'sylius.resource.delete_error', 'parameters' => ['%resource%' => 'Product']])->shouldBeCalled();
$router->generate(Argument::cetera())->shouldNotBeCalled();
$this->onResourceDeleteException($event);
}
function it_redirects_to_index_when_no_referer_present_and_adds_flash_message(
KernelInterface $kernel,
Request $request,
RequestStack $requestStack,
FlashBagInterface $flashBag,
UrlGeneratorInterface $router,
SessionInterface $session,
): void {
$request->attributes = new ParameterBag(['_route' => 'sylius_admin_product_delete']);
$request->headers = new HeaderBag();
$exception = new ResourceDeleteException('Product');
$event = new ExceptionEvent($kernel->getWrappedObject(), $request->getWrappedObject(), HttpKernelInterface::MAIN_REQUEST, $exception);
$requestStack->getSession()->willReturn($session);
$session->getBag('flashes')->willReturn($flashBag);
$flashBag->add('error', ['message' => 'sylius.resource.delete_error', 'parameters' => ['%resource%' => 'Product']])->shouldBeCalled();
$router->generate('sylius_admin_product_index')->willReturn('/admin/product/index');
$this->onResourceDeleteException($event);
}
}

View file

@ -0,0 +1,72 @@
<?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 spec\Sylius\Bundle\AdminBundle\EventListener;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelInterface;
final class ResourceDeleteListenerSpec extends ObjectBehavior
{
function it_does_nothing_if_exception_is_not_foreign_key_constraint(KernelInterface $kernel): void
{
$this->onResourceDelete(new ExceptionEvent($kernel->getWrappedObject(), new Request(), HttpKernelInterface::MAIN_REQUEST, new \Exception()))->shouldReturn(null);
}
function it_does_nothing_if_request_is_not_main(KernelInterface $kernel, ForeignKeyConstraintViolationException $exception): void
{
$event = new ExceptionEvent($kernel->getWrappedObject(), new Request(), HttpKernelInterface::SUB_REQUEST, $exception->getWrappedObject());
$this->onResourceDelete($event)->shouldReturn(null);
}
function it_should_throw_resource_delete_exception_if_all_conditions_are_met(KernelInterface $kernel, ForeignKeyConstraintViolationException $exception): void
{
$event = new ExceptionEvent($kernel->getWrappedObject(), new Request(), HttpKernelInterface::MAIN_REQUEST, $exception->getWrappedObject());
$this->onResourceDelete($event)->shouldThrow(ResourceDeleteException::class);
}
function it_should_do_nothing_if_method_is_not_delete(KernelInterface $kernel, ForeignKeyConstraintViolationException $exception): void
{
$request = new Request([], [], ['_route' => 'sylius_admin_product_delete', '_sylius' => ['section' => 'admin']]);
$request->setMethod('GET');
$event = new ExceptionEvent($kernel->getWrappedObject(), $request, HttpKernelInterface::MAIN_REQUEST, $exception->getWrappedObject());
$this->onResourceDelete($event)->shouldReturn(null);
}
function it_should_do_nothing_if_route_is_not_sylius(KernelInterface $kernel, ForeignKeyConstraintViolationException $exception): void
{
$request = new Request([], [], ['_route' => 'non_sylius_route', '_sylius' => ['section' => 'admin']]);
$event = new ExceptionEvent($kernel->getWrappedObject(), $request, HttpKernelInterface::MAIN_REQUEST, $exception->getWrappedObject());
$this->onResourceDelete($event)->shouldReturn(null);
}
function it_should_do_nothing_if_section_is_not_admin(KernelInterface $kernel, ForeignKeyConstraintViolationException $exception): void
{
$request = new Request([], [], ['_route' => 'sylius_admin_product_delete', '_sylius' => ['section' => 'shop']]);
$event = new ExceptionEvent($kernel->getWrappedObject(), $request, HttpKernelInterface::MAIN_REQUEST, $exception->getWrappedObject());
$this->onResourceDelete($event)->shouldReturn(null);
}
}

View file

@ -0,0 +1,3 @@
<div class="col text-end">
{{ form_row(hookable_metadata.context.form.vars.button_delete, { attr: {class: 'btn btn-outline-danger', 'data-test-delete-option-value': '' }}) }}
</div>

View file

@ -0,0 +1,25 @@
<?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\ApiBundle\Exception;
final class ProductOptionValueCannotBeRemoved extends \RuntimeException
{
public function __construct(
string $message = 'Cannot delete, the product option value is in use.',
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
}

View file

@ -96,6 +96,7 @@
name="sylius_api_admin_product_option_put"
class="ApiPlatform\Metadata\Put"
uriTemplate="/admin/product-options/{code}"
processor="sylius_api.state_processor.admin.product_option_value.persist"
>
<denormalizationContext>
<values>

View file

@ -59,6 +59,7 @@ api_platform:
Sylius\Bundle\ApiBundle\Exception\PaymentMethodCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductAttributeCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductOptionValueCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProductVariantCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\PromotionCouponCannotBeRemoved: 422
Sylius\Bundle\ApiBundle\Exception\ProvinceCannotBeRemoved: 422

View file

@ -68,6 +68,11 @@
<tag name="api_platform.state_processor" />
</service>
<service id="sylius_api.state_processor.admin.product_option_value.persist" class="Sylius\Bundle\ApiBundle\StateProcessor\Admin\ProductOption\PersistProcessor">
<argument type="service" id="api_platform.doctrine.orm.state.persist_processor" />
<tag name="api_platform.state_processor" />
</service>
<service id="sylius_api.state_processor.admin.product_attribute.remove" class="Sylius\Bundle\ApiBundle\StateProcessor\Admin\ProductAttribute\RemoveProcessor">
<argument type="service" id="api_platform.doctrine.orm.state.remove_processor" />
<tag name="api_platform.state_processor" />

View file

@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\ApiBundle\Exception\PaymentMethodCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Webmozart\Assert\Assert;

View file

@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\ApiBundle\Exception\ProductAttributeCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\ProductInterface;
use Sylius\Component\Product\Model\ProductAttributeInterface;
use Webmozart\Assert\Assert;

View file

@ -0,0 +1,45 @@
<?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\ApiBundle\StateProcessor\Admin\ProductOption;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Put;
use ApiPlatform\State\ProcessorInterface;
use Sylius\Bundle\ApiBundle\Exception\ProductOptionValueCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Product\Model\ProductOptionInterface;
use Webmozart\Assert\Assert;
/** @implements ProcessorInterface<ProductOptionInterface> */
final readonly class PersistProcessor implements ProcessorInterface
{
public function __construct(
private ProcessorInterface $processor,
) {
}
public function process($data, Operation $operation, array $uriVariables = [], array $context = [])
{
Assert::isInstanceOf($data, ProductOptionInterface::class);
Assert::isInstanceOf($operation, Put::class);
try {
$this->processor->process($data, $operation, $uriVariables, $context);
} catch (ResourceDeleteException) {
throw new ProductOptionValueCannotBeRemoved();
}
return $this->processor->process($data, $operation, $uriVariables, $context);
}
}

View file

@ -18,6 +18,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\ApiBundle\Exception\ShippingMethodCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\ShippingMethodInterface;
use Webmozart\Assert\Assert;

View file

@ -20,6 +20,7 @@ use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use Sylius\Bundle\ApiBundle\Exception\ZoneCannotBeRemoved;
use Sylius\Component\Addressing\Checker\ZoneDeletionCheckerInterface;
use Sylius\Component\Addressing\Model\ZoneInterface;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Webmozart\Assert\Assert;
/** @implements ProcessorInterface<ZoneInterface> */

View file

@ -21,6 +21,7 @@ use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\PaymentMethodCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\Model\ShippingMethodInterface;

View file

@ -19,6 +19,7 @@ use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\ProductAttributeCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Product\Model\ProductAttributeInterface;
final class RemoveProcessorSpec extends ObjectBehavior

View file

@ -0,0 +1,67 @@
<?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 spec\Sylius\Bundle\ApiBundle\StateProcessor\Admin\ProductOption;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Put;
use ApiPlatform\State\ProcessorInterface;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\ProductOptionValueCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\ShippingMethodInterface;
use Sylius\Component\Product\Model\ProductOptionInterface;
final class PersistProcessorSpec extends ObjectBehavior
{
function let(ProcessorInterface $processor): void
{
$this->beConstructedWith($processor);
}
function it_is_a_processor_interface(): void
{
$this->shouldImplement(ProcessorInterface::class);
}
public function it_processes_remove_operation(
ProcessorInterface $processor,
ProductOptionInterface $productOption,
): void {
$operation = new Put();
$processor->process($productOption, $operation, [], [])->shouldBeCalled();
$this->process($productOption, $operation);
}
public function it_throws_an_exception_when_resource_delete_exception_occurs(
ProcessorInterface $processor,
ProductOptionInterface $productOption,
): void {
$operation = new Put();
$processor->process($productOption, $operation, [], [])->willThrow(ResourceDeleteException::class);
$this->shouldThrow(ProductOptionValueCannotBeRemoved::class)->during('process', [$productOption, $operation]);
}
public function it_throws_an_exception_if_operation_is_not_put(
ShippingMethodInterface $shippingMethod,
): void {
$this->shouldThrow(\InvalidArgumentException::class)->during('process', [$shippingMethod, new Delete()]);
}
public function it_throws_exception_if_data_is_not_product_option_interface(): void
{
$this->shouldThrow(\InvalidArgumentException::class)->during('process', [new \stdClass(), new Put()]);
}
}

View file

@ -21,6 +21,7 @@ use ApiPlatform\State\ProcessorInterface;
use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ApiBundle\Exception\ShippingMethodCannotBeRemoved;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\ShippingMethodInterface;
final class RemoveProcessorSpec extends ObjectBehavior

View file

@ -22,6 +22,7 @@ use Prophecy\Argument;
use Sylius\Bundle\ApiBundle\Exception\ZoneCannotBeRemoved;
use Sylius\Component\Addressing\Checker\ZoneDeletionCheckerInterface;
use Sylius\Component\Addressing\Model\ZoneInterface;
use Sylius\Component\Core\Exception\ResourceDeleteException;
final class RemoveProcessorSpec extends ObjectBehavior
{

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Doctrine\ORM;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ProductBundle\Doctrine\ORM\ProductVariantRepository as BaseProductVariantRepository;
use Sylius\Component\Core\Model\CatalogPromotionInterface;
@ -64,4 +65,14 @@ class ProductVariantRepository extends BaseProductVariantRepository implements P
->setParameter('locale', $locale)
;
}
public function countByProductOptionValueId(mixed $id): int
{
return (int) $this->createQueryBuilder('o')
->select('COUNT(o.id)')
->innerJoin('o.optionValues', 'optionValue', Join::WITH, 'optionValue.id = :id')
->setParameter('id', $id)
->getQuery()
->getSingleScalarResult();
}
}

View file

@ -0,0 +1,37 @@
<?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;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface;
use Sylius\Component\Product\Model\ProductOptionValueInterface;
final class ProductOptionValueDeletionListener
{
/**
* @param ProductVariantRepositoryInterface<ProductVariantInterface> $productVariantRepository
*/
public function __construct(
private ProductVariantRepositoryInterface $productVariantRepository,
) {
}
public function preRemove(ProductOptionValueInterface $optionValue): void
{
if ($this->productVariantRepository->countByProductOptionValueId($optionValue->getId()) > 0) {
throw new ResourceDeleteException('product option value');
}
}
}

View file

@ -0,0 +1,41 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractPostgreSQLMigration;
final class Version20240926122944 extends AbstractPostgreSQLMigration
{
public function getDescription(): string
{
return 'Remove cascade onDelete from sylius_product_variant_option_value table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP CONSTRAINT FK_76CDAFA13B69A9AF');
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP CONSTRAINT FK_76CDAFA1D957CA06');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA13B69A9AF FOREIGN KEY (variant_id) REFERENCES sylius_product_variant (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA1D957CA06 FOREIGN KEY (option_value_id) REFERENCES sylius_product_option_value (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP CONSTRAINT fk_76cdafa13b69a9af');
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP CONSTRAINT fk_76cdafa1d957ca06');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT fk_76cdafa13b69a9af FOREIGN KEY (variant_id) REFERENCES sylius_product_variant (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT fk_76cdafa1d957ca06 FOREIGN KEY (option_value_id) REFERENCES sylius_product_option_value (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View file

@ -0,0 +1,41 @@
<?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\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Sylius\Bundle\CoreBundle\Doctrine\Migrations\AbstractMigration;
final class Version20240927155535 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove cascade onDelete from sylius_product_variant_option_value table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP FOREIGN KEY FK_76CDAFA13B69A9AF');
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP FOREIGN KEY FK_76CDAFA1D957CA06');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA13B69A9AF FOREIGN KEY (variant_id) REFERENCES sylius_product_variant (id)');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA1D957CA06 FOREIGN KEY (option_value_id) REFERENCES sylius_product_option_value (id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP FOREIGN KEY FK_76CDAFA13B69A9AF');
$this->addSql('ALTER TABLE sylius_product_variant_option_value DROP FOREIGN KEY FK_76CDAFA1D957CA06');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA13B69A9AF FOREIGN KEY (variant_id) REFERENCES sylius_product_variant (id) ON UPDATE NO ACTION ON DELETE CASCADE');
$this->addSql('ALTER TABLE sylius_product_variant_option_value ADD CONSTRAINT FK_76CDAFA1D957CA06 FOREIGN KEY (option_value_id) REFERENCES sylius_product_option_value (id) ON UPDATE NO ACTION ON DELETE CASCADE');
}
}

View file

@ -119,6 +119,11 @@
<tag name="kernel.event_listener" event="sylius.product.pre_delete" method="protectFromRemovingProductInUseByPromotionRule" />
</service>
<service id="sylius.listener.product_option_value" class="Sylius\Bundle\CoreBundle\EventListener\ProductOptionValueDeletionListener">
<argument type="service" id="sylius.repository.product_variant" />
<tag name="doctrine.orm.entity_listener" event="preRemove" entity="%sylius.model.product_option_value.class%" />
</service>
<service id="Sylius\Bundle\CoreBundle\EventListener\PostgreSQLDefaultSchemaListener">
<tag name="doctrine.event_listener" event="postGenerateSchema" method="postGenerateSchema" lazy="true" />
</service>

View file

@ -0,0 +1,47 @@
<?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 spec\Sylius\Bundle\CoreBundle\EventListener;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Exception\ResourceDeleteException;
use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface;
use Sylius\Component\Product\Model\ProductOptionValueInterface;
final class ProductOptionValueDeletionListenerSpec extends ObjectBehavior
{
function let(ProductVariantRepositoryInterface $productVariantRepository): void
{
$this->beConstructedWith($productVariantRepository);
}
function it_throws_resource_delete_exception_if_product_variants_exist_for_option_value(
ProductVariantRepositoryInterface $productVariantRepository,
ProductOptionValueInterface $optionValue,
): void {
$optionValue->getId()->willReturn(1);
$productVariantRepository->countByProductOptionValueId(1)->willReturn(1);
$this->shouldThrow(ResourceDeleteException::class)->during('preRemove', [$optionValue]);
}
function it_does_nothing_if_no_product_variants_exist_for_option_value(
ProductVariantRepositoryInterface $productVariantRepository,
ProductOptionValueInterface $optionValue,
): void {
$optionValue->getId()->willReturn(1);
$productVariantRepository->countByProductOptionValueId(1)->willReturn(0);
$this->preRemove($optionValue)->shouldReturn(null);
}
}

View file

@ -44,10 +44,10 @@
<join-table name="sylius_product_variant_option_value">
<join-columns>
<join-column name="variant_id" referenced-column-name="id" unique="false" nullable="false" on-delete="CASCADE" />
<join-column name="variant_id" referenced-column-name="id" unique="false" nullable="false" />
</join-columns>
<inverse-join-columns>
<join-column name="option_value_id" referenced-column-name="id" unique="false" nullable="false" on-delete="CASCADE" />
<join-column name="option_value_id" referenced-column-name="id" unique="false" nullable="false" />
</inverse-join-columns>
</join-table>
</many-to-many>

View file

@ -0,0 +1,35 @@
<?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\Exception;
class ResourceDeleteException extends \RuntimeException
{
public function __construct(
private string $resourceName,
string $message = '',
int $code = 0,
?\Throwable $previous = null,
) {
if (empty($message)) {
$message = sprintf('Cannot delete, the %s is in use.', $resourceName);
}
parent::__construct($message, $code, $previous);
}
public function getResourceName(): string
{
return $this->resourceName;
}
}

View file

@ -34,4 +34,6 @@ interface ProductVariantRepositoryInterface extends BaseProductVariantRepository
string $locale,
CatalogPromotionInterface $catalogPromotion,
): QueryBuilder;
public function countByProductOptionValueId(mixed $id): int;
}