diff --git a/docs/reference/search.rst b/docs/reference/search.rst index 8bf28698168..141961bc59a 100644 --- a/docs/reference/search.rst +++ b/docs/reference/search.rst @@ -5,6 +5,23 @@ The admin comes with a basic global search available in the upper navigation men and look for filter with the option ``global_search`` set to true. If you are using the ``SonataDoctrineORMBundle`` any text filter will be set to ``true`` by default. +Disabling the search by admin +----------------------------- + +You can disable the search for a whole admin by setting the ``global_search`` attribute +to ``false`` at your admin definition using the tag ``sonata.admin``. + +.. configuration-block:: + + .. code-block:: xml + + + + + App\Entity\Post + + + Customization ------------- diff --git a/src/DependencyInjection/Compiler/AdminSearchCompilerPass.php b/src/DependencyInjection/Compiler/AdminSearchCompilerPass.php new file mode 100644 index 00000000000..7be26d8e6be --- /dev/null +++ b/src/DependencyInjection/Compiler/AdminSearchCompilerPass.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\DependencyInjection\Compiler; + +use Sonata\AdminBundle\Admin\AdminInterface; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\LogicException; + +/** + * This class configures which admins must be considered for global search at `SearchHandler`. + * + * @author Javier Spagnoletti + */ +final class AdminSearchCompilerPass implements CompilerPassInterface +{ + public const TAG_ATTRIBUTE_TOGGLE_SEARCH = 'global_search'; + + private const TAG_ADMIN = 'sonata.admin'; + + public function process(ContainerBuilder $container): void + { + $adminSearch = []; + + foreach ($container->findTaggedServiceIds(self::TAG_ADMIN) as $id => $tags) { + $definition = $container->getDefinition($id); + + // Trim possible parameter delimiters ("%") from the class name. + $adminClass = trim($definition->getClass(), '%'); + if (!class_exists($adminClass) && $container->hasParameter($adminClass)) { + $adminClass = $container->getParameter($adminClass); + } + + if (!is_subclass_of($adminClass, AdminInterface::class)) { + throw new LogicException(sprintf( + 'Service "%s" must implement `%s`.', + $id, + AdminInterface::class + )); + } + + foreach ($tags as $attributes) { + if (!isset($attributes[self::TAG_ATTRIBUTE_TOGGLE_SEARCH])) { + continue; + } + + $globalSearch = $attributes[self::TAG_ATTRIBUTE_TOGGLE_SEARCH]; + + if (!\is_bool($globalSearch)) { + throw new LogicException(sprintf( + 'Attribute "%s" in tag "%s" at service "%s" must be of type boolean, "%s" given.', + self::TAG_ATTRIBUTE_TOGGLE_SEARCH, + self::TAG_ADMIN, + $id, + \gettype($globalSearch) + )); + } + + $adminSearch[$id] = $globalSearch; + } + } + + $searchHandlerDefinition = $container->getDefinition('sonata.admin.search.handler'); + $searchHandlerDefinition->addMethodCall('configureAdminSearch', [$adminSearch]); + } +} diff --git a/src/Search/SearchHandler.php b/src/Search/SearchHandler.php index cb113b8620a..596b232b2fd 100644 --- a/src/Search/SearchHandler.php +++ b/src/Search/SearchHandler.php @@ -35,6 +35,11 @@ class SearchHandler */ private $caseSensitive; + /** + * @var array + */ + private $adminsSearchConfig = []; + /** * NEXT_MAJOR: Change signature to __construct(bool $caseSensitive) and remove pool property. * @@ -69,6 +74,11 @@ public function __construct($deprecatedPoolOrCaseSensitive, $caseSensitive = tru */ public function search(AdminInterface $admin, $term, $page = 0, $offset = 20) { + // If the search is disabled for the whole admin, skip any further processing. + if (false === ($this->adminsSearchConfig[$admin->getCode()] ?? true)) { + return false; + } + $datagrid = $admin->getDatagrid(); $found = false; @@ -95,4 +105,28 @@ public function search(AdminInterface $admin, $term, $page = 0, $offset = 20) return $pager; } + + /** + * Sets whether the search must be enabled or not for the passed admin codes. + * Receives an array with the admin code as key and a boolean as value. + * + * @param array $adminsSearchConfig + */ + final public function configureAdminSearch(array $adminsSearchConfig): void + { + $this->adminsSearchConfig = []; + + foreach ($adminsSearchConfig as $adminCode => $adminSearchConfig) { + if (!\is_bool($adminSearchConfig)) { + throw new \InvalidArgumentException(sprintf( + 'Values in the array passed as argument 1 to "%s()" must be of type boolean, %s given in the offset "%s".', + __METHOD__, + \is_object($adminSearchConfig) ? 'instance of "'.\get_class($adminSearchConfig).'"' : '"'.\gettype($adminSearchConfig).'"', + $adminCode + )); + } + + $this->adminsSearchConfig[$adminCode] = $adminSearchConfig; + } + } } diff --git a/src/SonataAdminBundle.php b/src/SonataAdminBundle.php index 5c99f7235ba..4aa333d8d29 100644 --- a/src/SonataAdminBundle.php +++ b/src/SonataAdminBundle.php @@ -16,6 +16,7 @@ use Mopa\Bundle\BootstrapBundle\Form\Type\TabType; use Sonata\AdminBundle\DependencyInjection\Compiler\AddDependencyCallsCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\AddFilterTypeCompilerPass; +use Sonata\AdminBundle\DependencyInjection\Compiler\AdminSearchCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\GlobalVariablesCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\ModelManagerCompilerPass; @@ -50,6 +51,7 @@ public function build(ContainerBuilder $container) { $container->addCompilerPass(new AddDependencyCallsCompilerPass()); $container->addCompilerPass(new AddFilterTypeCompilerPass()); + $container->addCompilerPass(new AdminSearchCompilerPass()); $container->addCompilerPass(new ExtensionCompilerPass()); $container->addCompilerPass(new GlobalVariablesCompilerPass()); $container->addCompilerPass(new ModelManagerCompilerPass()); diff --git a/tests/DependencyInjection/Compiler/AdminSearchCompilerPassTest.php b/tests/DependencyInjection/Compiler/AdminSearchCompilerPassTest.php new file mode 100644 index 00000000000..0cce3d979b6 --- /dev/null +++ b/tests/DependencyInjection/Compiler/AdminSearchCompilerPassTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Sonata\AdminBundle\Tests\DependencyInjection\Compiler; + +use PHPUnit\Framework\TestCase; +use Sonata\AdminBundle\DependencyInjection\Compiler\AdminSearchCompilerPass; +use Sonata\AdminBundle\Tests\Fixtures\Admin\PostAdmin; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +/** + * @author Javier Spagnoletti + */ +final class AdminSearchCompilerPassTest extends TestCase +{ + public function testProcess(): void + { + $containerBuilderMock = $this->createMock(ContainerBuilder::class); + $containerBuilderMock->expects($this->once()) + ->method('findTaggedServiceIds') + ->with($this->equalTo('sonata.admin')) + ->willReturn([ + 'admin.foo' => [ + ['global_search' => true], + ], + 'admin.bar' => [ + ['global_search' => false], + ], + 'admin.baz' => [ + ['some_attribute' => 42], + ], + ]); + + $adminFooDefinition = $this->createMock(Definition::class); + $adminFooDefinition->expects($this->once()) + ->method('getClass') + ->willReturn(PostAdmin::class); + + $adminBarDefinition = $this->createMock(Definition::class); + $adminBarDefinition->expects($this->once()) + ->method('getClass') + ->willReturn(PostAdmin::class); + + $adminBazDefinition = $this->createMock(Definition::class); + $adminBazDefinition->expects($this->once()) + ->method('getClass') + ->willReturn(PostAdmin::class); + + $searchHandlerDefinition = $this->createMock(Definition::class); + $searchHandlerDefinition->expects($this->once()) + ->method('addMethodCall') + ->with('configureAdminSearch', [['admin.foo' => true, 'admin.bar' => false]]); + + $containerBuilderMock + ->method('getDefinition') + ->willReturnMap([ + ['admin.foo', $adminFooDefinition], + ['admin.bar', $adminBarDefinition], + ['admin.baz', $adminBazDefinition], + ['sonata.admin.search.handler', $searchHandlerDefinition], + ]); + + (new AdminSearchCompilerPass())->process($containerBuilderMock); + } +} diff --git a/tests/Search/SearchHandlerTest.php b/tests/Search/SearchHandlerTest.php index a04ac4275dd..9a5c6129d8b 100644 --- a/tests/Search/SearchHandlerTest.php +++ b/tests/Search/SearchHandlerTest.php @@ -24,14 +24,14 @@ class SearchHandlerTest extends TestCase { public function testBuildPagerWithNoGlobalSearchField(): void { - $filter = $this->getMockForAbstractClass(FilterInterface::class); - $filter->expects($this->once())->method('getOption')->willReturn(false); + $filter = $this->createMock(FilterInterface::class); + $filter->expects($this->once())->method('getOption')->with('global_search')->willReturn(false); $filter->expects($this->never())->method('setOption'); - $datagrid = $this->getMockForAbstractClass(DatagridInterface::class); + $datagrid = $this->createMock(DatagridInterface::class); $datagrid->expects($this->once())->method('getFilters')->willReturn([$filter]); - $admin = $this->getMockForAbstractClass(AdminInterface::class); + $admin = $this->createMock(AdminInterface::class); $admin->expects($this->once())->method('getDatagrid')->willReturn($datagrid); $handler = new SearchHandler(true); @@ -43,20 +43,20 @@ public function testBuildPagerWithNoGlobalSearchField(): void */ public function testBuildPagerWithGlobalSearchField(bool $caseSensitive): void { - $filter = $this->getMockForAbstractClass(FilterInterface::class); - $filter->expects($this->once())->method('getOption')->willReturn(true); + $filter = $this->createMock(FilterInterface::class); + $filter->expects($this->once())->method('getOption')->with('global_search')->willReturn(true); $filter->expects($this->once())->method('setOption')->with('case_sensitive', $caseSensitive); - $pager = $this->getMockForAbstractClass(PagerInterface::class); + $pager = $this->createMock(PagerInterface::class); $pager->expects($this->once())->method('setPage'); $pager->expects($this->once())->method('setMaxPerPage'); - $datagrid = $this->getMockForAbstractClass(DatagridInterface::class); + $datagrid = $this->createMock(DatagridInterface::class); $datagrid->expects($this->once())->method('getFilters')->willReturn([$filter]); $datagrid->expects($this->once())->method('setValue'); $datagrid->expects($this->once())->method('getPager')->willReturn($pager); - $admin = $this->getMockForAbstractClass(AdminInterface::class); + $admin = $this->createMock(AdminInterface::class); $admin->expects($this->once())->method('getDatagrid')->willReturn($datagrid); $handler = new SearchHandler($caseSensitive); @@ -70,4 +70,71 @@ public function buildPagerWithGlobalSearchFieldProvider(): array [false], ]; } + + /** + * @dataProvider provideAdminSearchConfigurations + */ + public function testAdminSearch($expected, $filterCallsCount, ?bool $enabled, string $adminCode): void + { + $filter = $this->createMock(FilterInterface::class); + $filter->expects($this->exactly($filterCallsCount))->method('getOption')->with('global_search')->willReturn(true); + $filter->expects($this->exactly($filterCallsCount))->method('setOption')->with('case_sensitive', true); + + $pager = $this->createMock(PagerInterface::class); + $pager->expects($this->exactly($filterCallsCount))->method('setPage'); + $pager->expects($this->exactly($filterCallsCount))->method('setMaxPerPage'); + + $datagrid = $this->createMock(DatagridInterface::class); + $datagrid->expects($this->exactly($filterCallsCount))->method('getFilters')->willReturn([$filter]); + $datagrid->expects($this->exactly($filterCallsCount))->method('setValue'); + $datagrid->expects($this->exactly($filterCallsCount))->method('getPager')->willReturn($pager); + + $admin = $this->createMock(AdminInterface::class); + $admin->expects($this->exactly($filterCallsCount))->method('getDatagrid')->willReturn($datagrid); + $admin->expects($this->once())->method('getCode')->willReturn($adminCode); + + $handler = new SearchHandler(true); + + if (null !== $enabled) { + $handler->configureAdminSearch([$adminCode => $enabled]); + } + + if (false === $expected) { + $this->assertFalse($handler->search($admin, 'myservice')); + } else { + $this->assertInstanceOf($expected, $handler->search($admin, 'myservice')); + } + } + + public function provideAdminSearchConfigurations(): iterable + { + yield 'admin_search_enabled' => [PagerInterface::class, 1, true, 'admin.foo']; + yield 'admin_search_disabled' => [false, 0, false, 'admin.bar']; + yield 'admin_search_omitted' => [PagerInterface::class, 1, null, 'admin.baz']; + } + + /** + * @dataProvider provideAdminSearchInvalidConfigurations + */ + public function testAdminSearchInvalidConfig($enabled): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches( + '/^Values in the array passed as argument 1 to "Sonata\\\AdminBundle\\\Search\\\SearchHandler::configureAdminSearch\(\)"' + .' must be of type boolean, .+ given in the offset "admin\.foo"\.$/' + ); + + (new SearchHandler(true))->configureAdminSearch(['admin.foo' => $enabled]); + } + + public function provideAdminSearchInvalidConfigurations(): iterable + { + yield [1]; + yield [0]; + yield [null]; + yield ['']; + yield ['a']; + yield [[]]; + yield [new \stdClass()]; + } } diff --git a/tests/SonataAdminBundleTest.php b/tests/SonataAdminBundleTest.php index 74bd1d15532..c77d787dccf 100644 --- a/tests/SonataAdminBundleTest.php +++ b/tests/SonataAdminBundleTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase; use Sonata\AdminBundle\DependencyInjection\Compiler\AddDependencyCallsCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\AddFilterTypeCompilerPass; +use Sonata\AdminBundle\DependencyInjection\Compiler\AdminSearchCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\ExtensionCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\GlobalVariablesCompilerPass; use Sonata\AdminBundle\DependencyInjection\Compiler\ModelManagerCompilerPass; @@ -35,7 +36,7 @@ public function testBuild(): void { $containerBuilder = $this->createMock(ContainerBuilder::class); - $containerBuilder->expects($this->exactly(7)) + $containerBuilder->expects($this->exactly(8)) ->method('addCompilerPass') ->willReturnCallback(function (CompilerPassInterface $pass, $type = PassConfig::TYPE_BEFORE_OPTIMIZATION): void { if ($pass instanceof AddDependencyCallsCompilerPass) { @@ -46,6 +47,10 @@ public function testBuild(): void return; } + if ($pass instanceof AdminSearchCompilerPass) { + return; + } + if ($pass instanceof ExtensionCompilerPass) { return; } @@ -70,6 +75,7 @@ public function testBuild(): void 'CompilerPass is not one of the expected types. Expects "%s", "%s", "%s", "%s", "%s", "%s" or "%s", but got "%s".', AddDependencyCallsCompilerPass::class, AddFilterTypeCompilerPass::class, + AdminSearchCompilerPass::class, ExtensionCompilerPass::class, GlobalVariablesCompilerPass::class, ModelManagerCompilerPass::class,