diff --git a/src/Bundle/Routing/OperationRouteFactory.php b/src/Bundle/Routing/OperationRouteFactory.php
index 51c26631d..fe6f2b80c 100644
--- a/src/Bundle/Routing/OperationRouteFactory.php
+++ b/src/Bundle/Routing/OperationRouteFactory.php
@@ -79,6 +79,10 @@ private function getDefaultRoutePathForOperation(MetadataInterface $metadata, Ht
return sprintf('%s/{id}', $rootPath);
}
+ if ('bulk_delete' === $shortName) {
+ return sprintf('%s/bulk_delete', $rootPath);
+ }
+
if ('show' === $shortName) {
return sprintf('%s/{id}', $rootPath);
}
diff --git a/src/Bundle/spec/Routing/OperationRouteFactorySpec.php b/src/Bundle/spec/Routing/OperationRouteFactorySpec.php
index fe6ab0658..d31ad52ec 100644
--- a/src/Bundle/spec/Routing/OperationRouteFactorySpec.php
+++ b/src/Bundle/spec/Routing/OperationRouteFactorySpec.php
@@ -16,6 +16,7 @@
use PhpSpec\ObjectBehavior;
use Sylius\Bundle\ResourceBundle\Routing\OperationRouteFactory;
use Sylius\Component\Resource\Action\PlaceHolderAction;
+use Sylius\Component\Resource\Metadata\BulkDelete;
use Sylius\Component\Resource\Metadata\Create;
use Sylius\Component\Resource\Metadata\Delete;
use Sylius\Component\Resource\Metadata\HttpOperation;
@@ -132,6 +133,26 @@ function it_generates_delete_routes(): void
]);
}
+ function it_generates_bulk_delete_routes(): void
+ {
+ $metadata = Metadata::fromAliasAndConfiguration('app.dummy', ['driver' => 'dummy_driver']);
+
+ $route = $this->create(
+ $metadata,
+ new Resource('app.dummy'),
+ new BulkDelete(),
+ );
+
+ $route->getPath()->shouldReturn('/dummies/bulk_delete');
+ $route->getMethods()->shouldReturn(['DELETE']);
+ $route->getDefaults()->shouldReturn([
+ '_controller' => PlaceHolderAction::class,
+ '_sylius' => [
+ 'resource' => 'app.dummy',
+ ],
+ ]);
+ }
+
function it_generates_custom_operations_routes(): void
{
$metadata = Metadata::fromAliasAndConfiguration('app.dummy', ['driver' => 'dummy_driver']);
diff --git a/src/Bundle/test/config/packages/test/sylius_grid.yaml b/src/Bundle/test/config/packages/test/sylius_grid.yaml
index 5ad3ea50e..5f32e2125 100644
--- a/src/Bundle/test/config/packages/test/sylius_grid.yaml
+++ b/src/Bundle/test/config/packages/test/sylius_grid.yaml
@@ -5,3 +5,5 @@ sylius_grid:
delete: 'grid/action/delete.html.twig'
show: 'grid/action/show.html.twig'
update: 'grid/action/update.html.twig'
+ bulk_action:
+ delete: 'grid/bulk_action/delete.html.twig'
diff --git a/src/Bundle/test/src/Subscription/Entity/Subscription.php b/src/Bundle/test/src/Subscription/Entity/Subscription.php
index fcfe981ea..aee4ebb9d 100644
--- a/src/Bundle/test/src/Subscription/Entity/Subscription.php
+++ b/src/Bundle/test/src/Subscription/Entity/Subscription.php
@@ -16,6 +16,7 @@
use App\Subscription\Form\Type\SubscriptionType;
use Doctrine\ORM\Mapping as ORM;
use Sylius\Component\Resource\Metadata\ApplyStateMachineTransition;
+use Sylius\Component\Resource\Metadata\BulkDelete;
use Sylius\Component\Resource\Metadata\Create;
use Sylius\Component\Resource\Metadata\Delete;
use Sylius\Component\Resource\Metadata\Index;
@@ -23,7 +24,6 @@
use Sylius\Component\Resource\Metadata\Show;
use Sylius\Component\Resource\Metadata\Update;
use Sylius\Component\Resource\Model\ResourceInterface;
-use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[Resource(
@@ -36,6 +36,7 @@
#[Index(grid: 'app_subscription')]
#[Create]
#[Update]
+#[BulkDelete]
#[ApplyStateMachineTransition(stateMachineTransition: 'accept')]
#[ApplyStateMachineTransition(stateMachineTransition: 'reject')]
#[Delete]
@@ -48,10 +49,9 @@ class Subscription implements ResourceInterface
public function __construct(
#[ORM\Id]
- #[ORM\Column(type: 'uuid', unique: true)]
- #[ORM\GeneratedValue(strategy: 'CUSTOM')]
- #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
- public ?Uuid $id = null,
+ #[ORM\Column(type: 'integer', unique: true)]
+ #[ORM\GeneratedValue(strategy: 'AUTO')]
+ public ?int $id = null,
#[Assert\NotBlank]
#[Assert\Email]
@@ -60,7 +60,7 @@ public function __construct(
) {
}
- public function getId(): ?Uuid
+ public function getId(): ?int
{
return $this->id;
}
diff --git a/src/Bundle/test/src/Subscription/Grid/SubscriptionGrid.php b/src/Bundle/test/src/Subscription/Grid/SubscriptionGrid.php
index 2f3e886fd..9ec9c1783 100644
--- a/src/Bundle/test/src/Subscription/Grid/SubscriptionGrid.php
+++ b/src/Bundle/test/src/Subscription/Grid/SubscriptionGrid.php
@@ -19,6 +19,7 @@
use Sylius\Bundle\GridBundle\Builder\Action\DeleteAction;
use Sylius\Bundle\GridBundle\Builder\Action\ShowAction;
use Sylius\Bundle\GridBundle\Builder\Action\UpdateAction;
+use Sylius\Bundle\GridBundle\Builder\ActionGroup\BulkActionGroup;
use Sylius\Bundle\GridBundle\Builder\ActionGroup\ItemActionGroup;
use Sylius\Bundle\GridBundle\Builder\ActionGroup\MainActionGroup;
use Sylius\Bundle\GridBundle\Builder\Field\StringField;
@@ -67,6 +68,11 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void
]),
),
)
+ ->addActionGroup(
+ BulkActionGroup::create(
+ DeleteAction::create(),
+ ),
+ )
;
}
diff --git a/src/Bundle/test/src/Tests/Controller/SubscriptionUiTest.php b/src/Bundle/test/src/Tests/Controller/SubscriptionUiTest.php
index caa18886a..579b48df4 100644
--- a/src/Bundle/test/src/Tests/Controller/SubscriptionUiTest.php
+++ b/src/Bundle/test/src/Tests/Controller/SubscriptionUiTest.php
@@ -137,6 +137,22 @@ public function it_allows_deleting_a_subscription(): void
$this->assertEmpty($subscriptions);
}
+ /** @test */
+ public function it_allows_deleting_multiple_subscriptions(): void
+ {
+ $this->loadFixturesFromFile('subscriptions.yml');
+
+ $this->client->request('GET', '/admin/subscriptions');
+ $this->client->submitForm('Bulk delete');
+
+ $this->assertResponseRedirects(null, expectedCode: Response::HTTP_FOUND);
+
+ /** @var Subscription[] $subscriptions */
+ $subscriptions = static::getContainer()->get('app.repository.subscription')->findAll();
+
+ $this->assertEmpty($subscriptions);
+ }
+
/** @test */
public function it_allows_accepting_a_subscription(): void
{
diff --git a/src/Bundle/test/templates/crud/index.html.twig b/src/Bundle/test/templates/crud/index.html.twig
index ef0a5265c..db664227e 100644
--- a/src/Bundle/test/templates/crud/index.html.twig
+++ b/src/Bundle/test/templates/crud/index.html.twig
@@ -3,6 +3,12 @@
{% set grid = resources %}
{% set definition = grid.definition %}
+{% if definition.actionGroups.bulk is defined and definition.getEnabledActions('bulk')|length > 0 %}
+ {% for action in definition.getEnabledActions('bulk') %}
+ {{ sylius_grid_render_bulk_action(grid, action, null) }}
+ {% endfor %}
+{% endif %}
+
diff --git a/src/Bundle/test/templates/grid/bulk_action/delete.html.twig b/src/Bundle/test/templates/grid/bulk_action/delete.html.twig
new file mode 100644
index 000000000..bfa9becc4
--- /dev/null
+++ b/src/Bundle/test/templates/grid/bulk_action/delete.html.twig
@@ -0,0 +1,13 @@
+{% set path = options.link.url|default(path(options.link.route|default(grid.requestConfiguration.getRouteName('bulk_delete')), options.link.parameters|default({}))) %}
+
+
diff --git a/src/Component/Doctrine/Common/State/RemoveProcessor.php b/src/Component/Doctrine/Common/State/RemoveProcessor.php
index 5d2d4b416..0e4bd3d9d 100644
--- a/src/Component/Doctrine/Common/State/RemoveProcessor.php
+++ b/src/Component/Doctrine/Common/State/RemoveProcessor.php
@@ -30,12 +30,16 @@ public function __construct(private ManagerRegistry $managerRegistry)
public function process(mixed $data, Operation $operation, Context $context): mixed
{
- if (!\is_object($data) || !$manager = $this->getManager($data)) {
- return null;
- }
+ $data = \is_array($data) ? $data : [$data];
+
+ foreach ($data as $row) {
+ if (!\is_object($row) || !$manager = $this->getManager($row)) {
+ return null;
+ }
- $manager->remove($data);
- $manager->flush();
+ $manager->remove($row);
+ $manager->flush();
+ }
return null;
}
diff --git a/src/Component/Metadata/BulkDelete.php b/src/Component/Metadata/BulkDelete.php
new file mode 100644
index 000000000..4cc345d02
--- /dev/null
+++ b/src/Component/Metadata/BulkDelete.php
@@ -0,0 +1,59 @@
+attributes->all('_route_params'), $request->query->all());
- $matchedArguments = FunctionArgumentsFilter::filter($reflector, $arguments);
+ $allArguments = [
+ $request->attributes->all('_route_params'),
+ $request->query->all(),
+ $request->request->all(),
+ ];
- if (0 === count($matchedArguments) && $this->hasOnlyOneRequiredArrayParameter($reflector)) {
- $arguments = $this->filterPrivateArguments($arguments);
+ foreach ($allArguments as $arguments) {
+ $matchedArguments = FunctionArgumentsFilter::filter($reflector, $arguments);
- return [$arguments];
+ if (0 === count($matchedArguments) && $this->hasOnlyOneRequiredArrayParameter($reflector)) {
+ $arguments = $this->filterPrivateArguments($arguments);
+
+ return [$arguments];
+ }
+
+ if ('__call' === $reflector->getName()) {
+ $arguments = $this->filterPrivateArguments($arguments);
+
+ if ([] === $arguments) {
+ continue;
+ }
+
+ return array_values($arguments);
+ }
+
+ if ([] === $matchedArguments) {
+ continue;
+ }
+
+ return $matchedArguments;
}
- return $matchedArguments;
+ return [];
}
/**
diff --git a/src/Component/Symfony/Request/State/Provider.php b/src/Component/Symfony/Request/State/Provider.php
index 1e2ad2785..7935c4298 100644
--- a/src/Component/Symfony/Request/State/Provider.php
+++ b/src/Component/Symfony/Request/State/Provider.php
@@ -17,6 +17,7 @@
use Psr\Container\ContainerInterface;
use Sylius\Component\Resource\Context\Context;
use Sylius\Component\Resource\Context\Option\RequestOption;
+use Sylius\Component\Resource\Metadata\BulkOperationInterface;
use Sylius\Component\Resource\Metadata\CollectionOperationInterface;
use Sylius\Component\Resource\Metadata\Operation;
use Sylius\Component\Resource\Reflection\CallableReflection;
@@ -43,8 +44,15 @@ public function provide(Operation $operation, Context $context): object|iterable
return null;
}
+ $repositoryInstance = null;
+
if (\is_string($repository)) {
$defaultMethod = $operation instanceof CollectionOperationInterface ? 'createPaginator' : 'findOneBy';
+
+ if ($operation instanceof BulkOperationInterface) {
+ $defaultMethod = 'findById';
+ }
+
$method = $operation->getRepositoryMethod() ?? $defaultMethod;
if (!$this->locator->has($repository)) {
@@ -58,9 +66,20 @@ public function provide(Operation $operation, Context $context): object|iterable
$repository = [$repositoryInstance, $method];
}
- $reflector = CallableReflection::from($repository);
- $arguments = $this->argumentResolver->getArguments($request, $reflector);
+ try {
+ $reflector = CallableReflection::from($repository);
+ } catch (\ReflectionException $exception) {
+ if (null === $repositoryInstance) {
+ throw $exception;
+ }
+
+ /** @var callable $callable */
+ $callable = [$repositoryInstance, '__call'];
+ $reflector = CallableReflection::from($callable);
+ }
+
+ $arguments = $this->argumentResolver->getArguments($request, $reflector);
$data = $repository(...$arguments);
if ($data instanceof Pagerfanta) {
diff --git a/src/Component/Tests/Dummy/RepositoryWithCallables.php b/src/Component/Tests/Dummy/RepositoryWithCallables.php
index f80321582..d85d8b99e 100644
--- a/src/Component/Tests/Dummy/RepositoryWithCallables.php
+++ b/src/Component/Tests/Dummy/RepositoryWithCallables.php
@@ -32,4 +32,9 @@ public static function findOneBy(array $criteria, ?array $orderBy = null): array
{
return [];
}
+
+ public function __call(string $method, mixed $arguments): array
+ {
+ return [];
+ }
}
diff --git a/src/Component/spec/Symfony/Request/RepositoryArgumentResolverSpec.php b/src/Component/spec/Symfony/Request/RepositoryArgumentResolverSpec.php
index 1cb52c325..6ead5cfae 100644
--- a/src/Component/spec/Symfony/Request/RepositoryArgumentResolverSpec.php
+++ b/src/Component/spec/Symfony/Request/RepositoryArgumentResolverSpec.php
@@ -34,6 +34,7 @@ function it_gets_arguments_to_sent_to_the_repository(
): void {
$request->attributes = $attributes;
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$attributes->all('_route_params')->willReturn(['id' => 'my_id']);
@@ -45,12 +46,31 @@ function it_gets_arguments_to_sent_to_the_repository(
]);
}
- function it_merges_arguments_from_route_params_and_query_params(
+ function it_uses_query_params_when_route_params_are_not_matching(
Request $request,
ParameterBag $attributes,
): void {
$request->attributes = $attributes;
$request->query = new InputBag(['id' => 'my_id']);
+ $request->request = new ParameterBag();
+
+ $attributes->all('_route_params')->willReturn(['_sylius' => ['resource' => 'app.dummy']]);
+
+ $callable = [RepositoryWithCallables::class, 'find'];
+ $reflector = CallableReflection::from($callable);
+
+ $this->getArguments($request, $reflector)->shouldReturn([
+ 'id' => 'my_id',
+ ]);
+ }
+
+ function it_uses_request_params_when_route_params_are_not_matching(
+ Request $request,
+ ParameterBag $attributes,
+ ): void {
+ $request->attributes = $attributes;
+ $request->query = new InputBag();
+ $request->request = new ParameterBag(['id' => 'my_id']);
$attributes->all('_route_params')->willReturn(['_sylius' => ['resource' => 'app.dummy']]);
@@ -68,6 +88,7 @@ function it_encapsulates_arguments_when_the_method_has_only_one_required_array_a
): void {
$request->attributes = $attributes;
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$attributes->all('_route_params')->willReturn(['enabled' => 'true', 'author' => 'author@example.com']);
@@ -81,4 +102,20 @@ function it_encapsulates_arguments_when_the_method_has_only_one_required_array_a
],
]);
}
+
+ function it_return_array_values_when_method_is_magic(
+ Request $request,
+ ParameterBag $attributes,
+ ): void {
+ $request->attributes = $attributes;
+ $request->query = new InputBag();
+ $request->request = new ParameterBag(['ids' => ['first_id', 'second_id']]);
+
+ $attributes->all('_route_params')->willReturn(['_sylius' => ['resource' => 'app.dummy']]);
+
+ $callable = [new RepositoryWithCallables(), '__call'];
+ $reflector = CallableReflection::from($callable);
+
+ $this->getArguments($request, $reflector)->shouldReturn([['first_id', 'second_id']]);
+ }
}
diff --git a/src/Component/spec/Symfony/Request/State/ProviderSpec.php b/src/Component/spec/Symfony/Request/State/ProviderSpec.php
index af6f63f02..66d0676e8 100644
--- a/src/Component/spec/Symfony/Request/State/ProviderSpec.php
+++ b/src/Component/spec/Symfony/Request/State/ProviderSpec.php
@@ -47,6 +47,7 @@ function it_calls_repository_as_callable(
$operation->getRepository()->willReturn([RepositoryWithCallables::class, 'find']);
$request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id']]);
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$response = $this->provide($operation, new Context(new RequestOption($request->getWrappedObject())));
$response->shouldHaveType(\stdClass::class);
@@ -65,6 +66,7 @@ function it_calls_repository_as_string(
$request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]);
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$locator->has('App\Repository')->willReturn(true);
$locator->get('App\Repository')->willReturn($repository);
@@ -85,6 +87,7 @@ function it_calls_create_paginator_by_default_on_collection_operations(
$request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]);
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$locator->has('App\Repository')->willReturn(true);
$locator->get('App\Repository')->willReturn($repository);
@@ -106,6 +109,7 @@ function it_sets_current_page_from_request_when_data_is_a_paginator(
$request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]);
$request->query = new InputBag(['page' => 42]);
+ $request->request = new ParameterBag();
$locator->has('App\Repository')->willReturn(true);
$locator->get('App\Repository')->willReturn($repository);
@@ -130,6 +134,7 @@ function it_calls_repository_as_string_with_specific_repository_method(
$request->attributes = new ParameterBag(['_route_params' => ['id' => 'my_id', '_sylius' => ['resource' => 'app.dummy']]]);
$request->query = new InputBag([]);
+ $request->request = new ParameterBag();
$locator->has('App\Repository')->willReturn(true);
$locator->get('App\Repository')->willReturn($repository);