├── LICENSE ├── README.md ├── assets ├── dist │ └── checker_controller.js └── package.json ├── composer.json ├── docs ├── advanced-example.md └── live-component.md ├── src ├── Controller │ └── DatagridControllerHelper.php ├── DependencyInjection │ └── KibaticDatagridExtension.php ├── Dto │ └── DateRange.php ├── Form │ ├── BooleanChoiceType.php │ └── DateRangeType.php ├── Grid │ ├── Column.php │ ├── Filter.php │ ├── Grid.php │ ├── GridBuilder.php │ ├── Template.php │ └── Theme.php ├── KibaticDatagridBundle.php ├── Maker │ └── MakeDatagrid.php ├── Resources │ ├── config │ │ └── services.yaml │ ├── translations │ │ ├── KibaticDatagridBundle.en.yaml │ │ └── KibaticDatagridBundle.fr.yaml │ └── views │ │ ├── _column_value.html.twig │ │ ├── column_type │ │ ├── array.html.twig │ │ ├── datetime.html.twig │ │ ├── entity.html.twig │ │ └── text.html.twig │ │ ├── components │ │ ├── datagrid-filters.html.twig │ │ └── datagrid.html.twig │ │ └── theme │ │ └── bootstrap5 │ │ ├── column_type │ │ ├── actions.html.twig │ │ └── boolean.html.twig │ │ ├── datagrid-filters.html.twig │ │ ├── datagrid-table.html.twig │ │ └── datagrid.html.twig └── Twig │ ├── AppExtension.php │ └── Components │ ├── DatagridComponent.php │ └── DatagridFiltersComponent.php └── templates └── maker └── GridBuilder.tpl.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kibatic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Kibatic Datagrid Bundle 2 | ======================= 3 | 4 | Datagrid bundle for Symfony with the following design philosophy : less magic for more flexibility. 5 | 6 | It's not the usual one line datagrid generator, it's a more verbose one but we think it's worth it. 7 | 8 | Features 9 | -------- 10 | 11 | - Your entities in a table 12 | - Pagination 13 | - Sortable 14 | - Filterable 15 | - Actions (single & batch) 16 | - Customizable templates 17 | - Only supports Doctrine ORM 18 | - Theme support (Bootstrap 5 included) 19 | 20 | 21 | Quick start 22 | ----------- 23 | 24 | ### Install the bundle 25 | 26 | ```bash 27 | composer require kibatic/datagrid-bundle 28 | ``` 29 | 30 | Add this to your `assets/controllers.json` : 31 | 32 | ```json 33 | { 34 | "controllers": { 35 | "@kibatic/datagrid-bundle": { 36 | "checker": { 37 | "enabled": true, 38 | "fetch": "eager" 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | You'll most likely also need to enable this twig function : https://twig.symfony.com/doc/2.x/functions/template_from_string.html 45 | 46 | ### Basic usage 47 | 48 | You can simply generate a specialized datagrid builder class skeleton using the make command : 49 | 50 | ``` 51 | bin/console make:datagrid 52 | ``` 53 | 54 | Or do everything manually, for example in your controller : 55 | 56 | ```php 57 | getUser(); 80 | 81 | // create query builder filtered by current user 82 | $queryBuilder = $projectRepository->createQueryBuilder('p') 83 | ->where('p.owner = :user') 84 | ->setParameter('user', $user) 85 | ->orderBy('p.createdAt', 'DESC'); 86 | ; 87 | $grid = $gridBuilder 88 | ->initialize(queryBuilder: $queryBuilder) 89 | ->addColumn('Name', 'name') 90 | ->addColumn( 91 | 'Created at', 92 | 'createdAt', 93 | Template::DATETIME, 94 | sortable: 'createdAt' 95 | ) 96 | ->getGrid() 97 | ; 98 | 99 | 100 | return $this->render('project/index.html.twig', [ 101 | 'grid' => $grid 102 | ]); 103 | } 104 | } 105 | ``` 106 | 107 | Then with Symfony UX handy twig components : 108 | 109 | ```twig 110 | {% extends 'base.html.twig' %} 111 | 112 | {% block body %} 113 |

Project list

114 | 115 | 116 | {% endblock %} 117 | ``` 118 | 119 | 120 | Or a more classic twig approach : 121 | 122 | ```twig 123 | {% extends 'base.html.twig' %} 124 | 125 | {% block body %} 126 |

Project list

127 | 128 | {% include grid.theme ~ '/datagrid.html.twig' %} 129 | {% endblock %} 130 | ``` 131 | 132 | 133 | Documentation 134 | ------------- 135 | 136 | You can find a more advanced example on [how to generate your datagrid](docs/advanced-example.md). 137 | 138 | If you want to customize the pagination, use the knp paginator configuration. 139 | 140 | ``` 141 | # config/packages/knp_paginator.yaml 142 | knp_paginator: 143 | page_limit: 20 144 | ``` 145 | 146 | If you're using a datagrid inside a live component (symfony ux), [you'll need to do this](docs/advanced-example.md). 147 | 148 | Requirements 149 | ------------ 150 | 151 | - Symfony 6 152 | - PHP 8.2 153 | - Doctrine ORM 154 | 155 | Roadmap 156 | ------- 157 | 158 | - Adding a Flex recipe 159 | -------------------------------------------------------------------------------- /assets/dist/checker_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | 3 | export default class extends Controller { 4 | static targets = ["master", "checkbox"] 5 | 6 | masterTargetConnected(element) { 7 | element.addEventListener("click", this.toggleAll.bind(this)) 8 | } 9 | 10 | toggleAll() { 11 | console.log(this.checkboxTargets) 12 | 13 | for (let checkbox of this.checkboxTargets) { 14 | checkbox.checked = this.masterTarget.checked 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kibatic/datagrid-bundle", 3 | "description": "", 4 | "license": "MIT", 5 | "version": "0.0.1", 6 | "main": "dist/checker_controller.js", 7 | "symfony": { 8 | "controllers": { 9 | "checker": { 10 | "main": "dist/checker_controller.js", 11 | "name": "checker", 12 | "webpackMode": "eager", 13 | "fetch": "eager", 14 | "enabled": true 15 | } 16 | }, 17 | "importmap": { 18 | "@hotwired/stimulus": "^3.0.0" 19 | } 20 | }, 21 | "peerDependencies": { 22 | "@hotwired/stimulus": "^3.0.0" 23 | }, 24 | "devDependencies": { 25 | "@hotwired/stimulus": "^3.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kibatic/datagrid-bundle", 3 | "description": "Datagrid for Symfony", 4 | "license": "MIT", 5 | "type": "symfony-bundle", 6 | "keywords": ["datagrid"], 7 | "authors": [ 8 | { 9 | "name": "Kibatic", 10 | "email": "contact@kibatic.com", 11 | "homepage": "https://kibatic.com" 12 | } 13 | ], 14 | "require": { 15 | "php": "^8.1", 16 | "doctrine/orm": "^2.7|^3.0", 17 | "twig/twig": "^2.12.1|^3.0", 18 | "twig/extra-bundle": "^3.4", 19 | "twig/string-extra": "^3.4", 20 | "knplabs/knp-paginator-bundle": "^5.8|^6.3", 21 | "symfony/property-access": "^6.3|^7.0", 22 | "symfony/form": "^6.3|^7.0", 23 | "symfony/options-resolver": "^6.3|^7.0", 24 | "kibatic/ux-bundle": "^0.7.2" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Kibatic\\DatagridBundle\\": "src" 29 | } 30 | }, 31 | "require-dev": { 32 | "symfony/asset-mapper": "^7.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docs/advanced-example.md: -------------------------------------------------------------------------------- 1 | # Advanced example 2 | 3 | ```php 4 | // App\Controller\BookController 5 | #[Route('/book/list', name: 'book_list', methods=['GET', 'POST'])] 6 | public function list( 7 | Request $request, 8 | GridBuilder $gridBuilder, 9 | BookRepository $repository 10 | ): Response { 11 | $form = $this->createForm(BookFiltersType::class) 12 | ->handleRequest($request); 13 | 14 | $qb = $repository->createQueryBuilder('b') 15 | ->leftJoin('b.tags', 't') 16 | ->addSelect('count(t) as tagsCount') 17 | ->where('b.published = true') 18 | ; 19 | 20 | $grid = $gridBuilder 21 | ->initialize(queryBuilder: $qb, filtersForm: $form) 22 | ->setTheme(Theme::BOOTSTRAP4_SONATA) // (optional) default theme is Bootstrap 5 23 | ->addColumn( 24 | 'ID', 25 | 'id', // first way of getting the value, using a string accessor 26 | templateParameters: ['col_class' => 'col-md-1'], 27 | sortable: 't.id' 28 | ) 29 | ->addColumn( 30 | 'Title', 31 | fn(Book $book) => $book->getTitle(), // second way using a callable returning wanted value 32 | templateParameters: ['truncate' => 30] 33 | ) 34 | ->addColumn( 35 | 'Created at', 36 | fn(Book $book) => $book->getCreatedAt(), 37 | Template::DATETIME, 38 | ['format' => 'd/m/Y'] 39 | ) 40 | ->addColumn( 41 | 'Promoted', 42 | fn(Book $book) => $book->isPromoted(), 43 | Template::BOOLEAN, 44 | sortable: 'b.promoted' 45 | ) 46 | ->addColumn( 47 | 'Editor', 48 | fn(Book $book) => $book->getEditor()->getName(), 49 | sortable: 'editor', // The name of the sort option in the query can be customized 50 | sortableQuery: 'b.editor.name', // and then you can specify what will actually be used in the query to sort 51 | ) 52 | ->addColumn( 53 | 'Spell checked At', 54 | fn(Book $book) => $book->getSpellCheckedAt(), 55 | Template::DATETIME, 56 | sortable: 'spellCheckedAt', 57 | sortableQuery: function (QueryBuilder $qb, string $direction) { // You can also work with the QueryBuilder directly 58 | // ORDER BY NULLS LAST does not exist in vanilla doctrine 59 | $qb->addSelect('CASE WHEN t.spellCheckedAt IS NULL THEN 1 ELSE 0 END as HIDDEN spellCheckedAtIsNull'); 60 | $qb->addOrderBy( "spellCheckedAtIsNull", $direction === 'ASC' ? 'DESC': 'ASC'); 61 | $qb->addOrderBy( "t.spellCheckedAt", $direction); 62 | } 63 | ) 64 | ->addColumn( 65 | 'Tags', 66 | // You can access extra select data too 67 | value: 'tagsCount', 68 | // If you want to use a callback, read the value from the second argument to get any extra select data 69 | value: fn(Book $book, array $extra) => $extra['tagsCount'], 70 | sortable: 'tagsCount' 71 | ) 72 | ->addColumn( 73 | 'Actions', 74 | fn(Book $book) => [ 75 | [ 76 | 'name' => 'Edit', 77 | 'url' => $this->generateUrl('book_edit', ['id' => $book->getId()]), 78 | 'icon_class' => 'fa fa-pencil' 79 | ], 80 | [ 81 | 'url' => $this->generateUrl('book_delete', ['id' => $book->getId()]), 82 | 'name' => 'Delete', 83 | 'btn_type' => 'danger', 84 | 'icon_class' => 'fa fa-trash', 85 | ], 86 | [ 87 | 'url' => $this->generateUrl('book_promote', ['id' => $book->getId()]), 88 | 'name' => 'Promote', 89 | 'btn_type' => 'default', 90 | 'icon_class' => 'fa fa-map-pin', 91 | 'visible' => !$book->isPromoted(), 92 | ], 93 | [ 94 | 'url' => $this->generateUrl('book_demote', ['id' => $book->getId()]), 95 | 'name' => 'Demote', 96 | 'btn_type' => 'default', 97 | 'icon_class' => 'fa fa-map-pin', 98 | 'visible' => $book->isPromoted(), 99 | ], 100 | ], 101 | Template::ACTIONS, 102 | ) 103 | ->addBatchAction( 104 | 'delete', 105 | 'Delete', 106 | $this->generateUrl('book_batch_delete') 107 | ) 108 | ->addFilter( 109 | 'id', 110 | fn (QueryBuilder $qb, $formValue) => $qb 111 | ->andWhere('t.id = :id')->setParameter('id', $formValue) 112 | ) 113 | ->addFilter( 114 | 'message', 115 | fn (QueryBuilder $qb, $formValue) => $qb 116 | ->andWhere('LOWER(t.message) LIKE LOWER(:message)') 117 | ->setParameter('message', "%$formValue%") 118 | ) 119 | ->addFilter( 120 | 'promoted', 121 | fn (QueryBuilder $qb, $formValue) => $qb 122 | ->andWhere('t.promoted = :promoted') 123 | ->setParameter('promoted', $formValue) 124 | ) 125 | ->getGrid() 126 | ; 127 | 128 | return $this->render('book/list.html.twig', [ 129 | 'grid' => $grid, 130 | 'form' => $form->createView() 131 | ]); 132 | } 133 | 134 | /** 135 | * @Route("/book/batch-delete", methods={"POST"}, name="book_batch_delete") 136 | */ 137 | public function batchDelete(Request $request): Response 138 | { 139 | $request->get('ids'); 140 | // etc. 141 | } 142 | ``` 143 | 144 | ```php 145 | // App\Form\BookFiltersType 146 | public function buildForm(FormBuilderInterface $builder, array $options) 147 | { 148 | $builder 149 | ->setMethod('GET') 150 | ->add('id', NumberType::class, ['required' => false]) 151 | ->add('message', TextType::class, ['required' => false]) 152 | ->add('promoted', BooleanChoiceType::class, ['label' => 'Promoted ?']) 153 | ; 154 | } 155 | ``` 156 | 157 | ```twig 158 | {# templates/book/list.html.twig #} 159 | 160 | {% include grid.theme ~ '/datagrid.html.twig' %} 161 | ``` 162 | -------------------------------------------------------------------------------- /docs/live-component.md: -------------------------------------------------------------------------------- 1 | # Live component 2 | 3 | If you want to insert a datagrid in a live component template, you'll need to explicitly specify the current route and its parameters for the sort and pagination links to work. 4 | 5 | ```php 6 | #[AsLiveComponent(name: 'hello')] 7 | final class HelloComponent extends AbstractComponent 8 | { 9 | // ... 10 | 11 | #[LiveProp] 12 | public ?string $routeName = null; 13 | #[LiveProp] 14 | public ?array $routeParams = []; 15 | 16 | // ... 17 | 18 | public function getGrid(): Grid 19 | { 20 | // ... 21 | 22 | return $this->gridBuilder->initialize($this->requestStack->getMainRequest(), $qb); 23 | // ... 24 | ->setExplicitRoute($this->routeName, $this->routeParams) 25 | ->getGrid() 26 | ; 27 | } 28 | ``` 29 | 30 | ```twig 31 | {{ component('hello', { 32 | routeName: app.request.attributes.get('_route'), 33 | routeParams: app.request.attributes.get('_route_params'), 34 | }) }} 35 | ``` 36 | 37 | ```twig 38 | 39 | {% include grid.theme ~ '/datagrid.html.twig' %} 40 | 41 | ``` -------------------------------------------------------------------------------- /src/Controller/DatagridControllerHelper.php: -------------------------------------------------------------------------------- 1 | container->get('form.factory') 12 | ->createNamedBuilder($name, options: array_merge([ 13 | 'method' => $method, 14 | 'csrf_protection' => $csrfProtection, 15 | ], $options)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/DependencyInjection/KibaticDatagridExtension.php: -------------------------------------------------------------------------------- 1 | load('services.yaml'); 21 | } 22 | 23 | public function prepend(ContainerBuilder $container): void 24 | { 25 | if ($this->isAssetMapperAvailable($container)) { 26 | $container->prependExtensionConfig('framework', [ 27 | 'asset_mapper' => [ 28 | 'paths' => [ 29 | __DIR__.'/../../assets/dist' => '@kibatic/datagrid-bundle', 30 | ], 31 | ], 32 | ]); 33 | } 34 | } 35 | 36 | private function isAssetMapperAvailable(ContainerBuilder $container): bool 37 | { 38 | if (!interface_exists(AssetMapperInterface::class)) { 39 | return false; 40 | } 41 | 42 | // check that FrameworkBundle 6.3 or higher is installed 43 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); 44 | if (!isset($bundlesMetadata['FrameworkBundle'])) { 45 | return false; 46 | } 47 | 48 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Dto/DateRange.php: -------------------------------------------------------------------------------- 1 | setDefaults([ 14 | 'required' => false, 15 | 'translation_domain' => 'KibaticDatagridBundle', 16 | 'choices' => [ 17 | 'column.boolean.true' => true, 18 | 'column.boolean.false' => false, 19 | ] 20 | ]); 21 | } 22 | 23 | public function getParent(): string 24 | { 25 | return ChoiceType::class; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Form/DateRangeType.php: -------------------------------------------------------------------------------- 1 | add('start', DateType::class, [ 17 | 'label' => $options['start_label'], 18 | 'widget' => 'single_text', 19 | 'required' => false, 20 | ]) 21 | ->add('end', DateType::class, [ 22 | 'label' => $options['end_label'], 23 | 'widget' => 'single_text', 24 | 'required' => false, 25 | ]) 26 | ; 27 | } 28 | 29 | public function configureOptions(OptionsResolver $resolver): void 30 | { 31 | $resolver->setDefaults([ 32 | 'data_class' => DateRange::class, 33 | 'start_label' => 'From', 34 | 'end_label' => 'To', 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Grid/Column.php: -------------------------------------------------------------------------------- 1 | name = $name; 28 | $this->value = $value ?? fn($item) => $item; 29 | $this->template = $template; 30 | $this->templateParameters = $templateParameters; 31 | $this->sortable = $sortable; 32 | $this->sortableQuery = $sortableQuery; 33 | $this->enabled = $enabled; 34 | } 35 | 36 | public function getTemplate(null|object|array $entity = null): string 37 | { 38 | if ($this->template !== null) { 39 | return $this->template; 40 | } 41 | 42 | if ($entity !== null && is_array($this->getValue($entity))) { 43 | return Template::ARRAY; 44 | } 45 | 46 | return Template::TEXT; 47 | } 48 | 49 | public function getValue(object|array $entity) 50 | { 51 | if (is_array($entity)) { 52 | $extra = $entity; 53 | $entity = $entity[0]; 54 | } 55 | 56 | if (is_callable($this->value)) { 57 | $valueCallback = $this->value; 58 | return $valueCallback($entity, $extra ?? []); 59 | } 60 | 61 | if ($this->value === null) { 62 | return isset($extra) ? [$entity, $extra] : $entity; 63 | } 64 | 65 | try { 66 | return (PropertyAccess::createPropertyAccessor())->getValue($entity, $this->value); 67 | } catch (NoSuchPropertyException $e) { 68 | if (isset($extra)) { 69 | return (PropertyAccess::createPropertyAccessor())->getValue($extra, "[{$this->value}]"); 70 | } 71 | 72 | throw $e; 73 | } 74 | } 75 | 76 | public function getTemplateParameter(string $parameterName, ?string $defaultValue = null) 77 | { 78 | return $this->templateParameters[$parameterName] ?? $defaultValue; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Grid/Filter.php: -------------------------------------------------------------------------------- 1 | formFieldName = $formFieldName; 17 | $this->callback = $callback; 18 | $this->enabled = $enabled; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Grid/Grid.php: -------------------------------------------------------------------------------- 1 | columns = $columns; 33 | $this->request = $request; 34 | $this->pagination = $pagination; 35 | $this->batchActions = $batchActions; 36 | $this->batchMethod = $batchMethod; 37 | $this->theme = $theme; 38 | $this->rowAttributesCallback = $rowAttributesCallback; 39 | } 40 | 41 | public function getColumns(): array 42 | { 43 | return $this->columns; 44 | } 45 | 46 | public function getRequest(): Request 47 | { 48 | return $this->request; 49 | } 50 | 51 | public function getPagination(): PaginationInterface 52 | { 53 | return $this->pagination; 54 | } 55 | 56 | public function getBatchActions(): array 57 | { 58 | return $this->batchActions; 59 | } 60 | 61 | public function hasBatchActions(): bool 62 | { 63 | return !empty($this->batchActions); 64 | } 65 | 66 | public function getBatchMethod(): string 67 | { 68 | return $this->batchMethod; 69 | } 70 | 71 | public function getBatchActionsTokenId(): string 72 | { 73 | return json_encode($this->getBatchActions()); 74 | } 75 | 76 | public function getTheme(): string 77 | { 78 | return $this->theme; 79 | } 80 | 81 | public function getRowAttributes($item, bool $keepAsArray = false): null|array|string 82 | { 83 | if (!is_callable($this->rowAttributesCallback)) { 84 | return null; 85 | } 86 | 87 | $callback = $this->rowAttributesCallback; 88 | $attributes = $callback($item); 89 | 90 | if (!is_array($attributes)) { 91 | return null; 92 | } 93 | 94 | if ($keepAsArray) { 95 | return $attributes; 96 | } 97 | 98 | return AppExtension::attributesToHtml($attributes); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Grid/GridBuilder.php: -------------------------------------------------------------------------------- 1 | paginator = $paginator; 43 | $this->defaultItemsPerPage = $params->get('knp_paginator.page_limit') ?? 25; 44 | } 45 | 46 | /** 47 | * @deprecated 48 | */ 49 | public function create(QueryBuilder $queryBuilder, Request $request, ?FormInterface $filtersForm = null): self 50 | { 51 | return $this->initialize($request, $queryBuilder, $filtersForm); 52 | } 53 | 54 | public function initialize(QueryBuilder $queryBuilder, ?FormInterface $filtersForm = null, ?Request $request = null): self 55 | { 56 | $request ??= $this->requestStack->getMainRequest(); 57 | 58 | if ($filtersForm !== null 59 | && !$filtersForm->isSubmitted() 60 | ) { 61 | $filtersForm?->handleRequest($request); 62 | } 63 | 64 | $this->request = $request; 65 | $this->queryBuilder = $queryBuilder; 66 | $this->filtersForm = $filtersForm; 67 | 68 | $this->reset(); 69 | 70 | return $this; 71 | } 72 | 73 | public function reset() 74 | { 75 | $this->itemsPerPage = $this->defaultItemsPerPage; 76 | $this->rowAttributesCallback = null; 77 | $this->columns = []; 78 | $this->filters = []; 79 | $this->batchActions = []; 80 | $this->batchMethod = 'POST'; 81 | $this->theme = '@KibaticDatagrid/theme/bootstrap5'; 82 | $this->explicitRouteName = null; 83 | $this->explicitRouteParams = []; 84 | $this->grid = null; 85 | } 86 | 87 | public function setTheme(string $theme): self 88 | { 89 | $this->theme = $theme; 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * @param string|null $template #Template 96 | */ 97 | public function addColumn( 98 | string $name, 99 | string|callable|null $value = null, 100 | string $template = null, 101 | array $templateParameters = [], 102 | string $sortable = null, 103 | callable|string|null $sortableQuery = null, 104 | bool $enabled = true, 105 | ): self { 106 | $this->columns[] = new Column( 107 | $name, 108 | $value, 109 | $template, 110 | $templateParameters, 111 | $sortable, 112 | $sortableQuery, 113 | $enabled, 114 | ); 115 | 116 | return $this; 117 | } 118 | 119 | public function getColumns(): array 120 | { 121 | return array_filter($this->columns, fn(Column $column) => $column->enabled); 122 | } 123 | 124 | public function getColumn(string $name): Column 125 | { 126 | foreach ($this->columns as $column) { 127 | if ($column->name === $name) { 128 | return $column; 129 | } 130 | } 131 | 132 | throw new \Exception("Column named {$name} not found."); 133 | } 134 | 135 | public function removeColumn(string $name): self 136 | { 137 | foreach ($this->columns as $key => $column) { 138 | if ($column->name === $name) { 139 | unset($this->columns[$key]); 140 | } 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | public function addFilter(string $formFieldName, callable $callback, bool $enabled = true): self 147 | { 148 | $this->filters[] = new Filter($formFieldName, $callback, $enabled); 149 | 150 | return $this; 151 | } 152 | 153 | public function removeFilter(string $formFieldName): self 154 | { 155 | foreach ($this->filters as $key => $filter) { 156 | if ($filter->formFieldName === $formFieldName) { 157 | unset($this->filters[$key]); 158 | } 159 | } 160 | 161 | return $this; 162 | } 163 | 164 | private function applySort(): void 165 | { 166 | $sortBy = $this->request->get('sort_by'); 167 | $direction = $this->request->get('sort_order', 'ASC'); 168 | 169 | if ($sortBy === null) { 170 | return; 171 | } 172 | 173 | // check if the sortBy param is configured 174 | foreach ($this->columns as $column) { 175 | if (!$column->enabled) { 176 | continue; 177 | } 178 | 179 | if ($column->sortable !== $sortBy) { 180 | continue; 181 | } 182 | 183 | if (is_callable($column->sortableQuery)) { 184 | $sortCallback = $column->sortableQuery; 185 | $sortCallback($this->queryBuilder, $direction); 186 | continue; 187 | } 188 | 189 | if ($column->sortableQuery !== null) { 190 | $this->queryBuilder->orderBy($column->sortableQuery, $direction); 191 | continue; 192 | } 193 | 194 | $this->queryBuilder->orderBy($column->sortable, $direction); 195 | } 196 | } 197 | 198 | public function applyFilters(): void 199 | { 200 | if (empty($this->filters) || 201 | $this->filtersForm === null 202 | ) { 203 | return; 204 | } 205 | 206 | foreach ($this->filters as $filter) { 207 | if (!$filter->enabled) { 208 | continue; 209 | } 210 | 211 | $filterField = $this->filtersForm->get($filter->formFieldName); 212 | 213 | if ($filterField === null) { 214 | throw new \Exception("Form field named {$filter->formFieldName} not found in the filters form of the datagrid."); 215 | } 216 | 217 | $filterValue = $filterField->getData(); 218 | 219 | if ($filterValue === null) { 220 | continue; 221 | } 222 | 223 | $callback = $filter->callback; // TODO ($f->c)() 224 | $callback($this->queryBuilder, $filterValue, $this->filtersForm); 225 | } 226 | } 227 | 228 | public function addBatchAction(string $id, string $label, string $url): self 229 | { 230 | $this->batchActions[] = [ 231 | 'id' => $id, 232 | 'label' => $label, 233 | 'url' => $url 234 | ]; 235 | 236 | return $this; 237 | } 238 | 239 | public function setBatchMethod(string $method): self 240 | { 241 | $this->batchMethod = $method; 242 | 243 | return $this; 244 | } 245 | 246 | public function setItemsPerPage(?int $itemsPerPage): self 247 | { 248 | $this->itemsPerPage = $itemsPerPage; 249 | 250 | return $this; 251 | } 252 | 253 | public function setExplicitRoute(string $routeName, array $routeParams = []): self 254 | { 255 | $this->explicitRouteName = $routeName; 256 | $this->explicitRouteParams = $routeParams; 257 | 258 | return $this; 259 | } 260 | 261 | public function setRowAttributesCallback(callable $callback): self 262 | { 263 | $this->rowAttributesCallback = $callback; 264 | 265 | return $this; 266 | } 267 | 268 | public function getQueryBuilder(): QueryBuilder 269 | { 270 | return $this->queryBuilder; 271 | } 272 | 273 | public function getGrid(bool $forceRecreate = false): Grid 274 | { 275 | if ($this->grid === null || $forceRecreate) { 276 | $this->applySort(); 277 | $this->applyFilters(); 278 | 279 | $pagination = $this->paginator->paginate( 280 | $this->queryBuilder->getQuery(), 281 | $this->request->query->getInt('page', 1), 282 | $this->itemsPerPage 283 | ); 284 | 285 | if ($this->explicitRouteName) { 286 | $pagination->setUsedRoute($this->explicitRouteName); 287 | 288 | foreach ($this->explicitRouteParams as $key => $value) { 289 | $pagination->setParam($key, $value); 290 | } 291 | } 292 | 293 | $this->grid = new Grid( 294 | $this->getColumns(), 295 | $this->request, 296 | $pagination, 297 | $this->theme, 298 | $this->batchActions, 299 | $this->batchMethod, 300 | $this->rowAttributesCallback 301 | ); 302 | } 303 | 304 | return $this->grid; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/Grid/Template.php: -------------------------------------------------------------------------------- 1 | addArgument('entity-class', InputArgument::REQUIRED, 'The name of Entity or fully qualified model class name that the new datagrid will be listing') 69 | ; 70 | 71 | $inputConfig->setArgumentAsNonInteractive('entity-class'); 72 | } 73 | 74 | public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void 75 | { 76 | if (null === $input->getArgument('entity-class')) { 77 | $argument = $command->getDefinition()->getArgument('entity-class'); 78 | 79 | $entities = $this->entityHelper->getEntitiesForAutocomplete(); 80 | 81 | $question = new Question($argument->getDescription()); 82 | $question->setValidator(fn ($answer) => Validator::existsOrNull($answer, $entities)); 83 | $question->setAutocompleterValues($entities); 84 | $question->setMaxAttempts(3); 85 | 86 | $input->setArgument('entity-class', $io->askQuestion($question)); 87 | } 88 | 89 | $defaultGridBuilderClass = Str::asClassName(\sprintf('%s GridBuilder', $input->getArgument('entity-class'))); 90 | 91 | $this->gridBuilderClassName = $io->ask( 92 | \sprintf('Choose a name for your class (e.g. %s)', $defaultGridBuilderClass), 93 | $defaultGridBuilderClass 94 | ); 95 | } 96 | 97 | public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void 98 | { 99 | $entityClassDetails = $generator->createClassNameDetails( 100 | $input->getArgument('entity-class'), 101 | 'Entity\\' 102 | ); 103 | 104 | $entityDetails = $this->entityHelper->createDoctrineDetails($entityClassDetails->getFullName()); 105 | 106 | $repositoryClassDetails = $generator->createClassNameDetails( 107 | '\\'.$entityDetails->getRepositoryClass(), 108 | 'Repository\\', 109 | 'Repository' 110 | ); 111 | 112 | $classData = ClassData::create( 113 | class: \sprintf('Datagrid\%s', $this->gridBuilderClassName), 114 | extendsClass: GridBuilder::class, 115 | useStatements: [ 116 | $entityClassDetails->getFullName(), 117 | $repositoryClassDetails->getFullName(), 118 | GridBuilder::class, 119 | PaginatorInterface::class, 120 | ParameterBagInterface::class, 121 | QueryBuilder::class, 122 | RouterInterface::class, 123 | Request::class, 124 | RequestStack::class, 125 | FormInterface::class, 126 | Template::class, 127 | TranslatableMessage::class, 128 | ], 129 | ); 130 | 131 | $columns = []; 132 | 133 | foreach ($entityDetails->getDisplayFields() as $field) { 134 | $columns[] = [ 135 | 'name' => ucfirst(strtolower(Str::asHumanWords($field['fieldName']))), 136 | 'value' => $field['fieldName'], 137 | 'template' => $this->getColumnTemplateByType($field['type']), 138 | ]; 139 | } 140 | 141 | $generator->generateClass( 142 | $classData->getFullClassName(), 143 | \sprintf('%s/../templates/maker/GridBuilder.tpl.php', \dirname(__DIR__)), 144 | [ 145 | 'class_data' => $classData, 146 | 'repository_class' => $repositoryClassDetails->getShortName(), 147 | 'entity_short_name' => $entityClassDetails->getShortName(), 148 | 'entity_var' => lcfirst($entityClassDetails->getShortName()), 149 | 'entity_snake_case' => Str::asSnakeCase($entityClassDetails->getShortName()), 150 | 'query_entity_alias' => strtolower($entityClassDetails->getShortName()[0]), 151 | 'entity_display_fields' => $entityDetails->getDisplayFields(), 152 | 'columns' => $columns, 153 | ] 154 | ); 155 | 156 | // $this->formTypeRenderer->render( 157 | // $generator->createClassNameDetails( 158 | // "{$entityClassDetails->getRelativeNameWithoutSuffix()}FiltersType", 159 | // 'Form\\DatagridFilters\\', 160 | // 'Type' 161 | // ), 162 | // ['search' => null], 163 | // ); 164 | 165 | $generator->writeChanges(); 166 | 167 | $this->writeSuccessMessage($io); 168 | } 169 | 170 | private function getColumnTemplateByType(string $type): ?string 171 | { 172 | return match ($type) { 173 | 'datetime' => 'Template::DATETIME', 174 | 'datetime_immutable' => 'Template::DATETIME', 175 | 'boolean' => 'Template::BOOLEAN', 176 | default => null, 177 | }; 178 | } 179 | 180 | private function decamel(string $string): string 181 | { 182 | return ucfirst(strtolower(preg_replace('/([a-z])([A-Z])/', '$1 $2', $string))); 183 | } 184 | 185 | public function configureDependencies(DependencyBuilder $dependencies): void 186 | { 187 | // $dependencies->addClassDependency(AbstractType::class, 'form'); 188 | // $dependencies->addClassDependency(DoctrineBundle::class, 'orm', false); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/Resources/config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | 6 | Kibatic\DatagridBundle\Grid\GridBuilder: 7 | public: true 8 | 9 | Kibatic\DatagridBundle\Twig\AppExtension: 10 | public: true 11 | tags: 12 | - { name: twig.extension } 13 | 14 | Kibatic\DatagridBundle\Maker\MakeDatagrid: 15 | arguments: 16 | $entityHelper: '@maker.doctrine_helper' 17 | $formTypeRenderer: '@maker.renderer.form_type_renderer' 18 | tags: 19 | - { name: maker.command } 20 | 21 | Kibatic\DatagridBundle\Twig\Components\DatagridComponent: 22 | tags: 23 | - twig.component: { key: 'datagrid', template: '@KibaticDatagrid/components/datagrid.html.twig' } 24 | 25 | Kibatic\DatagridBundle\Twig\Components\DatagridFiltersComponent: 26 | tags: 27 | - twig.component: { key: 'datagrid-filters', template: '@KibaticDatagrid/components/datagrid-filters.html.twig' } 28 | -------------------------------------------------------------------------------- /src/Resources/translations/KibaticDatagridBundle.en.yaml: -------------------------------------------------------------------------------- 1 | column: 2 | boolean: 3 | 'true': Yes 4 | 'false': No -------------------------------------------------------------------------------- /src/Resources/translations/KibaticDatagridBundle.fr.yaml: -------------------------------------------------------------------------------- 1 | No result: Aucun résultat 2 | Batch action: Traitement par lot 3 | Select all: Tout selectionner 4 | Sort: Trier 5 | Filter: Filtrer 6 | Reset: Réinitialiser 7 | 8 | column: 9 | boolean: 10 | 'true': Oui 11 | 'false': Non -------------------------------------------------------------------------------- /src/Resources/views/_column_value.html.twig: -------------------------------------------------------------------------------- 1 | {% set template = column.template(item) %} 2 | 3 | {% include [grid.theme ~ '/column_type/' ~ template, '@KibaticDatagrid/column_type/' ~ template, template] with { 4 | 'column': column, 5 | 'parameters': column.templateParameters, 6 | 'item': item, 7 | 'value': column.value(item) 8 | } %} 9 | -------------------------------------------------------------------------------- /src/Resources/views/column_type/array.html.twig: -------------------------------------------------------------------------------- 1 | {{ value|join(', ') }} 2 | -------------------------------------------------------------------------------- /src/Resources/views/column_type/datetime.html.twig: -------------------------------------------------------------------------------- 1 | {% set format = parameters.format|default('Y-m-d H:i:s') %} 2 | 3 | {% if value is not null %}{{ value|date(format) }}{% else %}{{ null_value|default('-') }}{% endif %} 4 | -------------------------------------------------------------------------------- /src/Resources/views/column_type/entity.html.twig: -------------------------------------------------------------------------------- 1 | {% if value is not null -%} 2 | {%- set action = { 3 | 'url': path(parameters.route, {'id': value.id}|merge(parameters.route_extra_params|default([]))), 4 | 'name': value, 5 | 'target': parameters.target|default('offcanvas'), 6 | 'suffix': parameters.suffix|default(''), 7 | 'class': parameters.class|default('') 8 | } -%} 9 | {%- set as_link = as_link|default(true) -%} 10 | 11 | 16 | {{ value|trans }} 17 | 18 | {% else -%} 19 | {{- null_value|default('-') -}} 20 | {%- endif %} 21 | -------------------------------------------------------------------------------- /src/Resources/views/column_type/text.html.twig: -------------------------------------------------------------------------------- 1 | {% set truncate = parameters.truncate|default(0) %} 2 | {% set value = truncate == 0 ? value : value|u.truncate(truncate, '...') %} 3 | 4 | {% if parameters.escape ?? true %} 5 | {{ value|trans }} 6 | {% else %} 7 | {{ value|trans|raw }} 8 | {% endif %} 9 | -------------------------------------------------------------------------------- /src/Resources/views/components/datagrid-filters.html.twig: -------------------------------------------------------------------------------- 1 | 2 | {% include grid.theme ~ '/datagrid-filters.html.twig' %} 3 | 4 | -------------------------------------------------------------------------------- /src/Resources/views/components/datagrid.html.twig: -------------------------------------------------------------------------------- 1 | 2 | {% if grid is defined and form is defined %} 3 | {% include grid.theme ~ '/datagrid.html.twig' %} 4 | {% elseif grid is defined and form is not defined %} 5 | {% include grid.theme ~ '/datagrid-table.html.twig' %} 6 | {% endif %} 7 | 8 | -------------------------------------------------------------------------------- /src/Resources/views/theme/bootstrap5/column_type/actions.html.twig: -------------------------------------------------------------------------------- 1 | {% set actions = value %} 2 | 3 |
    4 | {% for action in value %} 5 | {% if action.visible is not defined or action.visible %} 6 |
  • 7 | {% if action.template is defined %} 8 | {% include action.template with action %} 9 | {% else %} 10 | 18 | {{ action.name|trans }} 19 | 20 | {% endif %} 21 |
  • 22 | {% endif %} 23 | {% endfor %} 24 |
25 | -------------------------------------------------------------------------------- /src/Resources/views/theme/bootstrap5/column_type/boolean.html.twig: -------------------------------------------------------------------------------- 1 | {% if value %} 2 | {{ 'column.boolean.true'|trans({}, 'KibaticDatagridBundle') }} 3 | {% else %} 4 | {{ 'column.boolean.false'|trans({}, 'KibaticDatagridBundle') }} 5 | {% endif %} -------------------------------------------------------------------------------- /src/Resources/views/theme/bootstrap5/datagrid-filters.html.twig: -------------------------------------------------------------------------------- 1 | {{ form_start(form) }} 2 | 3 | {% for field in form %} 4 | {% if field.vars.block_prefixes[0] != 'button' %} 5 | {{ form_row(field) }} 6 | {% endif %} 7 | {% endfor %} 8 | 9 |
10 |
11 |
    12 |
  • 13 | 14 | {{ 'Reset'|trans({}, 'KibaticDatagridBundle') }} 15 | 16 |
  • 17 | 18 | {% for field in form %} 19 |
  • 20 | {% if field.vars.block_prefixes[0] == 'button' %} 21 | {{ form_row(field) }} 22 | {% endif %} 23 |
  • 24 | {% endfor %} 25 | 26 |
  • 27 | 30 |
  • 31 |
32 |
33 |
34 | {{ form_end(form) }} 35 | -------------------------------------------------------------------------------- /src/Resources/views/theme/bootstrap5/datagrid-table.html.twig: -------------------------------------------------------------------------------- 1 | {% set header_tr_class = block('grid_header_tr_class') is defined ? block('grid_header_tr_class') : '' %} 2 | 3 | {% if grid.pagination.totalItemCount > 0 %} 4 | {% if grid.hasBatchActions %} 5 |
6 | 7 | {% endif %} 8 | 9 | 10 | 11 | 12 | {% if grid.hasBatchActions %} 13 | 16 | {% endif %} 17 | {% for column in grid.columns %} 18 | {% set th_class = block('grid_header_th_class') is defined ? block('grid_header_th_class') : '' %} 19 | 20 | {% if column.templateParameter('col_class', null) is not null %} 21 | {% set th_class = th_class ~ ' ' ~ column.templateParameter('col_class', null) %} 22 | {% endif %} 23 | 24 | 40 | {% endfor %} 41 | 42 | 43 | 44 | {% for item in grid.pagination %} 45 | 46 | {% if grid.hasBatchActions %} 47 | 50 | {% endif %} 51 | {% for column in grid.columns %} 52 | 55 | {% endfor %} 56 | 57 | {% endfor %} 58 | 59 |
14 | 15 | 25 | {% if not (column.name starts with '_') %} 26 | {% if column.sortable is not null %} 27 | {% set sortUrl = path( 28 | grid.pagination.route, 29 | grid.pagination.params|merge({ 30 | 'sort_by': column.sortable, 31 | 'sort_order': grid.request.get('sort_order') == 'ASC' ? 'DESC' : 'ASC' 32 | }) 33 | ) %} 34 | {{ column.name|trans }} 35 | {% else %} 36 | {{ column.name|trans }} 37 | {% endif %} 38 | {% endif %} 39 |
48 | 49 | 53 | {% include '@KibaticDatagrid/_column_value.html.twig' %} 54 |
60 | 61 |
62 | {% if grid.hasBatchActions %} 63 |
64 |
65 | 68 | 73 | 74 |
75 |
76 | {% endif %} 77 | 78 |
79 | {{ knp_pagination_render(grid.pagination, '@KnpPaginator/Pagination/bootstrap_v5_pagination.html.twig') }} 80 |
81 |
82 | 83 | {% if grid.hasBatchActions %}
{% endif %} 84 | {% else %} 85 |

{{ 'No result'|trans({}, 'KibaticDatagridBundle') }}

86 | {% endif %} 87 | -------------------------------------------------------------------------------- /src/Resources/views/theme/bootstrap5/datagrid.html.twig: -------------------------------------------------------------------------------- 1 | {% if form is defined and form != null %} 2 | {% include grid.theme ~ '/datagrid-filters.html.twig' %} 3 | {% endif %} 4 | 5 | {% include grid.theme ~ '/datagrid-table.html.twig' %} 6 | -------------------------------------------------------------------------------- /src/Twig/AppExtension.php: -------------------------------------------------------------------------------- 1 | attributesToHtml(...), ['is_safe' => ['html']]), 24 | new TwigFilter('inline_if', $this->inlineIf(...)), 25 | ]; 26 | } 27 | 28 | public function getFunctions(): array 29 | { 30 | return [ 31 | new TwigFunction('datagrid_reset_url', $this->resetUrl(...)), 32 | ]; 33 | } 34 | 35 | public static function attributesToHtml(array $attributes): string 36 | { 37 | return array_reduce( 38 | array_keys($attributes), 39 | function (string $carry, string $key) use ($attributes) { 40 | $value = $attributes[$key]; 41 | 42 | if (!\is_scalar($value) && null !== $value) { 43 | throw new \LogicException(sprintf('A "%s" was passed, the value is not a scalar (it\'s a %s)', $key, $key, get_debug_type($value))); 44 | } 45 | 46 | if (null === $value) { 47 | throw new \Exception('Passing "null" as an attribute value is forbidden'); 48 | } 49 | 50 | return match ($value) { 51 | true => "{$carry} {$key}", 52 | false => $carry, 53 | default => sprintf('%s %s="%s"', $carry, $key, $value), 54 | }; 55 | }, 56 | '' 57 | ); 58 | } 59 | 60 | public function inlineIf(array $array, string $separator = ' '): string 61 | { 62 | $filtered = array_filter($array, fn ($value) => $value !== false); 63 | 64 | return implode($separator, array_keys($filtered)); 65 | } 66 | 67 | public function resetUrl(FormView $form): string 68 | { 69 | if ($form->vars['method'] !== 'GET') { 70 | return ''; 71 | } 72 | 73 | $request = $this->requestStack->getMainRequest(); 74 | $queryAll = $request->query->all(); 75 | unset($queryAll[$form->vars['name']]); 76 | 77 | return $this->router->generate( 78 | $request->attributes->get('_route'), 79 | array_merge($request->attributes->get('_route_params'), $queryAll) 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Twig/Components/DatagridComponent.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace getNamespace() ?>; 4 | 5 | getUseStatements(); ?> 6 | 7 | getClassDeclaration() ?> 8 | { 9 | public function __construct( 10 | private readonly $repository, 11 | private readonly RouterInterface $router, 12 | RequestStack $requestStack, 13 | PaginatorInterface $paginator, 14 | ParameterBagInterface $params 15 | ) { 16 | parent::__construct($paginator, $params); 17 | } 18 | 19 | public function initialize(QueryBuilder $queryBuilder = null, FormInterface $filtersForm = null, Request $request = null): GridBuilder 20 | { 21 | // TODO: déplacer ça dans le parent ? 22 | $request ??= $this->requestStack->getMainRequest(); 23 | // TODO: déplacer ça dans le parent ? 24 | $filtersForm?->handleRequest($request); 25 | 26 | $queryBuilder ??= $this->repository->createQueryBuilder('') 27 | ->orderBy('.id', 'ASC') 28 | ; 29 | 30 | return parent::initialize($queryBuilder, $filtersForm, $request) 31 | ->setItemsPerPage(30) 32 | 33 | ->addColumn( 34 | new TranslatableMessage(''), 35 | '', 36 | 37 | template: , 38 | 39 | sortable: '.' 40 | ) 41 | 42 | //->addColumn( 43 | // new TranslatableMessage('Total'), 44 | // fn( $) => $->getTotal(), 45 | //) 46 | //->addColumn( 47 | // 'Relation', 48 | // 'relation', 49 | // template: Template::ENTITY, 50 | // templateParameters: ['route' => 'app_relation_show'], 51 | // sortable: 'u.relation.name' 52 | //) 53 | ->addColumn( 54 | new TranslatableMessage('Actions'), 55 | fn( $) => [ 56 | [ 57 | 'name' => new TranslatableMessage('Edit'), 58 | 'url' => $this->router->generate( 59 | 'app__edit', 60 | ['id' => $->getId()] 61 | ), 62 | 'btn_type' => 'outline-primary', 63 | 'icon_class' => 'bi bi-pencil', 64 | 'modal' => true, 65 | ], 66 | ], 67 | Template::ACTIONS 68 | ) 69 | ->addFilter( 70 | 'search', 71 | fn(QueryBuilder $qb, ?string $formValue) => $qb 72 | ->andWhere( 73 | $qb->expr()->orX( 74 | 75 | 76 | $qb->expr()->like('LOWER(.)', $qb->expr()->literal(strtolower("%$formValue%"))), 77 | 78 | 79 | ) 80 | ) 81 | ) 82 | ; 83 | } 84 | } 85 | --------------------------------------------------------------------------------