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 00000000..9168bd1c Binary files /dev/null and b/docs/src/public/action_confirmation_modal.png differ diff --git a/docs/src/public/export_modal.png b/docs/src/public/export_modal.png new file mode 100644 index 00000000..d6bc07b1 Binary files /dev/null and b/docs/src/public/export_modal.png differ diff --git a/docs/src/public/logo.png b/docs/src/public/logo.png new file mode 100644 index 00000000..d47d7cd7 Binary files /dev/null and b/docs/src/public/logo.png differ diff --git a/docs/src/public/personalization_modal.png b/docs/src/public/personalization_modal.png new file mode 100644 index 00000000..78c0f5de Binary files /dev/null and b/docs/src/public/personalization_modal.png differ diff --git a/docs/src/public/search_filter_type.png b/docs/src/public/search_filter_type.png new file mode 100644 index 00000000..407a19e2 Binary files /dev/null and b/docs/src/public/search_filter_type.png differ 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()); + } +}