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({}))) %} + + + + + + + {% for resource in grid.data %} + + {% endfor %} + 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);