From 67c3558d0f7408117c7b7e3b8250de7ab35c75ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Wr=C3=B3blewski?= Date: Sun, 21 Jan 2024 19:23:14 +0100 Subject: [PATCH] wip --- CHANGELOG.md | 8 +- composer.json | 3 +- docs/.gitignore | 3 + docs/.vitepress/config.mts | 175 ++ docs/.vitepress/theme/index.js | 4 + docs/.vitepress/theme/style.css | 30 + docs/README.md | 7 + docs/package-lock.json | 1571 +++++++++++++++++ docs/package.json | 12 + docs/src/docs/components/actions.md | 571 ++++++ docs/src/docs/components/columns.md | 276 +++ docs/src/docs/components/exporters.md | 74 + docs/src/docs/components/filters.md | 394 +++++ docs/src/docs/contributing.md | 13 + docs/src/docs/features/asynchronicity.md | 33 + docs/src/docs/features/exporting.md | 301 ++++ docs/src/docs/features/extensibility.md | 270 +++ docs/src/docs/features/filtering.md | 225 +++ docs/src/docs/features/pagination.md | 225 +++ docs/src/docs/features/persistence.md | 405 +++++ docs/src/docs/features/personalization.md | 263 +++ docs/src/docs/features/sorting.md | 297 ++++ docs/src/docs/features/theming.md | 163 ++ docs/src/docs/installation.md | 81 + .../docs/integrations/doctrine-orm/events.md | 75 + .../doctrine-orm/expression-transformers.md | 241 +++ docs/src/docs/introduction.md | 106 ++ docs/src/docs/troubleshooting.md | 181 ++ docs/src/index.md | 45 + docs/src/public/action_confirmation_modal.png | Bin 0 -> 24894 bytes docs/src/public/export_modal.png | Bin 0 -> 83156 bytes docs/src/public/logo.png | Bin 0 -> 30120 bytes docs/src/public/personalization_modal.png | Bin 0 -> 20634 bytes docs/src/public/search_filter_type.png | Bin 0 -> 6751 bytes docs/src/reference/configuration.md | 132 ++ docs/src/reference/twig.md | 218 +++ docs/src/reference/types/action.md | 8 + docs/src/reference/types/action/action.md | 11 + docs/src/reference/types/action/button.md | 47 + docs/src/reference/types/action/form.md | 66 + docs/src/reference/types/action/link.md | 47 + .../reference/types/action/options/action.md | 135 ++ docs/src/reference/types/column.md | 20 + docs/src/reference/types/column/actions.md | 74 + docs/src/reference/types/column/boolean.md | 27 + docs/src/reference/types/column/checkbox.md | 32 + docs/src/reference/types/column/collection.md | 62 + docs/src/reference/types/column/column.md | 11 + .../src/reference/types/column/date-period.md | 34 + docs/src/reference/types/column/date-time.md | 27 + docs/src/reference/types/column/date.md | 29 + docs/src/reference/types/column/link.md | 42 + docs/src/reference/types/column/money.md | 81 + docs/src/reference/types/column/number.md | 51 + .../reference/types/column/options/column.md | 226 +++ docs/src/reference/types/column/template.md | 25 + docs/src/reference/types/column/text.md | 15 + docs/src/reference/types/data-table.md | 150 ++ docs/src/reference/types/exporter.md | 25 + docs/src/reference/types/exporter/callback.md | 34 + docs/src/reference/types/exporter/exporter.md | 11 + .../types/exporter/open-spout/csv.md | 41 + .../types/exporter/open-spout/ods.md | 42 + .../types/exporter/open-spout/xlsx.md | 51 + .../types/exporter/options/exporter.md | 27 + .../types/exporter/options/php-spreadsheet.md | 8 + .../types/exporter/php-spreadsheet/csv.md | 94 + .../types/exporter/php-spreadsheet/html.md | 120 ++ .../types/exporter/php-spreadsheet/ods.md | 17 + .../types/exporter/php-spreadsheet/pdf.md | 120 ++ .../types/exporter/php-spreadsheet/xls.md | 17 + .../types/exporter/php-spreadsheet/xlsx.md | 23 + docs/src/reference/types/filter.md | 20 + docs/src/reference/types/filter/callback.md | 34 + .../types/filter/doctrine-orm/boolean.md | 21 + .../types/filter/doctrine-orm/date-range.md | 21 + .../types/filter/doctrine-orm/date-time.md | 21 + .../types/filter/doctrine-orm/date.md | 21 + .../types/filter/doctrine-orm/doctrine-orm.md | 16 + .../types/filter/doctrine-orm/entity.md | 76 + .../types/filter/doctrine-orm/numeric.md | 20 + .../types/filter/doctrine-orm/string.md | 20 + docs/src/reference/types/filter/filter.md | 11 + .../types/filter/options/doctrine-orm.md | 53 + .../reference/types/filter/options/filter.md | 90 + docs/src/reference/types/filter/search.md | 91 + phpunit.xml.dist | 3 + .../Orm/Event/DoctrineOrmFilterEvent.php | 35 + .../Orm/Event/DoctrineOrmFilterEvents.php | 19 + .../Orm/Event/PreApplyExpressionEvent.php | 31 + .../Orm/Event/PreSetParametersEvent.php | 35 + .../ApplyExpressionTransformers.php | 44 + .../TransformDateRangeFilterData.php | 52 + .../Orm/Filter/DoctrineOrmFilterHandler.php | 65 + .../ExpressionFactory/ExpressionFactory.php | 56 + .../ExpressionFactoryInterface.php | 18 + ...bstractComparisonExpressionTransformer.php | 53 + .../CallbackExpressionTransformer.php | 20 + .../ExpressionTransformerInterface.php | 10 + .../LowerExpressionTransformer.php | 20 + .../TrimExpressionTransformer.php | 20 + .../UpperExpressionTransformer.php | 20 + .../Extension/DoctrineOrmFilterExtension.php | 32 + .../Formatter/DateActiveFilterFormatter.php | 22 + .../DateRangeActiveFilterFormatter.php | 33 + .../DateTimeActiveFilterFormatter.php | 38 + .../Formatter/EntityActiveFilterFormatter.php | 27 + .../ParameterFactory/ParameterFactory.php | 45 + .../ParameterFactoryInterface.php | 18 + .../Type/AbstractDoctrineOrmFilterType.php | 83 - .../Orm/Filter/Type/AbstractFilterType.php | 12 - .../Orm/Filter/Type/BooleanFilterType.php | 41 +- .../Orm/Filter/Type/CallbackFilterType.php | 37 - .../Orm/Filter/Type/DateFilterType.php | 95 +- .../Orm/Filter/Type/DateRangeFilterType.php | 89 +- .../Orm/Filter/Type/DateTimeFilterType.php | 115 +- .../Orm/Filter/Type/DoctrineOrmFilterType.php | 43 +- .../Orm/Filter/Type/EntityFilterType.php | 93 +- .../Orm/Filter/Type/NumericFilterType.php | 45 +- .../Orm/Filter/Type/StringFilterType.php | 53 +- .../Orm/Paginator/PaginatorFactory.php | 131 ++ .../Paginator/PaginatorFactoryInterface.php | 13 + .../Doctrine/Orm/Query/AliasResolver.php | 36 + .../Orm/Query/AliasResolverInterface.php | 12 + .../Orm/Query/DoctrineOrmProxyQuery.php | 171 +- .../Query/DoctrineOrmProxyQueryFactory.php | 6 +- .../Query/DoctrineOrmProxyQueryInterface.php | 29 +- .../Orm/Query/DoctrineOrmResultSetFactory.php | 62 + .../DoctrineOrmResultSetFactoryInterface.php | 13 + .../Test/ExpressionTransformerTestCase.php | 22 + src/Bridge/Doctrine/README.md | 4 - .../Exporter/OpenSpoutExportHandler.php | 85 + .../Type/AbstractOpenSpoutExporterType.php | 4 +- src/Column/Type/ColumnType.php | 2 + src/Resources/views/themes/base.html.twig | 6 +- src/Type/DataTableType.php | 15 +- .../Orm/Event/PreSetParametersEventTest.php | 31 + .../DoctrineOrmFilterExtensionTest.php | 40 + .../Filter/DoctrineOrmFilterHandlerTest.php | 164 ++ .../ApplyExpressionTransformersTest.php | 92 + .../TransformDateRangeFilterDataTest.php | 85 + .../ExpressionFactoryTest.php | 202 +++ .../CallbackExpressionTransformerTest.php | 44 + .../ComparisonExpressionTransformerTest.php | 36 + .../LowerExpressionTransformerTest.php | 31 + .../TrimExpressionTransformerTest.php | 31 + .../UpperExpressionTransformerTest.php | 31 + .../DateActiveFilterFormatterTest.php | 50 + .../DateRangeActiveFilterFormatterTest.php | 65 + .../DateTimeActiveFilterFormatterTest.php | 60 + .../EntityActiveFilterFormatterTest.php | 46 + .../ParameterFactory/ParameterFactoryTest.php | 162 ++ .../Orm/Filter/Type/BooleanFilterTypeTest.php | 78 + .../Orm/Filter/Type/DateFilterTypeTest.php | 64 + .../Filter/Type/DateRangeFilterTypeTest.php | 34 + .../Filter/Type/DateTimeFilterTypeTest.php | 64 + .../Type/DoctrineOrmFilterTypeTestCase.php | 34 + .../Orm/Filter/Type/EntityFilterTypeTest.php | 124 ++ .../Orm/Filter/Type/NumericFilterTypeTest.php | 34 + .../Orm/Filter/Type/StringFilterTypeTest.php | 33 + .../Doctrine/Orm/Fixtures/Entity/Car.php | 29 + .../Doctrine/Orm/Fixtures/Entity/Category.php | 29 + .../Doctrine/Orm/Fixtures/Entity/Product.php | 36 + .../Orm/Fixtures/Entity/ProductAttribute.php | 29 + .../CustomExpressionTransformer.php | 20 + .../Fixtures/Query/NotSupportedProxyQuery.php | 40 + .../Orm/Fixtures/TestEntityManagerFactory.php | 35 + .../Orm/Paginator/PaginatorFactoryTest.php | 154 ++ .../Doctrine/Orm/Query/AliasResolverTest.php | 68 + .../DoctrineOrmProxyQueryFactoryTest.php | 41 + .../Orm/Query/DoctrineOrmProxyQueryTest.php | 286 +++ .../Query/DoctrineOrmResultSetFactoryTest.php | 87 + 172 files changed, 12564 insertions(+), 725 deletions(-) create mode 100644 docs/.gitignore create mode 100644 docs/.vitepress/config.mts create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/.vitepress/theme/style.css create mode 100644 docs/README.md create mode 100644 docs/package-lock.json create mode 100644 docs/package.json create mode 100644 docs/src/docs/components/actions.md create mode 100644 docs/src/docs/components/columns.md create mode 100644 docs/src/docs/components/exporters.md create mode 100644 docs/src/docs/components/filters.md create mode 100644 docs/src/docs/contributing.md create mode 100644 docs/src/docs/features/asynchronicity.md create mode 100644 docs/src/docs/features/exporting.md create mode 100644 docs/src/docs/features/extensibility.md create mode 100644 docs/src/docs/features/filtering.md create mode 100644 docs/src/docs/features/pagination.md create mode 100644 docs/src/docs/features/persistence.md create mode 100644 docs/src/docs/features/personalization.md create mode 100644 docs/src/docs/features/sorting.md create mode 100644 docs/src/docs/features/theming.md create mode 100644 docs/src/docs/installation.md create mode 100644 docs/src/docs/integrations/doctrine-orm/events.md create mode 100644 docs/src/docs/integrations/doctrine-orm/expression-transformers.md create mode 100644 docs/src/docs/introduction.md create mode 100644 docs/src/docs/troubleshooting.md create mode 100644 docs/src/index.md create mode 100644 docs/src/public/action_confirmation_modal.png create mode 100644 docs/src/public/export_modal.png create mode 100644 docs/src/public/logo.png create mode 100644 docs/src/public/personalization_modal.png create mode 100644 docs/src/public/search_filter_type.png create mode 100644 docs/src/reference/configuration.md create mode 100644 docs/src/reference/twig.md create mode 100644 docs/src/reference/types/action.md create mode 100644 docs/src/reference/types/action/action.md create mode 100644 docs/src/reference/types/action/button.md create mode 100644 docs/src/reference/types/action/form.md create mode 100644 docs/src/reference/types/action/link.md create mode 100644 docs/src/reference/types/action/options/action.md create mode 100644 docs/src/reference/types/column.md create mode 100644 docs/src/reference/types/column/actions.md create mode 100644 docs/src/reference/types/column/boolean.md create mode 100644 docs/src/reference/types/column/checkbox.md create mode 100644 docs/src/reference/types/column/collection.md create mode 100644 docs/src/reference/types/column/column.md create mode 100644 docs/src/reference/types/column/date-period.md create mode 100644 docs/src/reference/types/column/date-time.md create mode 100644 docs/src/reference/types/column/date.md create mode 100644 docs/src/reference/types/column/link.md create mode 100644 docs/src/reference/types/column/money.md create mode 100644 docs/src/reference/types/column/number.md create mode 100644 docs/src/reference/types/column/options/column.md create mode 100644 docs/src/reference/types/column/template.md create mode 100644 docs/src/reference/types/column/text.md create mode 100644 docs/src/reference/types/data-table.md create mode 100644 docs/src/reference/types/exporter.md create mode 100644 docs/src/reference/types/exporter/callback.md create mode 100644 docs/src/reference/types/exporter/exporter.md create mode 100644 docs/src/reference/types/exporter/open-spout/csv.md create mode 100644 docs/src/reference/types/exporter/open-spout/ods.md create mode 100644 docs/src/reference/types/exporter/open-spout/xlsx.md create mode 100644 docs/src/reference/types/exporter/options/exporter.md create mode 100644 docs/src/reference/types/exporter/options/php-spreadsheet.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/csv.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/html.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/ods.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/pdf.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/xls.md create mode 100644 docs/src/reference/types/exporter/php-spreadsheet/xlsx.md create mode 100644 docs/src/reference/types/filter.md create mode 100644 docs/src/reference/types/filter/callback.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/boolean.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/date-range.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/date-time.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/date.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/doctrine-orm.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/entity.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/numeric.md create mode 100644 docs/src/reference/types/filter/doctrine-orm/string.md create mode 100644 docs/src/reference/types/filter/filter.md create mode 100644 docs/src/reference/types/filter/options/doctrine-orm.md create mode 100644 docs/src/reference/types/filter/options/filter.md create mode 100644 docs/src/reference/types/filter/search.md create mode 100644 src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvent.php create mode 100644 src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvents.php create mode 100644 src/Bridge/Doctrine/Orm/Event/PreApplyExpressionEvent.php create mode 100644 src/Bridge/Doctrine/Orm/Event/PreSetParametersEvent.php create mode 100644 src/Bridge/Doctrine/Orm/EventListener/ApplyExpressionTransformers.php create mode 100644 src/Bridge/Doctrine/Orm/EventListener/TransformDateRangeFilterData.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandler.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactory.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryInterface.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/AbstractComparisonExpressionTransformer.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformer.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ExpressionTransformerInterface.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformer.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformer.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformer.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/Extension/DoctrineOrmFilterExtension.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/Formatter/DateActiveFilterFormatter.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatter.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatter.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatter.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactory.php create mode 100644 src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryInterface.php delete mode 100755 src/Bridge/Doctrine/Orm/Filter/Type/AbstractFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterType.php delete mode 100755 src/Bridge/Doctrine/Orm/Filter/Type/CallbackFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php create mode 100644 src/Bridge/Doctrine/Orm/Paginator/PaginatorFactory.php create mode 100644 src/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryInterface.php create mode 100644 src/Bridge/Doctrine/Orm/Query/AliasResolver.php create mode 100644 src/Bridge/Doctrine/Orm/Query/AliasResolverInterface.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQuery.php mode change 100755 => 100644 src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactory.php create mode 100644 src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactory.php create mode 100644 src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryInterface.php create mode 100644 src/Bridge/Doctrine/Orm/Test/ExpressionTransformerTestCase.php delete mode 100644 src/Bridge/Doctrine/README.md create mode 100644 src/Bridge/OpenSpout/Exporter/OpenSpoutExportHandler.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Event/PreSetParametersEventTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Extension/DoctrineOrmFilterExtensionTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/ApplyExpressionTransformersTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/TransformDateRangeFilterDataTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ComparisonExpressionTransformerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformerTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateActiveFilterFormatterTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatterTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatterTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatterTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterTypeTestCase.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/EntityFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/NumericFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Filter/Type/StringFilterTypeTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Car.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Category.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Product.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/ProductAttribute.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Filter/ExpressionTransformer/CustomExpressionTransformer.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/Query/NotSupportedProxyQuery.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Fixtures/TestEntityManagerFactory.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Query/AliasResolverTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactoryTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryTest.php create mode 100644 tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 21adbdd0..a41ec4ee 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ -# 0.15 +# 0.16 +- **[Feature]** French translation (https://github.com/Kreyu/data-table-bundle/pull/53) - **[Feature]** Filter events -- **[Breaking change]** The Doctrine ORM integration is now moved into separate [DataTableDoctrineOrmBundle](https://github.com/Kreyu/data-table-doctrine-orm-bundle) + +# 0.15 + +- **[Feature]** Integration with AssetMapper (https://github.com/Kreyu/data-table-bundle/issues/42) # 0.14 diff --git a/composer.json b/composer.json index 55e95222..51ed21ce 100755 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "doctrine/orm": "^2.15", "doctrine/doctrine-bundle": "^2.9", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.4" + "phpunit/phpunit": "^10.4", + "dg/bypass-finals": "dev-master" }, "autoload": { "psr-4": { diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..39b34360 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/cache/ +.vitepress/dist/ \ No newline at end of file diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 00000000..6c3ea24d --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,175 @@ +import { defineConfig } from 'vitepress' + +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: 'DataTableBundle', + description: 'Streamlines creation process of the data tables', + srcDir: './src', + head: [ + ['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }], + ], + themeConfig: { + logo: '/logo.png', + externalLinkIcon: true, + outline: 'deep', + + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: 'Home', link: '/' }, + { text: 'Documentation', link: '/docs/introduction', activeMatch: '/docs/' }, + { text: 'Reference', link: '/reference/types/data-table', activeMatch: '/reference/' }, + ], + + sidebar: { + '/docs/': [ + { + text: 'Getting started', + items: [ + { text: 'Introduction', link: '/docs/introduction' }, + { text: 'Installation', link: '/docs/installation' }, + ] + }, + { + text: 'Components', + items: [ + { text: 'Columns', link: '/docs/components/columns' }, + { text: 'Filters', link: '/docs/components/filters' }, + { text: 'Actions', link: '/docs/components/actions' }, + { text: 'Exporters', link: '/docs/components/exporters' }, + ] + }, + { + text: 'Features', + items: [ + { text: 'Sorting', link: '/docs/features/sorting' }, + { text: 'Filtering', link: '/docs/features/filtering' }, + { text: 'Exporting', link: '/docs/features/exporting' }, + { text: 'Pagination', link: '/docs/features/pagination' }, + { text: 'Personalization', link: '/docs/features/personalization' }, + { text: 'Persistence', link: '/docs/features/persistence' }, + { text: 'Theming', link: '/docs/features/theming' }, + { text: 'Asynchronicity', link: '/docs/features/asynchronicity' }, + { text: 'Extensibility', link: '/docs/features/extensibility' }, + ] + }, + { + text: 'Integrations', + items: [ + { + text: 'Doctrine ORM', + collapsed: true, + items: [ + { text: 'Expression transformers', link: '/docs/integrations/doctrine-orm/expression-transformers' }, + { text: 'Events', link: '/docs/integrations/doctrine-orm/events' } + ], + }, + ] + }, + { text: 'Troubleshooting', link: '/docs/troubleshooting' }, + { text: 'Contributing', link: '/docs/contributing' }, + ], + '/reference/': [ + { + text: 'Types', + items: [ + { + text: 'DataTable', + link: '/reference/types/data-table' + }, + { + text: 'Column', + link: '/reference/types/column', + collapsed: true, + items: [ + { text: 'Text', link: '/reference/types/column/text' }, + { text: 'Number', link: '/reference/types/column/number' }, + { text: 'Money', link: '/reference/types/column/money' }, + { text: 'Boolean', link: '/reference/types/column/boolean' }, + { text: 'Link', link: '/reference/types/column/link' }, + { text: 'Date', link: '/reference/types/column/date' }, + { text: 'DateTime', link: '/reference/types/column/date-time' }, + { text: 'DatePeriod', link: '/reference/types/column/date-period' }, + { text: 'Collection', link: '/reference/types/column/collection' }, + { text: 'Template', link: '/reference/types/column/template' }, + { text: 'Actions', link: '/reference/types/column/actions' }, + { text: 'Checkbox', link: '/reference/types/column/checkbox' }, + { text: 'Column', link: '/reference/types/column/column' }, + ] + }, + { + text: 'Filter', + link: '/reference/types/filter', + collapsed: true, + items: [ + { + text: 'Doctrine ORM', + collapsed: false, + items: [ + { text: 'String', link: '/reference/types/filter/doctrine-orm/string' }, + { text: 'Numeric', link: '/reference/types/filter/doctrine-orm/numeric' }, + { text: 'Boolean', link: '/reference/types/filter/doctrine-orm/boolean' }, + { text: 'Date', link: '/reference/types/filter/doctrine-orm/date' }, + { text: 'DateTime', link: '/reference/types/filter/doctrine-orm/date-time' }, + { text: 'DateRange', link: '/reference/types/filter/doctrine-orm/date-range' }, + { text: 'Entity', link: '/reference/types/filter/doctrine-orm/entity' }, + { text: 'DoctrineOrm', link: '/reference/types/filter/doctrine-orm/doctrine-orm' }, + ], + }, + { text: 'Callback', link: '/reference/types/filter/callback' }, + { text: 'Search', link: '/reference/types/filter/search' }, + { text: 'Filter', link: '/reference/types/filter/filter' }, + ], + }, + { + text: 'Action', + link: '/reference/types/action', + collapsed: true, + items: [ + { text: 'Link', link: '/reference/types/action/link' }, + { text: 'Button', link: '/reference/types/action/button' }, + { text: 'Form', link: '/reference/types/action/form' }, + { text: 'Action', link: '/reference/types/action/action' }, + ], + }, + { + text: 'Exporter', + link: '/reference/types/exporter', + collapsed: true, + items: [ + { + text: 'PhpSpreadsheet', + collapsed: false, + items: [ + { text: 'Csv', link: '/reference/types/exporter/php-spreadsheet/csv' }, + { text: 'Xls', link: '/reference/types/exporter/php-spreadsheet/xls' }, + { text: 'Xlsx', link: '/reference/types/exporter/php-spreadsheet/xlsx' }, + { text: 'Ods', link: '/reference/types/exporter/php-spreadsheet/ods' }, + { text: 'Pdf', link: '/reference/types/exporter/php-spreadsheet/pdf' }, + { text: 'Html', link: '/reference/types/exporter/php-spreadsheet/html' }, + ], + }, + { + text: 'OpenSpout', + collapsed: false, + items: [ + { text: 'Csv', link: '/reference/types/exporter/open-spout/csv' }, + { text: 'Xlsx', link: '/reference/types/exporter/open-spout/xlsx' }, + { text: 'Ods', link: '/reference/types/exporter/open-spout/ods' }, + ], + }, + { text: 'Callback', link: '/reference/types/exporter/callback' }, + { text: 'Exporter', link: '/reference/types/exporter/exporter' }, + ], + }, + ] + }, + { text: 'Configuration', link: '/reference/configuration' }, + { text: 'Twig', link: '/reference/twig' }, + ], + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/kreyu/data-table-bundle' } + ] + } +}) diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 00000000..0ed10c5f --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './style.css' + +export default DefaultTheme \ No newline at end of file diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 00000000..da2d41d5 --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,30 @@ +.VPContent.is-home { + display: flex; + align-items: center; + justify-content: center; +} + +:root { + --vp-button-brand-bg: #7986CB; + --vp-button-brand-hover-bg: #9FA8DA; + + --vp-c-brand-1: #7986CB; + --vp-c-brand-2: #9FA8DA; + + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #32bffc 10%, #7986CB); + + --vp-home-hero-image-background-image: -webkit-linear-gradient(120deg, rgba(68, 113, 210, 1), 30%, rgba(50, 191, 252, 1)); +} + +@media (min-width: 640px) { + :root { + --vp-home-hero-image-filter: blur(56px); + } +} + +@media (min-width: 960px) { + :root { + --vp-home-hero-image-filter: blur(65px); + } +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..e92d3786 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# Documentation + +To start the development server, run following commands: + +```shell +npm install && npm run docs:dev +``` diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 00000000..b549fff0 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,1571 @@ +{ + "name": "docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "markdown-it": "^14.0.0", + "vitepress": "^1.0.0-rc.36", + "vitepress-plugin-tabs": "^0.5.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz", + "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==", + "dev": true, + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.9.3", + "@algolia/autocomplete-shared": "1.9.3" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz", + "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==", + "dev": true, + "dependencies": { + "@algolia/autocomplete-shared": "1.9.3" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz", + "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==", + "dev": true, + "dependencies": { + "@algolia/autocomplete-shared": "1.9.3" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz", + "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==", + "dev": true, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/cache-browser-local-storage": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.22.1.tgz", + "integrity": "sha512-Sw6IAmOCvvP6QNgY9j+Hv09mvkvEIDKjYW8ow0UDDAxSXy664RBNQk3i/0nt7gvceOJ6jGmOTimaZoY1THmU7g==", + "dev": true, + "dependencies": { + "@algolia/cache-common": "4.22.1" + } + }, + "node_modules/@algolia/cache-common": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.22.1.tgz", + "integrity": "sha512-TJMBKqZNKYB9TptRRjSUtevJeQVXRmg6rk9qgFKWvOy8jhCPdyNZV1nB3SKGufzvTVbomAukFR8guu/8NRKBTA==", + "dev": true + }, + "node_modules/@algolia/cache-in-memory": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.22.1.tgz", + "integrity": "sha512-ve+6Ac2LhwpufuWavM/aHjLoNz/Z/sYSgNIXsinGofWOysPilQZPUetqLj8vbvi+DHZZaYSEP9H5SRVXnpsNNw==", + "dev": true, + "dependencies": { + "@algolia/cache-common": "4.22.1" + } + }, + "node_modules/@algolia/client-account": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.22.1.tgz", + "integrity": "sha512-k8m+oegM2zlns/TwZyi4YgCtyToackkOpE+xCaKCYfBfDtdGOaVZCM5YvGPtK+HGaJMIN/DoTL8asbM3NzHonw==", + "dev": true, + "dependencies": { + "@algolia/client-common": "4.22.1", + "@algolia/client-search": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.22.1.tgz", + "integrity": "sha512-1ssi9pyxyQNN4a7Ji9R50nSdISIumMFDwKNuwZipB6TkauJ8J7ha/uO60sPJFqQyqvvI+px7RSNRQT3Zrvzieg==", + "dev": true, + "dependencies": { + "@algolia/client-common": "4.22.1", + "@algolia/client-search": "4.22.1", + "@algolia/requester-common": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/@algolia/client-common": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.22.1.tgz", + "integrity": "sha512-IvaL5v9mZtm4k4QHbBGDmU3wa/mKokmqNBqPj0K7lcR8ZDKzUorhcGp/u8PkPC/e0zoHSTvRh7TRkGX3Lm7iOQ==", + "dev": true, + "dependencies": { + "@algolia/requester-common": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.22.1.tgz", + "integrity": "sha512-sl+/klQJ93+4yaqZ7ezOttMQ/nczly/3GmgZXJ1xmoewP5jmdP/X/nV5U7EHHH3hCUEHeN7X1nsIhGPVt9E1cQ==", + "dev": true, + "dependencies": { + "@algolia/client-common": "4.22.1", + "@algolia/requester-common": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/@algolia/client-search": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.22.1.tgz", + "integrity": "sha512-yb05NA4tNaOgx3+rOxAmFztgMTtGBi97X7PC3jyNeGiwkAjOZc2QrdZBYyIdcDLoI09N0gjtpClcackoTN0gPA==", + "dev": true, + "dependencies": { + "@algolia/client-common": "4.22.1", + "@algolia/requester-common": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/@algolia/logger-common": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.22.1.tgz", + "integrity": "sha512-OnTFymd2odHSO39r4DSWRFETkBufnY2iGUZNrMXpIhF5cmFE8pGoINNPzwg02QLBlGSaLqdKy0bM8S0GyqPLBg==", + "dev": true + }, + "node_modules/@algolia/logger-console": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.22.1.tgz", + "integrity": "sha512-O99rcqpVPKN1RlpgD6H3khUWylU24OXlzkavUAMy6QZd1776QAcauE3oP8CmD43nbaTjBexZj2nGsBH9Tc0FVA==", + "dev": true, + "dependencies": { + "@algolia/logger-common": "4.22.1" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.22.1.tgz", + "integrity": "sha512-dtQGYIg6MteqT1Uay3J/0NDqD+UciHy3QgRbk7bNddOJu+p3hzjTRYESqEnoX/DpEkaNYdRHUKNylsqMpgwaEw==", + "dev": true, + "dependencies": { + "@algolia/requester-common": "4.22.1" + } + }, + "node_modules/@algolia/requester-common": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.22.1.tgz", + "integrity": "sha512-dgvhSAtg2MJnR+BxrIFqlLtkLlVVhas9HgYKMk2Uxiy5m6/8HZBL40JVAMb2LovoPFs9I/EWIoFVjOrFwzn5Qg==", + "dev": true + }, + "node_modules/@algolia/requester-node-http": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.22.1.tgz", + "integrity": "sha512-JfmZ3MVFQkAU+zug8H3s8rZ6h0ahHZL/SpMaSasTCGYR5EEJsCc8SI5UZ6raPN2tjxa5bxS13BRpGSBUens7EA==", + "dev": true, + "dependencies": { + "@algolia/requester-common": "4.22.1" + } + }, + "node_modules/@algolia/transporter": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.22.1.tgz", + "integrity": "sha512-kzWgc2c9IdxMa3YqA6TN0NW5VrKYYW/BELIn7vnLyn+U/RFdZ4lxxt9/8yq3DKV5snvoDzzO4ClyejZRdV3lMQ==", + "dev": true, + "dependencies": { + "@algolia/cache-common": "4.22.1", + "@algolia/logger-common": "4.22.1", + "@algolia/requester-common": "4.22.1" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.5.2.tgz", + "integrity": "sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==", + "dev": true + }, + "node_modules/@docsearch/js": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.5.2.tgz", + "integrity": "sha512-p1YFTCDflk8ieHgFJYfmyHBki1D61+U9idwrLh+GQQMrBSP3DLGKpy0XUJtPjAOPltcVbqsTjiPFfH7JImjUNg==", + "dev": true, + "dependencies": { + "@docsearch/react": "3.5.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.5.2.tgz", + "integrity": "sha512-9Ahcrs5z2jq/DcAvYtvlqEBHImbm4YJI8M9y0x6Tqg598P40HTEkX7hsMcIuThI+hTFxRGZ9hll0Wygm2yEjng==", + "dev": true, + "dependencies": { + "@algolia/autocomplete-core": "1.9.3", + "@algolia/autocomplete-preset-algolia": "1.9.3", + "@docsearch/css": "3.5.2", + "algoliasearch": "^4.19.1" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", + "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", + "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", + "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", + "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", + "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", + "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", + "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", + "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", + "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", + "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", + "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", + "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", + "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", + "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", + "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", + "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", + "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", + "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", + "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", + "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", + "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", + "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", + "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.4.tgz", + "integrity": "sha512-ub/SN3yWqIv5CWiAZPHVS1DloyZsJbtXmX4HxUTIpS0BHm9pW5iYBo2mIZi+hE3AeiTzHz33blwSnhdUo+9NpA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.4.tgz", + "integrity": "sha512-ehcBrOR5XTl0W0t2WxfTyHCR/3Cq2jfb+I4W+Ch8Y9b5G+vbAecVv0Fx/J1QKktOrgUYsIKxWAKgIpvw56IFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.4.tgz", + "integrity": "sha512-1fzh1lWExwSTWy8vJPnNbNM02WZDS8AW3McEOb7wW+nPChLKf3WG2aG7fhaUmfX5FKw9zhsF5+MBwArGyNM7NA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.4.tgz", + "integrity": "sha512-Gc6cukkF38RcYQ6uPdiXi70JB0f29CwcQ7+r4QpfNpQFVHXRd0DfWFidoGxjSx1DwOETM97JPz1RXL5ISSB0pA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.4.tgz", + "integrity": "sha512-g21RTeFzoTl8GxosHbnQZ0/JkuFIB13C3T7Y0HtKzOXmoHhewLbVTFBQZu+z5m9STH6FZ7L/oPgU4Nm5ErN2fw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.4.tgz", + "integrity": "sha512-TVYVWD/SYwWzGGnbfTkrNpdE4HON46orgMNHCivlXmlsSGQOx/OHHYiQcMIOx38/GWgwr/po2LBn7wypkWw/Mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.4.tgz", + "integrity": "sha512-XcKvuendwizYYhFxpvQ3xVpzje2HHImzg33wL9zvxtj77HvPStbSGI9czrdbfrf8DGMcNNReH9pVZv8qejAQ5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.4.tgz", + "integrity": "sha512-LFHS/8Q+I9YA0yVETyjonMJ3UA+DczeBd/MqNEzsGSTdNvSJa1OJZcSH8GiXLvcizgp9AlHs2walqRcqzjOi3A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.4.tgz", + "integrity": "sha512-dIYgo+j1+yfy81i0YVU5KnQrIJZE8ERomx17ReU4GREjGtDW4X+nvkBak2xAUpyqLs4eleDSj3RrV72fQos7zw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.4.tgz", + "integrity": "sha512-RoaYxjdHQ5TPjaPrLsfKqR3pakMr3JGqZ+jZM0zP2IkDtsGa4CqYaWSfQmZVgFUCgLrTnzX+cnHS3nfl+kB6ZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.4.tgz", + "integrity": "sha512-T8Q3XHV+Jjf5e49B4EAaLKV74BbX7/qYBRQ8Wop/+TyyU0k+vSjiLVSHNWdVd1goMjZcbhDmYZUYW5RFqkBNHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.4.tgz", + "integrity": "sha512-z+JQ7JirDUHAsMecVydnBPWLwJjbppU+7LZjffGf+Jvrxq+dVjIE7By163Sc9DKc3ADSU50qPVw0KonBS+a+HQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.4.tgz", + "integrity": "sha512-LfdGXCV9rdEify1oxlN9eamvDSjv9md9ZVMAbNHA87xqIfFCxImxan9qZ8+Un54iK2nnqPlbnSi4R54ONtbWBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "dev": true + }, + "node_modules/@types/markdown-it": { + "version": "13.0.7", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.7.tgz", + "integrity": "sha512-U/CBi2YUUcTHBt5tjO2r5QV/x0Po6nsYwQU4Y04fBS6vfoImaiZ6f8bi3CjTCxBPQSO1LMyUqkByzi8AidyxfA==", + "dev": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", + "dev": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.2.tgz", + "integrity": "sha512-kEjJHrLb5ePBvjD0SPZwJlw1QTRcjjCA9sB5VyfonoXVBxTS7TMnqL6EkLt1Eu61RDeiuZ/WN9Hf6PxXhPI2uA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.7.tgz", + "integrity": "sha512-hhCaE3pTMrlIJK7M/o3Xf7HV8+JoNTGOQ/coWS+V+pH6QFFyqtoXqQzpqsNp7UK17xYKua/MBiKj4e1vgZOBYw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.23.6", + "@vue/shared": "3.4.7", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.7.tgz", + "integrity": "sha512-qDKBAIurCTub4n/6jDYkXwgsFuriqqmmLrIq1N2QDfYJA/mwiwvxi09OGn28g+uDdERX9NaKDLji0oTjE3sScg==", + "dev": true, + "dependencies": { + "@vue/compiler-core": "3.4.7", + "@vue/shared": "3.4.7" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.7.tgz", + "integrity": "sha512-Gec6CLkReVswDYjQFq79O5rktri4R7TsD/VPCiUoJw40JhNNxaNJJa8mrQrWoJluW4ETy6QN0NUyC/JO77OCOw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.23.6", + "@vue/compiler-core": "3.4.7", + "@vue/compiler-dom": "3.4.7", + "@vue/compiler-ssr": "3.4.7", + "@vue/shared": "3.4.7", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.5", + "postcss": "^8.4.32", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.7.tgz", + "integrity": "sha512-PvYeSOvnCkST5mGS0TLwEn5w+4GavtEn6adcq8AspbHaIr+mId5hp7cG3ASy3iy8b+LuXEG2/QaV/nj5BQ/Aww==", + "dev": true, + "dependencies": { + "@vue/compiler-dom": "3.4.7", + "@vue/shared": "3.4.7" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.1.tgz", + "integrity": "sha512-+KpckaAQyfbvshdDW5xQylLni1asvNSGme1JFs8I1+/H5pHEhqUKMEQD/qn3Nx5+/nycBq11qAEi8lk+LXI2dA==", + "dev": true + }, + "node_modules/@vue/reactivity": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.7.tgz", + "integrity": "sha512-F539DO0ogH0+L8F9Pnw7cjqibcmSOh5UTk16u5f4MKQ8fraqepI9zdh+sozPX6VmEHOcjo8qw3Or9ZcFFw4SZA==", + "dev": true, + "dependencies": { + "@vue/shared": "3.4.7" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.7.tgz", + "integrity": "sha512-QMMsWRQaD3BpGyjjChthpl4Mji4Fjx1qfdufsXlDkKU3HV+hWNor2z+29F+E1MmVcP0ZfRZUfqYgtsQoL7IGwQ==", + "dev": true, + "dependencies": { + "@vue/reactivity": "3.4.7", + "@vue/shared": "3.4.7" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.7.tgz", + "integrity": "sha512-XwegyUY1rw8zxsX1Z36vwYcqo+uOgih5ti7y9vx+pPFhNdSQmN4LqK2RmSeAJG1oKV8NqSUmjpv92f/x6h0SeQ==", + "dev": true, + "dependencies": { + "@vue/runtime-core": "3.4.7", + "@vue/shared": "3.4.7", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.7.tgz", + "integrity": "sha512-3bWnYLEkLLhkDWqvNk7IvbQD4UcxvFKxELBiOO2iG3m6AniFIsBWfHOO5tLVQnjdWkODu4rq0GipmfEenVAK5Q==", + "dev": true, + "dependencies": { + "@vue/compiler-ssr": "3.4.7", + "@vue/shared": "3.4.7" + }, + "peerDependencies": { + "vue": "3.4.7" + } + }, + "node_modules/@vue/shared": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.7.tgz", + "integrity": "sha512-G+i4glX1dMJk88sbJEcQEGWRQnVm9eIY7CcQbO5dpdsD9SF8jka3Mr5OqZYGjczGN1+D6EUwdu6phcmcx9iuPA==", + "dev": true + }, + "node_modules/@vueuse/core": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.7.1.tgz", + "integrity": "sha512-74mWHlaesJSWGp1ihg76vAnfVq9NTv1YT0SYhAQ6zwFNdBkkP+CKKJmVOEHcdSnLXCXYiL5e7MaewblfiYLP7g==", + "dev": true, + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.7.1", + "@vueuse/shared": "10.7.1", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/integrations": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-10.7.1.tgz", + "integrity": "sha512-cKo5LEeKVHdBRBtMTOrDPdR0YNtrmN9IBfdcnY2P3m5LHVrsD0xiHUtAH1WKjHQRIErZG6rJUa6GA4tWZt89Og==", + "dev": true, + "dependencies": { + "@vueuse/core": "10.7.1", + "@vueuse/shared": "10.7.1", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "*", + "axios": "*", + "change-case": "*", + "drauu": "*", + "focus-trap": "*", + "fuse.js": "*", + "idb-keyval": "*", + "jwt-decode": "*", + "nprogress": "*", + "qrcode": "*", + "sortablejs": "*", + "universal-cookie": "*" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/integrations/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.7.1.tgz", + "integrity": "sha512-jX8MbX5UX067DYVsbtrmKn6eG6KMcXxLRLlurGkZku5ZYT3vxgBjui2zajvUZ18QLIjrgBkFRsu7CqTAg18QFw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.7.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.7.1.tgz", + "integrity": "sha512-v0jbRR31LSgRY/C5i5X279A/WQjD6/JsMzGa+eqt658oJ75IvQXAeONmwvEMrvJQKnRElq/frzBR7fhmWY5uLw==", + "dev": true, + "dependencies": { + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/algoliasearch": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.22.1.tgz", + "integrity": "sha512-jwydKFQJKIx9kIZ8Jm44SdpigFwRGPESaxZBaHSV0XWN2yBJAOT4mT7ppvlrpA4UGzz92pqFnVKr/kaZXrcreg==", + "dev": true, + "dependencies": { + "@algolia/cache-browser-local-storage": "4.22.1", + "@algolia/cache-common": "4.22.1", + "@algolia/cache-in-memory": "4.22.1", + "@algolia/client-account": "4.22.1", + "@algolia/client-analytics": "4.22.1", + "@algolia/client-common": "4.22.1", + "@algolia/client-personalization": "4.22.1", + "@algolia/client-search": "4.22.1", + "@algolia/logger-common": "4.22.1", + "@algolia/logger-console": "4.22.1", + "@algolia/requester-browser-xhr": "4.22.1", + "@algolia/requester-common": "4.22.1", + "@algolia/requester-node-http": "4.22.1", + "@algolia/transporter": "4.22.1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.19.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", + "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.11", + "@esbuild/android-arm": "0.19.11", + "@esbuild/android-arm64": "0.19.11", + "@esbuild/android-x64": "0.19.11", + "@esbuild/darwin-arm64": "0.19.11", + "@esbuild/darwin-x64": "0.19.11", + "@esbuild/freebsd-arm64": "0.19.11", + "@esbuild/freebsd-x64": "0.19.11", + "@esbuild/linux-arm": "0.19.11", + "@esbuild/linux-arm64": "0.19.11", + "@esbuild/linux-ia32": "0.19.11", + "@esbuild/linux-loong64": "0.19.11", + "@esbuild/linux-mips64el": "0.19.11", + "@esbuild/linux-ppc64": "0.19.11", + "@esbuild/linux-riscv64": "0.19.11", + "@esbuild/linux-s390x": "0.19.11", + "@esbuild/linux-x64": "0.19.11", + "@esbuild/netbsd-x64": "0.19.11", + "@esbuild/openbsd-x64": "0.19.11", + "@esbuild/sunos-x64": "0.19.11", + "@esbuild/win32-arm64": "0.19.11", + "@esbuild/win32-ia32": "0.19.11", + "@esbuild/win32-x64": "0.19.11" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/focus-trap": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.4.tgz", + "integrity": "sha512-N7kHdlgsO/v+iD/dMoJKtsSqs5Dz/dXZVebRgJw23LDk+jMi/974zyiOYDziY2JPp8xivq9BmUGwIJMiuSBi7w==", + "dev": true, + "dependencies": { + "tabbable": "^6.2.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true + }, + "node_modules/markdown-it": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.0.0.tgz", + "integrity": "sha512-seFjF0FIcPt4P9U39Bq1JYblX0KZCjDLFFQPHpL5AzHpqPEKtosxmdq/LTVZnjfH7tjt9BxStm+wXcDBNuYmzw==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true + }, + "node_modules/minisearch": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz", + "integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.4.33", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", + "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.19.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", + "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.4.tgz", + "integrity": "sha512-2ztU7pY/lrQyXSCnnoU4ICjT/tCG9cdH3/G25ERqE3Lst6vl2BCM5hL2Nw+sslAvAf+ccKsAq1SkKQALyqhR7g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.9.4", + "@rollup/rollup-android-arm64": "4.9.4", + "@rollup/rollup-darwin-arm64": "4.9.4", + "@rollup/rollup-darwin-x64": "4.9.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.9.4", + "@rollup/rollup-linux-arm64-gnu": "4.9.4", + "@rollup/rollup-linux-arm64-musl": "4.9.4", + "@rollup/rollup-linux-riscv64-gnu": "4.9.4", + "@rollup/rollup-linux-x64-gnu": "4.9.4", + "@rollup/rollup-linux-x64-musl": "4.9.4", + "@rollup/rollup-win32-arm64-msvc": "4.9.4", + "@rollup/rollup-win32-ia32-msvc": "4.9.4", + "@rollup/rollup-win32-x64-msvc": "4.9.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", + "integrity": "sha512-Orrsjf9trHHxFRuo9/rzm0KIWmgzE8RMlZMzuhZOJ01Rnz3D0YBAe+V6473t6/H6c7irs6Lt48brULAiRWb3Vw==", + "dev": true, + "peer": true + }, + "node_modules/shikiji": { + "version": "0.9.18", + "resolved": "https://registry.npmjs.org/shikiji/-/shikiji-0.9.18.tgz", + "integrity": "sha512-/tFMIdV7UQklzN13VjF0/XFzmii6C606Jc878hNezvB8ZR8FG8FW9j0I4J9EJre0owlnPntgLVPpHqy27Gs+DQ==", + "dev": true, + "dependencies": { + "shikiji-core": "0.9.18" + } + }, + "node_modules/shikiji-core": { + "version": "0.9.18", + "resolved": "https://registry.npmjs.org/shikiji-core/-/shikiji-core-0.9.18.tgz", + "integrity": "sha512-PKTXptbrp/WEDjNHV8OFG9KkfhmR0pSd161kzlDDlgQ0HXAnqJYNDSjqsy1CYZMx5bSvLMy42yJj9oFTqmkNTQ==", + "dev": true + }, + "node_modules/shikiji-transformers": { + "version": "0.9.18", + "resolved": "https://registry.npmjs.org/shikiji-transformers/-/shikiji-transformers-0.9.18.tgz", + "integrity": "sha512-lvKVfgx1ETDqUNxqiUn+whlnjQiunsAg76DOpzjjxkHE/bLcwa+jrghcMxQhui86SLR1tzCdM4Imh+RxW0LI2Q==", + "dev": true, + "dependencies": { + "shikiji": "0.9.18" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "dev": true + }, + "node_modules/uc.micro": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.0.0.tgz", + "integrity": "sha512-DffL94LsNOccVn4hyfRe5rdKa273swqeA5DJpMOeFmEn1wCDc7nAbbB0gXlgBCL7TNzeTv6G7XVWzan7iJtfig==", + "dev": true + }, + "node_modules/vite": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.11.tgz", + "integrity": "sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==", + "dev": true, + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.32", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.0.0-rc.36", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.0.0-rc.36.tgz", + "integrity": "sha512-2z4dpM9PplN/yvTifhavOIAazlCR6OJ5PvLoRbc+7LdcFeIlCsuDGENLX4HjMW18jQZF5/j7++PNqdBfeazxUA==", + "dev": true, + "dependencies": { + "@docsearch/css": "^3.5.2", + "@docsearch/js": "^3.5.2", + "@types/markdown-it": "^13.0.7", + "@vitejs/plugin-vue": "^5.0.2", + "@vue/devtools-api": "^6.5.1", + "@vueuse/core": "^10.7.1", + "@vueuse/integrations": "^10.7.1", + "focus-trap": "^7.5.4", + "mark.js": "8.11.1", + "minisearch": "^6.3.0", + "shikiji": "^0.9.17", + "shikiji-core": "^0.9.17", + "shikiji-transformers": "^0.9.17", + "vite": "^5.0.11", + "vue": "^3.4.5" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4.3.2", + "postcss": "^8.4.33" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress-plugin-tabs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/vitepress-plugin-tabs/-/vitepress-plugin-tabs-0.5.0.tgz", + "integrity": "sha512-SIhFWwGsUkTByfc2b279ray/E0Jt8vDTsM1LiHxmCOBAEMmvzIBZSuYYT1DpdDTiS3SuJieBheJkYnwCq/yD9A==", + "dev": true, + "peerDependencies": { + "vitepress": "^1.0.0-rc.27", + "vue": "^3.3.8" + } + }, + "node_modules/vue": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.7.tgz", + "integrity": "sha512-4urmkWpudekq0CPNMO7p6mBGa9qmTXwJMO2r6CT4EzIJVG7WoSReiysiNb7OSi/WI113oX0Srn9Rz1k/DCXKFQ==", + "dev": true, + "dependencies": { + "@vue/compiler-dom": "3.4.7", + "@vue/compiler-sfc": "3.4.7", + "@vue/runtime-dom": "3.4.7", + "@vue/server-renderer": "3.4.7", + "@vue/shared": "3.4.7" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..eea8fa08 --- /dev/null +++ b/docs/package.json @@ -0,0 +1,12 @@ +{ + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "markdown-it": "^14.0.0", + "vitepress": "^1.0.0-rc.36", + "vitepress-plugin-tabs": "^0.5.0" + } +} diff --git a/docs/src/docs/components/actions.md b/docs/src/docs/components/actions.md new file mode 100644 index 00000000..16d0bc09 --- /dev/null +++ b/docs/src/docs/components/actions.md @@ -0,0 +1,571 @@ +# Actions + +[[toc]] + +## Prerequisites + +There are three contexts that the action can be defined with: + +**Regular actions** + +Regular actions are not bound to any data, and are displayed on top of the data table. +This kind of action can be used, for example, for "Create" button that redirects to a form. + +**Row actions** + +Actions that are bound to a row, displayed in an "actions" column. +This kind of action can be used, for example, for "Update" button that redirects to edit form for a record. + +**Batch actions** + +Actions that are bound to a multiple rows, selected by a checkbox column. +Batch actions require `batch` Stimulus controller enabled: + +```json5 +// assets/controllers.json +{ + "controllers": { + "@kreyu/data-table-bundle": { + // ... + "batch": { + "enabled": true + } + } + } +} +``` + +## Adding actions + +Actions can be added by using a data table builder's `addAction()`, `addRowAction()` and `addBatchAction()` methods: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addAction('create', ButtonActionType::class, [ + 'href' => $this->urlGenerator->generate('app_product_create'), + ]) + // note that row action has access to a row data in a callable + ->addRowAction('update', ButtonActionType::class, [ + 'href' => function (Product $product) { + return $this->urlGenerator->generate('app_product_update', [ + 'id' => $product->getId(), + ]); + } + ]) + ->addBatchAction('delete', ButtonActionType::class, [ + 'href' => $this->urlGenerator->generate('app_product_batch_delete'), + ]) + ; + } +} +``` + +Those methods accept _three_ arguments: + +- action name; +- action type — with a fully qualified class name; +- action options — defined by the action type, used to configure the action; + +For reference, see [available action types](../../reference/types/action.md). + +## Creating action types + +If built-in action types are not enough, you can create your own. In following chapters, we'll be creating an action that opens a modal. + +Action types are classes that implement [ActionTypeInterface](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ActionTypeInterface.php), although, it is recommended to extend from the [AbstractActionType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/AbstractActionType.php) class: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\AbstractActionType; + +class ModalActionType extends AbstractActionType +{ +} +``` + +
+ +Recommended namespace for the action type classes is `App\DataTable\Action\Type\`. + +
+ +### Action type inheritance + +Because our modal action fundamentally renders as a button, let's base it off the built-in [`ButtonActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ButtonActionType.php). +Provide the fully-qualified class name of the parent type in the `getParent()` method: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\AbstractActionType; +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +class ModalActionType extends AbstractActionType +{ + public function getParent(): ?string + { + return ButtonActionType::class; + } +} +``` + +::: tip +If you take a look at the [`AbstractActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/AbstractActionType.php), +you'll see that `getParent()` method returns fully-qualified name of the [`ActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ActionType.php) type class. +This is the type that defines all the basic options, such as `attr`, `label`, etc. +::: + +### Rendering the action type + +Because our modal action is based off the built-in [`ButtonActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ButtonActionType.php), +it will be rendered as a button without any additional configuration. However, in our case, we want to add the modal itself. + +First, create a custom theme for the data table, and create a `action_modal_value` block: + +```twig +{# templates/data_table/theme.html.twig #} + +{% block action_modal_value %} + + + +{% endblock %} +``` + +The block naming follows a set of rules: + +- for actions, it always starts with `action_` prefix; +- next comes the block prefix of the action type; +- last part is always the `_value` suffix; + +If you take a look at the [`AbstractActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/AbstractActionType.php), +you'll see that `getBlockPrefix()` returns snake cased short name of the type class, without the `ActionType` suffix. + +In our case, because the type class is named `ModalActionType`, the default block prefix equals `modal`. Simple as that. + +Now, the custom theme should be added to the bundle configuration: + +::: code-group + +```yaml [YAML] +kreyu_data_table: + defaults: + themes: + # ... + - 'data_table/theme.html.twig' +``` + +```php [PHP] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults()->themes([ + // ... + 'data_table/theme.html.twig', + ]); +}; +``` + +::: + +If the `action_modal_value` block wasn't defined in any of the configured themes, the bundle will render block of the parent type. +In our example, because we set [`ButtonActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ButtonActionType.php) as a parent, a `action_button_value` block will be rendered. + +### Adding configuration options + +Action type options allow to configure the behavior of the action types. +The options are defined in the `configureOptions()` method, using the [OptionsResolver component](https://symfony.com/doc/current/components/options_resolver.html). + +Imagine, that you want to provide a template to render as the action modal contents. +The template could be provided by a custom `template_path` and `template_vars` options: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\AbstractActionType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ModalActionType extends AbstractActionType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + // define options required by the type + ->setRequired('template_path') + // define available options and their default values + ->setDefaults([ + 'template_vars' => [], + ]) + // optionally you can restrict type of the options + ->setAllowedTypes('template_path', 'string') + ->setAllowedTypes('template_vars', 'array') + ; + } +} +``` + +Now you can configure the new option when using the action type: + +```php +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + // ... + ->addRowAction('details', ModalActionType::class, [ + 'template_path' => 'user/details.html.twig', + ]) + ; + } +} +``` + +### Passing variables to the template + +Now, the `template_path` and `template_vars` options are defined, but are not utilized by the system in any way. +In our case, we'll pass the options to the view, and use them to render the template itself: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; +use Kreyu\Bundle\DataTableBundle\Action\ActionView; +use Kreyu\Bundle\DataTableBundle\Action\ActionInterface; + +class ModalActionType extends ButtonActionType +{ + public function buildView(ActionView $view, ActionInterface $action, array $options): void + { + $view->vars['template_path'] = $options['template_path']; + $view->vars['template_vars'] = $options['template_vars']; + } +} +``` + +Now we can update the template of the type class to use the newly added variables: + +```twig +{# templates/data_table/theme.html.twig #} + +{% block action_modal_value %} + + + +{% endblock %} +``` + +### Using row data in options + +> What if I want to pass an option based on the row data? + +If the action type is used for a row action, the `ActionView` parent will be an instance of `ColumnValueView`, +which can be used to retrieve a data of the row. This can be used in combination with accepting the `callable` options: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; +use Kreyu\Bundle\DataTableBundle\Action\ActionView; +use Kreyu\Bundle\DataTableBundle\Action\ActionInterface; +use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView; // [!code ++] + +class ModalActionType extends ButtonActionType +{ + public function buildView(ActionView $view, ActionInterface $action, array $options): void + { + if ($view->parent instanceof ColumnValueView) { // [!code ++] + $value = $view->parent->vars['value']; // [!code ++] + + foreach (['template_path', 'template_vars'] as $optionName) { // [!code ++] + if (is_callable($templatePath)) { // [!code ++] + $options[$optionName] = $options[$optionName]($value); // [!code ++] + } // [!code ++] + } // [!code ++] + } // [!code ++] + + $view->vars['template_path'] = $options['template_path']; + $view->vars['template_vars'] = $options['template_vars']; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + // define options required by the type + ->setRequired('template_path') + // define available options and their default values + ->setDefaults([ + 'template_vars' => [], + ]) + // optionally you can restrict type of the options + ->setAllowedTypes('template_path', 'string') // [!code --] + ->setAllowedTypes('template_path', ['string', 'callable']) // [!code ++] + ->setAllowedTypes('template_vars', 'array') // [!code --] + ->setAllowedTypes('template_vars', ['array', 'callable']) // [!code ++] + ; + } +} +``` + +Now, you can use the `callable` options when defining the modal row action: + +```php +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + // ... + ->addRowAction('details', ModalActionType::class, [ + 'template_path' => 'user/details.html.twig', + 'template_vars' => function (User $user) { // [!code ++] + return [ // [!code ++] + 'user_id' => $user->getId(), // [!code ++] + ]; // [!code ++] + }, // [!code ++] + ]) + ; + } +} +``` + +## Action type extensions + +Action type extensions allows modifying configuration of the existing action types, even the built-in ones. +Let's assume, that we want to add a [Bootstrap tooltip](https://getbootstrap.com/docs/5.3/components/tooltips/#overview) for every button action, as long as their `title` attribute is defined. + +Action type extensions are classes that implement [`ActionTypeExtensionInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Extension/ActionTypeExtensionInterface.php). +However, it's better to extend from the [`AbstractActionTypeExtension`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Extension/AbstractActionTypeExtension.php): + +```php +use Kreyu\Bundle\DataTableBundle\Action\Extension\AbstractActionTypeExtension; +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TooltipActionTypeExtension extends AbstractActionTypeExtension +{ + public function buildValueView(ActionValueView $view, ActionInterface $column, array $options): void + { + if (!$options['tooltip']) { + return; + } + + $title = $view->vars['attr']['title'] ?? null; + + if (empty($title)) { + return; + } + + $view->vars['attr']['data-bs-toggle'] = 'tooltip'; + $view->vars['attr']['data-bs-placement'] = 'top'; + $view->vars['attr']['data-bs-title'] = $title; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefault('tooltip', true) + ->setAllowedTypes('tooltip', 'bool') + ; + } + + public static function getExtendedTypes(): iterable + { + return [ButtonActionType::class]; + } +} +``` + +Now, as long as the button action `tooltip` option equals to `true` (by default), and a `title` attribute is set, +the action will be rendered with Bootstrap tooltip attributes. You can even use the action name instead of the `title` attribute! + +## Adding action confirmation + +Actions can be configured to require confirmation (by the user) before being executed. + +![Action confirmation modal with the Tabler theme](/action_confirmation_modal.png) + +To enable action confirmation, set its `confirmation` option to `true`: + +```php +$builder->addRowAction('remove', ButtonActionType::class, [ + 'confirmation' => true, +]); +``` + +To configure the confirmation modal, pass the array as the `confirmation` option: + +```php +$builder->addRowAction('remove', ButtonActionType::class, [ + 'confirmation' => [ + 'translation_domain' => 'KreyuDataTable', + 'label_title' => 'Action confirmation', + 'label_description' => 'Are you sure you want to execute this action?', + 'label_confirm' => 'Confirm', + 'label_cancel' => 'Cancel', + 'type' => 'danger', // "danger", "warning" or "info" + ], +]); +``` + +For reference, see details about the [`confirmation`](#) option. + +## Conditionally rendering the action + +Action visibility can be configured using its [`visible`](#) option: + +```php +$builder->addRowAction('remove', ButtonActionType::class, [ + 'visible' => $this->isGranted('ROLE_ADMIN'), +]); +``` + +Another approach would be simply not adding the action at all: + +```php +if ($this->isGranted('ROLE_ADMIN')) { + $builder->addRowAction('remove', ButtonActionType::class); +} +``` + +What differentiates those two methods, is that by using the `visible` option, the action is still defined in the data table, but is not rendered in the view. +It may be useful in some cases, for example, when the actions can be modified outside the data table builder. + +## Batch action specifics + +### Adding checkbox column + +Batch actions require the user to select specific rows. This is handled by the [`CheckboxColumnType`](../../reference/types/column/checkbox.md), +which simply renders a checkbox with value set to row identifier. To help with that process, +if at least one batch action is defined, this checkbox column will be added automatically. + +This column will be named `__batch`, which can be referenced using the constant: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; + +$column = $builder->getColumn(DataTableBuilderInterface::BATCH_CHECKBOX_COLUMN_NAME); +``` + +This behavior can be disabled (or enabled back again) using the builder's method: + +```php +$builder->setAutoAddingBatchCheckboxColumn(false); +``` + +### Changing identifier parameter name + +By default, the checkbox column type will add the `id` parameter to the batch actions. +For example, checking rows with ID 1, 2 will result in: + +- every batch action's `href` parameter appended with `id[]=1&id[]=2` +- every batch action's `data-id` parameter set to `[1,2]` + +The parameter name can be changed by providing the `identifier_name` option: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\CheckboxColumnType; + +$builder->addColumn('__batch', CheckboxColumnType::class, [ + 'identifier_name' => 'product_id', +]); +``` + +Using the above configuration, checking rows with ID 1, 2 will result in: + +- every batch action's `href` parameter appended with `product_id[]=1&product_id[]=2` +- every batch action's `data-product-id` parameter set to `[1,2]` + +If the action has no `href` parameter, the query parameters will not be appended. +The data parameters are not used internally and can be used for custom scripts. + +If `FormActionType` is used, the scripts will append hidden inputs with selected values, for example: + +```html + + +``` + +### Changing identifier parameter value + +By default, the checkbox column type will try to retrieve the identifier on the `id` property path. +This can be changed similarly to other column types, by providing the `property_path` option: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\CheckboxColumnType; + +$builder->addColumn('__batch', CheckboxColumnType::class, [ + 'property_path' => 'uuid', +]); +``` + +If property accessor is not enough, use the `getter` option: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\CheckboxColumnType; + +$builder->addColumn('__batch', CheckboxColumnType::class, [ + 'getter' => fn (Product $product) => $product->getUuid(), +]); +``` + +### Multiple checkbox columns + +Using multiple checkbox columns for a single data table is supported. +For example, using the following configuration: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Column\Type\CheckboxColumnType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('productId', CheckboxColumnType::class, [ + 'property_path' => 'id', + 'identifier_name' => 'product_id', + ]) + ->addColumn('categoryId', CheckboxColumnType::class, [ + 'property_path' => 'category.id', + 'identifier_name' => 'category_id', + ]) + ; + } +} +``` + +And having a data set which consists of two rows: + +| Product ID | Category ID | +|------------|-------------| +| 1 | 3 | +| 2 | 4 | + +Checking the first row's product and second row's category will result in: + +- every batch action's `href` parameter appended with `product_id[]=1&category_id[]=4` +- every batch action's `data-product-id` parameter set to `[1]` and `data-category-id` set to `[4]` + +If the action has no `href` parameter, the query parameters will not be appended. +The data parameters are not used internally and can be used for custom scripts. + +If `FormActionType` is used, the scripts will append hidden inputs with selected values, for example: + +```html + + +``` \ No newline at end of file diff --git a/docs/src/docs/components/columns.md b/docs/src/docs/components/columns.md new file mode 100644 index 00000000..b1b29ec5 --- /dev/null +++ b/docs/src/docs/components/columns.md @@ -0,0 +1,276 @@ +# Columns + +[[toc]] + +## Adding columns + +To add a column, use the data table builder's `addColumn()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\DateTimeColumnType; + +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class) + ->addColumn('name', TextColumnType::class, [ + 'label' => 'Full name', + ]) + ->addColumn('createdAt', DateTimeColumnType::class, [ + 'format' => 'Y-m-d H:i:s', + ]) + ; + } +} +``` + +This method accepts _three_ arguments: + +- column name; +- column type — with a fully qualified class name; +- column options — defined by the column type, used to configure the column; + +For reference, see [available column types](../../reference/types/column.md). + +## Creating column types + +If [built-in column types](../../reference/types/column.md) are not enough, you can create your own. +In following chapters, we'll be creating a column that renders a phone number stored as an object: + +```php +readonly class PhoneNumber +{ + public function __construct( + public string $nationalNumber, + public string $countryCode, + ) +} +``` + +Column types are classes that implement [`ColumnTypeInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/ColumnTypeInterface.php), although, it's better to extend from the [`AbstractColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/AbstractColumnType.php): + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\AbstractColumnType; + +class PhoneNumberColumnType extends AbstractColumnType +{ +} +``` + +
+ +Recommended namespace for the column type classes is `App\DataTable\Column\Type\`. + +
+ +### Column type inheritance + +Because our phone number column fundamentally renders as a text, let's base it off the built-in [`TextColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/TextColumnType.php). +Provide the fully-qualified class name of the parent type in the `getParent()` method: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\AbstractColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; + +class PhoneNumberColumnType extends AbstractColumnType +{ + public function getParent(): ?string + { + return TextColumnType::class; + } +} +``` + +::: tip +If you take a look at the [`AbstractColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/AbstractColumnType.php), +you'll see that `getParent()` method returns fully-qualified name of the [`ColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/ColumnType.php) type class. +This is the type that defines all the basic options, such as `attr`, `label`, etc. +::: + +### Rendering the column type + +Because our phone number column is based off the built-in [`TextColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/TextColumnType.php), +it will be rendered as a text as long as the `PhoneNumber` object can be cast to string. However, in our case, let's store this logic in the template. + +First, create a custom theme for the data table, and create a `column_phone_number_value` block: + +```twig +{# templates/data_table/theme.html.twig #} + +{% block column_phone_number_value %} + +{{ value.countryCode }} {{ value.nationalNumber }} +{% endblock %} +``` + +The block naming follows a set of rules: + +- for columns, it always starts with `column` prefix; +- next comes the block prefix of the column type; +- last part of the block name represents a part of the column. The column is split into multiple parts when rendering: + - `label` - displayed in the column header and in [personalization](../features/personalization.md) column list; + - `header` - displayed at the top of the column, allows [sorting](../features/sorting.md) if the column is sortable; + - `value` - like shown in example above, it renders the value itself; + +If you take a look at the [`AbstractColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/AbstractColumnType.php), +you'll see that `getBlockPrefix()` returns snake cased short name of the type class, without the `ColumnType` suffix. + +In our case, because the type class is named `PhoneNumberColumnType`, the default block prefix equals `phone_number`. Simple as that. + +Now, the custom theme should be added to the bundle configuration: + +::: code-group + +```yaml [YAML] +kreyu_data_table: + defaults: + themes: + # ... + - 'data_table/theme.html.twig' +``` + +```php [PHP] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults()->themes([ + // ... + 'data_table/theme.html.twig', + ]); +}; +``` + +::: + + +If the `column_phone_number_value` block wasn't defined in any of the configured themes, the bundle will render block of the parent type. +In our example, because we set [`TextColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/TextColumnType.php) as a parent, a `column_phone_number_value` block will be rendered. + +### Adding configuration options + +Column type options allow to configure the behavior of the column types. +The options are defined in the `configureOptions()` method, using the [OptionsResolver component](https://symfony.com/doc/current/components/options_resolver.html). + +Imagine, that you want to determine whether the country code should be rendered. This could be achieved by using a `show_country_code` option: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\AbstractColumnType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PhoneNumberColumnType extends AbstractColumnType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + // define available options and their default values + ->setDefaults([ + 'show_country_code' => true, + ]) + // optionally you can restrict type of the options + ->setAllowedTypes('country_code', 'bool') + ; + } +} +``` + +Now you can configure the new option when using the column type: + +```php +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + // ... + ->addColumn('phone', PhoneNumberColumnType::class, [ + 'show_country_code' => false, + ]) + ; + } +} +``` + +### Passing variables to the template + +Now, the `show_country_code` option is defined, but is not utilized by the system in any way. +In our case, we'll pass the options to the view, and use them to render the template itself: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\AbstractColumnType; +use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView; +use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; + +class PhoneNumberColumnType extends AbstractColumnType +{ + public function buildValueView(ColumnValueView $view, ColumnInterface $column, array $options): void + { + $view->vars['show_country_code'] = $options['show_country_code']; + } +} +``` + +Now we can update the template of the type class to use the newly added variable: + +```twig +{# templates/data_table/theme.html.twig #} + +{% block column_phone_number_value %} + {% if show_country_code %} + +{{ value.countryCode }} + {% endif %} + + {{ value.nationalNumber }} +{% endblock %} +``` + +## Column type extensions + +Column type extensions allows modifying configuration of the existing column types, even the built-in ones. +Let's assume, that we want to add a `trim` option, which will automatically apply the PHP `trim` method +on every column type in the system that uses [`TextColumnType`](../../reference/types/column/text.md) as its parent. + +Column type extensions are classes that implement [`ColumnTypeExtensionInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Extension/ColumnTypeExtensionInterface.php). +However, it's better to extend from the [`AbstractColumnTypeExtension`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Extension/AbstractColumnTypeExtension.php): + +```php +use Kreyu\Bundle\DataTableBundle\Column\Extension\AbstractColumnTypeExtension; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class TrimColumnTypeExtension extends AbstractColumnTypeExtension +{ + public function buildValueView(ColumnValueView $view, ColumnInterface $column, array $options): void + { + $value = $view->vars['value']; + + if (!$options['trim'] || !is_string($value)) { + return; + } + + $view->vars['value'] = trim($value); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefault('trim', true) + ->setAllowedTypes('country_code', 'bool') + ; + } + + public static function getExtendedTypes(): iterable + { + return [TextColumnType::class]; + } +} +``` + +Now, automatically, the [`TextColumnType`](../../reference/types/column/text.md) type, as well as every other type that uses it as a parent, have a `trim` option available, +and its value is trimmed based on this option. + +If your extension aims to cover every column type in the system, provide the base [`ColumnType`](../../reference/types/column/column.md) in the `getExtendedTypes()` method. diff --git a/docs/src/docs/components/exporters.md b/docs/src/docs/components/exporters.md new file mode 100644 index 00000000..25cb923d --- /dev/null +++ b/docs/src/docs/components/exporters.md @@ -0,0 +1,74 @@ +# Exporters + +[[toc]] + +## Adding exporters + +To add an exporter, use the data table builder's `addExporter()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\OpenSpout\Exporter\Type\CsvExporterType; +use Kreyu\Bundle\DataTableBundle\Bridge\OpenSpout\Exporter\Type\XlsxExporterType; + +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addExporter('csv', CsvExporterType::class) + ->addExporter('xlsx', XlsxExporterType::class) + ; + } +} +``` + +This method accepts _three_ arguments: + +- exporter name; +- exporter type — with a fully qualified class name; +- exporter options — defined by the exporter type, used to configure the exporter; + +For reference, see [available exporter types](../../reference/types/exporter.md). + +## Creating exporter types + +Exporter types are classes that implement [`ExporterTypeInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/ExporterTypeInterface.php). However, it's better to extend from the [`AbstractExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/AbstractExporterType.php): + +```php +use Kreyu\Bundle\DataTableBundle\Exporter\Type\AbstractExporterType; + +class CustomExporterType extends AbstractExporterType +{ +} +``` + +
+ +Recommended namespace for the exporter type classes is `App\DataTable\Exporter\Type\`. + +
+ +## Exporter type inheritance + +To make a type class use another type as a parent, provide its fully-qualified class name in the `getParent()` method: + +```php +use Kreyu\Bundle\DataTableBundle\Exporter\Type\AbstractExporterType; +use Kreyu\Bundle\DataTableBundle\Exporter\Type\CallbackExporterType; + +class CustomExporterType extends AbstractExporterType +{ + public function getParent(): ?string + { + return CallbackExporterType::class; + } +} +``` + +::: tip +If you take a look at the [`AbstractExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/AbstractExporterType.php), +you'll see that `getParent()` method returns fully-qualified name of the [`ExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/ExporterType.php) type class. +This is the type that defines all the basic options, such as `label`, `use_headers`, etc. +::: diff --git a/docs/src/docs/components/filters.md b/docs/src/docs/components/filters.md new file mode 100644 index 00000000..f1cdf9c2 --- /dev/null +++ b/docs/src/docs/components/filters.md @@ -0,0 +1,394 @@ +# Filters + +[[toc]] + +## Adding filters + +To add a filter, use the data table builder's `addFilter()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\NumberFilterType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\DateTimeFilterType; + +class UserDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('id', NumberFilterType::class) + ->addFilter('name', TextFilterType::class) + ->addFilter('createdAt', DateTimeFilterType::class) + ; + } +} +``` + +This method accepts _three_ arguments: + +- filter name; +- filter type — with a fully qualified class name; +- filter options — defined by the filter type, used to configure the filter; + +For reference, see [available filter types](../../reference/types/filter.md). + +## Creating filter types + +This bundle and integrations, such as [Doctrine ORM integration bundle](../integrations/doctrine-orm/installation.md), come with plenty of the [filter types](../../reference/types/filter.md). +However, those may not cover complex cases. Luckily, creating custom filter types are easy. + +Filter types are classes that implement [`FilterTypeInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/FilterTypeInterface.php). However, it's better to extend from the [`AbstractFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/AbstractFilterType.php): + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; + +class PhoneNumberFilterType extends AbstractFilterType +{ +} +``` + +
+ +Recommended namespace for the filter type classes is `App\DataTable\Filter\Type\`. + +
+ +### Filter type inheritance + +If you take a look at the [`AbstractFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/AbstractFilterType.php), you'll see that `getParent()` method returns fully-qualified name of the `FilterType` type class. +This is the type that defines all the required options, such as `label`, `form_type`, `form_options`, etc. + +::: danger This is not recommended: do _not_ use PHP inheritance! +```php +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class PhoneNumberFilterType extends TextFilterType +{ +} +``` +::: + +::: tip This is recommended: provide parent using the `getParent()` method +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class PhoneNumberFilterType extends AbstractFilterType +{ + public function getParent(): ?string + { + return TextFilterType::class; + } +} +``` +::: + +Both methods _will work_, but using PHP inheritance may result in unexpected behavior when using the [filter type extensions](#filter-type-extensions). + +### Form type and options + +To define form type and its options for the filter, use `form_type` and `form_options` options: + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ColorFilterType extends AbstractFilterType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'form_type' => ChoiceType::class, + 'form_options' => [ + 'choices' => [ + '#F44336' => 'Red', + '#4CAF50' => 'Green', + '#2196F3' => 'Blue', + ], + ], + ]); + } +} +``` + +### Creating filter handler + +Filter type classes is used to define the filter, not the actual logic executed when the filter is used. +This logic should be delegated to a filter handler instead. Filter handlers are classes that implement [`FilterHandlerInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/FilterHandlerInterface.php): + +```php +use Kreyu\Bundle\DataTableBundle\Filter\FilterHandlerInterface; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterData; +use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; + +class CustomFilterHandler implements FilterHandlerInterface +{ + public function handle(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter): void + { + // ... + } +} +``` +
+ +For example, take a look at the [`DoctrineOrmFilterHandler`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandler.php), +which is used by all Doctrine ORM integration filter types. + +
+ +The filter handler can be applied to a custom filter type by using the filter builder's `setHandler()` method: + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; + +class CustomFilterType extends AbstractFilterType +{ + public function buildFilter(FilterBuilderInterface $builder, array $options): void + { + $builder->setHandler(new CustomFilterHandler()); + } +} +``` + +If separate class seems like an overkill, you can implement the handler interface on the type class instead: + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterHandlerInterface; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterData; +use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; + +class CustomFilterType extends AbstractFilterType implements FilterHandlerInterface +{ + public function buildFilter(FilterBuilderInterface $builder, array $options): void + { + $builder->setHandler($this); + } + + public function handle(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter): void + { + // ... + } +} +``` + +## Filter type extensions + +Filter type extensions allows modifying configuration of the existing filter types, even the built-in ones. +Let's assume, that we want to [change default operator](#changing-default-operator) of [`TextFilterType`](#) +to `Operator::Equals`, so it is not necessary to pass `default_operator` option for each filter using this type. + +Filter type extensions are classes that implement [`FilterTypeExtensionInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Extension/FilterTypeExtensionInterface.php). +However, it's better to extend from the [`AbstractFilterTypeExtension`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Extension/AbstractColumnTypeExtension.php): + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Extension\AbstractFilterTypeExtension; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class DefaultOperatorTextFilterTypeExtension extends AbstractFilterTypeExtension +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('default_operator', Operator::Equals); + } + + public static function getExtendedTypes(): iterable + { + return [TextFilterType::class]; + } +} +``` + +If your extension aims to cover every filter type in the system, provide the base [`FilterType`](#) in the `getExtendedTypes()` method. + +## Formatting active filter value + +When the filter is active, its value is rendered to the user as a "pill", which removes the filter upon clicking it. +By default, the filter value requires to be stringable. However, there are some cases, where value cannot be stringable. + +Let's assume, that the application contains a `Product` entity, which contains a `Category`, which is **not** stringable: + +```php +readonly class Product +{ + public function __construct( + public Category $category, + ) +} + +readonly class Category +{ + public function __construct( + public string $name, + ) +} +``` + +In the product data table, we want to filter products by their category. +Using [EntityFilterType](#) will allow selecting a category from a list of existing categories. +Unfortunately, when the filter is applied, a `Cannot convert value of type Category to string` exception will occur. + +In that case, you can use the `active_filter_formatter` option, to determine what should be rendered based on the filter data: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\EntityFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('category', EntityFilterType::class, [ + 'form_options' => [ + 'class' => Category::class, + 'choice_label' => 'name', + ], + 'active_filter_formatter' => function (FilterData $data) { + $value = $data->getValue(); + + if ($value instanceof Category) { + return $value->getName(); + } + + return $value; + }, + ]) + ; + } +} +``` + +::: tip This is only a simple example of using the `active_filter_formatter` option. +The [`EntityFilterType`](#) has a `choice_label` option, which can be used to provide property path to the value to render: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\EntityFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('category', EntityFilterType::class, [ + 'form_options' => [ + 'class' => Category::class, + 'choice_label' => 'name', + ], + 'choice_label' => 'name', // same as the form choice_label option + ]) + ; + } +} +``` +::: + +## Changing default operator + +Let's assume, that the application contains a `Book` entity with ISBN: + +```php +readonly class Book +{ + public function __construct( + public string $isbn, + ) +} +``` + +If we use a [TextFilterType](#) on the `isbn` column, the filter will perform partial matching (`LIKE %value%`), +because the filter type has `default_operator` option set to `Operator::Contains`. +In this case, we want to perform exact matching, therefore, we have to change this option value to `Operator::Equals`: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('isbn', TextFilterType::class, [ + 'default_operator' => Operator::Equals, + ]) + ; + } +} +``` + +
+ +Each filter supports different set of operators. + +
+ +
+ +To change default operator filter type without having to explicitly provide the `default_operator`, +consider creating a [filter type extension](#filter-type-extensions). + +
+ +## Displaying operator selector + +The operator can be selected by the user, when operator selector is visible. +By default, operator selector is **not** visible. To change that, use `operator_visible` option: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('isbn', TextFilterType::class, [ + 'operator_visible' => true, + ]) + ; + } +} +``` + +## Operator form type and options + +You can customize form type and options of the operator form field, using `operator_form_type` and `operator_form_options`: + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\Form\Type\OperatorType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductFilterType extends AbstractFilterType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + // Note: this is default operator type + 'operator_form_type' => OperatorType::class, + 'operator_form_options' => [ + 'required' => true, + ], + ]); + } +} +``` diff --git a/docs/src/docs/contributing.md b/docs/src/docs/contributing.md new file mode 100644 index 00000000..d1eab678 --- /dev/null +++ b/docs/src/docs/contributing.md @@ -0,0 +1,13 @@ +# Contributing + +## Documentation + +The documentation is powered by the [VitePress](https://vitepress.dev/). + +To locally preview the documentation, first, install the [VitePress](https://vitepress.dev/) locally. +The installation instructions are available in the ["Getting Started" documentation section](https://vitepress.dev/guide/getting-started). +Then, to build the documentation locally (and rebuild when change is detected), run the following command: + +```shell +npm install && npm run docs:dev +``` diff --git a/docs/src/docs/features/asynchronicity.md b/docs/src/docs/features/asynchronicity.md new file mode 100644 index 00000000..cd417980 --- /dev/null +++ b/docs/src/docs/features/asynchronicity.md @@ -0,0 +1,33 @@ +# Asynchronicity + +[Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html) is a Symfony bundle integrating the [Hotwire Turbo](https://turbo.hotwired.dev/) library in Symfony applications. +It allows having the same user experience as with [Single Page Apps](https://en.wikipedia.org/wiki/Single-page_application) but without having to write a single line of JavaScript! + +This bundle provides integration that works out-of-the-box. + +## The magic part + +Make sure your application uses the [Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html). +You don't have to configure anything extra, your data tables automatically work asynchronously! +The magic comes from the [base template](https://github.com/Kreyu/data-table-bundle/blob/main/src/Resources/views/themes/base.html.twig), +which wraps the whole table in the `` tag: + +```twig +{# @KreyuDataTable/themes/base.html.twig #} +{% block kreyu_data_table %} + + {# ... #} + +{% endblock %} +``` + +This ensures every data table is wrapped in its own frame, making them work asynchronously. + +
+ +This integration also works on other built-in templates, because they all extend the base one. +If you're making a data table theme from scratch, make sure the table is wrapped in the Turbo frame, as shown above. + +
+ +For more information, see [official documentation about the Turbo frames](https://symfony.com/bundles/ux-turbo/current/index.html#decomposing-complex-pages-with-turbo-frames). diff --git a/docs/src/docs/features/exporting.md b/docs/src/docs/features/exporting.md new file mode 100644 index 00000000..c69378e6 --- /dev/null +++ b/docs/src/docs/features/exporting.md @@ -0,0 +1,301 @@ +# Exporting + +The data tables can be _exported_, with use of the [exporters](#). + +::: details Screenshots +![Export modal with the Tabler theme](/export_modal.png) +::: + +[[toc]] + +## Toggling the feature + +By default, the exporting feature is **enabled** for every data table. +This can be configured with the `exporting_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + exporting: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->exporting()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'exporting_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'exporting_enabled' => true, + ], + ); + } +} +``` +::: + +::: tip Enabling the feature does not mean that any column will be exportable by itself. +By default, columns **are not** exportable. +::: + +## Making the columns exportable + +To make any column exportable, use its `export` option: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class, [ + 'export' => true, + ]) + ; + } +} +``` + +The column can be configured separately for the export by providing the array in the `export` option. +For example, to change the label of the column in the export: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('category', TextColumnType::class, [ + 'export' => [ + 'label' => 'Category Name', + ], + ]) + ; + } +} +``` + +## Default export configuration + +The default export data, such as filename, exporter, strategy and a flag whether the personalization should be included, +can be configured using the data table builder's `setDefaultExportData()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Exporter\ExportData; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->setDefaultExportData(ExportData::fromArray([ + 'filename' => sprintf('products_%s', date('Y-m-d')), + 'exporter' => 'xlsx', + 'strategy' => ExportStrategy::IncludeAll, + 'include_personalization' => true, + ])) + ; + } +} +``` + +## Handling the export form + +In the controller, use the `isExporting()` method to make sure the request should be handled as an export: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(Request $request) + { + $dataTable = $this->createDataTable(ProductDataTableType::class); + $dataTable->handleRequest($request); + + if ($dataTable->isExporting()) { + return $this->file($dataTable->export()); + } + } +} +``` + +## Exporting without user input + +To export the data table manually, without user input, use the `export()` method directly: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable(ProductDataTableType::class); + + // An instance of ExportFile, which extends the HttpFoundation File object + $file = $dataTable->export(); + + // For example, save it manually: + $file->move(__DIR__); + + // Or return a BinaryFileResponse to download it in browser: + return $this->file($file); + } +} +``` + +The export data (configuration, e.g. a filename) can be included by passing it directly to the `export()` method: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable(ProductDataTableType::class); + + $exportData = ExportData::fromDataTable($dataTable); + $exportData->filename = sprintf('products_%s', date('Y-m-d')); + $exportData->includePersonalization = false; + + $file = $dataTable->export($exportData); + + // ... + } +} +``` + +## Optimization with Doctrine ORM + +The exporting process including all pages of the large datasets can take a very long time. +To optimize this process, when using Doctrine ORM, change the hydration mode to array during the export: + +```php +use Doctrine\ORM\AbstractQuery; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Event\DataTableEvent; +use Kreyu\Bundle\DataTableBundle\Event\DataTableEvents; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->addEventListener(DataTableEvents::PRE_EXPORT, function (DataTableEvent $event) { + $event->getDataTable()->getQuery()->setHydrationMode(AbstractQuery::HYDRATE_ARRAY); + }); + } +} +``` + +This will prevent the Doctrine ORM from hydrating the entities, which is not needed for the export. +Unfortunately, this means each exportable column property path has to be changed to array (wrapped in square brackets): + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class, [ + 'export' => [ + 'property_path' => '[id]', + ], + ]) + ; + } +} + +``` + +## Events + +The following events are dispatched when `export()` method of the [`DataTableInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/DataTableInterface.php) is called: + +::: info PRE_EXPORT +Dispatched before the exporter is called. +Can be used to modify the exporting data (configuration), e.g. to force an export strategy or change the filename. + +**See**: [`DataTableEvents::PRE_EXPORT`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +The dispatched events are instance of the [`DataTablePersonalizationEvent`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTablePersonalizationEvent.php): + +```php +use Kreyu\Bundle\DataTableBundle\Event\DataTableExportEvent; + +class DataTableExportListener +{ + public function __invoke(DataTableExportEvent $event): void + { + $dataTable = $event->getDataTable(); + $exportData = $event->getExportData(); + + // for example, modify the export data (configuration), then save it in the event + $event->setExportData($exportData); + } +} +``` \ No newline at end of file diff --git a/docs/src/docs/features/extensibility.md b/docs/src/docs/features/extensibility.md new file mode 100644 index 00000000..4bd655d5 --- /dev/null +++ b/docs/src/docs/features/extensibility.md @@ -0,0 +1,270 @@ +# Extensibility + +There are multiple concepts that can be modified for a specific case. + +[[toc]] + +## Request handlers + +The data tables by default have no clue about the requests. +To solve this problem, a request can be handled by the data table using the `handleRequest()` method. +This means an underlying request handler will be called, extracting the required data from the request, +and calling methods such as `sort()` or `paginate()` on the data table. + +### Built-in request handlers + +This bundle comes with [HttpFoundationRequestHandler](https://github.com/Kreyu/data-table-bundle/blob/main/src/Request/HttpFoundationRequestHandler.php), +which supports the [request object](https://github.com/symfony/http-foundation/blob/6.4/Request.php) common for the Symfony applications: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(Request $request) + { + $dataTable = $this->createDataTable(ProductDataTableType::class); + $dataTable->handleRequest($request); + } +} +``` + +### Creating request handlers + +To create a request handler, create a class that implements [RequestHandlerInterface](https://github.com/Kreyu/data-table-bundle/blob/main/src/Request/RequestHandlerInterface.php): + +```php +use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface; +use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; +use Kreyu\Bundle\DataTableBundle\Sorting\SortingField; + +class CustomRequestHandler implements RequestHandlerInterface +{ + public function handle(DataTableInterface $dataTable, mixed $request = null): void + { + // Call desired methods with arguments based on the data from $request + $dataTable->paginate(...); + $dataTable->sort(...); + $dataTable->personalize(...); + $dataTable->filter(...); + $dataTable->export(...); + } +} +``` + +
+ +The recommended namespace for the request handlers is `App\DataTable\Request`. + +
+ +You can apply this request handler globally using the configuration file, or use `request_handler` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + # this should be a service id - which is class by default + request_handler: 'App\DataTable\Request\CustomRequestHandler' +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + // this should be a service id - which is class by default + $defaults->requestHandler('App\DataTable\Request\CustomRequestHandler'); +}; +``` + +```php [For data table type] +use App\DataTable\Request\CustomRequestHandler; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + private CustomRequestHandler $requestHandler, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'request_handler' => $this->requestHandler, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Request\CustomRequestHandler; +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + private CustomRequestHandler $requestHandler, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'request_handler' => $this->requestHandler, + ], + ); + } +} +``` +::: + +## Proxy queries + +This bundle is data source agnostic, meaning it is not tied to any specific ORM, such as Doctrine ORM. +This is accomplished thanks to **proxy queries**, which work as an adapter for the specific data source. + +### Creating custom proxy query + +To create a custom proxy query, create a class that implements [ProxyQueryInterface](https://github.com/Kreyu/data-table-bundle/blob/main/src/Query/ProxyQueryInterface.php): + +```php +use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; +use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Query\ResultSetInterface; + +class ArrayProxyQuery implements ProxyQueryInterface +{ + public function __construct( + private array $data, + ) { + } + + public function sort(SortingData $sortingData): void + { + } + + public function paginate(PaginationData $paginationData): void + { + } + + public function getResult(): ResultSetInterface + { + } +} +``` + +
+ +The recommended namespace for the proxy queries is `App\DataTable\Query`. + +
+ +Now you can use the custom proxy query when creating the data tables: + +```php +use App\DataTable\Type\ProductDataTableType; +use App\DataTable\Query\ArrayProxyQuery; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + // Note: the products are an instance of ArrayProxyQuery + $products = new ArrayProxyQuery([ + new Product(name: 'Product #1'), + new Product(name: 'Product #2'), + new Product(name: 'Product #3'), + ]); + + $dataTable = $this->createDataTable(ProductDataTableType::class, $products); + } +} +``` + +### Creating proxy query factory + +Each proxy query should have a factory, so the bundle can handle passing the raw data like so: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + // Note: products are just a simple array, ArrayProxyQuery is not required + $products = [ + new Product(name: 'Product #1'), + new Product(name: 'Product #2'), + new Product(name: 'Product #3'), + ]; + + $dataTable = $this->createDataTable(ProductDataTableType::class, $products); + } +} +``` + +Without dedicated proxy query factory to handle array data, the bundle will throw an exception: + +> Unable to create ProxyQuery for given data + +In the background, the [ChainProxyQueryFactory](https://github.com/Kreyu/data-table-bundle/blob/main/src/Query/ChainProxyQueryFactory.php) +iterates through registered proxy query factories, and returns the first successfully created proxy query. +The error occurs because there is no factory to create the custom type. + +To create a proxy query factory, create a class that implements the [ProxyQueryFactoryInterface](https://github.com/Kreyu/data-table-bundle/blob/main/src/Query/ProxyQueryFactoryInterface.php): + +```php +use App\DataTable\Query\ArrayProxyQuery; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryFactoryInterface; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; + +class ArrayProxyQueryFactory implements ProxyQueryFactoryInterface +{ + public function create(mixed $data): ProxyQueryInterface + { + if (!is_array($data)) { + throw new UnexpectedTypeException($data, ArrayProxyQuery::class); + } + + return new ArrayProxyQuery($data); + } +} +``` + +
+ +The recommended namespace for the proxy query factories is `App\DataTable\Query`. + +
+ +If the custom proxy query does not support a specific data class, the factory **have** to throw an [UnexpectedTypeException](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exception/UnexpectedTypeException.php), +so the chain proxy query factory will know to skip that factory and check other ones. + +Proxy query factories must be registered as services and tagged with the `kreyu_data_table.proxy_query.factory` tag. +If you're using the [default services.yaml configuration](https://symfony.com/doc/current/service_container.html#service-container-services-load-example), +this is already done for you, thanks to [autoconfiguration](https://symfony.com/doc/current/service_container.html#services-autoconfigure). diff --git a/docs/src/docs/features/filtering.md b/docs/src/docs/features/filtering.md new file mode 100644 index 00000000..16a865dd --- /dev/null +++ b/docs/src/docs/features/filtering.md @@ -0,0 +1,225 @@ +# Filtering + +The data tables can be _filtered_, with use of the [filters](#). + +[[toc]] + +## Toggling the feature + +By default, the filtration feature is **enabled** for every data table. +This can be configured with the `filtration_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + filtration: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->filtration()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'filtration_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'filtration_enabled' => true, + ], + ); + } +} +``` +::: + +## Saving applied filters + +By default, the filtration feature [persistence](persistence.md) is **disabled** for every data table. + +You can configure the [persistence](persistence.md) globally using the package configuration file, or its related options: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + filtration: + persistence_enabled: true + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.filtration.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->filtration() + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.filtration.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + #[Autowire(service: 'kreyu_data_table.filtration.persistence.adapter.cache')] + private PersistenceAdapterInterface $persistenceAdapter, + #[Autowire(service: 'kreyu_data_table.persistence.subject_provider.token_storage')] + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'filtration_persistence_enabled' => true, + 'filtration_persistence_adapter' => $this->persistenceAdapter, + 'filtration_persistence_subject_provider' => $this->persistenceSubjectProvider, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + #[Autowire(service: 'kreyu_data_table.filtration.persistence.adapter.cache')] + private PersistenceAdapterInterface $persistenceAdapter, + #[Autowire(service: 'kreyu_data_table.persistence.subject_provider.token_storage')] + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'filtration_persistence_enabled' => true, + 'filtration_persistence_adapter' => $this->persistenceAdapter, + 'filtration_persistence_subject_provider' => $this->persistenceSubjectProvider, + ], + ); + } +} +``` +::: + +## Default filtration + +The default filtration data can be overridden using the data table builder's `setDefaultFiltrationData()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Filter\FiltrationData; +use Kreyu\Bundle\DataTableBundle\Filter\FilterData; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->setDefaultFiltrationData(new FiltrationData([ + 'name' => new FilterData(value: 'John', operator: Operator::Contains), + ])); + + // or by creating the filtration data from an array: + $builder->setDefaultFiltrationData(FiltrationData::fromArray([ + 'name' => ['value' => 'John', 'operator' => 'contains'], + ])); + } +} +``` + +## Events + +The following events are dispatched when `filter()` method of the [`DataTableInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/DataTableInterface.php) is called: + +::: info PRE_FILTER +Dispatched before the filtration data is applied to the query. +Can be used to modify the filtration data, e.g. to force application of some filters. + +**See**: [`DataTableEvents::PRE_FILTER`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +::: info POST_FILTER +Dispatched after the filtration data is applied to the query and saved if the filtration persistence is enabled; +Can be used to execute additional logic after the filters are applied. + +**See**: [`DataTableEvents::POST_FILTER`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +The dispatched events are instance of the [`DataTableFiltrationEvent`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableFiltrationEvent.php): + +```php +use Kreyu\Bundle\DataTableBundle\Event\DataTableFiltrationEvent; + +class DataTableFiltrationListener +{ + public function __invoke(DataTableFiltrationEvent $event): void + { + $dataTable = $event->getDataTable(); + $filtrationData = $event->getFiltrationData(); + + // for example, modify the filtration data, then save it in the event + $event->setFiltrationData($filtrationData); + } +} +``` \ No newline at end of file diff --git a/docs/src/docs/features/pagination.md b/docs/src/docs/features/pagination.md new file mode 100644 index 00000000..07a6f1cf --- /dev/null +++ b/docs/src/docs/features/pagination.md @@ -0,0 +1,225 @@ +# Pagination + +The data tables can be _paginated_, which is crucial when working with large data sources. + +[[toc]] + +## Toggling the feature + +By default, the pagination feature is **enabled** for every data table. +This can be configured thanks to the `pagination_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + pagination: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->pagination()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'pagination_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'pagination_enabled' => true, + ], + ); + } +} +``` +::: + +::: tip If you don't see the pagination controls, make sure your data table has enough records! +By default, every page contains 25 records. +Built-in themes display pagination controls only when the data table contains more than one page. +Also, remember that you can [change the default pagination data](#default-pagination), reducing the per-page limit. +::: + +## Saving applied pagination + +By default, the pagination feature [persistence](persistence.md) is **disabled** for every data table. + +You can configure the [persistence](persistence.md) globally using the package configuration file, or its related options: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + pagination: + persistence_enabled: true + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.sorting.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->pagination() + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.sorting.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + private PersistenceAdapterInterface $persistenceAdapter, + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'pagination_persistence_enabled' => true, + 'pagination_persistence_adapter' => $this->persistenceAdapter, + 'pagination_persistence_subject_provider' => $this->persistenceSubjectProvider, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + private PersistenceAdapterInterface $persistenceAdapter, + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'pagination_persistence_enabled' => true, + 'pagination_persistence_adapter' => $this->persistenceAdapter, + 'pagination_persistence_subject_provider' => $this->persistenceSubjectProvider, + ], + ); + } +} +``` +::: + +## Default pagination + +The default pagination data can be overridden using the data table builder's `setDefaultPaginationData()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->setDefaultPaginationData(new PaginationData( + page: 1, + perPage: 25, + )); + + // or by creating the pagination data from an array: + $builder->setDefaultPaginationData(PaginationData::fromArray([ + 'page' => 1, + 'perPage' => 25, + ])); + } +} +``` + +## Events + +The following events are dispatched when `paginate()` method of the [`DataTableInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/DataTableInterface.php) is called: + +::: info PRE_PAGINATE +Dispatched before the pagination data is applied to the query. +Can be used to modify the pagination data, e.g. to force specific page or a per-page limit. + +**See**: [`DataTableEvents::PRE_PAGINATE`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +::: info POST_PAGINATE +Dispatched after the pagination data is applied to the query and saved if the pagination persistence is enabled. +Can be used to execute additional logic after the pagination is applied. + +**See**: [`DataTableEvents::POST_PAGINATE`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +The dispatched events are instance of the [`DataTablePaginationEvent`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTablePaginationEvent.php): + +```php +use Kreyu\Bundle\DataTableBundle\Event\DataTablePaginationEvent; + +class DataTablePaginationListener +{ + public function __invoke(DataTablePaginationEvent $event): void + { + $dataTable = $event->getDataTable(); + $paginationData = $event->getPaginationData(); + + // for example, modify the pagination data, then save it in the event + $event->setPaginationData($paginationData); + } +} +``` diff --git a/docs/src/docs/features/persistence.md b/docs/src/docs/features/persistence.md new file mode 100644 index 00000000..73645597 --- /dev/null +++ b/docs/src/docs/features/persistence.md @@ -0,0 +1,405 @@ +# Persistence + +This bundle provides a persistence feature, which is used to save data between requests. +For example, it can be used to persist applied filters or pagination, per user. + +[[toc]] + +## Toggling the feature + +Persistence can be toggled per feature with its own configuration: + +- [Saving applied pagination](pagination.md#saving-applied-pagination) +- [Saving applied sorting](sorting.md#configuring-the-feature-persistence) +- [Saving applied filters](filtering.md#configuring-the-feature-persistence) +- [Saving applied personalization](personalization.md#saving-applied-personalization) + +## Persistence adapters + +Adapters are classes that allow writing (to) and reading (from) the persistent data source. + +### Built-in cache adapter + +The bundle has a built-in cache adapter, which uses the [Symfony Cache component](https://symfony.com/doc/current/components/cache.html). + +It is registered as an [abstract service](https://symfony.com/doc/current/service_container/parent_services.html) in the service container: + +```bash +$ bin/console debug:container kreyu_data_table.persistence.adapter.cache +``` + +The adapters are then created based on the abstract definition: + +```bash +$ bin/console debug:container kreyu_data_table.pagination.persistence.adapter.cache +$ bin/console debug:container kreyu_data_table.sorting.persistence.adapter.cache +$ bin/console debug:container kreyu_data_table.filtration.persistence.adapter.cache +$ bin/console debug:container kreyu_data_table.personalization.persistence.adapter.cache +``` + +The bundle adds a `kreyu_data_table.persistence.cache.default` cache pool, which uses the `cache.adapter.filesystem` adapter, with `tags` enabled. + +::: tip It is recommended to use tag-aware cache adapter! +The built-in [cache persistence clearer](#persistence-clearers) requires tag-aware cache to clear persistence data. +::: + +### Creating custom adapters + +To create a custom adapter, create a class that implements `PersistenceAdapterInterface`. + +```php +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; + +class DatabasePersistenceAdapter implements PersistenceAdapterInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private string $prefix, + ) { + } + + public function read(DataTableInterface $dataTable, PersistenceSubjectInterface $subject): mixed + { + // ... + } + + public function write(DataTableInterface $dataTable, PersistenceSubjectInterface $subject, mixed $data): void + { + // ... + } +} +``` + +
+ +Recommended namespace for the column type classes is `App\DataTable\Persistence\`. + +
+ +The recommended way of creating those classes is accepting a `prefix` argument in the constructor. +This prefix will be different for each feature, for example, personalization persistence will use `personalization` prefix. + +Now, register it in the container as an abstract service: + +::: code-group +```yaml [YAML] +services: + app.data_table.persistence.database: + class: App\DataTable\Persistence\DatabasePersistenceAdapter + abstract: true + arguments: + - '@doctrine.orm.entity_manager' +``` + +```php [PHP] +use App\DataTable\Persistence\DatabasePersistenceAdapter; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return static function (ContainerConfigurator $configurator) { + $configurator->services() + ->set('app.data_table.persistence.database', DatabasePersistenceAdapter::class) + ->args([service('doctrine.orm.entity_manager')]) + ->abstract() + ; +``` +::: + +Now, create as many adapters as you need, based on the abstract definition. +For example, let's create an adapter for personalization feature, using the `personalization` prefix: + +::: code-group +```yaml [YAML] +services: + app.data_table.personalization.persistence.database: + parent: app.data_table.persistence.database + arguments: + $prefix: personalization + tags: + - { name: kreyu_data_table.proxy_query.factory } +``` + +```php [PHP] +use App\DataTable\Persistence\DatabasePersistenceAdapter; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return static function (ContainerConfigurator $configurator) { + $configurator->services() + ->set('app.data_table.personalization.persistence.database') + ->parent('app.data_table.persistence.database') + ->arg('$prefix', 'personalization') + ; +``` +::: + +The data tables can now be configured to use the new persistence adapter for the personalization feature: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + personalization: + persistence_adapter: app.data_table.personalization.persistence.database +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults() + ->personalization() + ->persistenceAdapter('app.data_table.personalization.persistence.database') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + #[Autowire(service: 'app.data_table.personalization.persistence.database')] + private PersistenceAdapterInterface $persistenceAdapter, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'personalization_persistence_adapter' => $this->persistenceAdapter, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + #[Autowire(service: 'app.data_table.personalization.persistence.database')] + private PersistenceAdapterInterface $persistenceAdapter, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'personalization_persistence_adapter' => $this->persistenceAdapter, + ], + ); + } +} +``` +::: + +## Persistence subjects + +Persistence subject can be any object that implements `PersistenceSubjectInterface`. + +The value returned in the `getDataTablePersistenceIdentifier()` is used in +[persistence adapters](#persistence-adapters) to associate persistent data with the subject. + +### Subject providers + +Persistence subject providers are classes that allow retrieving the [persistence subjects](#persistence-subjects). +Those classes contain `provide` method, that should return the subject, or throw an `PersistenceSubjectNotFoundException`. + +### Built-in token storage subject provider + +The bundle has a built-in token storage subject provider, which uses the [Symfony Security component](https://symfony.com/doc/current/security.html) to retrieve currently logged-in user. +This provider uses the [UserInterface](https://github.com/symfony/symfony/blob/6.4/src/Symfony/Component/Security/Core/User/UserInterface.php) `getUserIdentifier()` +method to retrieve the persistence identifier. + +::: danger The persistence identifier must be **unique** per user! +Otherwise, multiple users will override each other's data, like applied filters or current page. +::: + +You can manually provide the persistence identifier by implementing the `PersistenceSubjectInterface` interface on your User entity used by the security: + +```php +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectInterface; + +class User implements PersistenceSubjectInterface +{ + private Uuid $uuid; + + public function getDataTablePersistenceIdentifier(): string + { + return (string) $this->uuid; + } +} +``` + +::: tip Persistence "cache tag contains reserved characters" error? +If your User entity returns email address in `getUserIdentifier()` method, this creates a conflict +when using the [cache adapter](#built-in-cache-adapter), because the `@` character cannot be used as a cache key. + +For more information, see [troubleshooting section](../troubleshooting.md#persistence-cache-tag-contains-reserved-characters-error). +::: + +### Creating custom subject providers + +To create a custom subject provider, create a class that implements `PersistenceSubjectProviderInterface`: + +```php +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectInterface; + +class CustomPersistenceSubjectProvider implements PersistenceSubjectProviderInterface +{ + public function provide(): PersistenceSubjectInterface + { + // ... + } +} +``` + +Subject providers must be registered as services and tagged with the `kreyu_data_table.persistence.subject_provider` tag. +If you're using the [default services.yaml configuration](https://symfony.com/doc/current/service_container.html#service-container-services-load-example), +this is already done for you, thanks to [autoconfiguration](https://symfony.com/doc/current/service_container.html#services-autoconfigure). + +When using the default container configuration, that provider should be ready to use. +If not, consider tagging this class as `kreyu_data_table.persistence.subject_provider`: + +::: code-group +```yaml [YAML] +services: + app.data_table.persistence.subject_provider.custom: + class: App\DataTable\Persistence\CustomPersistenceSubjectProvider + tags: + - { name: kreyu_data_table.persistence.subject_provider } +``` + +```php [PHP] +use App\DataTable\Persistence\CustomPersistenceSubjectProvider; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + +return static function (ContainerConfigurator $configurator) { + $configurator->services() + ->set('app.data_table.persistence.database', CustomPersistenceSubjectProvider::class) + ->tag('kreyu_data_table.persistence.subject_provider') + ; +} +``` +::: + +The data tables can now be configured to use the new persistence subject provider for any feature. +For example, for personalization feature: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + personalization: + persistence_subject_provider: app.data_table.persistence.subject_provider.custom +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults() + ->personalization() + ->persistenceSubjectProvider('app.data_table.persistence.subject_provider.custom') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + #[Autowire(service: 'app.data_table.persistence.subject_provider.custom')] + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'personalization_persistence_subject_provider' => $this->persistenceSubjectProvider, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + #[Autowire(service: 'app.data_table.personalization.persistence.database')] + private PersistenceAdapterInterface $persistenceAdapter, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'personalization_persistence_adapter' => $this->persistenceAdapter, + ], + ); + } +} +``` +::: + +## Persistence clearers + +Persistence data can be cleared using persistence clearers, which are classes that implement [`PersistenceClearerInterface`](#). +Those classes contain a `clear()` method, which accepts a [persistence subject](#persistence-subjects) as an argument. + +Because the bundle has a built-in cache adapter, it also provides a cache persistence clearer: + +```bash +$ bin/console debug:container kreyu_data_table.persistence.clearer.cache +``` + +Let's assume, that the user has a "Clear data table persistence" button, somewhere on the "settings" page. +Handling this button in controller is very straightforward: + +```php +use App\Entity\User; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceClearerInterface; +use Symfony\Component\Routing\Annotation\Route; + +class UserController +{ + #[Route('/users/{id}/clear-persistence')] + public function clearPersistence(User $user, PersistenceClearerInterface $persistenceClearer) + { + $persistenceClearer->clear($user); + + // Flash with success, redirect, etc... + } +} +``` diff --git a/docs/src/docs/features/personalization.md b/docs/src/docs/features/personalization.md new file mode 100644 index 00000000..9b4619e4 --- /dev/null +++ b/docs/src/docs/features/personalization.md @@ -0,0 +1,263 @@ +# Personalization + +The data tables can be _personalized_, which can be helpful when working with many columns, by giving the user ability to: + +- set the priority (order) of the columns; +- show or hide specific columns; + +::: details Screenshots +![Personalization modal with Tabler theme](/personalization_modal.png) +::: + +[[toc]] + +## Prerequisites + +To begin with, make sure the [Symfony UX integration is enabled](../installation.md#enable-the-symfony-ux-integration). +Then, enable the **personalization** controller in your `assets/controllers.json` file: + +```json +{ + "controllers": { + "@kreyu/data-table-bundle": { + "personalization": { + "enabled": true + } + } + } +} +``` + +## Toggling the feature + +By default, the personalization feature is **disabled** for every data table. + +You can change this setting globally using the package configuration file, or use `personalization_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + personalization: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->personalization()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'personalization_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'personalization_enabled' => true, + ], + ); + } +} +``` +::: + +## Saving applied personalization + +By default, the personalization feature [persistence](persistence.md) is **disabled** for every data table. + +You can configure the [persistence](persistence.md) globally using the package configuration file, or its related options: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + personalization: + persistence_enabled: true + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.sorting.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->personalization() + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.sorting.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + private PersistenceAdapterInterface $persistenceAdapter, + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'personalization_persistence_enabled' => true, + 'personalization_persistence_adapter' => $this->persistenceAdapter, + 'personalization_persistence_subject_provider' => $this->persistenceSubjectProvider, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + private PersistenceAdapterInterface $persistenceAdapter, + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'personalization_persistence_enabled' => true, + 'personalization_persistence_adapter' => $this->persistenceAdapter, + 'personalization_persistence_subject_provider' => $this->persistenceSubjectProvider, + ], + ); + } +} +``` +::: + +## Default personalization + +There are two ways to configure the default personalization data for the data table: + +- using the columns [`priority`](../../reference/types/column/column.md#priority), [`visible`](../../reference/types/column/column.md#visible) and [`personalizable`](../../reference/types/column/column.md#personalizable) options (recommended); +- using the data table builder's `setDefaultPersonalizationData()` method; + +```php src/DataTable/Type/ProductDataTableType.php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; +use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationColumnData; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + // using the columns options: + $builder + ->addColumn('id', NumberColumnType::class, [ + 'priority' => -1, + ]) + ->addColumn('name', TextColumnType::class, [ + 'visible' => false, + ]) + ->addColumn('createdAt', DateTimeColumnType::class, [ + 'personalizable' => false, + ]) + ; + + // or using the data table builder's method: + $builder->setDefaultPersonalizationData(new PersonalizationData([ + new PersonalizationColumnData(name: 'id', priority: -1), + new PersonalizationColumnData(name: 'name', visible: false), + ])); + + // or by creating the personalization data from an array: + $builder->setDefaultPersonalizationData(PersonalizationData::fromArray([ + // each entry default values: name = from key, priority = 0, visible = false + 'id' => ['priority' => -1], + 'name' => ['visible' => false], + ])); + } +} +``` + +## Events + +The following events are dispatched when `personalize()` method of the [`DataTableInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/DataTableInterface.php) is called: + +::: info PRE_PERSONALIZE +Dispatched before the personalization data is applied to the data table. +Can be used to modify the personalization data, e.g. to dynamically specify priority or visibility of the columns. + +**See**: [`DataTableEvents::PRE_PERSONALIZE`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +::: info POST_PERSONALIZE +Dispatched after the personalization data is applied to the data table and saved if the personalization persistence is enabled. +Can be used to execute additional logic after the personalization is applied. + +**See**: [`DataTableEvents::POST_PERSONALIZE`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +The dispatched events are instance of the [`DataTablePersonalizationEvent`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTablePersonalizationEvent.php): + +```php +use Kreyu\Bundle\DataTableBundle\Event\DataTablePersonalizationEvent; + +class DataTablePersonalizationListener +{ + public function __invoke(DataTablePersonalizationEvent $event): void + { + $dataTable = $event->getDataTable(); + $personalizationData = $event->getPersonalizationData(); + + // for example, modify the personalization data, then save it in the event + $event->setPersonalizationData($personalizationData); + } +} +``` \ No newline at end of file diff --git a/docs/src/docs/features/sorting.md b/docs/src/docs/features/sorting.md new file mode 100644 index 00000000..e38ec251 --- /dev/null +++ b/docs/src/docs/features/sorting.md @@ -0,0 +1,297 @@ +# Sorting + +The data tables can be _sorted_ by any defined [column](../components/columns.md). + +[[toc]] + +## Toggling the feature + +By default, the sorting feature is **enabled** for every data table. + +You can change this setting globally using the package configuration file, or use `sorting_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + sorting: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->sorting()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'sorting_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'sorting_enabled' => true, + ], + ); + } +} +``` +::: + +::: tip Enabling the feature does not mean that any column will be sortable by itself. +By default, columns **are not** sortable. +::: + +## Making the columns sortable + +To make any column sortable, use its `sort` option: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class, [ + 'sort' => true, + ]) + ; + } +} +``` + +The bundle will use the column name as the path to perform sorting on. +However, if the path is different from the column name (for example, to display "category", but sort by the "category name"), provide it using the same `sort` option: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('category', TextColumnType::class, [ + 'sort' => 'category.name', + ]) + ; + } +} +``` + +If the column should be sorted by multiple database columns (for example, to sort by amount and currency at the same time), +when using the Doctrine ORM, provide a DQL expression as a sort property path: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('amount', TextColumnType::class, [ + 'sort' => 'CONCAT(product.amount, product.currency)', + ]) + ; + } +} +``` + +## Saving applied sorting + +By default, the sorting feature [persistence](persistence.md) is **disabled** for every data table. + +You can configure the [persistence](persistence.md) globally using the package configuration file, or its related options: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + sorting: + persistence_enabled: true + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.sorting.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $defaults = $config->defaults(); + $defaults->sorting() + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.sorting.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function __construct( + #[Autowire(service: 'kreyu_data_table.filtration.persistence.adapter.cache')] + private PersistenceAdapterInterface $persistenceAdapter, + #[Autowire(service: 'kreyu_data_table.persistence.subject_provider.token_storage')] + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'sorting_persistence_enabled' => true, + 'sorting_persistence_adapter' => $this->persistenceAdapter, + 'sorting_persistence_subject_provider' => $this->persistenceSubjectProvider, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function __construct( + #[Autowire(service: 'kreyu_data_table.filtration.persistence.adapter.cache')] + private PersistenceAdapterInterface $persistenceAdapter, + #[Autowire(service: 'kreyu_data_table.persistence.subject_provider.token_storage')] + private PersistenceSubjectProviderInterface $persistenceSubjectProvider, + ) { + } + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'sorting_persistence_enabled' => true, + 'sorting_persistence_adapter' => $this->persistenceAdapter, + 'sorting_persistence_subject_provider' => $this->persistenceSubjectProvider, + ], + ); + } +} +``` +::: + +## Default sorting + +The default sorting data can be overridden using the data table builder's `setDefaultSortingData()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; +use Kreyu\Bundle\DataTableBundle\Sorting\SortingColumnData; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->setDefaultSortingData(new SortingData([ + new SortingColumnData('id', 'desc'), + ])); + + // or by creating the sorting data from an array: + $builder->setDefaultSortingData(SortingData::fromArray([ + 'id' => 'desc', + ])); + } +} +``` + +::: tip The initial sorting can be performed on multiple columns! +Although, with built-in themes, the user can perform sorting only by a single column. +::: + +## Events + +The following events are dispatched when `sort()` method of the [`DataTableInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/DataTableInterface.php) is called: + +::: info PRE_SORT +Dispatched before the sorting data is applied to the query. +Can be used to modify the sorting data, e.g. to force sorting by additional column. + +**See**: [`DataTableEvents::PRE_SORT`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +::: info POST_SORT +Dispatched after the sorting data is applied to the query and saved if the sorting persistence is enabled; +Can be used to execute additional logic after the sorting is applied. + +**See**: [`DataTableEvents::POST_SORT`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableEvents.php) +::: + +The dispatched events are instance of the [`DataTableSortingEvent`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Event/DataTableSortingEvent.php): + +```php +use Kreyu\Bundle\DataTableBundle\Event\DataTableSortingEvent; + +class DataTableSortingListener +{ + public function __invoke(DataTableSortingEvent $event): void + { + $dataTable = $event->getDataTable(); + $sortingData = $event->getSortingData(); + + // for example, modify the sorting data, then save it in the event + $event->setSortingData($sortingData); + } +} +``` \ No newline at end of file diff --git a/docs/src/docs/features/theming.md b/docs/src/docs/features/theming.md new file mode 100644 index 00000000..d55a7b60 --- /dev/null +++ b/docs/src/docs/features/theming.md @@ -0,0 +1,163 @@ +# Theming + +Every HTML part of this bundle can be customized using [Twig](https://twig.symfony.com/) themes. + +[[toc]] + +## Built-in themes + +The following themes are natively available in the bundle: + +- [`@KreyuDataTable/themes/bootstrap_5.html.twig`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Resources/views/themes/bootstrap_5.html.twig) - integrates [Bootstrap 5](https://getbootstrap.com/docs/5.0/); +- [`@KreyuDataTable/themes/tabler.html.twig`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Resources/views/themes/tabler.html.twig) - integrates [Tabler UI Kit](https://tabler.io/); +- [`@KreyuDataTable/themes/base.html.twig`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Resources/views/themes/base.html.twig) - base HTML template; + +By default, the [`@KreyuDataTable/themes/base.html.twig`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Resources/views/themes/base.html.twig) theme is used. + +## Selecting a theme + +To select a theme, use `themes` option. + +For example, in order to use the [Bootstrap 5](https://getbootstrap.com/docs/5.0/) theme: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + themes: + - '@KreyuDataTable/themes/bootstrap_5.html.twig' +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults()->themes([ + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ]); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'themes' => [ + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ], + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'themes' => [ + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ], + ], + ); + } +} +``` +::: + +## Customizing existing theme + +To customize existing theme, you can either: + +- create a template that extends one of the built-in themes; +- create a template that [overrides the built-in theme](https://symfony.com/doc/current/bundles/override.html#templates); +- create a template from scratch; + +Because `themes` configuration option accepts an array of themes, +you can provide your own theme with only a fraction of Twig blocks, +using the built-in themes as a fallback, for example: + +```twig +{# templates/data_table/theme.html.twig #} +{% block column_boolean_value %} + {# ... #} +{% endblock %} +``` + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + defaults: + themes: + - '@KreyuDataTable/themes/bootstrap_5.html.twig' +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults()->themes([ + 'templates/data_table/theme.html.twig', + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ]); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'themes' => [ + 'templates/data_table/theme.html.twig', + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ], + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'themes' => [ + 'templates/data_table/theme.html.twig', + '@KreyuDataTable/themes/bootstrap_5.html.twig', + ], + ], + ); + } +} +``` +::: \ No newline at end of file diff --git a/docs/src/docs/installation.md b/docs/src/docs/installation.md new file mode 100644 index 00000000..77fa5f4c --- /dev/null +++ b/docs/src/docs/installation.md @@ -0,0 +1,81 @@ +# Installation + +This bundle can be installed at any moment during a project’s lifecycle. + +[[toc]] + +## Prerequisites + +- PHP version 8.1 or higher +- Symfony version 6.0 or higher + +## Download the bundle + +Use [Composer](https://getcomposer.org/) to install the bundle: + +```shell +composer require kreyu/data-table-bundle 0.16.* +``` + +::: danger This bundle is not production ready! +It is recommended to lock the minor version, as minor versions can provide breaking changes until the stable release! +::: + +## Enable the bundles + +Enable the bundles by adding them to the `config/bundles.php` file of your project: + +```php +return [ + // ... + Kreyu\Bundle\DataTableBundle\KreyuDataTableBundle::class => ['all' => true], +]; +``` + +## Enable the Stimulus controllers + +This bundle provides front-end scripts created using the [Stimulus JavaScript framework](https://stimulus.hotwired.dev/). +To begin with, make sure your application uses the [Symfony Stimulus Bridge](https://github.com/symfony/stimulus-bridge). + +Then, add `@kreyu/data-table-bundle` dependency to your `package.json` file: + +```json +{ + "devDependencies": { + "@kreyu/data-table-bundle": "file:vendor/kreyu/data-table-bundle/assets" + } +} +``` + +Now, add `@kreyu/data-table-bundle` controllers to your `assets/controllers.json` file: + +```json +{ + "controllers": { + "@kreyu/data-table-bundle": { + "personalization": { + "enabled": true + }, + "batch": { + "enabled": true + } + } + } +} +``` + +## Rebuild assets + +If you're using [AssetMapper](https://symfony.com/doc/current/frontend.html#assetmapper-recommended), you're good to go. Otherwise, run following command: + +::: code-group + +```shell [yarn] +yarn install --force && yarn watch +``` + +```shell [npm] +npm install --force && npm run watch +``` + +::: diff --git a/docs/src/docs/integrations/doctrine-orm/events.md b/docs/src/docs/integrations/doctrine-orm/events.md new file mode 100644 index 00000000..c188f9f0 --- /dev/null +++ b/docs/src/docs/integrations/doctrine-orm/events.md @@ -0,0 +1,75 @@ +# Events + +The Doctrine ORM filter handler dispatches two events, which can be used to modify the query parameters and the expression. + +## PreSetParametersEvent + +The [PreSetParametersEvent](../src/Event/PreSetParametersEvent.php) is dispatched before `$queryBuilder->setParameter()` +is called for every parameter required by the filter. It can be used to modify the parameters, before they are passed to the query builder. + +```php +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Event\DoctrineOrmFilterEvents; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Event\PreSetParametersEvent; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + // ... + + $builder + ->getFilter('name') + ->addEventListener(DoctrineOrmFilterEvents::PRE_SET_PARAMETERS, function (PreSetParametersEvent $event) { + $filter = $event->getFilter(); + $data = $event->getData(); + $query = $event->getQuery(); + $parameters = $event->getParameters(); + + // ... + + $event->setParameters($parameters); + }); + } +} +``` + +## PreApplyExpressionEvent + +The [PreApplyExpressionEvent](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Event/PreApplyExpressionEvent.php) is dispatched before `$queryBuilder->andWhere()` is called. +It can be used to modify the expression before it is passed to the query builder. + +::: tip Use [expression transformers](expression-transformers.md) for easier and reusable solution for modifying the expression. +Those transformers are called by the [ApplyExpressionTransformers](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/EventListener/ApplyExpressionTransformers.php) event subscriber, +which is automatically used in [DoctrineOrmFilterType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php) filter type, as well as +every filter type that uses it as a parent. +::: + +```php +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Event\DoctrineOrmFilterEvents; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Event\PreApplyExpressionEvent; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + // ... + + $builder + ->getFilter('name') + ->addEventListener(DoctrineOrmFilterEvents::PRE_APPLY_EXPRESSION, function (PreApplyExpressionEvent $event) { + $filter = $event->getFilter(); + $data = $event->getData(); + $query = $event->getQuery(); + $expression = $event->getExpression(); + + // ... + + $event->setExpression($expression); + }); + } +} +``` \ No newline at end of file diff --git a/docs/src/docs/integrations/doctrine-orm/expression-transformers.md b/docs/src/docs/integrations/doctrine-orm/expression-transformers.md new file mode 100644 index 00000000..3f3964e1 --- /dev/null +++ b/docs/src/docs/integrations/doctrine-orm/expression-transformers.md @@ -0,0 +1,241 @@ +# Expression transformers + +Expression transformers provide a way to extend Doctrine DQL expressions before they are executed by a filter handler. + +[[toc]] + +## Built-in expression transformers + +- `TrimExpressionTransformer` - wraps the expression in the `TRIM()` function +- `LowerExpressionTransformer` - wraps the expression in the `LOWER()` function +- `UpperExpressionTransformer` - wraps the expression in the `UPPER()` function +- `CallbackExpressionTransformer` - allows transforming the expression using the callback function + +The expression transformers can be passed using the `expression_transformers` option: + +```php +use App\DataTable\Filter\ExpressionTransformer\UnaccentExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\LowerExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\TrimExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('name', TextFilterType::class, [ + 'expression_transformers' => [ + new LowerExpressionTransformer(), + new TrimExpressionTransformer(), + ], + ]) + ; + } +} +``` + +
+ +The transformers are called in the same order as they are passed. + +
+ +For easier usage, some of the built-in transformers can be enabled using the `trim`, `lower` and `upper` filter options: + +```php +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('name', TextFilterType::class, [ + 'trim' => true, + 'lower' => true, + 'upper' => true, + ]) + ; + } +} +``` + +
+ +When using the `trim`, `lower` or `upper` options, their transformers are called **before** the `expression_transformers` ones. + +
+ +## Creating a custom expression transformer + +To create a custom expression transformer, create a new class that implements `ExpressionTransformerInterface`: + +```php +namespace App\DataTable\Filter\ExpressionTransformer; + +use Doctrine\ORM\Query\Expr; +use Doctrine\ORM\Query\Expr\Comparison; +use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\ExpressionTransformerInterface; + +class UnaccentExpressionTransformer implements ExpressionTransformerInterface +{ + public function transform(mixed $expression): mixed + { + if (!$expression instanceof Comparison) { + throw new UnexpectedTypeException($expression, Comparison::class); + } + + $leftExpr = sprintf('UNACCENT(%s)', (string) $expression->getLeftExpr()); + $rightExpr = sprintf('UNACCENT(%s)', (string) $expression->getRightExpr()); + + // or use expression API: + // + // $leftExpr = new Expr\Func('UNACCENT', $expression->getLeftExpr()); + // $rightExpr = new Expr\Func('UNACCENT', $expression->getRightExpr()); + + return new Comparison($leftExpr, $expression->getOperator(), $rightExpr); + } +} +``` + +If you're sure that the expression transformer requires the expression to be a comparison (it will be in most cases), +you can extend the `AbstractComparisonExpressionTransformer` class which simplifies the definition: + +```php +namespace App\DataTable\Filter\ExpressionTransformer; + +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\AbstractComparisonExpressionTransformer; + +class UnaccentExpressionTransformer extends AbstractComparisonExpressionTransformer +{ + protected function transformLeftExpr(mixed $leftExpr): mixed + { + return sprintf('UNACCENT(%s)', (string) $leftExpr); + + // or use expression API: + // + // return new Expr\Func('UNACCENT', $leftExpr); + } + + protected function transformRightExpr(mixed $rightExpr): mixed + { + return sprintf('UNACCENT(%s)', (string) $rightExpr); + + // or use expression API: + // + // return new Expr\Func('UNACCENT', $rightExpr); + } +} +``` + +The `AbstractComparisonExpressionTransformer` accepts two boolean arguments: + +- `transformLeftExpr` - defaults to `true` +- `transformRightExpr` - defaults to `true` + +Thanks to that, the user can specify which side of the expression should be transformed. +The `transformLeftExpr()` and `transformRightExpr()` methods are called only when necessary. For example: + +```php +$expression = new Expr\Comparison('foo', '=', 'bar'); + +// LOWER(foo) = LOWER(bar) +(new LowerExpressionTransformer()) + ->transform($expression); + +// foo = LOWER(bar) +(new LowerExpressionTransformer(transformLeftExpr: false, transformRightExpr: true)) + ->transform($expression); + +// LOWER(foo) = bar +(new LowerExpressionTransformer(transformLeftExpr: true, transformRightExpr: false)) + ->transform($expression); +``` + +To use the created expression transformer, pass it as the `expression_transformers` filter type option: + +```php +use App\DataTable\Filter\ExpressionTransformer\UnaccentExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('name', TextFilterType::class, [ + 'expression_transformers' => [ + new UnaccentExpressionTransformer(), + ], + ]) + ; + } +} +``` + +## Adding an option to automatically apply transformer + +Following the above example of `UnaccentExpressionTransformer`, let's assume, that we want to create such definition: + +```php +use App\DataTable\Filter\ExpressionTransformer\UnaccentExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('name', TextFilterType::class, [ + 'unaccent' => true, + ]) + ; + } +} +``` + +To achieve that, create a custom filter type extension: + +```php +use App\DataTable\Filter\ExpressionTransformer\UnaccentExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Filter\Extension\AbstractFilterTypeExtension; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\DoctrineOrmFilterType; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\OptionsResolver\Options; + +class UnaccentFilterTypeExtension extends AbstractFilterTypeExtension +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefault('unaccent', false) + ->setAllowedTypes('unaccent', 'bool') + ->addNormalizer('expression_transformers', function (Options $options, array $expressionTransformers) { + if ($options['unaccent']) { + $expressionTransformers[] = new UnaccentExpressionTransformer(); + } + + return $expressionTransformers; + }) + ; + } + + public static function getExtendedTypes(): iterable + { + return [DoctrineOrmFilterType::class]; + } +} +``` + +The `unaccent` option is now defined, and defaults to `false`. In addition, the options resolver normalizer will automatically push an instance +of the custom expression transformer to the `expression_transformers` option, but only if the `unaccent` option equals `true`. diff --git a/docs/src/docs/introduction.md b/docs/src/docs/introduction.md new file mode 100644 index 00000000..a9d240e2 --- /dev/null +++ b/docs/src/docs/introduction.md @@ -0,0 +1,106 @@ +# Introduction + +DataTableBundle is a Symfony bundle that aims to help with data tables. + +[[toc]] + +
+ +Just want to try it out? Skip to the [installation](installation.md). + +
+ +## Features + +- [Type classes](#similarity-to-form-component) for a class-based configuration, like in a Symfony Form component +- [Sorting](features/sorting.md), [filtering](features/filtering.md) and [pagination](features/pagination.md) - triforce of the data tables +- [Personalization](features/personalization.md) where the user decides the order and visibility of columns +- [Persistence](features/persistence.md) to save applied data (e.g. filters) between requests +- [Exporting](features/exporting.md) with or without applied pagination, filters and personalization +- [Theming](features/theming.md) of every part of the bundle using Twig +- [Data source agnostic](features/extensibility.md) with optional Doctrine ORM integration bundle +- [Integration with Hotwire Turbo](features/asynchronicity.md) for asynchronicity + +## Use cases + +If your application uses an admin panel generator, like a SonataAdminBundle or EasyAdminBundle, you won't need this bundle. +Those generators already cover the definition of data tables in their own way. + +However, if your application is complex enough that a simple panel generator is not enough, for example, a very specific B2B or CRM platform, +you may consider DataTableBundle, which focuses solely on the data tables, like a Form component focuses solely on the forms. +It can save a lot of time when compared to rendering tables manually (especially with filters), and helps with keeping visual homogeneity. + +## Similarity to form component + +Everything is designed to be friendly to a Symfony developers that used the Symfony Form component before. +Data tables and their components - [columns](components/columns.md), [filters](components/filters.md), [actions](components/actions.md) and [exporters](components/exporters.md), are defined using type classes, like a forms: + +```php +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class) + ->addColumn('name', TextColumnType::class); + + $builder + ->addFilter('id', NumberFilterType::class) + ->addFilter('name', TextFilterType::class); + + $builder + ->addAction('create', ButtonActionType::class) + ->addRowAction('update', ButtonActionType::class) + ->addBatchAction('delete', ButtonActionType::class); + + $builder + ->addExporter('csv', CsvExporterType::class) + ->addExporter('xlsx', XlsxExporterType::class); + } +} +``` + +Creating the data tables using those type classes may also seem very familiar: + +```php +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(Request $request): Response + { + $dataTable = $this->createDataTable(ProductDataTableType::class, $query); + $dataTable->handleRequest($request); + + return $this->render('product/index.html.twig', [ + 'products' => $dataTable->createView(), + ]) + } +} +``` + +Rendering the data table in Twig is as simple as executing a single function: + +```twig +{# templates/product/index.html.twig #} +
+ {{ data_table(products, { title: 'Products' }) }} +
+``` + +## Recommended practices + +When working with Form component, each "Type" refers to the form type. + +When working with DataTableBundle, there are many new and different types - data table, column, filter, action and exporter types. + +For readability, it is recommended to name form types with `FormType` suffix, instead of a simple `Type`. +This makes a context of the type class clear: + +- `ProductFormType` - defines product form; +- `ProductDataTableType` - defines product list; +- `ProductColumnType` - defines product column (if separate definition is needed); +- `ProductFilterType` - defines product filter (if separate definition is needed); +- etc. + +Also, type extensions - instead of `TypeExtension`, use `FormTypeExtension` suffix. diff --git a/docs/src/docs/troubleshooting.md b/docs/src/docs/troubleshooting.md new file mode 100644 index 00000000..e078b5c8 --- /dev/null +++ b/docs/src/docs/troubleshooting.md @@ -0,0 +1,181 @@ +# Troubleshooting + +This section covers common problems and how to fix them. + +[[toc]] + +## Pagination is enabled but its controls are not visible + +By default, every data table has pagination feature _enabled_. +However, if you don't see the pagination controls, make sure your data table has enough records! + +With default pagination data, every page contains 25 records. +Built-in themes display pagination controls only when the data table contains more than one page. +Also, remember that you can [change the default pagination data](features/pagination.md#default-pagination), reducing the per-page limit. + +For more information, consider reading: + +- Features › Pagination › [Toggling the feature](features/pagination.md#toggling-the-feature) +- Features › Pagination › [Default pagination](features/pagination.md#default-pagination) + +## Sorting is enabled but columns are not sortable + +Enabling the sorting feature for the data table does not mean that any column will be sortable by itself. +By default, columns **are not** sortable. To make a column sortable, use its `sort` option. + +For more information, consider reading: + +- Features › Sorting › [Making the columns sortable](features/sorting.md#making-the-columns-sortable) + +## Exporting is enabled but exported files are empty + +Enabling the exporting feature for the data table does not mean that any column will be exportable by itself. +By default, columns **are not** sortable. To make a column exportable, use its `export` option. + +For more information, consider reading: + +- Features › Exporting › [Making the columns exportable](features/exporting.md#making-the-columns-exportable) + +## Data table features are refreshing the page but not working + +If, for example, a data table is rendered properly, but: +- clicking on pagination, +- changing sort order, +- applying filters, +- etc. + +refreshes the page but does nothing else, make sure you handled the request using the `handleRequest()` method: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(Request $request) + { + $dataTable = $this->createDataTable(ProductDataTableType::class); + $dataTable->handleRequest($request); + } +} +``` + +## The N+1 problems + +This section covers common issues with N+1 queries when working with Doctrine ORM. + +### The N+1 problem with relation columns + +When using Doctrine ORM, if your data table contains columns with data from relationship: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('category.name', TextColumnType::class) + ; + } +} +``` + +...then, remember to join and select the association to prevent N+1 queries: + +```php +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index(ProductRepository $repository) + { + $query = $repository->createQueryBuilder('product') + ->addSelect('category') + ->leftJoin('product.category', 'category') + ; + + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + ); + } +} +``` + +### The N+1 problem with unused one-to-one relations + +If your entity contains a one-to-one relationship that is **not** used in the data table, +the additional queries will be generated anyway, because the [Doctrine Paginator](https://www.doctrine-project.org/projects/doctrine-orm/en/2.15/tutorials/pagination.html) is **always** loading them. +To prevent that, add a hint to force a partial load: + +```php +use Doctrine\ORM\Query; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Query\DoctrineOrmProxyQuery; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $query = $builder->getQuery(); + + if ($query instanceof DoctrineOrmProxyQuery) { + $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true); + } + } +} +``` + +::: warning +Forcing a partial load disables lazy loading, therefore, not specifying +every single association used in the query's `SELECT` will end up in an error. +::: + +For more information, consider reading: + +- [[Stack Overflow] Doctrine2 one-to-one relation auto loads on query](https://stackoverflow.com/questions/12362901/doctrine2-one-to-one-relation-auto-loads-on-query/22253783#22253783) + +## Persistence "cache tag contains reserved characters" error + +When using the default configuration, after enabling the persistence for any feature, it may result in the error: + +> Cache tag "kreyu_data_table_persistence_user\@example.com" contains reserved characters "{}()/\\@:". + +By default, the bundle is using a cache as a persistence storage, and currently logged-in user as a persistence subject. +To identify which data belongs to which user, the persistence subject must return a unique identifier. +To retrieve a unique identifier of a user without additional configuration, a `UserInterface::getUserIdentifier()` method is used. +Unfortunately, in some applications, it may return something with a reserved character — in case of above error, an email address "user\@example.com". + +To prevent that, implement a `PersistenceSubjectInterface` interface on the User object and manually return the **unique** identifier: + +```php +use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectInterface; +use Symfony\Component\Security\Core\User\UserInterface; + +class User implements UserInterface, PersistenceSubjectInterface +{ + private int $id; + + public function getDataTablePersistenceIdentifier(): string + { + return (string) $this->id; + } +} +``` + +For more information, consider reading: + +- Features › Persistence › [Subject providers](features/persistence.md#subject-providers) diff --git a/docs/src/index.md b/docs/src/index.md new file mode 100644 index 00000000..bdc22f59 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,45 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "DataTableBundle" + text: for Symfony + tagline: Streamlines creation process of the data tables + image: + src: /logo.png + alt: DataTableBundle + actions: + - theme: brand + text: Documentation + link: /docs/introduction + - theme: alt + text: Reference + link: /reference/types/data-table + +features: + - title: Type classes + details: Class-based configuration, like in a Symfony Form component + - title: Sorting, filtering, pagination + details: Classic triforce of the data tables + - title: Personalization + details: User decides the order and visibility of columns + - title: Persistence + details: Saving applied data (e.g. filters) between requests + - title: Exporting + details: Popular formats, with or without applied pagination, filters and personalization - you name it + - title: Theming + details: Every template part of the bundle is customizable using Twig + - title: Data source agnostic + details: With Doctrine ORM supported out of the box + - title: Asynchronous + details: Thanks to integration with Hotwire Turbo, data tables are asynchronous +--- + + + \ No newline at end of file diff --git a/docs/src/public/action_confirmation_modal.png b/docs/src/public/action_confirmation_modal.png new file mode 100644 index 0000000000000000000000000000000000000000..9168bd1cc73cf4a61d410fe17671b6ff872a4e83 GIT binary patch literal 24894 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 literal 0 HcmV?d00001 diff --git a/docs/src/public/export_modal.png b/docs/src/public/export_modal.png new file mode 100644 index 0000000000000000000000000000000000000000..d6bc07b1839161fc0d5b67a7e82033dbb3327d38 GIT binary patch literal 83156 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 z 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

ZZnzLEB6asd^}`!=r_YD@tCMZkJ0!ys7rpTZ-k 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 literal 0 HcmV?d00001 diff --git a/docs/src/public/personalization_modal.png b/docs/src/public/personalization_modal.png new file mode 100644 index 0000000000000000000000000000000000000000..78c0f5de1b71259435af73c9fb8951fe6b9cdf48 GIT binary patch literal 20634 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 literal 0 HcmV?d00001 diff --git a/docs/src/public/search_filter_type.png b/docs/src/public/search_filter_type.png new file mode 100644 index 0000000000000000000000000000000000000000..407a19e286732798645f7901a8781a6c41efaa94 GIT binary patch literal 6751 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 B6tDmQ literal 0 HcmV?d00001 diff --git a/docs/src/reference/configuration.md b/docs/src/reference/configuration.md new file mode 100644 index 00000000..63aa8cd8 --- /dev/null +++ b/docs/src/reference/configuration.md @@ -0,0 +1,132 @@ +# Configuration + +This bundle can be configured using the: + +- `config/packages/kreyu_data_table.yaml` when using YAML configuration; +- `config/packages/kreyu_data_table.php` when using PHP configuration; + +## Data table builder defaults + +You can specify default values applied to **all the data tables** using the `defaults` node. + +The defaults are loaded by the [DefaultConfigurationDataTableTypeExtension](https://github.com/Kreyu/data-table-bundle/blob/main/src/Extension/Core/DefaultConfigurationDataTableTypeExtension.php), +which extends every data table type class with [DataTableType](https://github.com/Kreyu/data-table-bundle/blob/main/src/Type/DataTableType.php) specified as a parent. + +The given values represent the default ones, unless specifically stated otherwise: + +::: code-group +```yaml [YAML] +# config/packages/kreyu_data_table.yaml +kreyu_data_table: + defaults: + themes: + - '@KreyuDataTable/themes/base.html.twig' + column_factory: kreyu_data_table.column.factory + request_handler: kreyu_data_table.request_handler.http_foundation + sorting: + enabled: true + persistence_enabled: false + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.sorting.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage + pagination: + enabled: true + persistence_enabled: false + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.pagination.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage + filtration: + enabled: true + persistence_enabled: false + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.filtration.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage + form_factory: form.factory + filter_factory: kreyu_data_table.filter.factory + personalization: + enabled: false + persistence_enabled: false + # if persistence is enabled and symfony/cache is installed, null otherwise + persistence_adapter: kreyu_data_table.personalization.persistence.adapter.cache + # if persistence is enabled and symfony/security-bundle is installed, null otherwise + persistence_subject_provider: kreyu_data_table.persistence.subject_provider.token_storage + form_factory: form.factory + exporting: + enabled: true + form_factory: form.factory + exporter_factory: kreyu_data_table.exporter.factory +``` + +```php [PHP] +defaults(); + + $defaults + ->themes([ + '@KreyuDataTable/themes/base.html.twig' + ]) + ->columnFactory('kreyu_data_table.column.factory') + ->requestHandler('kreyu_data_table.request_handler.http_foundation') + ; + + $defaults->sorting() + ->enabled(true) + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.sorting.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; + + $defaults->pagination() + ->enabled(true) + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.pagination.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; + + $defaults->filtration() + ->enabled(true) + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.filtration.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; + + $defaults->personalization() + ->enabled(true) + ->persistenceEnabled(true) + // if persistence is enabled and symfony/cache is installed, null otherwise + ->persistenceAdapter('kreyu_data_table.personalization.persistence.adapter.cache') + // if persistence is enabled and symfony/security-bundle is installed, null otherwise + ->persistenceSubjectProvider('kreyu_data_table.persistence.subject_provider.token_storage') + ; + + $defaults->exporting() + ->enabled(true) + ->formFactory('form.factory') + ->exporterFactory('kreyu_data_table.exporter.factory') + ; +}; +``` + + +::: tip +The default cache persistence adapters are provided only if the [Symfony Cache](https://symfony.com/doc/current/components/cache.html) component is installed. +If the component is not installed, then the default value equals null, meaning you'll have to specify an adapter manually if you wish to use the persistence. +::: + +::: tip +The persistence subject providers are provided only if the [Symfony Security](https://symfony.com/doc/current/security.html) component is installed. +If the component is not installed, then the default value equals null, meaning you'll have to specify a subject provider manually if you wish to use the persistence. +::: diff --git a/docs/src/reference/twig.md b/docs/src/reference/twig.md new file mode 100644 index 00000000..d398972d --- /dev/null +++ b/docs/src/reference/twig.md @@ -0,0 +1,218 @@ +# Twig + +## Functions + +Even though the helper functions simply renders a template block of a specific part of the data table, +they are very useful because they take use the theme configured in bundle. + +### `data_table` + +**Usage**: `data_table(data_table_view, variables)` + +Renders the HTML of a complete data table, with action bar, filtration, pagination, etc. + +```twig +{{ data_table(data_table, { 'title': 'Products' }) }} +``` + +You will mostly use this helper for prototyping or if you use custom theme. +If you need more flexibility in rendering the data table, you should use the other helpers +to render individual parts of the data table instead. + +### `data_table_table` + +**Usage**: `data_table_table(data_table_view, variables)` + +Renders the HTML of the data table. + +### `data_table_action_bar` + +**Usage**: `data_table_action_bar(data_table_view, variables)` + +Renders the HTML of the data table action bar, which includes filtration, exporting and personalization features. + +### `data_table_header_row` + +**Usage**: `data_table_header_row(header_row_view, variables)` + +Renders the header row of the data table. + +### `data_table_value_row` + +**Usage**: `data_table_value_row(value_row_view, variables)` + +Renders the value row of the data table. + +### `data_table_column_label` + +**Usage**: `data_table_column_label(column_view, variables)` + +Renders the label of the column. This takes care of all the label translation logic under the hood. + +### `data_table_column_header` + +**Usage**: `data_table_column_header(column_view, variables)` + +Renders the header of the column. Internally, this does the same as `data_table_column_label()` method, +but additionally handles the sorting feature. + +### `data_table_column_value` + +**Usage**: `data_table_column_value(column_view, variables)` + +Renders the value of the column. It handles all the required logic to extract value from the row data +based on the column configuration (e.g. to display formatted `name` of the `Project` entity). + +### `data_table_filters_form` + +**Usage**: `data_table_filters_form(form, variables)` + +Renders the filters form. Accepts both the `FormInterface` and `FormView`. +If given value is instance of `FormInterface`, the `createView()` method will be called. + +### `data_table_personalization_form` + +**Usage**: `data_table_personalization_form(form, variables)` + +Renders the personalization form. Accepts both the `FormInterface` and `FormView`. +If given value is instance of `FormInterface`, the `createView()` method will be called. + +### `data_table_export_form` + +**Usage**: `data_table_export_form(form, variables)` + +Renders the export form. Accepts both the `FormInterface` and `FormView`. +If given value is instance of `FormInterface`, the `createView()` method will be called. + +### `data_table_pagination` + +**Usage**: `data_table_pagination(pagination_view, variables)` + +Renders the pagination controls. + +Additionally, accepts the data table view as a first argument. +In this case, the pagination view is extracted from the data table view "pagination" variable. + +## Variables + +Certain types may define even more variables, and some variables here only really apply to certain types. +To know the exact variables available for each type, check out the code of the templates used by your data table theme. + +::: warning The type classes are constantly changing before the stable release! +Check source code of the type class to make sure of the variables exposed to the template. +::: + +### Data table variables + +The following variables are common to every data table type: + +| Variable | Usage | +|----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `themes` | Themes to apply for the data table. | +| `name` | Name of the data table. | +| `title` | Title of the data table | +| `title_translation_parameters` | Parameters used in title translation. | +| `translation_domain` | Translation domain used in translatable strings in the data table. If `false`, the translation is disabled. | +| `pagination_enabled` | If `true`, the pagination feature is enabled. | +| `sorting_enabled` | If `true`, the sorting feature is enabled. | +| `filtration_enabled` | If `true`, the filtration feature is enabled. | +| `personalization_enabled` | If `true`, the personalization feature is enabled. | +| `exporting_enabled` | If `true`, the exporting feature is enabled. | +| `page_parameter_name` | Name of the parameter that holds the current page number. | +| `per_page_parameter_name` | Name of the parameter that holds the pagination per page limit. | +| `sort_parameter_name` | Name of the parameter that holds the sorting data array (e.g. `[{sort_parameter_name}][field]`, `[{sort_parameter_name}][direction]`). | +| `filtration_parameter_name` | Name of the parameter that holds the filtration form data. | +| `personalization_parameter_name` | Name of the parameter that holds the personalization form data. | +| `export_parameter_name` | Name of the parameter that holds the export form data. | +| `has_active_filters` | If at least one filter is active, this value will equal `true`. | +| `filtration_data` | An instance of filtration data, that contains applied filters values. | +| `sorting_data` | An instance of sorting data, that contains applied sorting values. | +| `header_row` | An instance of headers row view. | +| `non_personalized_header_row` | An instance of headers row view without personalization applied. | +| `value_rows` | A list of instances of value rows views. | +| `pagination` | An instance of pagination. | +| `actions` | A list of actions defined for the data table. | +| `filters` | A list of filters defined for the data table. | +| `exporters` | A list of exporters defined for the data table. | +| `column_count` | Holds count of the columns, respecting the personalization. | +| `filtration_form` | Holds an instance of the filtration form view. | +| `personalization_form` | Holds an instance of the personalization form view. | +| `export_form` | Holds an instance of the export form view. | + +### Column header variables + +The following variables are common to every column type header: + +| Variable | Usage | +|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `name` | Name of the column. | +| `column` | An instance of column view. | +| `row` | An instance of header row that the column belongs to. | +| `data_table` | An instance of data table view. | +| `label` | Label that will be used when rendering the column header. | +| `translation_parameters` | Parameters used when translating the header translatable values (e.g. label). | +| `translation_domain` | Translation domain used when translating the column translatable values. If `false`, the translation is disabled. | +| `sort_parameter_name` | Name of the parameter that holds the sorting data array (e.g. `[{sort_parameter_name}][field]`, `[{sort_parameter_name}][direction]`). | +| `sorted` | Determines whether the column is currently being sorted. | +| `sort_field` | Sort field used by the sortable behavior. If `false`, the sorting is disabled for the column. | +| `sort_direction` | Direction in which the column is currently being sorted. | +| `block_prefixes` | A list of block prefixes respecting the type inheritance. | +| `export` | An array of export options, including `label` and `translation_domain` options. Equals `false` if the column is not exportable. | +| `attr` | An array of attributes used in rendering the column header. | + +### Column value variables + +The following variables are common to every column type value: + +| Variable | Usage | +|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| `row` | An instance of value row that the column belongs to. | +| `data_table` | An instance of data table view. | +| `data` | Holds the norm data of a column. | +| `value` | Holds the string representation of a column value. | +| `translation_parameters` | Parameters used when translating the translatable values. | +| `translation_domain` | Translation domain used when translating the column translatable values. If `false`, the translation is disabled. | +| `block_prefixes` | A list of block prefixes respecting the type inheritance. | +| `export` | An array of export options, including `data`, `value`, `label` and `translation_domain` options. Equals `false` if the column is not exportable. | +| `attr` | An array of attributes used in rendering the column value. | + +### Filter variables + +The following variables are common to every filter type: + +| Variable | Usage | +|--------------------------------|-------------------------------------------------------------------------------------------------------------------| +| `name` | Name of the filter. | +| `form_name` | Form field name of the column. | +| `label` | Label that will be used when rendering the column header. | +| `label_translation_parameters` | Parameters used when translating the `label` option. | +| `translation_domain` | Translation domain used when translating the column translatable values. If `false`, the translation is disabled. | +| `query_path` | Field name used in the query (e.g. in DQL, like `product.name`) | +| `field_type` | FQCN of the form field type used to render the filter control. | +| `field_options` | Array of options passed to the form type defined in the `field_type`. | +| `operator_type` | FQCN of the form field type used to render the operator control. | +| `operator_options` | Array of options passed to the form type defined in the `operator_type`. | +| `data` | Holds the norm data of a filter. | +| `value` | Holds the string representation of a filter value. | + +### Action variables + +The following variables are common to every action type: + +| Variable | Usage | +|--------------------------|-------------------------------------------------------------------------------------------------------------------| +| `name` | Name of the action. | +| `label` | Name of the action. | +| `data_table` | An instance of data table view. | +| `block_prefixes` | A list of block prefixes respecting the type inheritance. | +| `translation_domain` | Translation domain used when translating the action translatable values. If `false`, the translation is disabled. | +| `translation_parameters` | Parameters used when translating the action translatable values (e.g. label). | +| `attr` | An array of attributes used in rendering the action. | +| `icon_attr` | An array of attributes used in rendering the action icon. | +| `confirmation` | An array of action confirmation options. If `false`, action is not confirmable. | + +::: tip +Behind the scenes, these variables are made available to the `DataTableView`, `ColumnView` and `FilterView` objects of your data table +when the DataTable component calls `buildView()`. To see what "view" variables a particular type has, +find the source code for the used type class and look for the `buildView()` method. +::: diff --git a/docs/src/reference/types/action.md b/docs/src/reference/types/action.md new file mode 100644 index 00000000..8573bfe8 --- /dev/null +++ b/docs/src/reference/types/action.md @@ -0,0 +1,8 @@ +# Action types + +The following action types are natively available in the bundle: + +- [Link](#) +- [Button](#) +- [Callback](#) +- [Action](#) diff --git a/docs/src/reference/types/action/action.md b/docs/src/reference/types/action/action.md new file mode 100644 index 00000000..2ab71a04 --- /dev/null +++ b/docs/src/reference/types/action/action.md @@ -0,0 +1,11 @@ + + +# ActionType + +The [`ActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ActionType.php) represents a base action, used as a parent for every other action type in the bundle. + +## Options + + diff --git a/docs/src/reference/types/action/button.md b/docs/src/reference/types/action/button.md new file mode 100644 index 00000000..84111f51 --- /dev/null +++ b/docs/src/reference/types/action/button.md @@ -0,0 +1,47 @@ + + +# ButtonActionType + +The [`ButtonActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/ButtonActionType.php) represents an action rendered as a button. + +## Options + +### `href` + +- **type**: `string` or `callable` +- **default**: `'#'` + +A value used as an action link [href attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href). + +```php # +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addAction('back', ButtonActionType::class, [ + 'href' => $this->urlGenerator->generate('category_index'), + ]) +; +``` + +### `target` + +- **type**: `string` or `callable` +- **default**: `'_self'` + +Sets the value that will be used as an anchor [target attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target). + +```php # +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addAction('wiki', ButtonActionType::class, [ + 'target' => '_blank', + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/action/form.md b/docs/src/reference/types/action/form.md new file mode 100644 index 00000000..6409d693 --- /dev/null +++ b/docs/src/reference/types/action/form.md @@ -0,0 +1,66 @@ + + +# FormActionType + +The [`FormActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/FormActionType.php) represents an action rendered as a submit button to a hidden form, which allows the action to use any HTTP method. + +## Options + +### `action` + +- **type**: `string` or `callable` +- **default**: `'#'` + +Sets the value that will be used as a form's `action` attribute. + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\FormActionType; + +$builder + ->addAction('send', FormActionType::class, [ + 'action' => $this->urlGenerator->generate('sms_send'), + ]) +; +``` + +### `method` + +- **type**: `string` or `callable` +- **default**: `'GET'` + +Sets the value that will be used as a form's `method` attribute. + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\FormActionType; + +$builder + ->addAction('send', FormActionType::class, [ + 'method' => 'POST', + ]) +; +``` + +### `button_attr` + +- **type**: `array` +- **default**: `[]` + +An array of attributes used to render the form submit button. + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addAction('remove', ButtonActionType::class, [ + 'attr' => [ + 'class' => 'btn btn-danger', + ], + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/action/link.md b/docs/src/reference/types/action/link.md new file mode 100644 index 00000000..7b158961 --- /dev/null +++ b/docs/src/reference/types/action/link.md @@ -0,0 +1,47 @@ + + +# LinkActionType + +The [`LinkActionType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Action/Type/LinkActionType.php) represents an action rendered as a simple link. + +## Options + +### `href` + +- **type**: `string` or `callable` +- **default**: `'#'` + +A value used as an action link [href attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href). + +```php # +use Kreyu\Bundle\DataTableBundle\Action\Type\LinkActionType; + +$builder + ->addAction('back', LinkActionType::class, [ + 'href' => $this->urlGenerator->generate('category_index'), + ]) +; +``` + +### `target` + +- **type**: `string` or `callable` +- **default**: `'_self'` + +Sets the value that will be used as an anchor [target attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target). + +```php # +use Kreyu\Bundle\DataTableBundle\Action\Type\LinkActionType; + +$builder + ->addAction('wiki', LinkActionType::class, [ + 'target' => '_blank', + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/action/options/action.md b/docs/src/reference/types/action/options/action.md new file mode 100644 index 00000000..21ac83c2 --- /dev/null +++ b/docs/src/reference/types/action/options/action.md @@ -0,0 +1,135 @@ +### `label` + +- **type**: `null`, `string` or `Symfony\Component\Translation\TranslatableInterface` +- **default**: `null` + +A label representing the action. +When value equals `null`, a sentence cased action name is used as a label, for example: + +| Action name | Guessed label | +|--------------|----------------| +| create | Create | +| saveAndClose | Save and close | + +### `label_translation_parameters` + +- **type**: `array` +- **default**: `[]` + +An array of parameters used to translate the action label. + +### `translation_domain` + +- **type**: `false` or `string` +- **default**: `'KreyuDataTable'` + +Translation domain used in translation of action's translatable values. + +### `block_prefix` + +- **type**: `string` +- **default**: value returned by the action type `getBlockPrefix()` method + +Allows you to add a custom block prefix and override the block name used to render the action type. +Useful, for example, if you have multiple instances of the same action type, and you need to personalize +the rendering of some of them, without the need to create a new action type. + +### `visible` + +- **type**: `bool` or `callable` +- **default**: `true` + +Determines whether the action should be visible to the user. + +The callable can only be used by the row actions to determine visibility [based on the row data](../../../../docs/components/actions.md#using-row-data-in-options): + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addRowAction('remove', ButtonActionType::class, [ + 'visible' => function (Product $product) { + return $product->isRemovable(); + }, + ]) +; +``` + +### `attr` + +- **type**: `array` +- **default**: `[]` + +An array of attributes used to render the action. + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addAction('remove', ButtonActionType::class, [ + 'attr' => [ + 'class' => 'bg-danger', + ], + ]) +; +``` + +### `icon_attr` + +- **type**: `array` +- **default**: `[]` + +An array of attributes used to render the action's icon. + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; + +$builder + ->addAction('remove', ButtonActionType::class, [ + 'icon_attr' => [ + 'class' => 'ti ti-trash', + ], + ]) +; +``` + +### `confirmation` + +- **type**: `bool`, `array` or `callable` +- **default**: `false` + +Determines whether the action is confirmable, which displays a modal where user have to acknowledge the process. +The modal can be configured by passing an array with the following options: + +> #### `translation_domain` +> +> - **type**: `false` or `string` +> - **default**: `'KreyuDataTable'` +> +> #### `label_title` +> +> - **type**: `null` or `string` +> - **default**: `'Action confirmation'` +> +> #### `label_description` +> +> - **type**: `null` or `string` +> - **default**: `'Are you sure you want to execute this action?'` +> +> #### `label_confirm` +> +> - **type**: `null` or `string` +> - **default**: `'Confirm'` +> +> #### `label_cancel` +> +> - **type**: `null` or `string` +> - **default**: `'Cancel'` +> +> #### `type` +> +> - **type**: `null` or `string` +> - **default**: `danger` +> - **allowed values**: `danger`, `warning`, `info` +> +> Represents a type of the action confirmation, which determines the color of the displayed modal. diff --git a/docs/src/reference/types/column.md b/docs/src/reference/types/column.md new file mode 100644 index 00000000..40463fb6 --- /dev/null +++ b/docs/src/reference/types/column.md @@ -0,0 +1,20 @@ +# Column types + +The following column types are natively available in the bundle: + +- Text columns + - [Text](column/text.md) + - [Number](column/number.md) + - [Money](column/money.md) + - [Boolean](column/boolean.md) + - [Link](column/link.md) +- Date and time columns + - [DateTime](column/date-time.md) + - [DatePeriod](column/date-period.md) +- Special columns + - [Collection](column/collection.md) + - [Template](column/template.md) + - [Actions](column/actions.md) + - [Checkbox](column/checkbox.md) +- Base columns + - [Column](column/column.md) \ No newline at end of file diff --git a/docs/src/reference/types/column/actions.md b/docs/src/reference/types/column/actions.md new file mode 100644 index 00000000..4d30a59e --- /dev/null +++ b/docs/src/reference/types/column/actions.md @@ -0,0 +1,74 @@ + + +# ActionsColumnType + +The [`ActionsColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/ActionsColumnType.php) represents a column that contains row actions. + +::: info In most cases, it is not necessary to use this column type directly. +Instead, use data table builder's `addRowAction()` method. +If at least one row action is defined and is visible, an `ActionColumnType` is added to the data table. +::: + +## Options + +### `actions` + +- **type**: `array` +- **default**: `[]` + +This option contains a list of actions. Each action consists of _three_ options: + +> #### `type` +> +> - **type**: `string` +> +> Fully qualified class name of the [action type](#). +>

+> +> #### `type_options` +> +> - **type**: `array` +> - **default**: `[]` +> +> Options passed to the action type. +>

+> +> #### `visible` +> +> - **type**: `bool`or `callable` +> - **default**: `true` + +Determines whether the action should be visible. + +Example usage: + +```php +use Kreyu\Bundle\DataTableBundle\Action\Type\ButtonActionType; +use Kreyu\Bundle\DataTableBundle\Column\Type\ActionsColumnType; + +$builder + ->addColumn('actions', ActionsColumnType::class, [ + 'actions' => [ + 'show' => [ + 'type' => ButtonActionType::class, + 'type_options' => [ + 'url' => function (Product $product): string { + return $this->urlGenerator->generate('category_show', [ + 'id' => $product->getId(), + ]); + }), + ], + 'visible' => function (Product $product): bool { + return $product->isActive(); + } + ], + ], + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/column/boolean.md b/docs/src/reference/types/column/boolean.md new file mode 100644 index 00000000..40e29aa8 --- /dev/null +++ b/docs/src/reference/types/column/boolean.md @@ -0,0 +1,27 @@ + + +# BooleanColumnType + +The [`BooleanColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/NumberColumnType.php) represents a column with value displayed as a "yes" or "no". + +## Options + +### `label_true` + +- **type**: `string` or `Symfony\Component\Translation\TranslatableInterface` +- **default**: `'Yes'` + +Sets the value that will be displayed if value is truthy. + +### `label_false` + +- **type**: `string` or `Symfony\Component\Translation\TranslatableInterface` +- **default**: `'No'` + +Sets the value that will be displayed if row value is falsy. + +## Inherited options + + diff --git a/docs/src/reference/types/column/checkbox.md b/docs/src/reference/types/column/checkbox.md new file mode 100644 index 00000000..46dff6c6 --- /dev/null +++ b/docs/src/reference/types/column/checkbox.md @@ -0,0 +1,32 @@ + + +# CheckboxColumnType + +The [`CheckboxColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/CheckboxColumnType.php) represents a column with checkboxes, both in header and value rows. + +::: info In most cases, it is not necessary to use this column type directly. +Instead, use data table builder's `addBatchAction()` method. +If at least one batch action is defined and is visible, an `BatchActionType` is added to the data table. + +For details, see [adding checkbox column](../../../docs/components/actions.md#adding-checkbox-column) section of the action documentation. +::: + +## Options + +### `identifier_name` + +- **type**: `string` +- **default**: `'id'` + +A name of the property to use in the batch actions. + +For more details about this option's influence on the batch actions, see ["Changing the identifier parameter name"](#) section. + +## Inherited options + + diff --git a/docs/src/reference/types/column/collection.md b/docs/src/reference/types/column/collection.md new file mode 100644 index 00000000..490f5abb --- /dev/null +++ b/docs/src/reference/types/column/collection.md @@ -0,0 +1,62 @@ + + +# CollectionColumnType + +The [`CollectionColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/CollectionColumnType.php) represents a column with value displayed as a list. + +## Options + +### `entry_type` + +- **type**: `string` +- **default**: `'Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType'` + +This is the column type for each item in this collection (e.g. [TextColumnType](text.md), [LinkColumnType](link.md), etc). +For example, if you have an array of entities, you'd probably want to use the [LinkColumnType](link.md) to display them as links to their details view. + +### `entry_options` + +- **type**: `array` +- **default**: `['property_path' => false]` + +This is the array that's passed to the column type specified in the entry_type option. +For example, if you used the [LinkColumnType](link.md) as your `entry_type` option (e.g. for a collection of links of product tags), +then you'd want to pass the href option to the underlying type: + +```php +use Kreyu\Bundle\DataTableBundle\Column\Type\CollectionColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\LinkColumnType; + +$builder + ->addColumn('tags', CollectionColumnType::class, [ + 'entry_type' => LinkColumnType::class, + 'entry_options' => [ + 'href' => function (Tag $tag): string { + return $this->urlGenerator->generate('tag_show', [ + 'id' => $tag->getId(), + ]); + }, + 'formatter' => function (Tag $tag): string { + return $tag->getName(); + }, + ], + ]) +; +``` + +!!! Note +The options resolver normalizer ensures the `property_path` is always present in the `entry_options` array, and it defaults to `false`. +!!! + +### `separator` + +- **type**: `null` or `string` +- **default**: `', '` + +Sets the value displayed between every item in the collection. + +## Inherited options + + diff --git a/docs/src/reference/types/column/column.md b/docs/src/reference/types/column/column.md new file mode 100644 index 00000000..2992eedc --- /dev/null +++ b/docs/src/reference/types/column/column.md @@ -0,0 +1,11 @@ + + +# ColumnType + +The [`ColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/ColumnType.php) represents a base column, used as a parent for every other column type in the bundle. + +## Options + + diff --git a/docs/src/reference/types/column/date-period.md b/docs/src/reference/types/column/date-period.md new file mode 100644 index 00000000..646e1c97 --- /dev/null +++ b/docs/src/reference/types/column/date-period.md @@ -0,0 +1,34 @@ + + +# DatePeriodColumnType + +The [`DatePeriodColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/DatePeriodColumnType.php) represents a column with value displayed as a date (and with time by default). + +## Options + +### `format` + +- **type**: `string` +- **default**: `'d.m.Y H:i:s'` + +The format specifier is the same as supported by [date](https://www.php.net/date). + +### `timezone` + +- **type**: `null` or `string` +- **default**: `null` + +A timezone used to render the dates as string. + +### `separator` + +- **type**: `string` +- **default**: `' - '` + +Separator to display between the dates. + +## Inherited options + + diff --git a/docs/src/reference/types/column/date-time.md b/docs/src/reference/types/column/date-time.md new file mode 100644 index 00000000..70439492 --- /dev/null +++ b/docs/src/reference/types/column/date-time.md @@ -0,0 +1,27 @@ + + +# DateTimeColumnType + +The [`DateTimeColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/DateTimeColumnType.php) represents a column with value displayed as a date and time. + +## Options + +### `format` + +- **type**: `string` +- **default**: `'d.m.Y H:i:s'` + +The format specifier is the same as supported by [date](https://www.php.net/date). + +### `timezone` + +- **type**: `null` or `string` +- **default**: `null` + +A timezone used to render the date time as string. + +## Inherited options + + diff --git a/docs/src/reference/types/column/date.md b/docs/src/reference/types/column/date.md new file mode 100644 index 00000000..25257a9c --- /dev/null +++ b/docs/src/reference/types/column/date.md @@ -0,0 +1,29 @@ + + +# DateColumnType + +The [`DateColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/DateColumnType.php) represents a column with value displayed as a date. + +This column type works exactly like `DateTimeColumnType`, but has a different default format. + +## Options + +### `format` + +- **type**: `string` +- **default**: `'d.m.Y'` + +The format specifier is the same as supported by [date](https://www.php.net/date). + +### `timezone` + +- **type**: `null` or `string` +- **default**: `null` + +A timezone used to render the date time as string. + +## Inherited options + + diff --git a/docs/src/reference/types/column/link.md b/docs/src/reference/types/column/link.md new file mode 100644 index 00000000..6462e2ea --- /dev/null +++ b/docs/src/reference/types/column/link.md @@ -0,0 +1,42 @@ + + +# LinkColumnType + +The [`LinkColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/LinkColumnType.php) represents a column with value displayed as a link. + +## Options + +### `href` + +- **type**: `string` or `callable` +- **default**: `'#'` + +Sets the value that will be used as a [href attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href). + +```php +use App\Entity\Category; +use Kreyu\Bundle\DataTableBundle\Column\Type\LinkColumnType; + +$builder + ->addColumn('category', LinkColumnType::class, [ + 'href' => function (Category $category): string { + return $this->urlGenerator->generate('category_show', [ + 'id' => $category->getId(), + ]); + }, + ]) +; +``` + +### `target` + +- **type**: `string` or `callable` +- **default**: `'_self'` + +Sets the value that will be used as a [target attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-target). + +## Inherited options + + diff --git a/docs/src/reference/types/column/money.md b/docs/src/reference/types/column/money.md new file mode 100644 index 00000000..3c254c23 --- /dev/null +++ b/docs/src/reference/types/column/money.md @@ -0,0 +1,81 @@ + + +# MoneyColumnType + +The [`MoneyColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/MoneyColumnType.php) represents a column with monetary value, appropriately formatted and rendered with currency sign. + +## Options + +### `currency` + +- **type**: `string` or `callable` - any [3-letter ISO 4217 code](https://en.wikipedia.org/wiki/ISO_4217) + +Specifies the currency that the money is being specified in. +This determines the currency symbol that should be shown in the column. + +When using the [Intl number formatter](https://www.php.net/manual/en/class.numberformatter.php), +the ISO code will be automatically converted to the appropriate currency sign, for example: + +- `EUR` becomes `€`; +- `PLN` becomes `zł`; + +Please note that the end result is also dependent on the locale used in the application, for example, with value of `1000`: + +- `USD` currency will be rendered as `$1,000.00` when using the `en` locale; +- `USD` currency will be rendered as `1 000,00 USD` when using the `pl` locale; + +When the Intl formatter is **NOT** used, given currency is simply rendered after the raw value, e.g. `1000 USD`. + +Additionally, the option accepts a callable, which gets a row data as first argument: + +```php +$builder + ->addColumn('price', MoneyColumnType::class, [ + 'currency' => fn (Product $product) => $product->getPriceCurrency(), + ]) +; +``` + +### `use_intl_formatter` + +- **type**: `bool` +- **default**: `true` if [`symfony/intl`](https://packagist.org/packages/symfony/intl) is installed, `false` instead + +Determines whether the [Intl number formatter](https://www.php.net/manual/en/class.numberformatter.php) should be used. +Enabling this option will automatically handle the formatting based on the locale set in the application. +For example, value `123456.78` will be rendered differently: + +- `123,456.78` when using `en` locale; +- `123 456,78` when using `pl` locale; +- etc. + +### `intl_formatter_options` + +- **type**: `array` +- **default**: `['attrs' => [], 'style' => 'decimal']` + +Configures the [Intl number formatter](https://www.php.net/manual/en/class.numberformatter.php) if used. +For example, to limit decimal places to two: + +```php +$builder + ->addColumn('price', MoneyColumnType::class, [ + 'intl_formatter_options' => [ + 'attrs' => [ + // https://www.php.net/manual/en/class.numberformatter.php#numberformatter.constants.max-fraction-digits + 'max_fraction_digit' => 2, + ], + ]) + ]) +; +``` + +For more details, see: +- [Intl number formatter documentation](https://www.php.net/manual/en/class.numberformatter.php) +- [Twig `format_currency` filter documentation](https://twig.symfony.com/doc/2.x/filters/format_currency.html) + +## Inherited options + + diff --git a/docs/src/reference/types/column/number.md b/docs/src/reference/types/column/number.md new file mode 100644 index 00000000..5c50a04e --- /dev/null +++ b/docs/src/reference/types/column/number.md @@ -0,0 +1,51 @@ + + +# NumberColumnType + +The [`NumberColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/NumberColumnType.php) represents a column with value displayed as a number. + +## Options + +### `use_intl_formatter` + +- **type**: `bool` +- **default**: `true` if [`symfony/intl`](https://packagist.org/packages/symfony/intl), is installed `false` instead + +Determines whether the [Intl number formatter](https://www.php.net/manual/en/class.numberformatter.php) should be used. +Enabling this option will automatically handle the formatting based on the locale set in the application. +For example, value `123456.78` will be rendered differently: + +- `123,456.78` when using `en` locale; +- `123 456,78` when using `pl` locale; +- etc. + +### `intl_formatter_options` + +- **type**: `array` +- **default**: `['attrs' => [], 'style' => 'decimal']` + +Configures the [Intl number formatter](https://www.php.net/manual/en/class.numberformatter.php) if used. +For example, to limit decimal places to two: + +```php +$builder + ->addColumn('price', NumberColumnType::class, [ + 'intl_formatter_options' => [ + 'attrs' => [ + // https://www.php.net/manual/en/class.numberformatter.php#numberformatter.constants.max-fraction-digits + 'max_fraction_digit' => 2, + ], + ]) + ]) +; +``` + +For more details, see: +- [Intl number formatter documentation](https://www.php.net/manual/en/class.numberformatter.php) +- [Twig `format_number` filter documentation](https://twig.symfony.com/doc/2.x/filters/format_number.html) + +## Inherited options + + diff --git a/docs/src/reference/types/column/options/column.md b/docs/src/reference/types/column/options/column.md new file mode 100644 index 00000000..a067cbb6 --- /dev/null +++ b/docs/src/reference/types/column/options/column.md @@ -0,0 +1,226 @@ + + +### `label` + +- **type**: `null`, `string` or `Symfony\Component\Translation\TranslatableInterface` +- **default**: `{{ defaults.label }}` + +Sets the label that will be used in column header and personalization column list. + +When value equals `null`, a sentence cased column name is used as a label, for example: + +| Column name | Guessed label | +|-------------|---------------| +| name | Name | +| firstName | First name | + +### `header_translation_domain` + +- **type**: `false` or `string` +- **default**: `'KreyuDataTable'` + +Sets the translation domain used when translating the column header. +Setting the option to `false` disables its translation. + +### `header_translation_parameters` + +- **type**: `array` +- **default**: `[]` + +Sets the parameters used when translating the column header. + +### `value_translation_domain` + +- **type**: `false` or `string` +- **default**: inherited from the data table translation domain + +Sets the translation domain used when translating the column value. +Setting the option to `false` disables its translation. + +### `property_path` + +- **type**: `null`, `false` or `string` +- **default**: `{{ defaults.property_path }}` + +Sets the property path used by the [PropertyAccessor](https://symfony.com/doc/current/components/property_access.html) to retrieve column value of each row. +Setting the option to `false` disables property accessor. + +```php +$builder + ->addColumn('category', TextColumnType::class, [ + 'property_path' => 'category.name', + ]) +; +``` + +When value equals `null`, the column name is used as a property path. + +### `getter` + +- **type**: `null` or `callable` +- **default**: `null` + +When provided, this callable will be invoked to read the value from the underlying object that will be used within the column. +This disables the usage of the [PropertyAccessor](https://symfony.com/doc/current/components/property_access.html), described in the [property_path](#property_path) option. + +```php +$builder + ->addColumn('category', TextColumnType::class, [ + 'getter' => fn (Product $product) => $product->getCategory(), + ]) +; +``` + +### `sort` + +- **type**: `bool` or `string` +- **default**: `false` - the sortable behavior is disabled + +Sets the sort field used by the sortable behavior. Setting the option to: +- `true` - enables column sorting and uses the column name as a sort field name; +- `false` - disables column sorting; +- string - defines sort property path; + +### `block_prefix` + +- **type**: `string` +- **default**: value returned by the column type `getBlockPrefix()` method + +Allows you to add a custom block prefix and override the block name used to render the column type. +Useful for example if you have multiple instances of the same column type, and you need to personalize +the rendering of some of them, without the need to create a new column type. + +### `formatter` + +- **type**: `null` or `callable` +- **default**: `null` + +Formats the value to the desired string. + +```php +$builder + ->addColumn('quantity', NumberColumnType::class, [ + 'formatter' => fn (float $value) => number_format($value, 2) . 'kg', + ]) +; +``` + +### `export` + +- **type**: `bool` or `array` +- **default**: `[]` + +This option accepts an array of options available for the column type. +It is used to differentiate options for regular rendering, and excel rendering. + +For example, if you wish to display quantity column with "Quantity" label, but export with a "Qty" header: + +```php +$builder + ->addColumn('quantity', NumberColumnType::class, [ + 'label' => 'Quantity', + 'translation_domain' => 'product', + 'export' => [ + 'label' => 'Qty', + // rest of the options are inherited, therefore "translation_domain" equals "product", etc. + ], + ]) +; +``` + +Rest of the options are inherited from the column options. + +Setting this option to `true` automatically copies the column options as the export column options. +Setting this option to `false` excludes the column from the exports. + +### `header_attr` + +- **type**: `array` +- **default**: `[]` + +If you want to add extra attributes to an HTML column header representation (``) you can use the attr option. +It's an associative array with HTML attributes as keys. +This can be useful when you need to set a custom class for a column: + +```php +$builder + ->addColumn('quantity', NumberColumnType::class, [ + 'header_attr' => [ + 'class' => 'text-end', + ], + ]) +; +``` + +### `value_attr` + +- **type**: `array` or `callable` +- **default**: `[]` + +If you want to add extra attributes to an HTML column value representation (``) you can use the attr option. +It's an associative array with HTML attributes as keys. +This can be useful when you need to set a custom class for a column: + +```php +$builder + ->addColumn('quantity', NumberColumnType::class, [ + 'value_attr' => [ + 'class' => 'text-end', + ], + ]) +; +``` + +You can pass a `callable` to perform a dynamic attribute generation: + +```php +$builder + ->addColumn('quantity', NumberColumnType::class, [ + 'value_attr' => function (int $quantity, Product $product) { + return [ + 'class' => $quantity === 0 && !$product->isDisabled() ? 'text-danger' : '', + ], + }, + ]) +; +``` + +### `priority` + +- **type**: `integer` +- **default**: `0` + +Columns are rendered in the same order as they are included in the data table. +This option changes the column rendering priority, allowing you to display columns earlier or later than their original order. + +The higher this priority, the earlier the column will be rendered. +Priority can albo be negative and columns with the same priority will keep their original order. + +**Note**: column priority can be changed by the [personalization feature](../../../../docs/features/personalization.md). + +### `visible` + +- **type**: `bool` +- **default**: `true` + +Determines whether the column is visible to the user. + +**Note**: column visibility can be changed by the [personalization feature](../../../../docs/features/personalization.md). + +### `personalizable` + +- **type**: `bool` +- **default**: `true` + +Determines whether the column is personalizable. +The non-personalizable columns are not modifiable by the [personalization feature](../../../../docs/features/personalization.md). diff --git a/docs/src/reference/types/column/template.md b/docs/src/reference/types/column/template.md new file mode 100644 index 00000000..82c8b705 --- /dev/null +++ b/docs/src/reference/types/column/template.md @@ -0,0 +1,25 @@ + + +# TemplateColumnType + +The [`TemplateColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/TemplateColumnType.php) represents a column with value displayed as a Twig template. + +## Options + +### `template_path` + +- **type**: `string` or `callable` + +Sets the path to the template that should be rendered. + +### `template_vars` + +- **type**: `string` or `callable` + +Sets the variables used within the template. + +## Inherited options + + diff --git a/docs/src/reference/types/column/text.md b/docs/src/reference/types/column/text.md new file mode 100644 index 00000000..195a21ce --- /dev/null +++ b/docs/src/reference/types/column/text.md @@ -0,0 +1,15 @@ + + +# TextColumnType + +The [`TextColumnType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/TextColumnType.php) represents a column with value displayed as a text. + +## Options + +This column type has no additional options. + +## Inherited options + + diff --git a/docs/src/reference/types/data-table.md b/docs/src/reference/types/data-table.md new file mode 100644 index 00000000..82da5634 --- /dev/null +++ b/docs/src/reference/types/data-table.md @@ -0,0 +1,150 @@ +# DataTable type + +The [`DataTableType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Column/Type/ColumnType.php) represents a base data table, and should be used as a base for every data table defined in the system. + +## Options + +### `title` + +- **type**: `null`, `string` or `TranslatableInterface` +- **default**: `null` + +### `title_translation_parameters` + +- **type**: `array` +- **default**: `[]` + +### `translation_domain` + +- **type**: `null`, `bool` or `string` +- **default**: `null` + +### `themes` + +- **type**: `string[]` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `column_factory` + +- **type**: `ColumnFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filter_factory` + +- **type**: `FilterFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `action_factory` + +- **type**: `ActionFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `exporter_factory` + +- **type**: `ExporterFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `request_handler` + +- **type**: `RequestHandlerInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `sorting_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `sorting_persistence_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `sorting_persistence_adapter` + +- **type**: `null` or `PersistenceAdapterInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `sorting_persistence_subject_provider` + +- **type**: `null` or `PersistenceSubjectProviderInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `pagination_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `pagination_persistence_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `pagination_persistence_adapter` + +- **type**: `null` or `PersistenceAdapterInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `pagination_persistence_subject_provider` + +- **type**: `null` or `PersistenceSubjectProviderInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filtration_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filtration_persistence_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filtration_persistence_adapter` + +- **type**: `null` or `PersistenceAdapterInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filtration_persistence_subject_provider` + +- **type**: `null` or `PersistenceSubjectProviderInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `filtration_form_factory` + +- **type**: `null` or `FormFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `personalization_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `personalization_persistence_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `personalization_persistence_adapter` + +- **type**: `null` or `PersistenceAdapterInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `personalization_persistence_subject_provider` + +- **type**: `null` or `PersistenceSubjectProviderInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `personalization_form_factory` + +- **type**: `null` or `FormFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `exporting_enabled` + +- **type**: `bool` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) + +### `exporting_form_factory` + +- **type**: `null` or `FormFactoryInterface` +- **default**: value defined in [`defaults` configuration](../configuration.md#data-table-builder-defaults) diff --git a/docs/src/reference/types/exporter.md b/docs/src/reference/types/exporter.md new file mode 100644 index 00000000..accff261 --- /dev/null +++ b/docs/src/reference/types/exporter.md @@ -0,0 +1,25 @@ +# Exporter types + +The following action types are natively available in the bundle: + +- [Callback](#) +- [Exporter](#) + +## PhpSpreadsheet + +The following [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) exporter types are natively available in the bundle: + +- [Csv](./exporter/php-spreadsheet/csv) +- [Xls](./exporter/php-spreadsheet/xls) +- [Xlsx](./exporter/php-spreadsheet/xlsx) +- [Ods](./exporter/php-spreadsheet/ods) +- [Pdf](./exporter/php-spreadsheet/pdf) +- [Html](./exporter/php-spreadsheet/html) + +## OpenSpout + +The following [OpenSpout](https://github.com/openspout/openspout) exporter types are natively available in the bundle: + +- [Csv](./exporter/open-spout/csv) +- [Xlsx](./exporter/open-spout/xlsx) +- [Ods](./exporter/open-spout/ods) diff --git a/docs/src/reference/types/exporter/callback.md b/docs/src/reference/types/exporter/callback.md new file mode 100644 index 00000000..3f7e3f7d --- /dev/null +++ b/docs/src/reference/types/exporter/callback.md @@ -0,0 +1,34 @@ + + +# CallbackExporterType + +The [`CallbackExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/CallbackExporterType.php) represents a filter that uses a given callback as its handler. + +## Options + +### `callback` + +- **type**: `callable` + +Sets callable that works as an exporter handler. + +```php +use Kreyu\Bundle\DataTableBundle\Exporter\Type\CallbackExporterType; +use Kreyu\Bundle\DataTableBundle\Exporter\ExporterInterface; +use Kreyu\Bundle\DataTableBundle\Exporter\ExportFile; +use Kreyu\Bundle\DataTableBundle\DataTableView; + +$builder + ->addExporter('txt', CallbackExporterType::class, [ + 'callback' => function (DataTableView $view, ExporterInterface $exporter, string $filename): ExportFile { + // ... + }, + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/exporter/exporter.md b/docs/src/reference/types/exporter/exporter.md new file mode 100644 index 00000000..b5c0515b --- /dev/null +++ b/docs/src/reference/types/exporter/exporter.md @@ -0,0 +1,11 @@ + + +# ExporterType + +The [`ExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Exporter/Type/CallbackExporterType.php) represents a base exporter used as a parent for every other exporter type in the bundle. + +## Options + + diff --git a/docs/src/reference/types/exporter/open-spout/csv.md b/docs/src/reference/types/exporter/open-spout/csv.md new file mode 100644 index 00000000..cff62d1f --- /dev/null +++ b/docs/src/reference/types/exporter/open-spout/csv.md @@ -0,0 +1,41 @@ + + +# OpenSpout CsvExporterType + +The [`CsvExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/OpenSpout/Exporter/Type/CsvExporterType.php) represents an exporter that uses an [OpenSpout](https://github.com/openspout/openspout) CSV writer. + +## Options + +### `field_delimiter` + +- **type**: `string` +- **default**: `','` + +Represents a string that separates the values. + +### `field_enclosure` + +- **type**: `string` +- **default**: `'"'` + +Represents a string that wraps the values. + +### `should_add_bom` + +- **type**: `bool` +- **default**: `true` + +Determines whether a BOM character should be added at the beginning of the file. + +### `flush_threshold` + +- **type**: `int` +- **default**: `500` + +Represents a number of rows after which the output should be flushed to a file. + +## Inherited options + + \ No newline at end of file diff --git a/docs/src/reference/types/exporter/open-spout/ods.md b/docs/src/reference/types/exporter/open-spout/ods.md new file mode 100644 index 00000000..b1c458b1 --- /dev/null +++ b/docs/src/reference/types/exporter/open-spout/ods.md @@ -0,0 +1,42 @@ + + +# OpenSpout OdsExporterType + +The [`OdsExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/OpenSpout/Exporter/Type/OdsExporterType.php) represents an exporter that uses an [OpenSpout](https://github.com/openspout/openspout) ODS writer. + +## Options + +### `default_row_style` + +- **type**: `OpenSpout\Common\Entity\Style\Style` +- **default**: an unmodified instance of `Style` class + +An instance of style class that will be applied to all rows. + +### `should_create_new_sheets_automatically` + +- **type**: `bool` +- **default**: `true` + +Determines whether new sheets should be created automatically +when the maximum number of rows (1,048,576) per sheet is reached. + +### `default_column_width` + +- **type**: `null` or `float` +- **default**: `null` + +Represents a width that will be applied to all columns by default. + +### `default_row_height` + +- **type**: `null` or `float` +- **default**: `null` + +Represents a height that will be applied to all rows by default. + +## Inherited options + + \ No newline at end of file diff --git a/docs/src/reference/types/exporter/open-spout/xlsx.md b/docs/src/reference/types/exporter/open-spout/xlsx.md new file mode 100644 index 00000000..8c16b624 --- /dev/null +++ b/docs/src/reference/types/exporter/open-spout/xlsx.md @@ -0,0 +1,51 @@ + + +# OpenSpout XlsxExporterType + +The [`XlsxExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/OpenSpout/Exporter/Type/XlsxExporterType.php) represents an exporter that uses an [OpenSpout](https://github.com/openspout/openspout) XLSX writer. + +## Options + +### `default_row_style` + +- **type**: `OpenSpout\Common\Entity\Style\Style` +- **default**: an unmodified instance of `Style` class + +An instance of style class that will be applied to all rows. + +### `should_create_new_sheets_automatically` + +- **type**: `bool` +- **default**: `true` + +Determines whether new sheets should be created automatically +when the maximum number of rows (1,048,576) per sheet is reached. + +### `should_use_inline_strings` + +- **type**: `bool` +- **default**: `true` + +Determines whether inline strings should be used instead of shared strings. + +For more information about this configuration, see [OpenSpout documentation](https://github.com/openspout/openspout/blob/4.x/docs/documentation.md#strings-storage-xlsx-writer). + +### `default_column_width` + +- **type**: `null` or `float` +- **default**: `null` + +Represents a width that will be applied to all columns by default. + +### `default_row_height` + +- **type**: `null` or `float` +- **default**: `null` + +Represents a height that will be applied to all rows by default. + +## Inherited options + + \ No newline at end of file diff --git a/docs/src/reference/types/exporter/options/exporter.md b/docs/src/reference/types/exporter/options/exporter.md new file mode 100644 index 00000000..b9d2eb67 --- /dev/null +++ b/docs/src/reference/types/exporter/options/exporter.md @@ -0,0 +1,27 @@ +### `use_headers` + +- **type**: `bool` +- **default**: `true` + +Determines whether the exporter should add headers to the output file. + +### `label` + +- **type**: `null` or `string` +- **default**: `null` the label is "guessed" from the exporter name + +Sets the label of the exporter, visible in the export action modal. + +### `tempnam_dir` + +- **type**: `string` +- **default**: the value returned by the `sys_get_temp_dir()` function + +Sets the directory used to store temporary file during the export process. + +### `tempnam_prefix` + +- **type**: `string` +- **default**: `exporter_` + +Sets the prefix used to generate temporary file names during the export process. \ No newline at end of file diff --git a/docs/src/reference/types/exporter/options/php-spreadsheet.md b/docs/src/reference/types/exporter/options/php-spreadsheet.md new file mode 100644 index 00000000..f59ae356 --- /dev/null +++ b/docs/src/reference/types/exporter/options/php-spreadsheet.md @@ -0,0 +1,8 @@ +### `pre_calculate_formulas` + +- **type**: `bool` +- **default**: `true` + +By default, the PhpSpreadsheet writers pre-calculates all formulas in the spreadsheet. +This can be slow on large spreadsheets, and maybe even unwanted. +The value of this option determines whether the formula pre-calculation is enabled. diff --git a/docs/src/reference/types/exporter/php-spreadsheet/csv.md b/docs/src/reference/types/exporter/php-spreadsheet/csv.md new file mode 100644 index 00000000..c7ee8f46 --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/csv.md @@ -0,0 +1,94 @@ + + +# PhpSpreadsheet CsvExporterType + +The [`CsvExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/CsvExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) CSV writer. + +## Options + +### `delimiter` + +**type**: `string` **default**: `','` + +Represents a string that separates the CSV files values. + +### `enclosure` + +**type**: `string` **default**: `'"'` + +Represents a string that wraps all CSV fields. + +### `enclosure_required` + +**type**: `bool` **default**: `true` + +By default, all CSV fields are wrapped in the enclosure character. +Value of this option determines whether to use the enclosure character only when required. + +### `line_ending` + +**type**: `string` **default**: platform `PHP_EOL` constant value + +Represents a string that separates the CSV files lines. + +### `sheet_index` + +**type**: `int` **default**: `0` + +CSV files can only contain one worksheet. Therefore, you can specify which sheet to write to CSV. + +### `use_bom` + +**type**: `string` **default**: `false` + +CSV files are written in UTF-8. If they do not contain characters outside the ASCII range, nothing else need be done. +However, if such characters are in the file, or if the file starts with the 2 characters 'ID', it should explicitly include a BOM file header; +if it doesn't, Excel will not interpret those characters correctly. This can be enabled by setting this option to `true`. + +### `include_separator_line` + +**type**: `bool` **default**: `false` + +Determines whether a separator line should be included as the first line of the file. + +### `excel_compatibility` + +**type**: `bool` **default**: `false` + +Determines whether the file should be saved with full Excel compatibility. + +Note that this overrides other settings such as useBOM, enclosure and delimiter! + +### `output_encoding` + +**type**: `string` **default**: `''` + +It can be set to output with the encoding that can be specified by PHP's `mb_convert_encoding` (e.g. `'SJIS-WIN'`). + +### `decimal_separator` + +**type**: `string` **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with decimal separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems it is recommended to set the character explicitly. + +### `thousands_separator` + +**type**: `string` **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with thousands separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems it is recommended to set the character explicitly. + +## Inherited options + + + \ No newline at end of file diff --git a/docs/src/reference/types/exporter/php-spreadsheet/html.md b/docs/src/reference/types/exporter/php-spreadsheet/html.md new file mode 100644 index 00000000..6f4dfd01 --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/html.md @@ -0,0 +1,120 @@ + + +# PhpSpreadsheet HtmlExporterType + +The [`HtmlExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/HtmlExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) HTML writer. + +## Options + +### `sheet_index` + +- **type**: `null` or `int` +- **default**: `0` + +HTML files can only contain one or more worksheets. +Therefore, you can specify which sheet to write to HTML. +If you want to write all sheets into a single HTML file, set this option to `null`. + +### `images_root` + +- **type**: `string` +- **default**: `''` + +There might be situations where you want to explicitly set the included images root. For example, instead of: + +```html + +``` + +You might want to see: + +```html + +``` + +Use this option to achieve this result: + +```php +use Kreyu\Bundle\DataTableBundle\Bridge\PhpSpreadsheet\Exporter\Type\HtmlExporterType; + +$builder + ->addExporter('html', HtmlExporterType::class, [ + 'images_root' => 'https://www.domain.com', + ]) +; +``` + +### `embed_images` + +- **type**: `bool` +- **default**: `false` + +Determines whether the images should be embedded or not. + +### `use_inline_css` + +- **type**: `bool` +- **default**: `false` + +Determines whether the inline css should be used or not. + +### `generate_sheet_navigation_block` + +- **type**: `bool` +- **default**: `true` + +Determines whether the sheet navigation block should be generated or not. + +### `edit_html_callback` + +- **type**: `null` or `callable` +- **default**: `null` + +Accepts a callback function to edit the generated html before saving. +For example, you could change the gridlines from a thin solid black line: + +```php +use Kreyu\Bundle\DataTableBundle\Bridge\PhpSpreadsheet\Exporter\Type\HtmlExporterType; + +$builder + ->addExporter('html', HtmlExporterType::class, [ + 'edit_html_callback' => function (string $html): string { + return str_replace( + '{border: 1px solid black;}', + '{border: 2px dashed red;}', + $html, + ); + } + ]) +; +``` + +### `decimal_separator` + +- **type**: `string` +- **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with decimal separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems, it is recommended to set the character explicitly. + +### `thousands_separator` + +- **type**: `string` +- **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with thousands separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems, it is recommended to set the character explicitly. + +## Inherited options + + + diff --git a/docs/src/reference/types/exporter/php-spreadsheet/ods.md b/docs/src/reference/types/exporter/php-spreadsheet/ods.md new file mode 100644 index 00000000..bf069efb --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/ods.md @@ -0,0 +1,17 @@ + + +# PhpSpreadsheet OdsExporterType + +The [`XlsxExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/XlsxExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) ODS writer. + +## Options + +This exporter type has no additional options. + +## Inherited options + + + diff --git a/docs/src/reference/types/exporter/php-spreadsheet/pdf.md b/docs/src/reference/types/exporter/php-spreadsheet/pdf.md new file mode 100644 index 00000000..cba7fe7b --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/pdf.md @@ -0,0 +1,120 @@ + + +# PhpSpreadsheet PdfExporterType + +The [`PdfExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/PdfExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) PDF writer. + +## Options + +### `sheet_index` + +- **type**: `null` or `int` +- **default**: `0` + +PDF files can only contain one or more worksheets. +Therefore, you can specify which sheet to write to PDF. +If you want to write all sheets into a single PDF file, set this option to `null`. + +### `images_root` + +- **type**: `string` +- **default**: `''` + +There might be situations where you want to explicitly set the included images root. For example, instead of: + +```html + +``` + +You might want to see: + +```html + +``` + +Use this option to achieve this result: + +```php +use Kreyu\Bundle\DataTableBundle\Bridge\PhpSpreadsheet\Exporter\Type\PdfExporterType; + +$builder + ->addExporter('html', PdfExporterType::class, [ + 'images_root' => 'https://www.domain.com', + ]) +; +``` + +### `embed_images` + +- **type**: `bool` +- **default**: `false` + +Determines whether the images should be embedded or not. + +### `use_inline_css` + +- **type**: `bool` +- **default**: `false` + +Determines whether the inline css should be used or not. + +### `generate_sheet_navigation_block` + +- **type**: `bool` +- **default**: `true` + +Determines whether the sheet navigation block should be generated or not. + +### `edit_html_callback` + +- **type**: `null` or `callable` +- **default**: `null` + +Accepts a callback function to edit the generated html before saving. +For example, you could change the gridlines from a thin solid black line: + +```php +use Kreyu\Bundle\DataTableBundle\Bridge\PhpSpreadsheet\Exporter\Type\PdfExporterType; + +$builder + ->addExporter('html', PdfExporterType::class, [ + 'edit_html_callback' => function (string $html): string { + return str_replace( + '{border: 1px solid black;}', + '{border: 2px dashed red;}', + $html, + ); + } + ]) +; +``` + +### `decimal_separator` + +- **type**: `string` +- **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with decimal separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems, it is recommended to set the character explicitly. + +### `thousands_separator` + +- **type**: `string` +- **default**: depends on the server's locale setting + +If the worksheet you are exporting contains numbers with thousands separators, +then you should think about what characters you want to use for those before doing the export. + +By default, PhpSpreadsheet looks up in the server's locale settings to decide what character to use. +But to avoid problems, it is recommended to set the character explicitly. + +## Inherited options + + + diff --git a/docs/src/reference/types/exporter/php-spreadsheet/xls.md b/docs/src/reference/types/exporter/php-spreadsheet/xls.md new file mode 100644 index 00000000..fd908f7d --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/xls.md @@ -0,0 +1,17 @@ + + +# PhpSpreadsheet XlsExporterType + +The [`XlsxExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/XlsxExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) XLS writer. + +## Options + +This exporter type has no additional options. + +## Inherited options + + + diff --git a/docs/src/reference/types/exporter/php-spreadsheet/xlsx.md b/docs/src/reference/types/exporter/php-spreadsheet/xlsx.md new file mode 100644 index 00000000..e2373e68 --- /dev/null +++ b/docs/src/reference/types/exporter/php-spreadsheet/xlsx.md @@ -0,0 +1,23 @@ + + +# PhpSpreadsheet XlsxExporterType + +The [`XlsxExporterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/PhpSpreadsheet/Exporter/Type/XlsxExporterType.php) represents an exporter that uses an [PhpSpreadsheet](https://github.com/PHPOffice/PhpSpreadsheet) XLSX writer. + +## Options + +### `office_2003_compatibility` + +- **type**: `bool` +- **default**: `false` + +Because of a bug in the Office2003 compatibility pack, there can be some small issues when opening +Xlsx spreadsheets (mostly related to formula calculation). You can enable Office2003 compatibility by setting this option to `true`. + +## Inherited options + + + diff --git a/docs/src/reference/types/filter.md b/docs/src/reference/types/filter.md new file mode 100644 index 00000000..99d13f0d --- /dev/null +++ b/docs/src/reference/types/filter.md @@ -0,0 +1,20 @@ +# Filter types + +The following filter types are natively available in the bundle: + +- [Callback](types/callback.md) +- [Search](types/search.md) +- [Filter](types/filter.md) + +## Doctrine ORM + +The built-in Doctrine ORM integration provides additional filter types: + +- [String](filter/doctrine-orm/string.md) +- [Numeric](filter/doctrine-orm/numeric.md) +- [Boolean](filter/doctrine-orm/boolean.md) +- [Date](filter/doctrine-orm/date.md) +- [DateTime](filter/doctrine-orm/date-time.md) +- [DateRange](filter/doctrine-orm/date-range.md) +- [Entity](filter/doctrine-orm/entity.md) +- [DoctrineOrm](filter/doctrine-orm/doctrine-orm.md) diff --git a/docs/src/reference/types/filter/callback.md b/docs/src/reference/types/filter/callback.md new file mode 100644 index 00000000..22502970 --- /dev/null +++ b/docs/src/reference/types/filter/callback.md @@ -0,0 +1,34 @@ + + +# CallbackFilterType + +The [`CallbackFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/CallbackFilterType.php) represents a filter that uses a given callback as its handler. + +## Options + +### `callback` + +- **type**: `callable` + +Sets callable that works as a filter handler. + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\CallbackFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\FilterData; +use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; + +$builder + ->addFilter('name', CallbackFilterType::class, [ + 'callback' => function (ProxyQueryInterface $query, FilterData $data, FilterInterface $filter): void { + // ... + }, + ]) +; +``` + +## Inherited options + + diff --git a/docs/src/reference/types/filter/doctrine-orm/boolean.md b/docs/src/reference/types/filter/doctrine-orm/boolean.md new file mode 100644 index 00000000..d906ffd9 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/boolean.md @@ -0,0 +1,21 @@ + + +# Doctrine ORM BooleanFilterType + +The Doctrine ORM [`BooleanFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterType.php) represents a filter that operates on a boolean values. + +## Options + +This column type has no additional options. + +## Inherited options + + + + diff --git a/docs/src/reference/types/filter/doctrine-orm/date-range.md b/docs/src/reference/types/filter/doctrine-orm/date-range.md new file mode 100644 index 00000000..431e32e9 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/date-range.md @@ -0,0 +1,21 @@ + + +# Doctrine ORM DateRangeFilterType + +The Doctrine ORM [`DateRangeFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php) represents a filter that operates on a two date values that make a range. + +## Options + +This column type has no additional options. + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/doctrine-orm/date-time.md b/docs/src/reference/types/filter/doctrine-orm/date-time.md new file mode 100644 index 00000000..c9ee54d3 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/date-time.md @@ -0,0 +1,21 @@ + + +# Doctrine ORM DateTimeFilterType + +The Doctrine ORM [`DateTimeFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php) represents a filter that operates on a date time values. + +## Options + +This column type has no additional options. + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/doctrine-orm/date.md b/docs/src/reference/types/filter/doctrine-orm/date.md new file mode 100644 index 00000000..f0dd5a89 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/date.md @@ -0,0 +1,21 @@ + + +# Doctrine ORM DateFilterType + +The Doctrine ORM [`DateFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php) represents a filter that operates on a date (without time) values. + +## Options + +This column type has no additional options. + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/doctrine-orm/doctrine-orm.md b/docs/src/reference/types/filter/doctrine-orm/doctrine-orm.md new file mode 100644 index 00000000..b71e3a8d --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/doctrine-orm.md @@ -0,0 +1,16 @@ + + +# DoctrineOrmFilterType + +The Doctrine ORM [`DoctrineOrmFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php) represents a base type that every other filter type from the integration uses as a parent. + +## Options + + + +## Inherited options + + diff --git a/docs/src/reference/types/filter/doctrine-orm/entity.md b/docs/src/reference/types/filter/doctrine-orm/entity.md new file mode 100644 index 00000000..a5d213da --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/entity.md @@ -0,0 +1,76 @@ + + +# Doctrine ORM EntityFilterType + +The Doctrine ORM [`EntityFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php) represents a filter that operates on an entity values. + +## Options + +### `choice_label` + +This is the property that should be used for displaying the entities as text in the active filter HTML element. + +```php +use App\Entity\Category; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\EntityFilterType; +// ... + +$builder->addFilter('category', EntityFilterType::class, [ + 'form_options' => [ + 'class' => Category::class, + 'choice_label' => 'displayName', // choice label for form + ], + 'choice_label' => 'displayName', // separate choice label for data table filter +]); +``` + +If left blank, the entity object will be cast to a string and so must have a `__toString()` method. You can also pass a callback function for more control: + +```php +use App\Entity\Category; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\EntityFilterType; +use Kreyu\Bundle\DataTableBundle\Filter\FilterData; +// ... + +$builder->addFilter('category', EntityFilterType::class, [ + 'form_options' => [ + 'class' => Category::class, + 'choice_label' => 'displayName', + ], + 'choice_label' => function (FilterData $data): string { + return $data->getValue()->getDisplayName(); + }, +]); +``` + +::: tip This option works like a `choice_label` option in `ChoiceType` form option. +When passing a string, the `choice_label` option is a property path. So you can use anything supported by the [PropertyAccess component](https://symfony.com/doc/current/components/property_access.html). + +For example, if the translations property is actually an associative array of objects, each with a name property, then you could do this: + +```php +use App\Entity\Category; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\EntityFilterType; +// ... + +$builder->addFilter('category', EntityFilterType::class, [ + 'form_options' => [ + 'class' => Category::class, + 'choice_label' => 'translations[en].name', + ], + 'choice_label' => 'translations[en].name', +]); +``` +::: + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/doctrine-orm/numeric.md b/docs/src/reference/types/filter/doctrine-orm/numeric.md new file mode 100644 index 00000000..760fdf32 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/numeric.md @@ -0,0 +1,20 @@ + + +# Doctrine ORM NumericFilterType + +The Doctrine ORM [`NumericFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php) represents a filter that operates on a numeric values. + +## Options + +This column type has no additional options. + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/doctrine-orm/string.md b/docs/src/reference/types/filter/doctrine-orm/string.md new file mode 100644 index 00000000..2aee1119 --- /dev/null +++ b/docs/src/reference/types/filter/doctrine-orm/string.md @@ -0,0 +1,20 @@ + + +# Doctrine ORM StringFilterType + +The Doctrine ORM [`StringFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php) represents a filter that operates on a string values. + +## Options + +This column type has no additional options. + +## Inherited options + + + + \ No newline at end of file diff --git a/docs/src/reference/types/filter/filter.md b/docs/src/reference/types/filter/filter.md new file mode 100644 index 00000000..ef8fbbee --- /dev/null +++ b/docs/src/reference/types/filter/filter.md @@ -0,0 +1,11 @@ + + +# FilterType + +The [`FilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/FilterType.php) represents a base filter used as a parent for every other filter type in the bundle. + +## Options + + diff --git a/docs/src/reference/types/filter/options/doctrine-orm.md b/docs/src/reference/types/filter/options/doctrine-orm.md new file mode 100644 index 00000000..8f000df9 --- /dev/null +++ b/docs/src/reference/types/filter/options/doctrine-orm.md @@ -0,0 +1,53 @@ +### `trim` + +- **type**: `bool` +- **default**: `false` + +Determines whether the `TRIM()` function should be applied on the expression. Uses the [`TrimExpressionTransformer`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformer.php) transformer. + +### `lower` + +- **type**: `bool` +- **default**: `false` + +Determines whether the `LOWER()` function should be applied on the expression. Uses the [`LowerExpressionTransformer`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformer.php) transformer. + +### `upper` + +- **type**: `bool` +- **default**: `false` + +Determines whether the `UPPER()` function should be applied on the expression. Uses the [`UpperExpressionTransformer`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformer.php) transformer. + +### `expression_transformers` + +- **type**: [`ExpressionTransformerInterface[]`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ExpressionTransformerInterface.php) +- **default**: `[]` + +Defines expression transformers to apply on the expression. + +```php +use App\DataTable\Filter\ExpressionTransformer\UnaccentExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\LowerExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\TrimExpressionTransformer; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type\TextFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addFilter('name', TextFilterType::class, [ + 'expression_transformers' => [ + new LowerExpressionTransformer(), + new TrimExpressionTransformer(), + ], + ]) + ; + } +} +``` + +For more information about expression transformers, [read here](../../../../../docs/integrations/doctrine-orm/expression-transformers.md). diff --git a/docs/src/reference/types/filter/options/filter.md b/docs/src/reference/types/filter/options/filter.md new file mode 100644 index 00000000..b9c1a43f --- /dev/null +++ b/docs/src/reference/types/filter/options/filter.md @@ -0,0 +1,90 @@ + + +### `label` + +- **type**: `null`, `false`, `string` or `Symfony\Component\Translation\TranslatableInterface` +- **default**: `null` + +Sets the label that will be used when rendering the filter. + +When value is `null`, a sentence cased filter name is used as a label, for example: + +| Filter name | Guessed label | +|-------------|---------------| +| name | Name | +| firstName | First name | + +### `label_translation_parameters` + +- **type**: `array` +- **default**: `[]` + +Sets the parameters used when translating the `label` option. + +### `translation_domain` + +- **type**: `false` or `string` +- **default**: `'KreyuDataTable'` + +Sets the translation domain used when translating the translatable filter values. +Setting the option to `false` disables translation for the filter. + +### `query_path` + +- **type**: `null` or `string` +- **default**: `null` the query path is guessed from the filter name + +Sets the path used in the proxy query to perform the filtering on. + +### `form_type` + +- **type**: `string` +- **default**: `'{{ defaults.formType }}'` + +This is the form type used to render the filter value field. + +### `form_options` + +- **type**: `array` +- **default**: `{{ defaults.formOptions }}` + +This is the array that's passed to the form type specified in the `form_type` option. + +### `operator_form_type` + +- **type**: `string` +- **default**: `'Kreyu\Bundle\DataTableBundle\Filter\Form\Type\OperatorType'` + +This is the form type used to render the filter operator field. + +### `operator_form_options` + +- **type**: `array` +- **default**: `[]` + +This is the array that's passed to the form type specified in the `operator_form_type` option. + +### `operator_selectable` + +- **type**: `bool` +- **default**: `false` + +Determines whether the operator can be selected by the user. + +### `default_operator` + +- **type**: `Kreyu\Bundle\DataTableBundle\Filter\Operator` +- **default**: `{{ defaults.defaultOperator }}` + +Determines a default operator for the filter. diff --git a/docs/src/reference/types/filter/search.md b/docs/src/reference/types/filter/search.md new file mode 100644 index 00000000..fa644d02 --- /dev/null +++ b/docs/src/reference/types/filter/search.md @@ -0,0 +1,91 @@ + + +# SearchFilterType + +The [`SearchFilterType`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Filter/Type/SearchFilterType.php) represents a special filter that is rendered on the outside of filtering form as a search input. + +![Search filter type](/search_filter_type.png) + +## Adding the search handler + +Instead of using this filter, you can use the `setSearchHandler()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->setSearchHandler(function (ProxyQueryInterface $query, string $search) { + // ... + }); + } +} +``` + +Defining a search handler automatically adds search filter. + +To disable this behavior, use the `setAutoAddingSearchFilter()` method: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder->setAutoAddingSearchFilter(false); + } +} +``` + +To override configuration of the automatically added filter, you can add search filter manually with the same name: + +```php +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Filter\Type\SearchFilterType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + // Set "__search" as filter name or use constant: + $builder->addFilter(DataTableBuilderInterface::SEARCH_FILTER_NAME, SearchFilterType::class, [ + // ... + ]); + } +} +``` + +## Options + +### `handler` + +- **type**: `callable` + +Sets callable that operates on the query passed as a first argument: + +```php +use Kreyu\Bundle\DataTableBundle\Filter\Type\SearchFilterType; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; + +$builder + ->addFilter('search', SearchFilterType::class, [ + 'handler' => function (ProxyQueryInterface $query, string $search): void { + // ... + }, + ]) +``` + +## Inherited options + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8a9f03c4..7c32da77 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,6 +8,9 @@ tests + + + src diff --git a/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvent.php b/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvent.php new file mode 100644 index 00000000..69260d38 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvent.php @@ -0,0 +1,35 @@ +query; + } + + public function getData(): FilterData + { + return $this->data; + } + + public function getFilter(): FilterInterface + { + return $this->filter; + } +} diff --git a/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvents.php b/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvents.php new file mode 100644 index 00000000..82dbf8d5 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Event/DoctrineOrmFilterEvents.php @@ -0,0 +1,19 @@ +expression; + } + + public function setExpression(mixed $expression): void + { + $this->expression = $expression; + } +} diff --git a/src/Bridge/Doctrine/Orm/Event/PreSetParametersEvent.php b/src/Bridge/Doctrine/Orm/Event/PreSetParametersEvent.php new file mode 100644 index 00000000..4130d4a1 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Event/PreSetParametersEvent.php @@ -0,0 +1,35 @@ + + */ + public function getParameters(): array + { + return $this->parameters; + } + + public function setParameters(array $parameters): void + { + $this->parameters = $parameters; + } +} diff --git a/src/Bridge/Doctrine/Orm/EventListener/ApplyExpressionTransformers.php b/src/Bridge/Doctrine/Orm/EventListener/ApplyExpressionTransformers.php new file mode 100644 index 00000000..095e749d --- /dev/null +++ b/src/Bridge/Doctrine/Orm/EventListener/ApplyExpressionTransformers.php @@ -0,0 +1,44 @@ +getFilter(); + $expression = $event->getExpression(); + + if ($filter->getConfig()->getOption('trim')) { + $expression = (new TrimExpressionTransformer())->transform($expression); + } + + if ($filter->getConfig()->getOption('lower')) { + $expression = (new LowerExpressionTransformer())->transform($expression); + } + + if ($filter->getConfig()->getOption('upper')) { + $expression = (new UpperExpressionTransformer())->transform($expression); + } + + foreach ($filter->getConfig()->getOption('expression_transformers') as $expressionTransformer) { + $expression = $expressionTransformer->transform($expression); + } + + $event->setExpression($expression); + } + + public static function getSubscribedEvents(): array + { + return [DoctrineOrmFilterEvents::PRE_APPLY_EXPRESSION => 'preApplyExpression']; + } +} diff --git a/src/Bridge/Doctrine/Orm/EventListener/TransformDateRangeFilterData.php b/src/Bridge/Doctrine/Orm/EventListener/TransformDateRangeFilterData.php new file mode 100644 index 00000000..b70ec376 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/EventListener/TransformDateRangeFilterData.php @@ -0,0 +1,52 @@ +getData(); + $value = $data->getValue(); + + $valueFrom = $value['from'] ?? null; + $valueTo = $value['to'] ?? null; + + if ($valueFrom) { + $valueFrom = \DateTime::createFromInterface($valueFrom); + $valueFrom->setTime(0, 0); + } + + if ($valueTo) { + $valueTo = \DateTime::createFromInterface($valueTo)->modify('+1 day'); + $valueTo->setTime(0, 0); + } + + $data = clone $data; + + if ($valueFrom && $valueTo) { + $data->setValue(['from' => $valueFrom, 'to' => $valueTo]); + $data->setOperator(Operator::Between); + } elseif ($valueFrom) { + $data->setValue($valueFrom); + $data->setOperator(Operator::GreaterThanEquals); + } elseif ($valueTo) { + $data->setValue($valueTo); + $data->setOperator(Operator::LessThan); + } + + $event->setData($data); + } + + public static function getSubscribedEvents(): array + { + return [FilterEvents::PRE_HANDLE => 'preHandle']; + } +} \ No newline at end of file diff --git a/src/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandler.php b/src/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandler.php new file mode 100644 index 00000000..19cf631b --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandler.php @@ -0,0 +1,65 @@ +parameterFactory->create($query, $data, $filter); + + $event = new PreSetParametersEvent($query, $data, $filter, $parameters); + + $this->dispatch(DoctrineOrmFilterEvents::PRE_SET_PARAMETERS, $event); + + $queryBuilder = $query->getQueryBuilder(); + + foreach ($event->getParameters() as $parameter) { + $queryBuilder->setParameter($parameter->getName(), $parameter->getValue(), $parameter->getType()); + } + + $expression = $this->expressionFactory->create($query, $data, $filter, $event->getParameters()); + + $event = new PreApplyExpressionEvent($query, $data, $filter, $expression); + + $this->dispatch(DoctrineOrmFilterEvents::PRE_APPLY_EXPRESSION, $event); + + $queryBuilder->andWhere($event->getExpression()); + } + + private function dispatch(string $eventName, DoctrineOrmFilterEvent $event): void + { + $dispatcher = $event->getFilter()->getConfig()->getEventDispatcher(); + + if ($dispatcher->hasListeners($eventName)) { + $dispatcher->dispatch($event, $eventName); + } + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactory.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactory.php new file mode 100644 index 00000000..a258f44b --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactory.php @@ -0,0 +1,56 @@ +getAliasResolver()->resolve($filter->getQueryPath(), $query->getQueryBuilder()); + + $operator = $data->getOperator() ?? $filter->getConfig()->getDefaultOperator(); + + $expr = new Expr(); + + if (Operator::Between === $operator) { + $parameterFrom = $parameters['from'] ?? null; + $parameterTo = $parameters['to'] ?? null; + + if ($parameterFrom && $parameterTo) { + return $expr->between($queryPath, ':'.$parameterFrom->getName(), ':'.$parameterTo->getName()); + } + + throw new InvalidArgumentException('Operator "between" requires "from" and "to" parameters.'); + } + + $exprMethod = match ($operator) { + Operator::Equals => $expr->eq(...), + Operator::NotEquals => $expr->neq(...), + Operator::GreaterThan => $expr->gt(...), + Operator::GreaterThanEquals => $expr->gte(...), + Operator::LessThan => $expr->lt(...), + Operator::LessThanEquals => $expr->lte(...), + Operator::Contains, Operator::StartsWith, Operator::EndsWith => $expr->like(...), + Operator::NotContains => $expr->notLike(...), + Operator::In => $expr->in(...), + Operator::NotIn => $expr->notIn(...), + }; + + $parameter = $parameters[array_key_first($parameters)]; + + return $exprMethod($queryPath, ':'.$parameter->getName()); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryInterface.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryInterface.php new file mode 100644 index 00000000..80517803 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryInterface.php @@ -0,0 +1,18 @@ + $parameters + */ + public function create(DoctrineOrmProxyQueryInterface $query, FilterData $data, FilterInterface $filter, array $parameters): mixed; +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/AbstractComparisonExpressionTransformer.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/AbstractComparisonExpressionTransformer.php new file mode 100644 index 00000000..c0543879 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/AbstractComparisonExpressionTransformer.php @@ -0,0 +1,53 @@ +getLeftExpr(); + $rightExpr = $expression->getRightExpr(); + + if ($this->transformLeftExpr) { + $leftExpr = $this->transformLeftExpr($leftExpr); + } + + if ($this->transformRightExpr) { + $rightExpr = $this->transformRightExpr($rightExpr); + } + + return new Comparison($leftExpr, $expression->getOperator(), $rightExpr); + } + + protected function transformLeftExpr(mixed $leftExpr): mixed + { + return $leftExpr; + } + + protected function transformRightExpr(mixed $rightExpr): mixed + { + return $rightExpr; + } + + protected function getExpressionBuilder(): Expr + { + return new Expr(); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformer.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformer.php new file mode 100644 index 00000000..3a9dfee1 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformer.php @@ -0,0 +1,20 @@ +callback = $callback(...); + } + + public function transform(mixed $expression): mixed + { + return ($this->callback)($expression); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ExpressionTransformerInterface.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ExpressionTransformerInterface.php new file mode 100644 index 00000000..76de5313 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ExpressionTransformerInterface.php @@ -0,0 +1,10 @@ +getExpressionBuilder()->lower($leftExpr); + } + + protected function transformRightExpr(mixed $rightExpr): Expr\Func + { + return $this->getExpressionBuilder()->lower($rightExpr); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformer.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformer.php new file mode 100644 index 00000000..c4c34862 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformer.php @@ -0,0 +1,20 @@ +getExpressionBuilder()->trim($leftExpr); + } + + protected function transformRightExpr(mixed $rightExpr): Expr\Func + { + return $this->getExpressionBuilder()->trim($rightExpr); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformer.php b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformer.php new file mode 100644 index 00000000..6c9ce311 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformer.php @@ -0,0 +1,20 @@ +getExpressionBuilder()->upper($leftExpr); + } + + protected function transformRightExpr(mixed $rightExpr): Expr\Func + { + return $this->getExpressionBuilder()->upper($rightExpr); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/Extension/DoctrineOrmFilterExtension.php b/src/Bridge/Doctrine/Orm/Filter/Extension/DoctrineOrmFilterExtension.php new file mode 100644 index 00000000..a2fd756a --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/Extension/DoctrineOrmFilterExtension.php @@ -0,0 +1,32 @@ +getValue(); + + if ($value instanceof \DateTimeInterface) { + return $value->format($filter->getConfig()->getOption('form_options')['input_format'] ?? 'Y-m-d'); + } + + return (string) $value; + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatter.php b/src/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatter.php new file mode 100644 index 00000000..151b15b6 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatter.php @@ -0,0 +1,33 @@ +getValue(); + + $dateFrom = $value['from']; + $dateTo = $value['to']; + + if (null !== $dateFrom && null === $dateTo) { + return new TranslatableMessage('After %date%', ['%date%' => $dateFrom->format('Y-m-d')], 'KreyuDataTable'); + } + + if (null === $dateFrom && null !== $dateTo) { + return new TranslatableMessage('Before %date%', ['%date%' => $dateTo->format('Y-m-d')], 'KreyuDataTable'); + } + + if ($dateFrom == $dateTo) { + return $dateFrom->format('Y-m-d'); + } + + return $dateFrom->format('Y-m-d').' - '.$dateTo->format('Y-m-d'); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatter.php b/src/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatter.php new file mode 100644 index 00000000..8768787a --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatter.php @@ -0,0 +1,38 @@ +getValue(); + + if ($value instanceof \DateTimeInterface) { + $formOptions = $filter->getConfig()->getOption('form_options'); + + $format = $formOptions['input_format'] ?? null; + + if (null === $format) { + $format = 'Y-m-d H'; + + if ($formOptions['with_minutes'] ?? true) { + $format .= ':i'; + } + + if ($formOptions['with_seconds'] ?? true) { + $format .= ':s'; + } + } + + return $value->format($format); + } + + return (string) $value; + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatter.php b/src/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatter.php new file mode 100644 index 00000000..57c4364b --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatter.php @@ -0,0 +1,27 @@ +getConfig()->getOption('choice_label'); + + if (is_string($choiceLabel)) { + return PropertyAccess::createPropertyAccessor()->getValue($data->getValue(), $choiceLabel); + } + + if (is_callable($choiceLabel)) { + return $choiceLabel($data->getValue()); + } + + return (string) $data->getValue(); + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactory.php b/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactory.php new file mode 100644 index 00000000..58ad248d --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactory.php @@ -0,0 +1,45 @@ +getName().'_'.$query->getUniqueParameterId(); + + $value = $data->getValue(); + $operator = $data->getOperator(); + + if (Operator::Between === $operator) { + if (null !== $from = $value['from'] ?? null) { + $parameters['from'] = new Parameter($name.'_from', $from); + } + + if (null !== $to = $value['to'] ?? null) { + $parameters['to'] = new Parameter($name.'_to', $to); + } + + return $parameters; + } + + $parameters[] = new Parameter($name, match ($operator) { + Operator::Contains, Operator::NotContains => "%$value%", + Operator::StartsWith => "$value%", + Operator::EndsWith => "%$value", + default => $value, + }); + + return $parameters; + } +} diff --git a/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryInterface.php b/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryInterface.php new file mode 100644 index 00000000..bb3cb905 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryInterface.php @@ -0,0 +1,18 @@ + + */ + public function create(DoctrineOrmProxyQueryInterface $query, FilterData $data, FilterInterface $filter): array; +} diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/AbstractDoctrineOrmFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/AbstractDoctrineOrmFilterType.php index 6402d287..60b6e74f 100644 --- a/src/Bridge/Doctrine/Orm/Filter/Type/AbstractDoctrineOrmFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/AbstractDoctrineOrmFilterType.php @@ -4,93 +4,10 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Query\DoctrineOrmProxyQueryInterface; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; -use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; -use Kreyu\Bundle\DataTableBundle\Filter\Operator; use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; -use Kreyu\Bundle\DataTableBundle\Filter\Type\FilterTypeInterface; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; -/** - * @extends FilterTypeInterface - * - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\AbstractDoctrineOrmFilterType} instead - */ abstract class AbstractDoctrineOrmFilterType extends AbstractFilterType { - public function apply(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter, array $options): void - { - if (!$query instanceof DoctrineOrmProxyQueryInterface) { - throw new UnexpectedTypeException($query, DoctrineOrmProxyQueryInterface::class); - } - - $operator = $this->getFilterOperator($data, $filter); - $value = $this->getFilterValue($data); - - if (!in_array($operator, $filter->getConfig()->getSupportedOperators())) { - return; - } - - $queryPath = $this->getFilterQueryPath($query, $filter); - - $parameterName = $this->getUniqueParameterName($query, $filter); - - try { - $expression = $this->getOperatorExpression($queryPath, $parameterName, $operator, new Expr()); - } catch (InvalidArgumentException) { - return; - } - - $query - ->andWhere($expression) - ->setParameter($parameterName, $this->getParameterValue($operator, $value)); - } - - protected function getFilterOperator(FilterData $data, FilterInterface $filter): Operator - { - return $data->getOperator() ?? $filter->getConfig()->getDefaultOperator(); - } - - protected function getFilterValue(FilterData $data): mixed - { - return $data->getValue(); - } - - /** - * @throws InvalidArgumentException if operator is not supported by the filter - */ - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object - { - throw new InvalidArgumentException('Operator not supported'); - } - - public function getUniqueParameterName(DoctrineOrmProxyQueryInterface $query, FilterInterface $filter): string - { - return $filter->getFormName().'_'.$query->getUniqueParameterId(); - } - - protected function getParameterValue(Operator $operator, mixed $value): mixed - { - return $value; - } - - protected function getFilterQueryPath(DoctrineOrmProxyQueryInterface $query, FilterInterface $filter): string - { - $rootAlias = current($query->getRootAliases()); - - $queryPath = $filter->getQueryPath(); - - if ($rootAlias && !str_contains($queryPath, '.') && $filter->getConfig()->getOption('auto_alias_resolving')) { - $queryPath = $rootAlias.'.'.$queryPath; - } - - return $queryPath; - } - public function getParent(): ?string { return DoctrineOrmFilterType::class; diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/AbstractFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/AbstractFilterType.php deleted file mode 100755 index 1d8d2499..00000000 --- a/src/Bridge/Doctrine/Orm/Filter/Type/AbstractFilterType.php +++ /dev/null @@ -1,12 +0,0 @@ -setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver ->setDefaults([ 'form_type' => ChoiceType::class, - 'active_filter_formatter' => function (FilterData $data): TranslatableInterface { - return t($data->getValue() ? 'Yes' : 'No', domain: 'KreyuDataTable'); + 'active_filter_formatter' => function (FilterData $data) { + return new TranslatableMessage($data->getValue() ? 'Yes' : 'No', domain: 'KreyuDataTable'); }, ]) - ->addNormalizer('form_options', function (Options $options, mixed $value) { + ->addNormalizer('form_options', function (Options $options, array $value): array { if (ChoiceType::class !== $options['form_type']) { return $value; } return $value + [ - 'choices' => ['yes' => true, 'no' => false], - 'choice_label' => function (bool $choice, string $key): TranslatableInterface { - return t(ucfirst($key), domain: 'KreyuDataTable'); - }, + 'choices' => ['Yes' => true, 'No' => false], + 'choice_translation_domain' => 'KreyuDataTable', ]; }) ; } - - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object - { - $expression = match ($operator) { - Operator::Equals => $expr->eq(...), - Operator::NotEquals => $expr->neq(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); - } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/CallbackFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/CallbackFilterType.php deleted file mode 100755 index 1b2a0b89..00000000 --- a/src/Bridge/Doctrine/Orm/Filter/Type/CallbackFilterType.php +++ /dev/null @@ -1,37 +0,0 @@ -setDefault('supported_operators', Operator::cases()) - ->setRequired('callback') - ->setAllowedTypes('callback', ['callable']) - ; - } -} diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php old mode 100755 new mode 100644 index 0f7889b0..5ad3baae --- a/src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/DateFilterType.php @@ -4,104 +4,41 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Operator; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Formatter\DateActiveFilterFormatter; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\DateFilterType} instead - */ class DateFilterType extends AbstractDoctrineOrmFilterType { + public function buildFilter(FilterBuilderInterface $builder, array $options): void + { + $builder->setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + Operator::GreaterThan, + Operator::GreaterThanEquals, + Operator::LessThan, + Operator::LessThanEquals, + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver ->setDefaults([ 'form_type' => DateType::class, - 'supported_operators' => [ - Operator::Equals, - Operator::NotEquals, - Operator::GreaterThan, - Operator::GreaterThanEquals, - Operator::LessThan, - Operator::LessThanEquals, - ], - 'active_filter_formatter' => $this->getFormattedActiveFilterString(...), + 'active_filter_formatter' => new DateActiveFilterFormatter(), ]) - ->addNormalizer('form_options', function (Options $options, array $value): array { + ->addNormalizer('form_options', function (Options $options, array $value) { if (DateType::class !== $options['form_type']) { return $value; } return $value + ['widget' => 'single_text']; }) - ->addNormalizer('empty_data', function (Options $options, string|array $value): string|array { - if (DateType::class !== $options['form_type']) { - return $value; - } - - // Note: because choice and text widgets are split into three fields, - // we have to return an array with three empty values to properly set the empty data. - return match ($options['form_options']['widget'] ?? null) { - 'choice', 'text' => ['day' => '', 'month' => '', 'year' => ''], - default => '', - }; - }) ; } - - protected function getFilterValue(FilterData $data): \DateTimeInterface - { - $value = $data->getValue(); - - if ($value instanceof \DateTimeInterface) { - $dateTime = $value; - } elseif (is_string($value)) { - $dateTime = \DateTime::createFromFormat('Y-m-d', $value); - } elseif (is_array($value)) { - $dateTime = (new \DateTime())->setDate( - year: (int) $value['date']['year'] ?: 0, - month: (int) $value['date']['month'] ?: 0, - day: (int) $value['date']['day'] ?: 0, - ); - } else { - throw new InvalidArgumentException(sprintf('Unable to convert data of type "%s" to DateTime object.', get_debug_type($value))); - } - - $dateTime = \DateTime::createFromInterface($dateTime); - $dateTime->setTime(0, 0); - - return $dateTime; - } - - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object - { - $expression = match ($operator) { - Operator::Equals => $expr->eq(...), - Operator::NotEquals => $expr->neq(...), - Operator::GreaterThan => $expr->gt(...), - Operator::GreaterThanEquals => $expr->gte(...), - Operator::LessThan => $expr->lt(...), - Operator::LessThanEquals => $expr->lte(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); - } - - private function getFormattedActiveFilterString(FilterData $data, FilterInterface $filter, array $options): string - { - $value = $data->getValue(); - - if ($value instanceof \DateTimeInterface) { - return $value->format($options['field_options']['input_format'] ?? 'Y-m-d'); - } - - return (string) $value; - } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php old mode 100755 new mode 100644 index 9fbc3255..3df98598 --- a/src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterType.php @@ -4,65 +4,25 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Query\DoctrineOrmProxyQueryInterface; -use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Form\Type\DateRangeType; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Filter\Operator; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\EventListener\TransformDateRangeFilterData; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Formatter\DateRangeActiveFilterFormatter; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\Translation\TranslatableMessage; -use function Symfony\Component\Translation\t; - -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\DateRangeFilterType} instead - */ class DateRangeFilterType extends AbstractDoctrineOrmFilterType { - public function apply(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter, array $options): void + public function buildFilter(FilterBuilderInterface $builder, array $options): void { - if (!$query instanceof DoctrineOrmProxyQueryInterface) { - throw new UnexpectedTypeException($query, DoctrineOrmProxyQueryInterface::class); - } - - $value = $data->getValue(); - - $parameterName = $this->getUniqueParameterName($query, $filter); - - $queryPath = $this->getFilterQueryPath($query, $filter); - - $criteria = $query->expr()->andX(); - - if (!is_array($value)) { - return; - } - - if (null !== $dateFrom = $value['from'] ?? null) { - $parameterNameFrom = $parameterName.'_from'; - - $dateFrom = \DateTime::createFromInterface($dateFrom); - $dateFrom->setTime(0, 0); - - $criteria->add($query->expr()->gte($queryPath, ":$parameterNameFrom")); - - $query->setParameter($parameterNameFrom, $dateFrom); - } - - if (null !== $valueTo = $value['to'] ?? null) { - $parameterNameTo = $parameterName.'_to'; - - $valueTo = \DateTime::createFromInterface($valueTo)->modify('+1 day'); - $valueTo->setTime(0, 0); - - $criteria->add($query->expr()->lt($queryPath, ":$parameterNameTo")); - - $query->setParameter($parameterNameTo, $valueTo); - } + $builder + ->setOperatorSelectable(false) + ->setDefaultOperator(Operator::Between) + ; - if ($criteria->count() > 0) { - $query->andWhere($criteria); - } + $builder + ->addEventSubscriber(new TransformDateRangeFilterData()) + ; } public function configureOptions(OptionsResolver $resolver): void @@ -70,31 +30,8 @@ public function configureOptions(OptionsResolver $resolver): void $resolver ->setDefaults([ 'form_type' => DateRangeType::class, - 'active_filter_formatter' => $this->getFormattedActiveFilterString(...), - 'empty_data' => ['from' => '', 'to' => ''], + 'active_filter_formatter' => new DateRangeActiveFilterFormatter(), ]) ; } - - public function getFormattedActiveFilterString(FilterData $data): string|TranslatableMessage - { - $value = $data->getValue(); - - $dateFrom = $value['from']; - $dateTo = $value['to']; - - if (null !== $dateFrom && null === $dateTo) { - return t('After %date%', ['%date%' => $dateFrom->format('Y-m-d')], 'KreyuDataTable'); - } - - if (null === $dateFrom && null !== $dateTo) { - return t('Before %date%', ['%date%' => $dateTo->format('Y-m-d')], 'KreyuDataTable'); - } - - if ($dateFrom == $dateTo) { - return $dateFrom->format('Y-m-d'); - } - - return $dateFrom->format('Y-m-d').' - '.$dateTo->format('Y-m-d'); - } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php old mode 100755 new mode 100644 index b400979c..11273e99 --- a/src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/DateTimeFilterType.php @@ -4,34 +4,33 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Operator; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Formatter\DateTimeActiveFilterFormatter; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\DateTimeFilterType} instead - */ class DateTimeFilterType extends AbstractDoctrineOrmFilterType { + public function buildFilter(FilterBuilderInterface $builder, array $options): void + { + $builder->setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + Operator::GreaterThan, + Operator::GreaterThanEquals, + Operator::LessThan, + Operator::LessThanEquals, + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver ->setDefaults([ 'form_type' => DateTimeType::class, - 'supported_operators' => [ - Operator::Equals, - Operator::NotEquals, - Operator::GreaterThan, - Operator::GreaterThanEquals, - Operator::LessThan, - Operator::LessThanEquals, - ], - 'active_filter_formatter' => $this->getFormattedActiveFilterString(...), + 'active_filter_formatter' => new DateTimeActiveFilterFormatter(), ]) ->addNormalizer('form_options', function (Options $options, array $value): array { if (DateTimeType::class !== $options['form_type']) { @@ -40,90 +39,6 @@ public function configureOptions(OptionsResolver $resolver): void return $value + ['widget' => 'single_text']; }) - ->addNormalizer('empty_data', function (Options $options, string|array $value): string|array { - if (DateTimeType::class !== $options['form_type']) { - return $value; - } - - // Note: because choice and text widgets are split into three fields under "date" index, - // we have to return an array with three empty "date" values to properly set the empty data. - return match ($options['form_options']['widget'] ?? null) { - 'choice', 'text' => [ - 'date' => ['day' => '', 'month' => '', 'year' => ''], - ], - default => '', - }; - }) ; } - - protected function getFilterValue(FilterData $data): \DateTimeInterface - { - $value = $data->getValue(); - - if ($value instanceof \DateTimeInterface) { - return $value; - } - - if (is_string($value)) { - return \DateTime::createFromFormat('Y-m-d\TH:i', $value); - } - - if (is_array($value)) { - return (new \DateTime()) - ->setDate( - year: (int) $value['date']['year'] ?: 0, - month: (int) $value['date']['month'] ?: 0, - day: (int) $value['date']['day'] ?: 0, - ) - ->setTime( - hour: (int) $value['time']['hour'] ?: 0, - minute: (int) $value['time']['minute'] ?: 0, - second: (int) $value['time']['second'] ?: 0, - ) - ; - } - - throw new \InvalidArgumentException(sprintf('Unable to convert data of type "%s" to DateTime object.', get_debug_type($value))); - } - - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object - { - $expression = match ($operator) { - Operator::Equals => $expr->eq(...), - Operator::NotEquals => $expr->neq(...), - Operator::GreaterThan => $expr->gt(...), - Operator::GreaterThanEquals => $expr->gte(...), - Operator::LessThan => $expr->lt(...), - Operator::LessThanEquals => $expr->lte(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); - } - - private function getFormattedActiveFilterString(FilterData $data, FilterInterface $filter, array $options): string - { - $value = $data->getValue(); - - if ($value instanceof \DateTimeInterface) { - $format = $options['form_options']['input_format'] ?? null; - - if (null === $format) { - $format = 'Y-m-d H'; - - if ($options['form_options']['with_minutes'] ?? true) { - $format .= ':i'; - } - - if ($options['form_options']['with_seconds'] ?? true) { - $format .= ':s'; - } - } - - return $value->format($format); - } - - return (string) $value; - } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php old mode 100755 new mode 100644 index d7775bb9..d3ca1a09 --- a/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterType.php @@ -4,45 +4,34 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Query\DoctrineOrmProxyQuery as DeprecatedDoctrineOrmProxyQuery; use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterHandlerInterface; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; -use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; -use Kreyu\Bundle\DataTableDoctrineOrmBundle\Query\DoctrineOrmProxyQuery; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\EventListener\ApplyExpressionTransformers; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\DoctrineOrmFilterHandler; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\ExpressionTransformer\ExpressionTransformerInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\DoctrineOrmFilterType} instead - */ -class DoctrineOrmFilterType extends AbstractFilterType +final class DoctrineOrmFilterType extends AbstractFilterType { public function buildFilter(FilterBuilderInterface $builder, array $options): void { - // backwards compatible layer - $builder->setHandler(new class implements FilterHandlerInterface { - public function handle(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter): void - { - if ($query instanceof DoctrineOrmProxyQuery) { - $query = new DeprecatedDoctrineOrmProxyQuery($query->getQueryBuilder(), $query->getHints(), $query->getHydrationMode()); - } - - $filter->getConfig()->getType()->getInnerType()->apply($query, $data, $filter, $filter->getConfig()->getOptions()); - } - }); - } - - public function apply(ProxyQueryInterface $query, FilterData $data, FilterInterface $filter, array $options): void - { + $builder->setHandler(new DoctrineOrmFilterHandler()); + $builder->addEventSubscriber(new ApplyExpressionTransformers()); } public function configureOptions(OptionsResolver $resolver): void { $resolver - ->setDefault('auto_alias_resolving', true) - ->setAllowedTypes('auto_alias_resolving', 'bool') + ->setDefaults([ + 'trim' => false, + 'lower' => false, + 'upper' => false, + 'expression_transformers' => [], + ]) + ->setAllowedTypes('trim', 'bool') + ->setAllowedTypes('lower', 'bool') + ->setAllowedTypes('upper', 'bool') + ->setAllowedTypes('expression_transformers', ExpressionTransformerInterface::class.'[]') ; } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php old mode 100755 new mode 100644 index 0722afb4..1bd17622 --- a/src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/EntityFilterType.php @@ -4,92 +4,61 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\Bundle\DoctrineBundle\Registry; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; -use Kreyu\Bundle\DataTableBundle\Filter\FilterData; -use Kreyu\Bundle\DataTableBundle\Filter\FilterInterface; +use Doctrine\Persistence\ManagerRegistry; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Operator; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Formatter\EntityActiveFilterFormatter; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Component\PropertyAccess\PropertyAccess; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\EntityFilterType} instead - */ class EntityFilterType extends AbstractDoctrineOrmFilterType { public function __construct( - private readonly Registry $doctrineRegistry, + private readonly ?ManagerRegistry $managerRegistry = null, ) { } + public function buildFilter(FilterBuilderInterface $builder, array $options): void + { + $builder->setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + Operator::In, + Operator::NotIn, + ]); + } + public function configureOptions(OptionsResolver $resolver): void { $resolver ->setDefaults([ 'form_type' => EntityType::class, - 'supported_operators' => [ - Operator::Equals, - Operator::NotEquals, - Operator::Contains, - Operator::NotContains, - ], 'choice_label' => null, - 'active_filter_formatter' => $this->getFormattedActiveFilterString(...), + 'active_filter_formatter' => new EntityActiveFilterFormatter(), ]) ->setAllowedTypes('choice_label', ['null', 'string', 'callable']) - ->addNormalizer('form_options', function (Options $options, array $value) { - if (EntityType::class !== $options['form_type']) { - return $value; - } - - // The identifier field name of the entity has to be provided in the 'choice_value' form option. - // - // This is required by the persistence system, because only the entity identifier will be persisted, - // and the EntityType form type needs to know how to convert it back to the entity object. - // - // If it's not provided, try to retrieve it from the entity metadata. - if (null === ($value['choice_value'] ?? null)) { - $identifiers = $this->doctrineRegistry - ->getManagerForClass($value['class']) - ?->getClassMetadata($value['class']) - ->getIdentifier() ?? []; - - if (1 === count($identifiers)) { - $value += ['choice_value' => reset($identifiers)]; - } - } - - return $value; - }) ; - } - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object - { - $expression = match ($operator) { - Operator::Equals, Operator::Contains => $expr->in(...), - Operator::NotEquals, Operator::NotContains => $expr->notIn(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); - } + // The persistence feature is saving the identifier of the entity, not the entire selected entity. + // Therefore, the EntityType requires "choice_value" option with a name of the entity identifier field. + if (null !== $this->managerRegistry) { + $resolver->addNormalizer('form_options', function (Options $options, array $formOptions) { + if (EntityType::class !== $options['form_type'] || null === $class = $formOptions['class'] ?? null) { + return $formOptions; + } - private function getFormattedActiveFilterString(FilterData $data, FilterInterface $filter, array $options): string - { - $choiceLabel = $options['choice_label']; + $identifiers = $this->managerRegistry + ->getManagerForClass($class) + ?->getClassMetadata($class) + ->getIdentifier() ?? []; - if (is_string($choiceLabel)) { - return PropertyAccess::createPropertyAccessor()->getValue($data->getValue(), $choiceLabel); - } + if (1 === count($identifiers)) { + $formOptions += ['choice_value' => reset($identifiers)]; + } - if (is_callable($choiceLabel)) { - return $choiceLabel($data->getValue()); + return $formOptions; + }); } - - return (string) $data->getValue(); } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php old mode 100755 new mode 100644 index eab95472..f948c748 --- a/src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/NumericFilterType.php @@ -4,46 +4,29 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Operator; use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\OptionsResolver\OptionsResolver; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\NumberFilterType} instead - */ class NumericFilterType extends AbstractDoctrineOrmFilterType { - public function configureOptions(OptionsResolver $resolver): void + public function buildFilter(FilterBuilderInterface $builder, array $options): void { - $resolver - ->setDefaults([ - 'form_type' => NumberType::class, - 'supported_operators' => [ - Operator::Equals, - Operator::NotEquals, - Operator::GreaterThanEquals, - Operator::GreaterThan, - Operator::LessThanEquals, - Operator::LessThan, - ], - ]) - ; + $builder->setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + Operator::Contains, + Operator::NotContains, + Operator::StartsWith, + Operator::EndsWith, + ]); } - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object + public function configureOptions(OptionsResolver $resolver): void { - $expression = match ($operator) { - Operator::Equals => $expr->eq(...), - Operator::NotEquals => $expr->neq(...), - Operator::GreaterThanEquals => $expr->gte(...), - Operator::GreaterThan => $expr->gt(...), - Operator::LessThanEquals => $expr->lte(...), - Operator::LessThan => $expr->lt(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); + $resolver->setDefaults([ + 'form_type' => NumberType::class, + ]); } } diff --git a/src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php b/src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php old mode 100755 new mode 100644 index b0d7a070..45a51799 --- a/src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php +++ b/src/Bridge/Doctrine/Orm/Filter/Type/StringFilterType.php @@ -4,53 +4,28 @@ namespace Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Filter\Type; -use Doctrine\ORM\Query\Expr; -use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; +use Kreyu\Bundle\DataTableBundle\Filter\FilterBuilderInterface; use Kreyu\Bundle\DataTableBundle\Filter\Operator; use Symfony\Component\OptionsResolver\OptionsResolver; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Filter\Type\TextFilterType} instead - */ class StringFilterType extends AbstractDoctrineOrmFilterType { - public function configureOptions(OptionsResolver $resolver): void - { - $resolver - ->setDefaults([ - 'default_operator' => Operator::Contains, - 'supported_operators' => [ - Operator::Equals, - Operator::NotEquals, - Operator::Contains, - Operator::NotContains, - Operator::StartsWith, - Operator::EndsWith, - ], - ]) - ; - } - - protected function getOperatorExpression(string $queryPath, string $parameterName, Operator $operator, Expr $expr): object + public function buildFilter(FilterBuilderInterface $builder, array $options): void { - $expression = match ($operator) { - Operator::Equals => $expr->eq(...), - Operator::NotEquals => $expr->neq(...), - Operator::Contains, Operator::StartsWith, Operator::EndsWith => $expr->like(...), - Operator::NotContains => $expr->notLike(...), - default => throw new InvalidArgumentException('Operator not supported'), - }; - - return $expression($queryPath, ":$parameterName"); + $builder->setSupportedOperators([ + Operator::Equals, + Operator::NotEquals, + Operator::Contains, + Operator::NotContains, + Operator::StartsWith, + Operator::EndsWith, + ]); } - protected function getParameterValue(Operator $operator, mixed $value): string + public function configureOptions(OptionsResolver $resolver): void { - return (string) match ($operator) { - Operator::Contains, Operator::NotContains => "%$value%", - Operator::StartsWith => "$value%", - Operator::EndsWith => "%$value", - default => $value, - }; + $resolver->setDefaults([ + 'default_operator' => Operator::Contains, + ]); } } diff --git a/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactory.php b/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactory.php new file mode 100644 index 00000000..2a9c2cb8 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactory.php @@ -0,0 +1,131 @@ +getRootEntities()); + + if (false === $rootEntity) { + throw new \RuntimeException('There are no root entities defined in the query.'); + } + + $identifierFieldNames = $queryBuilder + ->getEntityManager() + ->getClassMetadata($rootEntity) + ->getIdentifierFieldNames(); + + $hasSingleIdentifierName = 1 === \count($identifierFieldNames); + $hasJoins = \count($queryBuilder->getDQLPart('join')) > 0; + + $query = $queryBuilder->getQuery(); + + if (!$hasJoins) { + $query->setHint(CountWalker::HINT_DISTINCT, false); + } + + foreach ($hints as $name => $value) { + $query->setHint($name, $value); + } + + // Paginator with fetchJoinCollection doesn't work with composite primary keys + // https://github.com/doctrine/orm/issues/2910 + // To stay safe fetch join only when we have single primary key and joins + $paginator = new Paginator($query, $hasSingleIdentifierName && $hasJoins); + + // it is only safe to disable output walkers for really simple queries + if ($this->canDisableOutputWalkers($queryBuilder)) { + $paginator->setUseOutputWalkers(false); + } + + return $paginator; + } + + /** + * @see https://github.com/doctrine/orm/issues/8278#issue-705517756 + */ + public function canDisableOutputWalkers(QueryBuilder $queryBuilder): bool + { + // Do not support queries using HAVING + if (null !== $queryBuilder->getDQLPart('having')) { + return false; + } + + $fromParts = $queryBuilder->getDQLPart('from'); + + // Do not support queries using multiple entities in FROM + if (1 !== \count($fromParts)) { + return false; + } + + $fromPart = current($fromParts); + + $classMetadata = $queryBuilder + ->getEntityManager() + ->getClassMetadata($fromPart->getFrom()); + + $identifierFieldNames = $classMetadata->getIdentifierFieldNames(); + + // Do not support entities using a composite identifier + if (1 !== \count($identifierFieldNames)) { + return false; + } + + $identifierName = current($identifierFieldNames); + + // Do not support entities using a foreign key as identifier + if ($classMetadata->hasAssociation($identifierName)) { + return false; + } + + // Do not support queries using a field from a toMany relation in the ORDER BY clause + if ($this->hasOrderByAssociation($queryBuilder)) { + return false; + } + + return true; + } + + public function hasOrderByAssociation(QueryBuilder $queryBuilder): bool + { + $joinParts = $queryBuilder->getDQLPart('join'); + + if (0 === \count($joinParts)) { + return false; + } + + $orderByParts = $queryBuilder->getDQLPart('orderBy'); + + if (empty($orderByParts)) { + return false; + } + + $joinAliases = []; + + foreach ($joinParts as $joinPart) { + foreach ($joinPart as $join) { + $joinAliases[] = $join->getAlias(); + } + } + + foreach ($orderByParts as $orderByPart) { + foreach ($orderByPart->getParts() as $part) { + foreach ($joinAliases as $joinAlias) { + if (str_starts_with($part, $joinAlias.'.')) { + return true; + } + } + } + } + + return false; + } +} diff --git a/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryInterface.php b/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryInterface.php new file mode 100644 index 00000000..18c076ef --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryInterface.php @@ -0,0 +1,13 @@ +isResolvable($queryPath, $queryBuilder)) { + $queryPath = current($queryBuilder->getRootAliases()).'.'.$queryPath; + } + + return $queryPath; + } + + private function isResolvable(string $path, QueryBuilder $queryBuilder): bool + { + if (str_contains($path, '.')) { + return false; + } + + foreach ($queryBuilder->getDQLPart('select') ?? [] as $select) { + $parts = preg_split('/ as( hidden)? /i', $select->getParts()[0]); + + if ($path === ($parts[1] ?? $parts[0])) { + return false; + } + } + + return true; + } +} diff --git a/src/Bridge/Doctrine/Orm/Query/AliasResolverInterface.php b/src/Bridge/Doctrine/Orm/Query/AliasResolverInterface.php new file mode 100644 index 00000000..d9cb93bc --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Query/AliasResolverInterface.php @@ -0,0 +1,12 @@ + $hints @@ -43,39 +39,20 @@ public function __call(string $name, array $args): mixed return $this->queryBuilder->$name(...$args); } - public function __get(string $name): mixed - { - return $this->queryBuilder->{$name}; - } - public function __clone(): void { $this->queryBuilder = clone $this->queryBuilder; } - public function getQueryBuilder(): QueryBuilder - { - return $this->queryBuilder; - } - public function sort(SortingData $sortingData): void { - $rootAlias = current($this->queryBuilder->getRootAliases()); - - if (false === $rootAlias) { - throw new \RuntimeException('There are no root aliases defined in the query.'); - } - $this->queryBuilder->resetDQLPart('orderBy'); - foreach ($sortingData->getColumns() as $column) { - $propertyPath = (string) $column->getPropertyPath(); - - if ($rootAlias && !str_contains($propertyPath, '.') && !str_starts_with($propertyPath, '__')) { - $propertyPath = $rootAlias.'.'.$propertyPath; - } - - $this->queryBuilder->addOrderBy($propertyPath, $column->getDirection()); + foreach ($sortingData->getColumns() as $sortCriterion) { + $this->queryBuilder->addOrderBy( + $this->getAliasResolver()->resolve((string) $sortCriterion->getPropertyPath(), $this->queryBuilder), + $sortCriterion->getDirection(), + ); } } @@ -87,58 +64,17 @@ public function paginate(PaginationData $paginationData): void ; } - /** - * @throws \Exception - */ - public function getPagination(): PaginationInterface + public function getResult(): ResultSetInterface { - $maxResults = $this->queryBuilder->getMaxResults(); + $paginator = $this->getPaginatorFactory()->create($this->queryBuilder, $this->hints); + $paginator->getQuery()->setHydrationMode($this->hydrationMode); - $paginator = $this->createPaginator(forceDisabledFetchJoinCollection: null === $maxResults); - - try { - return new Pagination( - currentPageNumber: $this->getCurrentPageNumber(), - currentPageItemCount: $paginator->count(), - totalItemCount: $paginator->count(), - itemNumberPerPage: $maxResults, - ); - } catch (CurrentPageOutOfRangeException) { - $this->queryBuilder->setFirstResult(null); - } - - return $this->getPagination(); + return $this->getResultSetFactory()->create($paginator, $this->batchSize); } - public function getItems(): iterable + public function getQueryBuilder(): QueryBuilder { - $paginator = $this->createPaginator(forceDisabledFetchJoinCollection: true); - - $batchSize = $this->batchSize; - - $cursorPosition = 0; - - do { - $hasItems = true; - - if (0 === $cursorPosition % $batchSize) { - $hasItems = false; - - $paginator->getQuery()->setMaxResults($batchSize); - $paginator->getQuery()->setFirstResult($cursorPosition); - - foreach ($paginator->getIterator() as $item) { - $hasItems = true; - yield $item; - } - - if ($this->entityManagerClearingEnabled) { - $this->getEntityManager()->clear(); - } - } - - ++$cursorPosition; - } while (0 === $cursorPosition || $hasItems); + return $this->queryBuilder; } public function getUniqueParameterId(): int @@ -146,24 +82,24 @@ public function getUniqueParameterId(): int return $this->uniqueParameterId++; } - public function setHint(string $name, mixed $value): void + public function getHints(): array { - $this->hints[$name] = $value; + return $this->hints; } - public function setHydrationMode(int|string $hydrationMode): void + public function setHint(string $name, mixed $value): void { - $this->hydrationMode = $hydrationMode; + $this->hints[$name] = $value; } - public function isEntityManagerClearingEnabled(): bool + public function getHydrationMode(): int|string { - return $this->entityManagerClearingEnabled; + return $this->hydrationMode; } - public function setEntityManagerClearingEnabled(bool $entityManagerClearingEnabled): void + public function setHydrationMode(int|string $hydrationMode): void { - $this->entityManagerClearingEnabled = $entityManagerClearingEnabled; + $this->hydrationMode = $hydrationMode; } public function getBatchSize(): int @@ -173,57 +109,40 @@ public function getBatchSize(): int public function setBatchSize(int $batchSize): void { + if ($batchSize <= 0) { + throw new InvalidArgumentException('The batch size must be positive.'); + } + $this->batchSize = $batchSize; } - private function getCurrentPageNumber(): int + public function getPaginatorFactory(): PaginatorFactoryInterface { - $firstResult = $this->queryBuilder->getFirstResult(); - $maxResults = $this->queryBuilder->getMaxResults() ?? 1; - - return (int) ($firstResult / $maxResults) + 1; + return $this->paginatorFactory ??= new PaginatorFactory(); } - private function createPaginator(bool $forceDisabledFetchJoinCollection = false): Paginator + public function setPaginatorFactory(PaginatorFactoryInterface $paginatorFactory): void { - $rootEntity = current($this->queryBuilder->getRootEntities()); - - if (false === $rootEntity) { - throw new \RuntimeException('There are no root entities defined in the query.'); - } - - $identifierFieldNames = $this->queryBuilder - ->getEntityManager() - ->getClassMetadata($rootEntity) - ->getIdentifierFieldNames(); - - $hasSingleIdentifierName = 1 === \count($identifierFieldNames); - $hasJoins = \count($this->queryBuilder->getDQLPart('join')) > 0; - - $query = (clone $this->queryBuilder)->getQuery(); - - $this->applyQueryHints($query); - - $query->setHydrationMode($this->hydrationMode); - - $fetchJoinCollection = $hasSingleIdentifierName && $hasJoins; + $this->paginatorFactory = $paginatorFactory; + } - if ($forceDisabledFetchJoinCollection) { - $fetchJoinCollection = false; - } + public function getAliasResolver(): AliasResolverInterface + { + return $this->aliasResolver ??= new AliasResolver(); + } - return new Paginator($query, $fetchJoinCollection); + public function setAliasResolver(AliasResolverInterface $aliasResolver): void + { + $this->aliasResolver = $aliasResolver; } - private function applyQueryHints(Query $query): void + public function getResultSetFactory(): DoctrineOrmResultSetFactoryInterface { - foreach ($this->hints as $name => $value) { - $query->setHint($name, $value); - } + return $this->resultSetFactory ??= new DoctrineOrmResultSetFactory(); } - public function getResult(): ResultSetInterface + public function setResultSetFactory(DoctrineOrmResultSetFactoryInterface $resultSetFactory): void { - throw new LogicException('To use Doctrine ORM with DataTableBundle, install KreyuDataTableDoctrineOrmBundle by running "composer require kreyu/data-table-doctrine-orm-bundle"'); + $this->resultSetFactory = $resultSetFactory; } } diff --git a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactory.php b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactory.php old mode 100755 new mode 100644 index dc544f11..1dfb94aa --- a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactory.php +++ b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactory.php @@ -7,13 +7,11 @@ use Doctrine\ORM\QueryBuilder; use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryFactoryInterface; +use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; -/** - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Query\DoctrineOrmProxyQueryFactory} instead - */ class DoctrineOrmProxyQueryFactory implements ProxyQueryFactoryInterface { - public function create(mixed $data): DoctrineOrmProxyQueryInterface + public function create(mixed $data): ProxyQueryInterface { if ($data instanceof QueryBuilder) { return new DoctrineOrmProxyQuery($data); diff --git a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryInterface.php b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryInterface.php index a20f9722..708e9f0c 100644 --- a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryInterface.php +++ b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryInterface.php @@ -6,11 +6,10 @@ use Doctrine\ORM\QueryBuilder; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Bridge\Doctrine\Orm\Paginator\PaginatorFactoryInterface; /** * @mixin QueryBuilder - * - * @deprecated since 0.15, install kreyu/data-table-doctrine-orm-bundle and use {@see \Kreyu\Bundle\DataTableDoctrineOrmBundle\Query\DoctrineOrmProxyQueryInterface} instead */ interface DoctrineOrmProxyQueryInterface extends ProxyQueryInterface { @@ -18,18 +17,36 @@ public function getQueryBuilder(): QueryBuilder; public function getUniqueParameterId(): int; + /** + * @return array + */ + public function getHints(): array; + public function setHint(string $name, mixed $value): void; + /** + * @psalm-return string|AbstractQuery::HYDRATE_* + */ + public function getHydrationMode(): int|string; + /** * @psalm-param string|AbstractQuery::HYDRATE_* $hydrationMode */ public function setHydrationMode(int|string $hydrationMode): void; - public function isEntityManagerClearingEnabled(): bool; - - public function setEntityManagerClearingEnabled(bool $entityManagerClearingEnabled): void; - public function getBatchSize(): int; public function setBatchSize(int $batchSize): void; + + public function getPaginatorFactory(): PaginatorFactoryInterface; + + public function setPaginatorFactory(PaginatorFactoryInterface $paginatorFactory): void; + + public function getAliasResolver(): AliasResolverInterface; + + public function setAliasResolver(AliasResolverInterface $aliasResolver): void; + + public function getResultSetFactory(): DoctrineOrmResultSetFactoryInterface; + + public function setResultSetFactory(DoctrineOrmResultSetFactoryInterface $resultSetFactory): void; } diff --git a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactory.php b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactory.php new file mode 100644 index 00000000..e48200c2 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactory.php @@ -0,0 +1,62 @@ + $this->getPaginatorItems($paginator, $batchSize)); + + $currentPageItemCount = $totalItemCount = $paginator->count(); + + if ($paginator->getQuery()->getMaxResults() > 0) { + $items = new \ArrayIterator(iterator_to_array($items)); + $currentPageItemCount = iterator_count($items); + } + + return new ResultSet($items, $currentPageItemCount, $totalItemCount); + } + + private function getPaginatorItems(Paginator $paginator, int $batchSize): \Generator + { + $query = $paginator->getQuery(); + + $firstResult = $query->getFirstResult(); + $maxResults = $limit = $query->getMaxResults(); + + if (null === $maxResults || $maxResults > $batchSize) { + $maxResults = $batchSize; + } + + $hasItems = true; + + $cursorPosition = 0; + + while ($hasItems && $firstResult < $paginator->count() && (null === $limit || $cursorPosition < $limit)) { + $hasItems = false; + + $query + ->setMaxResults($maxResults) + ->setFirstResult($firstResult); + + foreach ($paginator as $item) { + yield $item; + + $hasItems = true; + + ++$cursorPosition; + } + + $firstResult = $cursorPosition; + + $paginator->getQuery()->getEntityManager()->clear(); + } + } +} diff --git a/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryInterface.php b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryInterface.php new file mode 100644 index 00000000..28d08084 --- /dev/null +++ b/src/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryInterface.php @@ -0,0 +1,13 @@ +assertEquals($outputExpression, $expressionTransformer->transform($inputExpression)); + } +} diff --git a/src/Bridge/Doctrine/README.md b/src/Bridge/Doctrine/README.md deleted file mode 100644 index 616c3bae..00000000 --- a/src/Bridge/Doctrine/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Deprecated - -Built-in Doctrine ORM integration is **deprecated** since 0.15, -install [kreyu/data-table-doctrine-orm-bundle](https://github.com/Kreyu/data-table-doctrine-orm-bundle) instead. diff --git a/src/Bridge/OpenSpout/Exporter/OpenSpoutExportHandler.php b/src/Bridge/OpenSpout/Exporter/OpenSpoutExportHandler.php new file mode 100644 index 00000000..2cfc7fa6 --- /dev/null +++ b/src/Bridge/OpenSpout/Exporter/OpenSpoutExportHandler.php @@ -0,0 +1,85 @@ +tempnam( + $exporter->getConfig()->getOption('tempnam_dir'), + $exporter->getConfig()->getOption('tempnam_prefix'), + ); + + $this->writer->openToFile($outputFilePath); + + if ($exporter->getConfig()->getOption('use_headers')) { + $this->writer->addRow(new Row( + cells: $this->getHeaderRowCells($view->vars['headerRow'], $exporter), + style: $exporter->getConfig()->getOption('header_row_style', new Style()), + )); + } + + foreach ($view->vars['valueRows'] as $valueRow) { + $this->writer->addRow(new Row( + cells: $this->getValueRowCells($valueRow, $exporter), + style: $exporter->getConfig()->getOption('value_row_style', new Style()), + )); + } + + $this->writer->close(); + + return new ExportFile($outputFilePath, sprintf('%s.%s', $filename, $this->getExtension())); + } + + private function getExtension(): string + { + return match (get_class($this->writer)) { + Writer\CSV\Writer::class => 'csv', + Writer\XLSX\Writer::class => 'xlsx', + Writer\ODS\Writer::class => 'ods', + }; + } + + private function getHeaderRowCells(HeaderRowView $view, ExporterInterface $exporter): array + { + return array_map( + fn (ColumnHeaderView $columnHeaderView) => Cell::fromValue( + value: $view->vars['label'], + style: $exporter->getConfig()->getOption('header_cell_style'), + ), + $view->children, + ); + } + + private function getValueRowCells(ValueRowView $view, ExporterInterface $exporter): array + { + return array_map( + fn (ColumnValueView $columnValueView) => Cell::fromValue( + value: $view->vars['value'], + style: $exporter->getConfig()->getOption('value_cell_style'), + ), + $view->children, + ); + } +} diff --git a/src/Bridge/OpenSpout/Exporter/Type/AbstractOpenSpoutExporterType.php b/src/Bridge/OpenSpout/Exporter/Type/AbstractOpenSpoutExporterType.php index a8e89372..1a417707 100644 --- a/src/Bridge/OpenSpout/Exporter/Type/AbstractOpenSpoutExporterType.php +++ b/src/Bridge/OpenSpout/Exporter/Type/AbstractOpenSpoutExporterType.php @@ -78,7 +78,7 @@ private function getHeaderRowCells(HeaderRowView $view, array $options): array protected function getHeaderCell(ColumnHeaderView $view, array $options): Cell { return Cell::fromValue( - value: $view->vars['label'], + value: $view->vars['label'] ?? 'a', style: $this->getStyle($view, 'header_cell_style', $options), ); } @@ -94,7 +94,7 @@ protected function getValueRowCells(ValueRowView $view, array $options): array protected function getValueCell(ColumnValueView $view, array $options): Cell { return Cell::fromValue( - value: $view->value, + value: $view->vars['value'] ?? 'a', style: $this->getStyle($view, 'value_cell_style', $options), ); } diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 48868c69..9547ed07 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -127,6 +127,8 @@ public function buildExportHeaderView(ColumnHeaderView $view, ColumnInterface $c public function buildExportValueView(ColumnValueView $view, ColumnInterface $column, array $options): void { + return; + if (false === $options['export']) { return; } diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 8679a02e..16bc3073 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -179,9 +179,9 @@ {% block pagination_counters_message %} {{- 'Showing %current_page_first_item_index% - %current_page_last_item_index% of %total_item_count%'|trans({ - '%current_page_first_item_index%': current_page_first_item_index, - '%current_page_last_item_index%': current_page_last_item_index, - '%total_item_count%': total_item_count + '%current_page_first_item_index%': current_page_first_item_index|number_format(0, '', ' '), + '%current_page_last_item_index%': current_page_last_item_index|number_format(0, '', ' '), + '%total_item_count%': total_item_count|number_format(0, '', ' ') }, 'KreyuDataTable') -}} {% endblock %} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index 97e12d15..d9d4aed7 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -154,12 +154,12 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar public function buildExportView(DataTableView $view, DataTableInterface $dataTable, array $options): void { - $visibleColumns = $dataTable->getExportableColumns(); - $view->vars['translation_domain'] = $dataTable->getConfig()->getOption('translation_domain'); - $view->headerRow = $this->createExportHeaderRowView($view, $dataTable, $visibleColumns); - $view->valueRows = new RowIterator(fn () => $this->createExportValueRowsViews($view, $dataTable, $visibleColumns)); + $columns = $dataTable->getExportableColumns(); + + $view->headerRow = $this->createExportHeaderRowView($view, $dataTable, $columns); + $view->valueRows = new RowIterator(fn () => $this->createExportValueRowsViews($view, $dataTable, $columns)); } public function configureOptions(OptionsResolver $resolver): void @@ -315,6 +315,8 @@ private function createHeaderRowView(DataTableView $view, DataTableInterface $da $headerRowView->children[$column->getName()] = $column->createHeaderView($headerRowView); } + $headerRowView->vars['children'] = $headerRowView->children; + return $headerRowView; } @@ -365,6 +367,9 @@ private function createExportHeaderRowView(DataTableView $view, DataTableInterfa return $headerRowView; } + /** + * @param array $columns + */ private function createExportValueRowsViews(DataTableView $view, DataTableInterface $dataTable, array $columns): iterable { $items = $dataTable->getItems(); @@ -373,7 +378,7 @@ private function createExportValueRowsViews(DataTableView $view, DataTableInterf $valueRowView = new ValueRowView($view, $index, $data); foreach ($columns as $column) { - $valueRowView->children[$column->getName()] = $column->createExportValueView($valueRowView); + $valueRowView->children[$column->getName()] = $column->createValueView($valueRowView); } yield $valueRowView; diff --git a/tests/Unit/Bridge/Doctrine/Orm/Event/PreSetParametersEventTest.php b/tests/Unit/Bridge/Doctrine/Orm/Event/PreSetParametersEventTest.php new file mode 100644 index 00000000..93cf3b6f --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Event/PreSetParametersEventTest.php @@ -0,0 +1,31 @@ +createMock(ProxyQueryInterface::class), + $this->createMock(FilterData::class), + $this->createMock(FilterInterface::class), + [], + ); + + $parameters = [new Parameter('foo', 'bar')]; + + $event->setParameters($parameters); + + $this->assertEquals($parameters, $event->getParameters()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Extension/DoctrineOrmFilterExtensionTest.php b/tests/Unit/Bridge/Doctrine/Orm/Extension/DoctrineOrmFilterExtensionTest.php new file mode 100644 index 00000000..1cbccf81 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Extension/DoctrineOrmFilterExtensionTest.php @@ -0,0 +1,40 @@ +assertTrue($extension->hasType($type)); + $this->assertInstanceOf($type, $extension->getType($type)); + } + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php new file mode 100644 index 00000000..4648ceea --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/DoctrineOrmFilterHandlerTest.php @@ -0,0 +1,164 @@ +createFilterMock(); + $filterConfig = $this->createFilterConfigMock(); + $eventDispatcher = $this->createEventDispatcherMock(); + + $filter->method('getConfig')->willReturn($filterConfig); + $filterConfig->method('getEventDispatcher')->willReturn($eventDispatcher); + + $this->filter = $filter; + $this->eventDispatcher = $eventDispatcher; + $this->query = $this->createDoctrineOrmProxyQueryMock(); + $this->data = $this->createFilterDataMock(); + } + + public function testItThrowsExceptionWithNotSupportedProxyQueryClass(): void + { + $query = new NotSupportedProxyQuery(); + + $this->expectExceptionObject(new UnexpectedTypeException($query, DoctrineOrmProxyQueryInterface::class)); + + $this->createHandler()->handle($query, $this->data, $this->filter); + } + + public function testItAppliesExpression(): void + { + ($queryBuilder = $this->createQueryBuilderMock()) + ->expects($this->once()) + ->method('andWhere') + ->willReturnCallback(function (mixed $expression) { + $this->assertEquals('expression', $expression); + }); + + $this->query->method('getQueryBuilder')->willReturn($queryBuilder); + + $this->createHandler(expression: 'expression')->handle($this->query, $this->data, $this->filter); + } + + public function testItSetsParameter(): void + { + ($queryBuilder = $this->createQueryBuilderMock()) + ->expects($this->once()) + ->method('setParameter') + ->willReturnCallback(function ($name, $value) { + $this->assertEquals('foo', $name); + $this->assertEquals('bar', $value); + }); + + $this->query->method('getQueryBuilder')->willReturn($queryBuilder); + + $this->createHandler(parameters: [new Parameter('foo', 'bar')])->handle($this->query, $this->data, $this->filter); + } + + public function testItDispatchesEvents(): void + { + $this->eventDispatcher + ->expects($matcher = $this->exactly(2)) + ->method('dispatch') + ->willReturnCallback(function (DoctrineOrmFilterEvent $event, string $eventName) use ($matcher) { + // @phpstan-ignore-next-line + $this->assertInstanceOf(match ($matcher->numberOfInvocations()) { + 1 => PreSetParametersEvent::class, + 2 => PreApplyExpressionEvent::class, + }, $event); + + // @phpstan-ignore-next-line + $this->assertEquals(match ($matcher->numberOfInvocations()) { + 1 => DoctrineOrmFilterEvents::PRE_SET_PARAMETERS, + 2 => DoctrineOrmFilterEvents::PRE_APPLY_EXPRESSION, + }, $eventName); + + $this->assertEquals($this->query, $event->getQuery()); + $this->assertEquals($this->data, $event->getData()); + $this->assertEquals($this->filter, $event->getFilter()); + + return $event; + }); + + $this->createHandler()->handle($this->query, $this->data, $this->filter); + } + + private function createHandler(mixed $expression = null, array $parameters = []): DoctrineOrmFilterHandler + { + $expressionFactory = $this->createMock(ExpressionFactoryInterface::class); + $expressionFactory->method('create')->willReturn($expression); + + $parameterFactory = $this->createMock(ParameterFactoryInterface::class); + $parameterFactory->method('create')->willReturn($parameters); + + return new DoctrineOrmFilterHandler($expressionFactory, $parameterFactory); + } + + private function createFilterMock(): MockObject&FilterInterface + { + return $this->createMock(FilterInterface::class); + } + + private function createFilterConfigMock(): FilterConfigInterface&MockObject + { + $filterConfig = $this->createMock(FilterConfigInterface::class); + $filterConfig->method('getSupportedOperators')->willReturn([Operator::Equals]); + + return $filterConfig; + } + + private function createFilterDataMock(): FilterData&MockObject + { + $filterData = $this->createMock(FilterData::class); + $filterData->method('getOperator')->willReturn(Operator::Equals); + + return $filterData; + } + + private function createDoctrineOrmProxyQueryMock(): MockObject&DoctrineOrmProxyQueryInterface + { + return $this->createMock(DoctrineOrmProxyQueryInterface::class); + } + + private function createQueryBuilderMock(): MockObject&QueryBuilder + { + return $this->createMock(QueryBuilder::class); + } + + private function createEventDispatcherMock(): MockObject&EventDispatcherInterface + { + $eventDispatcher = $this->createMock(EventDispatcherInterface::class); + $eventDispatcher->method('hasListeners')->willReturn(true); + + return $eventDispatcher; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/ApplyExpressionTransformersTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/ApplyExpressionTransformersTest.php new file mode 100644 index 00000000..202a94b6 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/ApplyExpressionTransformersTest.php @@ -0,0 +1,92 @@ +listener = new ApplyExpressionTransformers(); + } + + #[DataProvider('providePreApplyExpressionCases')] + public function testPreApplyExpression(array $options, string $expected) + { + $expression = new Expr\Comparison('foo', '=', 'bar'); + + $event = new PreApplyExpressionEvent( + $this->createMock(DoctrineOrmProxyQueryInterface::class), + $this->createMock(FilterData::class), + $this->createFilterMock($options), + $expression, + ); + + $this->listener->preApplyExpression($event); + + $this->assertEquals($expected, $event->getExpression()); + } + + public static function providePreApplyExpressionCases(): iterable + { + yield 'Using "trim" option' => [ + ['trim' => true], + 'TRIM(foo) = TRIM(bar)', + ]; + + yield 'Using "lower" option' => [ + ['lower' => true], + 'LOWER(foo) = LOWER(bar)', + ]; + + yield 'Using "upper" option' => [ + ['upper' => true], + 'UPPER(foo) = UPPER(bar)', + ]; + + yield 'Using "expression_transformers" option' => [ + ['expression_transformers' => [new CustomExpressionTransformer()]], + 'CUSTOM(foo) = CUSTOM(bar)', + ]; + + yield 'Using "trim", "lower" and "upper" options with "expression_transformers" option' => [ + ['trim' => true, 'lower' => true, 'upper' => true, 'expression_transformers' => [new CustomExpressionTransformer()]], + 'CUSTOM(UPPER(LOWER(TRIM(foo)))) = CUSTOM(UPPER(LOWER(TRIM(bar))))', + ]; + } + + private function createFilterMock(array $options = []): FilterInterface&MockObject + { + $options += [ + 'trim' => false, + 'lower' => false, + 'upper' => false, + 'expression_transformers' => [], + ]; + + $filterConfig = $this->createMock(FilterConfigInterface::class); + $filterConfig->method('getOption')->willReturnCallback(function (string $option) use ($options) { + return $options[$option] ?? null; + }); + + $filter = $this->createMock(FilterInterface::class); + $filter->method('getConfig')->willReturn($filterConfig); + + return $filter; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/TransformDateRangeFilterDataTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/TransformDateRangeFilterDataTest.php new file mode 100644 index 00000000..e1c2bd88 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/EventListener/TransformDateRangeFilterDataTest.php @@ -0,0 +1,85 @@ +listener = new TransformDateRangeFilterData(); + } + + public static function providePreHandleCases(): iterable + { + yield 'empty value' => [ + 'given' => new FilterData(), + 'expected' => new FilterData(), + ]; + + yield 'value from only' => [ + 'given' => new FilterData( + value: ['from' => new \DateTime('2022-01-01 11:22:33')], + operator: Operator::Between, + ), + 'expected' => new FilterData( + value: new \DateTime('2022-01-01 00:00:00'), + operator: Operator::GreaterThanEquals, + ), + ]; + + yield 'value to only' => [ + 'given' => new FilterData( + value: ['to' => new \DateTime('2022-01-01 11:22:33')], + operator: Operator::Between, + ), + 'expected' => new FilterData( + value: new \DateTime('2022-01-02 00:00:00'), + operator: Operator::LessThan, + ), + ]; + + yield 'value from and to' => [ + 'given' => new FilterData( + value: [ + 'from' => new \DateTime('2022-01-01 11:22:33'), + 'to' => new \DateTime('2022-01-02 11:22:33'), + ], + operator: Operator::Between, + ), + 'expected' => new FilterData( + value: [ + 'from' => new \DateTime('2022-01-01 00:00:00'), + 'to' => new \DateTime('2022-01-03 00:00:00'), + ], + operator: Operator::Between, + ), + ]; + } + + #[DataProvider('providePreHandleCases')] + public function testPreHandle(FilterData $given, FilterData $expected): void + { + $event = new PreHandleEvent( + $this->createMock(DoctrineOrmProxyQueryInterface::class), + $given, + $this->createMock(FilterInterface::class), + ); + + $this->listener->preHandle($event); + + $this->assertEquals($expected, $event->getData()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryTest.php new file mode 100644 index 00000000..a3905f22 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionFactory/ExpressionFactoryTest.php @@ -0,0 +1,202 @@ +filter = $this->createMock(FilterInterface::class); + $this->query = $this->createMock(DoctrineOrmProxyQueryInterface::class); + $this->aliasResolver = $this->createMock(AliasResolverInterface::class); + + $this->query->method('getAliasResolver')->willReturn($this->aliasResolver); + } + + #[DataProvider('expectedExpressionProvider')] + public function testItCreatesExpression(string $queryPath, FilterData $data, array $parameters, mixed $expected): void + { + $this->aliasResolver->method('resolve')->willReturn($queryPath); + + $this->assertEquals($expected, $this->createExpression($data, $parameters)); + } + + public static function expectedExpressionProvider(): iterable + { + yield 'equals' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::Equals), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '=', ':bar'), + ]; + + yield 'not equals' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::NotEquals), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '<>', ':bar'), + ]; + + yield 'contains' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::Contains), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', 'LIKE', ':bar'), + ]; + + yield 'not contains' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::NotContains), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', 'NOT LIKE', ':bar'), + ]; + + yield 'in' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::In), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Func('foo IN', [':bar']), + ]; + + yield 'not in' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::NotIn), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Func('foo NOT IN', [':bar']), + ]; + + yield 'greater than' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::GreaterThan), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '>', ':bar'), + ]; + + yield 'greater than or equals' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::GreaterThanEquals), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '>=', ':bar'), + ]; + + yield 'less than' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::LessThan), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '<', ':bar'), + ]; + + yield 'less than or equals' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::LessThanEquals), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', '<=', ':bar'), + ]; + + yield 'starts with' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::StartsWith), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', 'LIKE', ':bar'), + ]; + + yield 'ends with' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::EndsWith), + 'parameters' => [ + new Parameter('bar', null), + ], + 'expected' => new Comparison('foo', 'LIKE', ':bar'), + ]; + + yield 'between' => [ + 'query_path' => 'foo', + 'data' => new FilterData(null, Operator::Between), + 'parameters' => [ + 'from' => new Parameter('bar', null), + 'to' => new Parameter('baz', null), + ], + 'expected' => 'foo BETWEEN :bar AND :baz', + ]; + } + + public function testItRequiresParameters() + { + $this->expectExceptionObject(new InvalidArgumentException('The expression factory requires at least one parameter.')); + + $this->createExpression(); + } + + public function testBetweenOperatorRequiresFromAndToParameters() + { + $this->expectExceptionObject(new InvalidArgumentException('Operator "between" requires "from" and "to" parameters.')); + + $this->createExpression(new FilterData(operator: Operator::Between), [ + new Parameter('foo', null), + new Parameter('bar', null), + ]); + } + + public function testDataWithoutOperatorUsesFilterDefaultOperator() + { + $filterConfig = $this->createMock(FilterConfigInterface::class); + $filterConfig->method('getDefaultOperator')->willReturn(Operator::NotEquals); + + $this->filter->method('getConfig')->willReturn($filterConfig); + $this->aliasResolver->method('resolve')->willReturn('foo'); + + $expression = $this->createExpression(new FilterData(), [ + new Parameter('bar', null), + ]); + + $this->assertEquals(new Comparison('foo', '<>', ':bar'), $expression); + } + + private function createExpression(FilterData $data = new FilterData(), array $parameters = []): mixed + { + return (new ExpressionFactory())->create($this->query, $data, $this->filter, $parameters); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformerTest.php new file mode 100644 index 00000000..b02103c4 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/CallbackExpressionTransformerTest.php @@ -0,0 +1,44 @@ + "$expression bar"), 'foo', 'foo bar']; + + yield [ + self::createTransformer(static function (Expr\Comparison $comparison) use ($expr) { + return $expr->eq($expr->lower($comparison->getLeftExpr()), $expr->upper($comparison->getRightExpr())); + }), + $expr->eq('foo', 'bar'), + $expr->eq($expr->lower('foo'), $expr->upper('bar')), + ]; + } + + public function testConstructorConvertsCallableToClosure(): void + { + $transformer = self::createTransformer(fn () => null); + + $reflectionProperty = new \ReflectionProperty($transformer, 'callback'); + + $this->assertEquals('Closure', $reflectionProperty->getType()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ComparisonExpressionTransformerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ComparisonExpressionTransformerTest.php new file mode 100644 index 00000000..3c8306c1 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/ComparisonExpressionTransformerTest.php @@ -0,0 +1,36 @@ +transformer = new class() extends AbstractComparisonExpressionTransformer {}; + } + + public function testItThrowsExceptionWhenExpressionIsNotComparison(): void + { + $expression = 'foo = bar'; + + $this->expectExceptionObject(new UnexpectedTypeException($expression, Comparison::class)); + + $this->transformer->transform($expression); + } + + public function testItDoesNotModifyExpressionByDefault(): void + { + $comparison = new Comparison('foo', '=', 'bar'); + + $this->assertEquals($comparison, $this->transformer->transform($comparison)); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformerTest.php new file mode 100644 index 00000000..f41cada0 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/LowerExpressionTransformerTest.php @@ -0,0 +1,31 @@ +eq('a', 'b'), $expr->eq($expr->lower('a'), $expr->lower('b'))]; + + yield [self::createTransformer(false), $expr->eq('a', 'b'), $expr->eq('a', $expr->lower('b'))]; + + yield [self::createTransformer(true, false), $expr->eq('a', 'b'), $expr->eq($expr->lower('a'), 'b')]; + + yield [self::createTransformer(false, false), $expr->eq('a', 'b'), $expr->eq('a', 'b')]; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformerTest.php new file mode 100644 index 00000000..1cb56efe --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/TrimExpressionTransformerTest.php @@ -0,0 +1,31 @@ +eq('a', 'b'), $expr->eq($expr->trim('a'), $expr->trim('b'))]; + + yield [self::createTransformer(false), $expr->eq('a', 'b'), $expr->eq('a', $expr->trim('b'))]; + + yield [self::createTransformer(true, false), $expr->eq('a', 'b'), $expr->eq($expr->trim('a'), 'b')]; + + yield [self::createTransformer(false, false), $expr->eq('a', 'b'), $expr->eq('a', 'b')]; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformerTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformerTest.php new file mode 100644 index 00000000..eab09890 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ExpressionTransformer/UpperExpressionTransformerTest.php @@ -0,0 +1,31 @@ +eq('a', 'b'), $expr->eq($expr->upper('a'), $expr->upper('b'))]; + + yield [self::createTransformer(false), $expr->eq('a', 'b'), $expr->eq('a', $expr->upper('b'))]; + + yield [self::createTransformer(true, false), $expr->eq('a', 'b'), $expr->eq($expr->upper('a'), 'b')]; + + yield [self::createTransformer(false, false), $expr->eq('a', 'b'), $expr->eq('a', 'b')]; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateActiveFilterFormatterTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateActiveFilterFormatterTest.php new file mode 100644 index 00000000..95de79c2 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateActiveFilterFormatterTest.php @@ -0,0 +1,50 @@ +assertEquals('123', $this->format(123)); + } + + public function testItFormatsDateTimeToDefaultFormatIfInputFormatFormOptionIsNotGiven() + { + $this->assertEquals('2023-01-01', $this->format(new \DateTime('2023-01-01'))); + } + + public function testItFormatsDateTimeUsingInputFormatFormOption() + { + $this->assertEquals('2023/01/01', $this->format(new \DateTime('2023-01-01'), [ + 'form_options' => [ + 'input_format' => 'Y/m/d', + ], + ])); + } + + private function format(mixed $value, array $filterOptions = []): string + { + $formatter = new DateActiveFilterFormatter(); + $data = new FilterData($value); + + $filter = $this->createMock(FilterInterface::class); + $filterConfig = $this->createMock(FilterConfigInterface::class); + + $filterConfig->method('getOption')->willReturnCallback(function (string $name) use ($filterOptions) { + return $filterOptions[$name] ?? null; + }); + + $filter->method('getConfig')->willReturn($filterConfig); + + return ($formatter)($data, $filter); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatterTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatterTest.php new file mode 100644 index 00000000..4f925be3 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateRangeActiveFilterFormatterTest.php @@ -0,0 +1,65 @@ +formatter = new DateRangeActiveFilterFormatter(); + } + + public function testDateRangeWithFromDateOnly() + { + $filterData = new FilterData(['from' => $dateFrom = new \DateTime('2023-01-01'), 'to' => null]); + + $result = ($this->formatter)($filterData); + + $this->assertEquals( + new TranslatableMessage('After %date%', ['%date%' => $dateFrom->format('Y-m-d')], 'KreyuDataTable'), + $result, + ); + } + + public function testDateRangeWithToDateOnly() + { + $filterData = new FilterData(['from' => null, 'to' => $dateTo = new \DateTime('2023-01-01')]); + + $result = ($this->formatter)($filterData); + + $this->assertEquals( + new TranslatableMessage('Before %date%', ['%date%' => $dateTo->format('Y-m-d')], 'KreyuDataTable'), + $result, + ); + } + + public function testDateRangeWithEqualDates() + { + $date = new \DateTime('2023-01-20'); + $filterData = new FilterData(['from' => $date, 'to' => $date]); + + $result = ($this->formatter)($filterData); + + $this->assertEquals('2023-01-20', $result); + } + + public function testDateRangeWithBothDates() + { + $dateFrom = new \DateTime('2023-01-01'); + $dateTo = new \DateTime('2023-01-15'); + $filterData = new FilterData(['from' => $dateFrom, 'to' => $dateTo]); + + $result = ($this->formatter)($filterData); + + $this->assertEquals('2023-01-01 - 2023-01-15', $result); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatterTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatterTest.php new file mode 100644 index 00000000..6244c635 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/DateTimeActiveFilterFormatterTest.php @@ -0,0 +1,60 @@ +assertEquals('123', $this->format(123)); + } + + public function testItFormatsDateTimeToDefaultFormatIfInputFormatFormOptionIsNotGiven() + { + $this->assertEquals('2023-01-01 11:22:33', $this->format(new \DateTime('2023-01-01 11:22:33'))); + } + + public function testItFormatsDateTimeUsingInputFormatFormOption() + { + $this->assertEquals('2023/01/01 11/22/33', $this->format(new \DateTime('2023-01-01 11:22:33'), [ + 'form_options' => [ + 'input_format' => 'Y/m/d H/i/s', + ], + ])); + } + + public function testItFormatsDateTimeWithMinutesAndSecondsFormOptionsSetToFalse() + { + $this->assertEquals('2023-01-01 11', $this->format(new \DateTime('2023-01-01 11:22:33'), [ + 'form_options' => [ + 'with_minutes' => false, + 'with_seconds' => false, + ], + ])); + } + + private function format(mixed $value, array $filterOptions = []): string + { + $formatter = new DateTimeActiveFilterFormatter(); + $data = new FilterData($value); + + $filter = $this->createMock(FilterInterface::class); + $filterConfig = $this->createMock(FilterConfigInterface::class); + + $filterConfig->method('getOption')->willReturnCallback(function (string $name) use ($filterOptions) { + return $filterOptions[$name] ?? null; + }); + + $filter->method('getConfig')->willReturn($filterConfig); + + return ($formatter)($data, $filter); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatterTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatterTest.php new file mode 100644 index 00000000..9cbd7518 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Formatter/EntityActiveFilterFormatterTest.php @@ -0,0 +1,46 @@ +assertEquals('123', $this->format(123)); + } + + public function testItUsesChoiceLabelOptionAsPropertyPath() + { + $this->assertEquals('bar', $this->format(['foo' => 'bar'], ['choice_label' => '[foo]'])); + } + + public function testItUsesChoiceLabelOptionCallable() + { + $this->assertEquals('bar', $this->format('foo', ['choice_label' => fn () => 'bar'])); + } + + private function format(mixed $value, array $filterOptions = []): string + { + $formatter = new EntityActiveFilterFormatter(); + $data = new FilterData($value); + + $filter = $this->createMock(FilterInterface::class); + $filterConfig = $this->createMock(FilterConfigInterface::class); + + $filterConfig->method('getOption')->willReturnCallback(function (string $name) use ($filterOptions) { + return $filterOptions[$name] ?? null; + }); + + $filter->method('getConfig')->willReturn($filterConfig); + + return ($formatter)($data, $filter); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryTest.php new file mode 100644 index 00000000..6c3c7045 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/ParameterFactory/ParameterFactoryTest.php @@ -0,0 +1,162 @@ +filter = $this->createMock(FilterInterface::class); + $this->query = $this->createMock(DoctrineOrmProxyQueryInterface::class); + } + + public static function expectedParametersProvider(): iterable + { + yield 'equals' => [ + 'data' => new FilterData('foo', Operator::Equals), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'not equals' => [ + 'data' => new FilterData('foo', Operator::NotEquals), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'contains' => [ + 'data' => new FilterData('foo', Operator::Contains), + 'expected' => [ + new Parameter('_0', '%foo%'), + ], + ]; + + yield 'not contains' => [ + 'data' => new FilterData('foo', Operator::NotContains), + 'expected' => [ + new Parameter('_0', '%foo%'), + ], + ]; + + yield 'in' => [ + 'data' => new FilterData('foo', Operator::In), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'not in' => [ + 'data' => new FilterData('foo', Operator::NotIn), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'greater than' => [ + 'data' => new FilterData('foo', Operator::GreaterThan), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'greater than or equals' => [ + 'data' => new FilterData('foo', Operator::GreaterThanEquals), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'less than' => [ + 'data' => new FilterData('foo', Operator::LessThan), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'less than or equals' => [ + 'data' => new FilterData('foo', Operator::LessThanEquals), + 'expected' => [ + new Parameter('_0', 'foo'), + ], + ]; + + yield 'starts with' => [ + 'data' => new FilterData('foo', Operator::StartsWith), + 'expected' => [ + new Parameter('_0', 'foo%'), + ], + ]; + + yield 'ends with' => [ + 'data' => new FilterData('foo', Operator::EndsWith), + 'expected' => [ + new Parameter('_0', '%foo'), + ], + ]; + + yield 'between with "from" and "to" in value' => [ + 'data' => new FilterData(['from' => 'foo', 'to' => 'bar'], Operator::Between), + 'expected' => [ + 'from' => new Parameter('_0_from', 'foo'), + 'to' => new Parameter('_0_to', 'bar'), + ], + ]; + + yield 'between with "from" only in value' => [ + 'data' => new FilterData(['from' => 'foo'], Operator::Between), + 'expected' => [ + 'from' => new Parameter('_0_from', 'foo'), + ], + ]; + + yield 'between with "to" only in value' => [ + 'data' => new FilterData(['to' => 'foo'], Operator::Between), + 'expected' => [ + 'to' => new Parameter('_0_to', 'foo'), + ], + ]; + + yield 'between without neither "from" or "to" in value' => [ + 'data' => new FilterData([], Operator::Between), + 'expected' => [], + ]; + } + + public function testItCreatesParametersWithFilterNameAndUniqueParameterId() + { + $this->filter->method('getName')->willReturn('foo'); + $this->query->method('getUniqueParameterId')->willReturn(10); + + $parameters = $this->createParameters(); + + $this->assertEquals('foo_10', $parameters[0]->getName()); + } + + #[DataProvider('expectedParametersProvider')] + public function testItCreatesParametersBasedOnFilterData(FilterData $data, array $expected): void + { + $this->assertEquals($expected, $this->createParameters($data)); + } + + private function createParameters(FilterData $data = new FilterData()): array + { + return (new ParameterFactory())->create($this->query, $data, $this->filter); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterTypeTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterTypeTest.php new file mode 100644 index 00000000..22ac06f9 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/BooleanFilterTypeTest.php @@ -0,0 +1,78 @@ +createFilter(); + + $formatter = $filter->getConfig()->getOption('active_filter_formatter'); + + $this->assertEquals(new TranslatableMessage('Yes', domain: 'KreyuDataTable'), $formatter(new FilterData(true))); + $this->assertEquals(new TranslatableMessage('No', domain: 'KreyuDataTable'), $formatter(new FilterData(false))); + } + + public function testItShouldAddChoicesAndChoiceTranslationDomainFormOptionsWhenFormTypeIsChoiceType(): void + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $expectedFormOptions = $formOptions + [ + 'choices' => ['Yes' => true, 'No' => false], + 'choice_translation_domain' => 'KreyuDataTable', + ]; + + $this->assertEquals($expectedFormOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotOverwriteChoicesAndChoiceTranslationDomainFormOptionsIfGivenWhenFormTypeIsChoiceType(): void + { + $formOptions = [ + 'choices' => ['True' => true, 'False' => false], + 'choice_translation_domain' => 'App', + ]; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotModifyFormOptionsWhenFormTypeIsNotChoiceType(): void + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_type' => TextType::class, 'form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateFilterTypeTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateFilterTypeTest.php new file mode 100644 index 00000000..8b3bb0bd --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateFilterTypeTest.php @@ -0,0 +1,64 @@ + false]; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $expectedFormOptions = $formOptions + ['widget' => 'single_text']; + + $this->assertEquals($expectedFormOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotOverwriteWidgetFormOptionIfGivenWhenFormTypeIsDateType(): void + { + $formOptions = ['widget' => 'choice']; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotModifyFormOptionsWhenFormTypeIsNotDateType(): void + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_type' => TextType::class, 'form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterTypeTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterTypeTest.php new file mode 100644 index 00000000..3dee0411 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DateRangeFilterTypeTest.php @@ -0,0 +1,34 @@ + false]; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $expectedFormOptions = $formOptions + ['widget' => 'single_text']; + + $this->assertEquals($expectedFormOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotOverwriteWidgetFormOptionIfGivenWhenFormTypeIsDateType(): void + { + $formOptions = ['widget' => 'choice']; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotModifyFormOptionsWhenFormTypeIsNotDateType(): void + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_type' => TextType::class, 'form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterTypeTestCase.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterTypeTestCase.php new file mode 100644 index 00000000..d954bac4 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/DoctrineOrmFilterTypeTestCase.php @@ -0,0 +1,34 @@ +assertEquals($this->getDefaultOperator(), $this->createFilter()->getConfig()->getDefaultOperator()); + } + + public function testItShouldSupportOperators() + { + $this->assertEquals($this->getSupportedOperators(), $this->createFilter()->getConfig()->getSupportedOperators()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/EntityFilterTypeTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/EntityFilterTypeTest.php new file mode 100644 index 00000000..0bd9f132 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/EntityFilterTypeTest.php @@ -0,0 +1,124 @@ +classMetadata = $this->createMock(ClassMetadata::class); + + $manager = $this->createMock(ObjectManager::class); + $manager->method('getClassMetadata')->willReturn($this->classMetadata); + + $this->managerRegistry = $this->createMock(ManagerRegistry::class); + $this->managerRegistry->method('getManagerForClass')->willReturn($manager); + + parent::setUp(); + } + + protected function getTestedType(): string + { + return EntityFilterType::class; + } + + protected function getSupportedOperators(): array + { + return [ + Operator::Equals, + Operator::NotEquals, + Operator::In, + Operator::NotIn, + ]; + } + + protected function getDefaultFormType(): string + { + return EntityType::class; + } + + protected function getExtensions(): array + { + $type = new EntityFilterType($this->managerRegistry); + + return [ + new PreloadedFilterExtension([$type], []), + ]; + } + + public function testItShouldNotModifyFormOptionsWhenFormTypeIsNotEntityType() + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_type' => TextType::class, 'form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotModifyFormOptionsWhenClassFormOptionIsNotGiven() + { + $formOptions = ['trim' => false]; + + $filter = $this->createFilter(['form_options' => $formOptions]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldAddChoiceValueFormOptionWhenFormTypeIsEntityTypeAndClassFormOptionIsGiven(): void + { + $this->classMetadata->method('getIdentifier')->willReturn(['id']); + + $filter = $this->createFilter([ + 'form_options' => $formOptions = [ + 'class' => 'App\\Entity\\Product', + ], + ]); + + $expectedFormOptions = $formOptions + ['choice_value' => 'id']; + + $this->assertEquals($expectedFormOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotAddChoiceValueFormOptionWhenClassMetadataReturnsMultipleIdentifiers(): void + { + $this->classMetadata->method('getIdentifier')->willReturn(['id', 'uuid']); + + $filter = $this->createFilter([ + 'form_options' => $formOptions = [ + 'class' => 'App\\Entity\\Product', + ], + ]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } + + public function testItShouldNotOverwriteChoiceValueFormOptionIfAlreadyGiven(): void + { + $this->classMetadata->method('getIdentifier')->willReturn(['id']); + + $filter = $this->createFilter([ + 'form_options' => $formOptions = [ + 'class' => 'App\\Entity\\Product', + 'choice_value' => 'uuid', + ], + ]); + + $this->assertEquals($formOptions, $filter->getConfig()->getOption('form_options')); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/NumericFilterTypeTest.php b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/NumericFilterTypeTest.php new file mode 100644 index 00000000..ce7eabe0 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Filter/Type/NumericFilterTypeTest.php @@ -0,0 +1,34 @@ +name; + } + + public function getYear(): int + { + return $this->year; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Category.php b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Category.php new file mode 100644 index 00000000..f68b3ef7 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Category.php @@ -0,0 +1,29 @@ +id; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Product.php b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Product.php new file mode 100644 index 00000000..bf9719a4 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/Product.php @@ -0,0 +1,36 @@ +id; + } + + public function getName(): string + { + return $this->name; + } + + public function getCategory(): Category + { + return $this->category; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/ProductAttribute.php b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/ProductAttribute.php new file mode 100644 index 00000000..d4049430 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Entity/ProductAttribute.php @@ -0,0 +1,29 @@ +product; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Filter/ExpressionTransformer/CustomExpressionTransformer.php b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Filter/ExpressionTransformer/CustomExpressionTransformer.php new file mode 100644 index 00000000..f22e0ce9 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Fixtures/Filter/ExpressionTransformer/CustomExpressionTransformer.php @@ -0,0 +1,20 @@ + 'pdo_sqlite', + 'memory' => true, + ], $config); + + return new EntityManager( + $connection, + $config, + new EventManager(), + ); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryTest.php new file mode 100644 index 00000000..449b2739 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Paginator/PaginatorFactoryTest.php @@ -0,0 +1,154 @@ +paginatorFactory = new PaginatorFactory(); + } + + public function testCreateWithQueryBuilderWithoutRootEntities(): void + { + $queryBuilder = TestEntityManagerFactory::create()->createQueryBuilder(); + + $this->expectExceptionObject(new \RuntimeException('There are no root entities defined in the query.')); + + $this->paginatorFactory->create($queryBuilder); + } + + public function testCreateWithoutJoins(): void + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertFalse($paginator->getQuery()->getHint(CountWalker::HINT_DISTINCT)); + } + + public function testCreateWithHints(): void + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product'); + + $paginator = $this->paginatorFactory->create($queryBuilder, ['foo' => 'bar']); + + $this->assertEquals('bar', $paginator->getQuery()->getHint('foo')); + } + + public function testCreateWithSingleIdentifierAndJoins() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertTrue($paginator->getFetchJoinCollection()); + $this->assertFalse($paginator->getUseOutputWalkers()); + } + + public function testCreateWithCompositeIdentifier() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Car::class, 'car'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertFalse($paginator->getFetchJoinCollection()); + $this->assertNull($paginator->getUseOutputWalkers()); + } + + public function testCreateWithCompositeIdentifierAndJoins() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Car::class, 'car') + ->leftJoin(Product::class, 'product'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertFalse($paginator->getFetchJoinCollection()); + $this->assertNull($paginator->getUseOutputWalkers()); + } + + public function testCreateWithHaving() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->having('product.name <> "test"'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertNull($paginator->getUseOutputWalkers()); + } + + public function testCreateWithMultipleFrom() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->from(Category::class, 'category'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertNull($paginator->getUseOutputWalkers()); + } + + public function testCreateWithForeignKeyAsIdentifier() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(ProductAttribute::class, 'productAttribute'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertNull($paginator->getUseOutputWalkers()); + } + + public function testCreateWithJoinsAndOrderBy() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category') + ->orderBy('product.id', 'ASC'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertFalse($paginator->getUseOutputWalkers()); + } + + public function testCreateWithOrderByAssociation() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category') + ->orderBy('category.id', 'ASC'); + + $paginator = $this->paginatorFactory->create($queryBuilder); + + $this->assertNull($paginator->getUseOutputWalkers()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Query/AliasResolverTest.php b/tests/Unit/Bridge/Doctrine/Orm/Query/AliasResolverTest.php new file mode 100644 index 00000000..2d6569d1 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Query/AliasResolverTest.php @@ -0,0 +1,68 @@ +resolver = new AliasResolver(); + } + + #[DataProvider('provideResolveCases')] + public function testResolve(QueryBuilder $queryBuilder, string $queryPath, string $resolvedQueryPath): void + { + $this->assertEquals($resolvedQueryPath, $this->resolver->resolve($queryPath, $queryBuilder)); + } + + public static function provideResolveCases(): iterable + { + yield 'Without alias in query path' => [ + TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product'), + 'name', + 'product.name', + ]; + + yield 'With alias in query path' => [ + TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category'), + 'category.name', + 'category.name', + ]; + + yield 'With query path present in SELECT part' => [ + TestEntityManagerFactory::create() + ->createQueryBuilder() + ->addSelect('UPPER(product.name) AS product_name') + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category'), + 'product_name', + 'product_name', + ]; + + yield 'With query path present in SELECT part marked as HIDDEN' => [ + TestEntityManagerFactory::create() + ->createQueryBuilder() + ->addSelect('UPPER(product.name) AS HIDDEN product_name') + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category'), + 'product_name', + 'product_name', + ]; + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactoryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactoryTest.php new file mode 100644 index 00000000..8429596a --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryFactoryTest.php @@ -0,0 +1,41 @@ +factory = new DoctrineOrmProxyQueryFactory(); + } + + public function testCreatingWithSupportedData(): void + { + $queryBuilder = $this->createStub(QueryBuilder::class); + + $data = $this->factory->create($queryBuilder); + + $this->assertInstanceOf(DoctrineOrmProxyQuery::class, $data); + $this->assertEquals($queryBuilder, $data->getQueryBuilder()); + } + + public function testCreatingWithNotSupportedData(): void + { + $data = $this->createStub(Query::class); + + $this->expectExceptionObject(new UnexpectedTypeException($data, QueryBuilder::class)); + + $this->factory->create($data); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryTest.php new file mode 100644 index 00000000..66faad25 --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmProxyQueryTest.php @@ -0,0 +1,286 @@ +createMock(QueryBuilder::class); + $queryBuilder->expects($this->once())->method('getDQL'); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->getDQL(); + } + + public function testCloning() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertNotSame($proxyQuery->getQuery(), (clone $proxyQuery)->getQueryBuilder()); + } + + public function testSort() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product') + ->leftJoin('product.category', 'category') + ->orderBy('product.id', 'ASC'); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->sort(new SortingData([ + new SortingColumnData('product.name', 'ASC'), + new SortingColumnData('category.name', 'DESC'), + ])); + + $orderBy = $queryBuilder->getDQLPart('orderBy'); + + $this->assertCount(2, $orderBy); + $this->assertEquals(['product.name ASC'], $orderBy[0]->getParts()); + $this->assertEquals(['category.name DESC'], $orderBy[1]->getParts()); + } + + public function testPaginate() + { + $queryBuilder = TestEntityManagerFactory::create() + ->createQueryBuilder() + ->from(Product::class, 'product'); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->paginate(new PaginationData(1, 25)); + + $this->assertEquals(0, $queryBuilder->getFirstResult()); + $this->assertEquals(25, $queryBuilder->getMaxResults()); + } + + public function testGetResult() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $paginator = $this->createMock(Paginator::class); + $paginator->method('getQuery')->willReturn($this->createMock(Query::class)); + + $paginationFactory = $this->createMock(PaginatorFactoryInterface::class); + $paginationFactory->expects($this->once()) + ->method('create') + ->with($queryBuilder, ['foo' => 'bar']) + ->willReturn($paginator); + + $resultSetFactory = $this->createMock(DoctrineOrmResultSetFactoryInterface::class); + $resultSetFactory->expects($this->once()) + ->method('create') + ->with($paginator, 5000); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder, ['foo' => 'bar']); + $proxyQuery->setResultSetFactory($resultSetFactory); + $proxyQuery->setPaginatorFactory($paginationFactory); + $proxyQuery->getResult(); + } + + public function testGetQueryBuilder() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertSame($queryBuilder, $proxyQuery->getQueryBuilder()); + } + + public function testGetUniqueParameterId() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertEquals(0, $proxyQuery->getUniqueParameterId()); + $this->assertEquals(1, $proxyQuery->getUniqueParameterId()); + $this->assertEquals(2, $proxyQuery->getUniqueParameterId()); + } + + public function testGetHints() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder, ['foo' => 'bar']); + + $this->assertEquals(['foo' => 'bar'], $proxyQuery->getHints()); + } + + public function testSetHint() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setHint('foo', 'bar'); + + $this->assertEquals(['foo' => 'bar'], $proxyQuery->getHints()); + } + + public function testGetDefaultHydrationMode() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $query = $this->createMock(Query::class); + $query->expects($this->once())->method('setHydrationMode')->with(AbstractQuery::HYDRATE_OBJECT); + + $paginator = $this->createMock(Paginator::class); + $paginator->method('getQuery')->willReturn($query); + + $paginatorFactory = $this->createMock(PaginatorFactoryInterface::class); + $paginatorFactory->method('create')->willReturn($paginator); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setPaginatorFactory($paginatorFactory); + + $this->assertEquals(AbstractQuery::HYDRATE_OBJECT, $proxyQuery->getHydrationMode()); + + $proxyQuery->getResult(); + } + + public function testSetHydrationMode() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $query = $this->createMock(Query::class); + $query->expects($this->once())->method('setHydrationMode')->with(AbstractQuery::HYDRATE_ARRAY); + + $paginator = $this->createMock(Paginator::class); + $paginator->method('getQuery')->willReturn($query); + + $paginatorFactory = $this->createMock(PaginatorFactoryInterface::class); + $paginatorFactory->method('create')->willReturn($paginator); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setPaginatorFactory($paginatorFactory); + $proxyQuery->setHydrationMode(AbstractQuery::HYDRATE_ARRAY); + + $this->assertEquals(AbstractQuery::HYDRATE_ARRAY, $proxyQuery->getHydrationMode()); + + $proxyQuery->getResult(); + } + + public function testGetDefaultBatchSize() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertEquals(5000, $proxyQuery->getBatchSize()); + } + + public function testSetBatchSizeZero() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->expectExceptionObject(new InvalidArgumentException('The batch size must be positive.')); + + $proxyQuery->setBatchSize(0); + } + + public function testSetBatchSizeNegative() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->expectExceptionObject(new InvalidArgumentException('The batch size must be positive.')); + + $proxyQuery->setBatchSize(-1); + } + + public function testSetBatchSize() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setBatchSize(25); + + $this->assertEquals(25, $proxyQuery->getBatchSize()); + } + + public function testGetDefaultPaginatorFactory() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertInstanceOf(PaginatorFactory::class, $proxyQuery->getPaginatorFactory()); + } + + public function testSetPaginatorFactory() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $aliasResolver = $this->createMock(PaginatorFactoryInterface::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setPaginatorFactory($aliasResolver); + + $this->assertEquals($aliasResolver, $proxyQuery->getPaginatorFactory()); + } + + public function testGetDefaultAliasResolver() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertInstanceOf(AliasResolver::class, $proxyQuery->getAliasResolver()); + } + + public function testSetAliasResolver() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $aliasResolver = $this->createMock(AliasResolverInterface::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setAliasResolver($aliasResolver); + + $this->assertEquals($aliasResolver, $proxyQuery->getAliasResolver()); + } + + public function testGetDefaultResultSetFactory() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + + $this->assertInstanceOf(DoctrineOrmResultSetFactory::class, $proxyQuery->getResultSetFactory()); + } + + public function testSetResultSetFactory() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $resultSetFactory = $this->createMock(DoctrineOrmResultSetFactoryInterface::class); + + $proxyQuery = new DoctrineOrmProxyQuery($queryBuilder); + $proxyQuery->setResultSetFactory($resultSetFactory); + + $this->assertEquals($resultSetFactory, $proxyQuery->getResultSetFactory()); + } +} diff --git a/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryTest.php b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryTest.php new file mode 100644 index 00000000..5b609c5d --- /dev/null +++ b/tests/Unit/Bridge/Doctrine/Orm/Query/DoctrineOrmResultSetFactoryTest.php @@ -0,0 +1,87 @@ +createMock(Paginator::class); + $paginator->method('count')->willReturn(25); + $paginator->method('getIterator')->willReturn(new \ArrayIterator(array_fill(0, 25, 'item'))); + + $query = $this->createMock(Query::class); + $query->method('getFirstResult')->willReturn(0); + $query->method('getMaxResults')->willReturn(null); + $query->method('getEntityManager')->willReturn($this->createMock(EntityManagerInterface::class)); + + $paginator->method('getQuery')->willReturn($query); + + $factory = new DoctrineOrmResultSetFactory(); + + $resultSet = $factory->create($paginator); + + $this->assertInstanceOf(RewindableGeneratorIterator::class, $resultSet->getIterator()); + $this->assertEquals(25, iterator_count($resultSet->getIterator())); + $this->assertEquals(25, $resultSet->getCurrentPageItemCount()); + $this->assertEquals(25, $resultSet->getTotalItemCount()); + } + + public function testCreateWithLimit() + { + $paginator = $this->createMock(Paginator::class); + $paginator->method('count')->willReturn(1000); + $paginator->method('getIterator')->willReturn(new \ArrayIterator(array_fill(0, 25, 'item'))); + + $query = $this->createMock(Query::class); + $query->method('getFirstResult')->willReturn(0); + $query->method('getMaxResults')->willReturn(25); + $query->method('getEntityManager')->willReturn($this->createMock(EntityManagerInterface::class)); + + $paginator->method('getQuery')->willReturn($query); + + $factory = new DoctrineOrmResultSetFactory(); + + $resultSet = $factory->create($paginator); + + $this->assertInstanceOf(\ArrayIterator::class, $resultSet->getIterator()); + $this->assertEquals(25, iterator_count($resultSet->getIterator())); + $this->assertEquals(25, $resultSet->getCurrentPageItemCount()); + $this->assertEquals(1000, $resultSet->getTotalItemCount()); + } + + public function testCreateWithLimitGreaterThanBatchSize() + { + $paginator = $this->createMock(Paginator::class); + $paginator->method('count')->willReturn(1000); + $paginator->method('getIterator')->willReturn(new \ArrayIterator(array_fill(0, 25, 'item'))); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->exactly(4))->method('clear'); + + $query = $this->createMock(Query::class); + $query->method('getFirstResult')->willReturn(0); + $query->method('getMaxResults')->willReturn(100); + $query->method('getEntityManager')->willReturn($entityManager); + + $paginator->method('getQuery')->willReturn($query); + + $factory = new DoctrineOrmResultSetFactory(); + + $resultSet = $factory->create($paginator, 25); + + $this->assertInstanceOf(\ArrayIterator::class, $resultSet->getIterator()); + $this->assertEquals(100, iterator_count($resultSet->getIterator())); + $this->assertEquals(100, $resultSet->getCurrentPageItemCount()); + $this->assertEquals(1000, $resultSet->getTotalItemCount()); + } +}