From 7cadde7b0d4a6206aff89733cc90a3d4e24a2ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Wr=C3=B3blewski?= Date: Tue, 30 Jul 2024 18:03:15 +0200 Subject: [PATCH] URL-oriented improvements (#117) * data table view `url_query_parameters` variable with an array of URL parameters for the specific data table * improved url generators for filter clearing buttons, column sorting and (new) pagination controls * new `state` Stimulus controller in place of previous `persistence` to load the state of the data table - currently it adds `url_query_parameters` to URL in the browser --- assets/controllers/persistence.js | 23 ---- assets/controllers/state.js | 41 +++++++ assets/package.json | 4 +- docs/src/docs/features/filtering.md | 4 +- docs/src/docs/features/pagination.md | 19 +++ docs/src/docs/features/sorting.md | 19 +++ docs/src/docs/installation.md | 2 +- src/Column/ColumnSortUrlGenerator.php | 12 +- .../ColumnSortUrlGeneratorInterface.php | 4 +- .../KreyuDataTableExtension.php | 1 + src/Filter/FilterClearUrlGenerator.php | 23 +++- .../FilterClearUrlGeneratorInterface.php | 4 +- src/Pagination/PaginationUrlGenerator.php | 48 ++++++++ .../PaginationUrlGeneratorInterface.php | 12 ++ src/Pagination/PaginationView.php | 1 + src/Resources/config/pagination.php | 23 ++++ src/Resources/config/twig.php | 1 + src/Resources/views/themes/base.html.twig | 48 +++----- .../views/themes/bootstrap_5.html.twig | 4 +- src/Twig/DataTableExtension.php | 16 ++- src/Type/DataTableType.php | 37 ++++++ .../Column/ColumnSortUrlGeneratorTest.php | 88 ++++++++++--- .../Filter/FilterClearUrlGeneratorTest.php | 105 ++++++++++++---- .../Pagination/PaginationUrlGeneratorTest.php | 116 ++++++++++++++++++ tests/Unit/Twig/DataTableExtensionTest.php | 2 + 25 files changed, 543 insertions(+), 114 deletions(-) delete mode 100644 assets/controllers/persistence.js create mode 100644 assets/controllers/state.js create mode 100644 src/Pagination/PaginationUrlGenerator.php create mode 100644 src/Pagination/PaginationUrlGeneratorInterface.php create mode 100644 src/Resources/config/pagination.php create mode 100644 tests/Unit/Pagination/PaginationUrlGeneratorTest.php diff --git a/assets/controllers/persistence.js b/assets/controllers/persistence.js deleted file mode 100644 index 1cecb115..00000000 --- a/assets/controllers/persistence.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Controller } from '@hotwired/stimulus' - -export default class extends Controller { - static targets = ['form']; - - connect() { - this.#loadFormsState(); - } - - #loadFormsState() { - const url = new URL(window.location.href); - - for (const form of this.formTargets) { - const formData = new FormData(form); - - for (const [key, value] of formData) { - url.searchParams.set(key, String(value)); - } - } - - window.history.replaceState(null, null, url); - } -} diff --git a/assets/controllers/state.js b/assets/controllers/state.js new file mode 100644 index 00000000..c177a7d3 --- /dev/null +++ b/assets/controllers/state.js @@ -0,0 +1,41 @@ +import { Controller } from '@hotwired/stimulus' + +export default class extends Controller { + static values = { + urlQueryParameters: Object, + } + + connect() { + this.#appendUrlQueryParameters(); + } + + #appendUrlQueryParameters() { + const url = new URL(window.location.href); + + const parameters = this.#flattenParameters(this.urlQueryParametersValue); + + for (const [key, value] of Object.entries(parameters)) { + if (!url.searchParams.has(key)) { + url.searchParams.set(key, String(value)); + } + } + + window.history.replaceState(null, null, url); + } + + #flattenParameters(input, keyName) { + let result = {}; + + for (const key in input) { + const newKey = keyName ? `${keyName}[${key}]` : key; + + if (typeof input[key] === "object" && !Array.isArray(input[key])) { + result = {...result, ...this.#flattenParameters(input[key], newKey)} + } else { + result[newKey] = input[key]; + } + } + + return result; + } +} diff --git a/assets/package.json b/assets/package.json index d0e6baff..36305897 100755 --- a/assets/package.json +++ b/assets/package.json @@ -15,8 +15,8 @@ "fetch": "eager", "enabled": true }, - "persistence": { - "main": "controllers/persistence.js", + "state": { + "main": "controllers/state.js", "fetch": "eager", "enabled": true } diff --git a/docs/src/docs/features/filtering.md b/docs/src/docs/features/filtering.md index 56b91d0d..924533b0 100644 --- a/docs/src/docs/features/filtering.md +++ b/docs/src/docs/features/filtering.md @@ -180,14 +180,14 @@ class ProductController extends AbstractController By default, the filters loaded from the persistence are not visible in the URL. -It is recommended to make sure the **persistence** controller is enabled in your `assets/controllers.json`, +It is recommended to make sure the **state** controller is enabled in your `assets/controllers.json`, which will automatically append the filters to the URL, even if multiple data tables are visible on the same page. ```json { "controllers": { "@kreyu/data-table-bundle": { - "persistence": { + "state": { "enabled": true } } diff --git a/docs/src/docs/features/pagination.md b/docs/src/docs/features/pagination.md index e76368bb..42c89cf0 100644 --- a/docs/src/docs/features/pagination.md +++ b/docs/src/docs/features/pagination.md @@ -176,6 +176,25 @@ class ProductController extends AbstractController ``` ::: +### Adding pagination loaded from persistence to URL + +By default, the pagination loaded from the persistence is not visible in the URL. + +It is recommended to make sure the **state** controller is enabled in your `assets/controllers.json`, +which will automatically append the pagination parameters to the URL, even if multiple data tables are visible on the same page. + +```json +{ + "controllers": { + "@kreyu/data-table-bundle": { + "state": { + "enabled": true + } + } + } +} +``` + ## Default pagination The default pagination data can be overridden using the data table builder's `setDefaultPaginationData()` method: diff --git a/docs/src/docs/features/sorting.md b/docs/src/docs/features/sorting.md index 9473041b..1e934a59 100644 --- a/docs/src/docs/features/sorting.md +++ b/docs/src/docs/features/sorting.md @@ -245,6 +245,25 @@ class ProductController extends AbstractController ``` ::: +### Adding sorting loaded from persistence to URL + +By default, the sorting loaded from the persistence is not visible in the URL. + +It is recommended to make sure the **state** controller is enabled in your `assets/controllers.json`, +which will automatically append the sorting parameters to the URL, even if multiple data tables are visible on the same page. + +```json +{ + "controllers": { + "@kreyu/data-table-bundle": { + "state": { + "enabled": true + } + } + } +} +``` + ## Default sorting The default sorting data can be overridden using the data table builder's `setDefaultSortingData()` method: diff --git a/docs/src/docs/installation.md b/docs/src/docs/installation.md index d4d8603a..e5398f27 100644 --- a/docs/src/docs/installation.md +++ b/docs/src/docs/installation.md @@ -56,7 +56,7 @@ Now, add `@kreyu/data-table-bundle` controllers to your `assets/controllers.json "personalization": { "enabled": true }, - "persistence": { + "state": { "enabled": true }, "batch": { diff --git a/src/Column/ColumnSortUrlGenerator.php b/src/Column/ColumnSortUrlGenerator.php index f0558af2..a7c1d5ae 100644 --- a/src/Column/ColumnSortUrlGenerator.php +++ b/src/Column/ColumnSortUrlGenerator.php @@ -4,6 +4,7 @@ namespace Kreyu\Bundle\DataTableBundle\Column; +use Kreyu\Bundle\DataTableBundle\DataTableView; use Kreyu\Bundle\DataTableBundle\Exception\LogicException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -17,7 +18,7 @@ public function __construct( ) { } - public function generate(ColumnHeaderView ...$columnHeaderViews): string + public function generate(DataTableView $dataTableView, ColumnHeaderView ...$columnHeaderViews): string { $request = $this->getRequest(); @@ -27,10 +28,19 @@ public function generate(ColumnHeaderView ...$columnHeaderViews): string $parameters = [...$routeParams, ...$queryParams]; + // Recursively replace/merge with the URL query parameters defined in the data table view. + // This allows the user to define custom query parameters that should be preserved when sorting columns. + $parameters = array_replace_recursive($parameters, $dataTableView->vars['url_query_parameters'] ?? []); + foreach ($columnHeaderViews as $columnHeaderView) { $parameters = array_replace_recursive($parameters, $this->getColumnSortQueryParameters($columnHeaderView)); } + // Clearing the filters should reset the pagination to the first page. + if ($dataTableView->vars['pagination_enabled']) { + $parameters[$dataTableView->vars['page_parameter_name']] = 1; + } + return $this->urlGenerator->generate($route, $parameters); } diff --git a/src/Column/ColumnSortUrlGeneratorInterface.php b/src/Column/ColumnSortUrlGeneratorInterface.php index ee55b77b..9f422a74 100644 --- a/src/Column/ColumnSortUrlGeneratorInterface.php +++ b/src/Column/ColumnSortUrlGeneratorInterface.php @@ -4,7 +4,9 @@ namespace Kreyu\Bundle\DataTableBundle\Column; +use Kreyu\Bundle\DataTableBundle\DataTableView; + interface ColumnSortUrlGeneratorInterface { - public function generate(ColumnHeaderView $columnHeaderView): string; + public function generate(DataTableView $dataTableView, ColumnHeaderView ...$columnHeaderView): string; } diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index b9e00e56..69fb45c4 100755 --- a/src/DependencyInjection/KreyuDataTableExtension.php +++ b/src/DependencyInjection/KreyuDataTableExtension.php @@ -52,6 +52,7 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('actions.php'); $loader->load('exporter.php'); $loader->load('filtration.php'); + $loader->load('pagination.php'); $loader->load('personalization.php'); $loader->load('twig.php'); diff --git a/src/Filter/FilterClearUrlGenerator.php b/src/Filter/FilterClearUrlGenerator.php index 437e8d2c..762e95fa 100644 --- a/src/Filter/FilterClearUrlGenerator.php +++ b/src/Filter/FilterClearUrlGenerator.php @@ -4,6 +4,7 @@ namespace Kreyu\Bundle\DataTableBundle\Filter; +use Kreyu\Bundle\DataTableBundle\DataTableView; use Kreyu\Bundle\DataTableBundle\Exception\LogicException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -17,7 +18,7 @@ public function __construct( ) { } - public function generate(FilterView ...$filterViews): string + public function generate(DataTableView $dataTableView, FilterView ...$filterViews): string { $request = $this->getRequest(); @@ -27,23 +28,35 @@ public function generate(FilterView ...$filterViews): string $parameters = [...$routeParams, ...$queryParams]; + // Recursively replace/merge with the URL query parameters defined in the data table view. + // This allows the user to define custom query parameters that should be preserved when clearing filters. + $parameters = array_replace_recursive($parameters, $dataTableView->vars['url_query_parameters'] ?? []); + foreach ($filterViews as $filterView) { $parameters = array_replace_recursive($parameters, $this->getFilterClearQueryParameters($filterView)); } + // Clearing the filters should reset the pagination to the first page. + if ($dataTableView->vars['pagination_enabled']) { + $parameters[$dataTableView->vars['page_parameter_name']] = 1; + } + return $this->urlGenerator->generate($route, $parameters); } private function getFilterClearQueryParameters(FilterView $filterView): array { + $parameters = ['value' => '']; + + if ($filterView->vars['operator_selectable']) { + $parameters['operator'] = null; + } + $dataTableView = $filterView->parent; return [ $dataTableView->vars['filtration_parameter_name'] => [ - $filterView->vars['name'] => [ - 'value' => '', - 'operator' => null, - ], + $filterView->vars['name'] => $parameters, ], ]; } diff --git a/src/Filter/FilterClearUrlGeneratorInterface.php b/src/Filter/FilterClearUrlGeneratorInterface.php index 2338421e..65c68eb9 100644 --- a/src/Filter/FilterClearUrlGeneratorInterface.php +++ b/src/Filter/FilterClearUrlGeneratorInterface.php @@ -4,7 +4,9 @@ namespace Kreyu\Bundle\DataTableBundle\Filter; +use Kreyu\Bundle\DataTableBundle\DataTableView; + interface FilterClearUrlGeneratorInterface { - public function generate(FilterView ...$filterViews): string; + public function generate(DataTableView $dataTableView, FilterView ...$filterViews): string; } diff --git a/src/Pagination/PaginationUrlGenerator.php b/src/Pagination/PaginationUrlGenerator.php new file mode 100644 index 00000000..3a11b198 --- /dev/null +++ b/src/Pagination/PaginationUrlGenerator.php @@ -0,0 +1,48 @@ +getRequest(); + + $route = $request->attributes->get('_route'); + $routeParams = $request->attributes->get('_route_params', []); + $queryParams = $request->query->all(); + + $parameters = [...$routeParams, ...$queryParams]; + + // Recursively replace/merge with the URL query parameters defined in the data table view. + // This allows the user to define custom query parameters that should be preserved when changing pages. + $parameters = array_replace_recursive($parameters, $dataTableView->vars['url_query_parameters'] ?? []); + + $parameters[$dataTableView->vars['page_parameter_name']] = $page; + + return $this->urlGenerator->generate($route, $parameters); + } + + private function getRequest(): Request + { + if (null === $request = $this->requestStack->getCurrentRequest()) { + throw new LogicException('Unable to retrieve current request.'); + } + + return $request; + } +} diff --git a/src/Pagination/PaginationUrlGeneratorInterface.php b/src/Pagination/PaginationUrlGeneratorInterface.php new file mode 100644 index 00000000..93205cee --- /dev/null +++ b/src/Pagination/PaginationUrlGeneratorInterface.php @@ -0,0 +1,12 @@ +vars = [ + 'data_table' => $this->parent, 'page_parameter_name' => $this->parent->vars['page_parameter_name'], 'current_page_number' => $pagination->getCurrentPageNumber(), 'current_page_item_count' => $pagination->getCurrentPageItemCount(), diff --git a/src/Resources/config/pagination.php b/src/Resources/config/pagination.php new file mode 100644 index 00000000..d370c40a --- /dev/null +++ b/src/Resources/config/pagination.php @@ -0,0 +1,23 @@ +services(); + + $services + ->set('kreyu_data_table.pagination.url_generator', PaginationUrlGenerator::class) + ->args([ + service('request_stack'), + service(UrlGeneratorInterface::class), + ]) + ->alias(PaginationUrlGeneratorInterface::class, 'kreyu_data_table.pagination.url_generator') + ; +}; diff --git a/src/Resources/config/twig.php b/src/Resources/config/twig.php index e6512217..696cbd32 100755 --- a/src/Resources/config/twig.php +++ b/src/Resources/config/twig.php @@ -16,6 +16,7 @@ ->args([ service('kreyu_data_table.column.column_sort_url_generator'), service('kreyu_data_table.filter.filter_clear_url_generator'), + service('kreyu_data_table.pagination.url_generator'), ]) ; }; diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index d6cb3935..cb33130e 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -3,13 +3,17 @@ {# Base HTML Theme #} {% block kreyu_data_table %} - {% set stimulus_controllers = ['kreyu--data-table-bundle--persistence'] %} + {% set stimulus_controllers = ['kreyu--data-table-bundle--state'] %} {% if has_batch_actions %} {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %} {% endif %} - + {{ block('action_bar') }} {{ block('table') }} @@ -20,13 +24,17 @@ {% endblock %} {% block kreyu_data_table_form_aware %} - {% set stimulus_controllers = ['kreyu--data-table-bundle--persistence'] %} + {% set stimulus_controllers = ['kreyu--data-table-bundle--state'] %} {% if has_batch_actions %} {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %} {% endif %} - + {{ block('action_bar') }} {{ form_start(form, form_variables) }} @@ -143,11 +151,11 @@ {% block pagination_controls %} {%- if has_previous_page -%} - {% with { path: path(app.request.get('_route'), app.request.attributes.get('_route_params')|merge(app.request.query.all)|merge({ (page_parameter_name): 1 })) } %} + {% with { path: data_table_pagination_url(data_table, 1) } %} {{ block('pagination_first', theme) }} {% endwith %} - {% with { path: path(app.request.get('_route'), app.request.attributes.get('_route_params')|merge(app.request.query.all)|merge({ (page_parameter_name): current_page_number - 1 })) } %} + {% with { path: data_table_pagination_url(data_table, current_page_number - 1) } %} {{ block('pagination_previous', theme) }} {% endwith %} {%- else -%} @@ -159,18 +167,18 @@ {% if page_number == current_page_number %} {{ block('pagination_page_active', theme) }} {% else %} - {% with { path: path(app.request.get('_route'), app.request.attributes.get('_route_params')|merge(app.request.query.all)|merge({ (page_parameter_name): page_number })) } %} + {% with { path: data_table_pagination_url(data_table, page_number) } %} {{ block('pagination_page', theme) }} {% endwith %} {% endif %} {% endfor %} {%- if has_next_page -%} - {% with { path: path(app.request.get('_route'), app.request.attributes.get('_route_params')|merge(app.request.query.all)|merge({ (page_parameter_name): current_page_number + 1 })) } %} + {% with { path: data_table_pagination_url(data_table, current_page_number + 1) } %} {{ block('pagination_next', theme) }} {% endwith %} - {% with { path: path(app.request.get('_route'), app.request.attributes.get('_route_params')|merge(app.request.query.all)|merge({ (page_parameter_name): page_count })) } %} + {% with { path: data_table_pagination_url(data_table, page_count) } %} {{ block('pagination_last', theme) }} {% endwith %} {%- else -%} @@ -262,14 +270,7 @@ {% block kreyu_data_table_filters_form %} {% form_theme form with form_themes|default([_self]) %} - {{ form_start(form, { - attr: { - 'data-turbo-action': 'advance', - 'data-turbo-frame': '_self', - 'data-kreyu--data-table-bundle--persistence-target': 'form', - 'hidden': 'hidden', - } - }) }} + {{ form_start(form, { attr: { 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', 'hidden': 'hidden' } }) }} {# This form should be empty - all its inputs should be on the outside, referenced using the "form" attribute #} {{ form_end(form, { render_rest: false }) }} @@ -317,24 +318,13 @@ {% set label_attr = label_attr|default({}) %} {% if data_table.vars.sorting_enabled and sortable %} - {% set current_sort_field = sorting_field_data.name|default(null) %} - {% set current_sort_direction = sorting_field_data.direction|default(null) %} - {% set active_attr = active_attr|default({}) %} {% set inactive_attr = inactive_attr|default({}) %} - {% set attr = attr|default({}) %} {% set attr = attr|merge(sorted ? active_attr : inactive_attr) %} - {% set query_params = app.request.query.all %} - {% set query_params = query_params|merge({ (sort_parameter_name): { - (name): sort_direction|lower == 'desc' ? 'asc' : 'desc' - } }) %} - - {% set query_params = app.request.attributes.get('_route_params')|merge(query_params) %} - - {% set label_attr = { href: path(app.request.get('_route'), query_params) }|merge(label_attr) %} + {% set label_attr = { href: data_table_column_sort_url(data_table, column) }|merge(label_attr) %} {% set label_attr = { 'data-turbo-action': 'advance', 'data-turbo-frame': '_self' }|merge(label_attr) %} diff --git a/src/Resources/views/themes/bootstrap_5.html.twig b/src/Resources/views/themes/bootstrap_5.html.twig index 9c385d7d..b0367327 100755 --- a/src/Resources/views/themes/bootstrap_5.html.twig +++ b/src/Resources/views/themes/bootstrap_5.html.twig @@ -199,7 +199,7 @@ {% block filter_clear_all_button %} {% set attr = { 'class': 'btn btn-icon btn-outline-danger', - 'href': data_table_filter_clear_url(filters), + 'href': data_table_filter_clear_url(data_table, filters), 'data-toggle': 'tooltip', 'data-placement': 'bottom', 'title': 'Clear all filters'|trans({}, 'KreyuDataTable'), @@ -211,7 +211,7 @@ {% block filter_clear_button %} generateFilterClearUrl(...)), new TwigFunction('data_table_column_sort_url', $this->generateColumnSortUrl(...)), + new TwigFunction('data_table_pagination_url', $this->generatePaginationUrl(...)), ]; foreach ($definitions as $name => $callable) { @@ -307,22 +310,27 @@ public function renderExportForm(Environment $environment, FormInterface|FormVie ); } - public function generateFilterClearUrl(FilterView|array $filterViews): string + public function generateFilterClearUrl(DataTableView $dataTableView, FilterView|array $filterViews): string { if ($filterViews instanceof FilterView) { $filterViews = [$filterViews]; } - return $this->filterClearUrlGenerator->generate(...$filterViews); + return $this->filterClearUrlGenerator->generate($dataTableView, ...$filterViews); } - public function generateColumnSortUrl(ColumnHeaderView|array $columnHeaderViews): string + public function generateColumnSortUrl(DataTableView $dataTableView, ColumnHeaderView|array $columnHeaderViews): string { if ($columnHeaderViews instanceof ColumnHeaderView) { $columnHeaderViews = [$columnHeaderViews]; } - return $this->columnSortUrlGenerator->generate(...$columnHeaderViews); + return $this->columnSortUrlGenerator->generate($dataTableView, ...$columnHeaderViews); + } + + public function generatePaginationUrl(DataTableView $dataTableView, int $page): string + { + return $this->paginationUrlGenerator->generate($dataTableView, $page); } /** diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index da3c7ae3..7795f002 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -81,6 +81,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar $visibleColumns = $dataTable->getVisibleColumns(); $view->vars = array_replace($view->vars, [ + 'data_table' => $view, 'themes' => $dataTable->getConfig()->getThemes(), 'title' => $options['title'], 'title_translation_parameters' => $options['title_translation_parameters'], @@ -132,6 +133,8 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar if ($dataTable->getConfig()->isExportingEnabled()) { $view->vars['export_form'] = $this->createExportFormView($view, $dataTable); } + + $view->vars['url_query_parameters'] = $this->getUrlQueryParameters($view, $dataTable); } public function buildExportView(DataTableView $view, DataTableInterface $dataTable, array $options): void @@ -389,4 +392,38 @@ private function createFormView(FormInterface $form, DataTableView $view, DataTa return $formView; } + + private function getUrlQueryParameters(DataTableView $view, DataTableInterface $dataTable): array + { + $parameters = []; + + if ($dataTable->getConfig()->isFiltrationEnabled()) { + foreach ($view->filters as $filterView) { + if (null === $filterView->data || !$filterView->data->hasValue()) { + continue; + } + + $filterParameter = ['value' => $filterView->data->getValue()]; + + if ($filterView->vars['operator_selectable']) { + $filterParameter['operator'] = $filterView->data->getOperator()?->value; + } + + $parameters[$dataTable->getConfig()->getFiltrationParameterName()][$filterView->vars['name']] = $filterParameter; + } + } + + if ($dataTable->getConfig()->isPaginationEnabled()) { + $parameters[$dataTable->getConfig()->getPageParameterName()] = $view->pagination->vars['current_page_number']; + $parameters[$dataTable->getConfig()->getPerPageParameterName()] = $view->pagination->vars['item_number_per_page']; + } + + if ($dataTable->getConfig()->isSortingEnabled()) { + foreach ($view->vars['sorting_data']->getColumns() as $sortingColumnData) { + $parameters[$dataTable->getConfig()->getSortParameterName()][$sortingColumnData->getName()] = $sortingColumnData->getDirection(); + } + } + + return $parameters; + } } diff --git a/tests/Unit/Column/ColumnSortUrlGeneratorTest.php b/tests/Unit/Column/ColumnSortUrlGeneratorTest.php index c03fa9df..5d4a2880 100644 --- a/tests/Unit/Column/ColumnSortUrlGeneratorTest.php +++ b/tests/Unit/Column/ColumnSortUrlGeneratorTest.php @@ -20,6 +20,8 @@ class ColumnSortUrlGeneratorTest extends TestCase { private const ROUTE_NAME = 'users_index'; private const DATA_TABLE_NAME = 'users'; + private const PAGE_PARAMETER_NAME = 'page_'.self::DATA_TABLE_NAME; + private const SORT_PARAMETER_NAME = 'sort_'.self::DATA_TABLE_NAME; private MockObject&Request $request; private MockObject&RequestStack $requestStack; @@ -38,10 +40,41 @@ protected function setUp(): void $this->urlGenerator->method('generate')->willReturn(''); } - public function testItGeneratesUrlWithOppositeDirection(): void + public function testItPreservesRouteParams() + { + $this->request->attributes->set('_route_params', ['id' => 1]); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'id' => 1, + ]); + + $this->generate(); + } + + public function testItPreservesQueryParams() + { + $this->request->query->set('action', 'list'); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'action' => 'list', + ]); + + $this->generate(); + } + + public function testItPreservesDataTableUrlQueryParameters() + { + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'foo' => 'bar', + ]); + + $this->generate($this->createDataTableViewMock(['foo' => 'bar'])); + } + + public function testItGeneratesWithOppositeDirections(): void { $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - self::DATA_TABLE_NAME => [ + self::SORT_PARAMETER_NAME => [ 'firstName' => 'asc', 'middleName' => 'desc', 'lastName' => 'asc', @@ -49,55 +82,78 @@ public function testItGeneratesUrlWithOppositeDirection(): void ]); $this->generate( + $this->createDataTableViewMock(), $this->createColumnHeaderViewMock('firstName', null), $this->createColumnHeaderViewMock('middleName', 'asc'), $this->createColumnHeaderViewMock('lastName', 'desc'), ); } - public function testItGeneratesWithoutColumnHeaderViews(): void + public function testItOverridesCurrentPageNumberToFirst() { - $this->request->attributes->set('_route_params', ['id' => 1]); - $this->request->query->set('action', 'list'); + $this->request->query->set(self::PAGE_PARAMETER_NAME, 3); + + $dataTableView = $this->createDataTableViewMock([self::PAGE_PARAMETER_NAME => 2]); + $dataTableView->vars['pagination_enabled'] = true; $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - 'id' => 1, - 'action' => 'list', + self::PAGE_PARAMETER_NAME => 1, ]); - $this->generate(); + $this->generate($dataTableView); } - public function testItMergesWithRouteAndQueryParameters(): void + public function testItMergesEverythingTogether(): void { $this->request->attributes->set('_route_params', ['id' => 1]); $this->request->query->set('action', 'list'); $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - self::DATA_TABLE_NAME => [ - 'firstName' => 'asc', - ], 'id' => 1, 'action' => 'list', + 'foo' => 'bar', + self::PAGE_PARAMETER_NAME => 1, + self::SORT_PARAMETER_NAME => [ + 'firstName' => 'asc', + 'middleName' => 'desc', + 'lastName' => 'asc', + ], ]); + $dataTableView = $this->createDataTableViewMock(['foo' => 'bar']); + $dataTableView->vars['pagination_enabled'] = true; + $this->generate( + $dataTableView, $this->createColumnHeaderViewMock('firstName', null), + $this->createColumnHeaderViewMock('middleName', 'asc'), + $this->createColumnHeaderViewMock('lastName', 'desc'), ); } - private function generate(MockObject&ColumnHeaderView ...$columnHeaderViews): void + private function generate(?DataTableView $dataTableView = null, ColumnHeaderView ...$columnHeaderViews): void { + $dataTableView ??= $this->createDataTableViewMock(); + $columnSortUrlGenerator = new ColumnSortUrlGenerator($this->requestStack, $this->urlGenerator); - $columnSortUrlGenerator->generate(...$columnHeaderViews); + $columnSortUrlGenerator->generate($dataTableView, ...$columnHeaderViews); + } + + private function createDataTableViewMock(array $urlQueryParameters = []): DataTableView + { + $dataTableView = $this->createMock(DataTableView::class); + $dataTableView->vars['sort_parameter_name'] = self::SORT_PARAMETER_NAME; + $dataTableView->vars['page_parameter_name'] = self::PAGE_PARAMETER_NAME; + $dataTableView->vars['url_query_parameters'] = $urlQueryParameters; + + return $dataTableView; } private function createColumnHeaderViewMock(string $name, ?string $direction): MockObject&ColumnHeaderView { $columnHeaderView = $this->createMock(ColumnHeaderView::class); $columnHeaderView->parent = $this->createMock(HeaderRowView::class); - $columnHeaderView->parent->parent = $this->createMock(DataTableView::class); - $columnHeaderView->parent->parent->vars['sort_parameter_name'] = self::DATA_TABLE_NAME; + $columnHeaderView->parent->parent = $this->createDataTableViewMock(); $columnHeaderView->vars['name'] = $name; $columnHeaderView->vars['sort_direction'] = $direction; diff --git a/tests/Unit/Filter/FilterClearUrlGeneratorTest.php b/tests/Unit/Filter/FilterClearUrlGeneratorTest.php index 7fb92471..75f862e7 100644 --- a/tests/Unit/Filter/FilterClearUrlGeneratorTest.php +++ b/tests/Unit/Filter/FilterClearUrlGeneratorTest.php @@ -19,6 +19,8 @@ class FilterClearUrlGeneratorTest extends TestCase { private const ROUTE_NAME = 'users_index'; private const DATA_TABLE_NAME = 'users'; + private const PAGE_PARAMETER_NAME = 'page_'.self::DATA_TABLE_NAME; + private const FILTRATION_PARAMETER_NAME = 'filter_'.self::DATA_TABLE_NAME; private MockObject&Request $request; private MockObject&RequestStack $requestStack; @@ -37,79 +39,128 @@ protected function setUp(): void $this->urlGenerator->method('generate')->willReturn(''); } - public function testItGenerates(): void + public function testItPreservesRouteParams() + { + $this->request->attributes->set('_route_params', ['id' => 1]); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'id' => 1, + ]); + + $this->generate(); + } + + public function testItPreservesQueryParams() + { + $this->request->query->set('action', 'list'); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'action' => 'list', + ]); + + $this->generate(); + } + + public function testItPreservesDataTableUrlQueryParameters() + { + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + 'foo' => 'bar', + ]); + + $this->generate($this->createDataTableViewMock(['foo' => 'bar'])); + } + + public function testItIncludesEmptyFilterParameters() { $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - self::DATA_TABLE_NAME => [ + self::FILTRATION_PARAMETER_NAME => [ 'firstName' => [ 'value' => '', 'operator' => null, ], - 'middleName' => [ - 'value' => '', - 'operator' => null, - ], 'lastName' => [ 'value' => '', - 'operator' => null, ], ], ]); $this->generate( - $this->createFilterViewMock('firstName'), - $this->createFilterViewMock('middleName'), - $this->createFilterViewMock('lastName'), + $this->createDataTableViewMock(), + $this->createFilterViewMock('firstName', operatorSelectable: true), + $this->createFilterViewMock('lastName', operatorSelectable: false), ); } - public function testItGeneratesWithoutFilterViews(): void + public function testItOverridesCurrentPageNumberToFirst() { - $this->request->attributes->set('_route_params', ['id' => 1]); - $this->request->query->set('action', 'list'); + $this->request->query->set(self::PAGE_PARAMETER_NAME, 3); + + $dataTableView = $this->createDataTableViewMock([self::PAGE_PARAMETER_NAME => 2]); + $dataTableView->vars['pagination_enabled'] = true; $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - 'id' => 1, - 'action' => 'list', + self::PAGE_PARAMETER_NAME => 1, ]); - $this->generate(); + $this->generate($dataTableView); } - public function testItMergesWithRouteAndQueryParameters(): void + public function testItMergesEverythingTogether(): void { $this->request->attributes->set('_route_params', ['id' => 1]); $this->request->query->set('action', 'list'); $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ - self::DATA_TABLE_NAME => [ + 'id' => 1, + 'action' => 'list', + 'foo' => 'bar', + self::PAGE_PARAMETER_NAME => 1, + self::FILTRATION_PARAMETER_NAME => [ 'firstName' => [ 'value' => '', 'operator' => null, ], + 'lastName' => [ + 'value' => '', + ], ], - 'id' => 1, - 'action' => 'list', ]); + $dataTableView = $this->createDataTableViewMock(['foo' => 'bar']); + $dataTableView->vars['pagination_enabled'] = true; + $this->generate( - $this->createFilterViewMock('firstName'), + $dataTableView, + $this->createFilterViewMock('firstName', operatorSelectable: true), + $this->createFilterViewMock('lastName', operatorSelectable: false), ); } - private function generate(MockObject&FilterView ...$filterViews): void + private function generate(?DataTableView $dataTableView = null, FilterView ...$filterViews): void { + $dataTableView ??= $this->createMock(DataTableView::class); + $filterClearUrlGenerator = new FilterClearUrlGenerator($this->requestStack, $this->urlGenerator); - $filterClearUrlGenerator->generate(...$filterViews); + $filterClearUrlGenerator->generate($dataTableView, ...$filterViews); } - private function createFilterViewMock(string $name): MockObject&FilterView + private function createDataTableViewMock(array $urlQueryParameters = []): DataTableView { - $filterView = $this->createMock(FilterView::class); - $filterView->parent = $this->createMock(DataTableView::class); - $filterView->parent->vars['filtration_parameter_name'] = self::DATA_TABLE_NAME; + $dataTableView = $this->createMock(DataTableView::class); + $dataTableView->vars['filtration_parameter_name'] = self::FILTRATION_PARAMETER_NAME; + $dataTableView->vars['page_parameter_name'] = self::PAGE_PARAMETER_NAME; + $dataTableView->vars['url_query_parameters'] = $urlQueryParameters; + + return $dataTableView; + } + private function createFilterViewMock(string $name, bool $operatorSelectable): MockObject&FilterView + { + $filterView = $this->createMock(FilterView::class); $filterView->vars['name'] = $name; + $filterView->vars['operator_selectable'] = $operatorSelectable; + + $filterView->parent = $this->createDataTableViewMock(); return $filterView; } diff --git a/tests/Unit/Pagination/PaginationUrlGeneratorTest.php b/tests/Unit/Pagination/PaginationUrlGeneratorTest.php new file mode 100644 index 00000000..1e6aeb27 --- /dev/null +++ b/tests/Unit/Pagination/PaginationUrlGeneratorTest.php @@ -0,0 +1,116 @@ +request = $this->createMock(Request::class); + $this->request->attributes = new ParameterBag(['_route' => self::ROUTE_NAME]); + $this->request->query = new InputBag(); + + $this->requestStack = $this->createMock(RequestStack::class); + $this->requestStack->method('getCurrentRequest')->willReturn($this->request); + + $this->urlGenerator = $this->createMock(UrlGeneratorInterface::class); + $this->urlGenerator->method('generate')->willReturn(''); + } + + public function testItPreservesRouteParams() + { + $this->request->attributes->set('_route_params', ['id' => 1]); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + self::PAGE_PARAMETER_NAME => 1, + 'id' => 1, + ]); + + $this->generate(); + } + + public function testItPreservesQueryParams() + { + $this->request->query->set('action', 'list'); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + self::PAGE_PARAMETER_NAME => 1, + 'action' => 'list', + ]); + + $this->generate(); + } + + public function testItPreservesDataTableUrlQueryParameters() + { + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + self::PAGE_PARAMETER_NAME => 1, + 'foo' => 'bar', + ]); + + $this->generate($this->createDataTableViewMock(['foo' => 'bar'])); + } + + public function testItIncludesGivenPage() + { + $this->request->query->set(self::PAGE_PARAMETER_NAME, 3); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + self::PAGE_PARAMETER_NAME => 5, + ]); + + $this->generate($this->createDataTableViewMock([self::PAGE_PARAMETER_NAME => 2]), page: 5); + } + + public function testItMergesEverythingTogether(): void + { + $this->request->attributes->set('_route_params', ['id' => 1]); + $this->request->query->set('action', 'list'); + + $this->urlGenerator->expects($this->once())->method('generate')->with(self::ROUTE_NAME, [ + self::PAGE_PARAMETER_NAME => 5, + 'id' => 1, + 'action' => 'list', + 'foo' => 'bar', + ]); + + $this->generate($this->createDataTableViewMock(['foo' => 'bar']), page: 5); + } + + private function generate(?DataTableView $dataTableView = null, int $page = 1): void + { + $dataTableView ??= $this->createDataTableViewMock(); + + $paginationUrlGenerator = new PaginationUrlGenerator($this->requestStack, $this->urlGenerator); + $paginationUrlGenerator->generate($dataTableView, $page); + } + + private function createDataTableViewMock(array $urlQueryParameters = []): MockObject&DataTableView + { + $dataTableView = $this->createMock(DataTableView::class); + $dataTableView->vars['page_parameter_name'] = self::PAGE_PARAMETER_NAME; + $dataTableView->vars['url_query_parameters'] = $urlQueryParameters; + + return $dataTableView; + } +} diff --git a/tests/Unit/Twig/DataTableExtensionTest.php b/tests/Unit/Twig/DataTableExtensionTest.php index 2e7e5af1..0c326c9e 100644 --- a/tests/Unit/Twig/DataTableExtensionTest.php +++ b/tests/Unit/Twig/DataTableExtensionTest.php @@ -7,6 +7,7 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnSortUrlGeneratorInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; use Kreyu\Bundle\DataTableBundle\Filter\FilterClearUrlGeneratorInterface; +use Kreyu\Bundle\DataTableBundle\Pagination\PaginationUrlGeneratorInterface; use Kreyu\Bundle\DataTableBundle\Twig\DataTableExtension; use PHPUnit\Framework\TestCase; @@ -37,6 +38,7 @@ private function createExtension(): DataTableExtension return new DataTableExtension( $this->createStub(ColumnSortUrlGeneratorInterface::class), $this->createStub(FilterClearUrlGeneratorInterface::class), + $this->createStub(PaginationUrlGeneratorInterface::class), ); } }