├── 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 |
28 | {{ 'Filter'|trans({}, 'KibaticDatagridBundle') }}
29 |
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 | {% 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 = $class_data->getNamespace() ?>;
4 |
5 | = $class_data->getUseStatements(); ?>
6 |
7 | = $class_data->getClassDeclaration() ?>
8 | {
9 | public function __construct(
10 | private readonly = $repository_class ?> $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('= $query_entity_alias ?>')
27 | ->orderBy('= $query_entity_alias ?>.id', 'ASC')
28 | ;
29 |
30 | return parent::initialize($queryBuilder, $filtersForm, $request)
31 | ->setItemsPerPage(30)
32 |
33 | ->addColumn(
34 | new TranslatableMessage('= $column['name'] ?>'),
35 | '= $column['value'] ?>',
36 |
37 | template: = $column['template'] ?>,
38 |
39 | sortable: '= $query_entity_alias ?>.= $column['value'] ?>'
40 | )
41 |
42 | //->addColumn(
43 | // new TranslatableMessage('Total'),
44 | // fn(= $entity_short_name ?> $= $entity_var ?>) => $= $entity_var ?>->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(= $entity_short_name ?> $= $entity_var ?>) => [
56 | [
57 | 'name' => new TranslatableMessage('Edit'),
58 | 'url' => $this->router->generate(
59 | 'app_= strtolower($entity_var) ?>_edit',
60 | ['id' => $= $entity_snake_case ?>->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(= $query_entity_alias ?>.= $field['fieldName'] ?>)', $qb->expr()->literal(strtolower("%$formValue%"))),
77 |
78 |
79 | )
80 | )
81 | )
82 | ;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------