diff --git a/docs/src/docs/components/filters.md b/docs/src/docs/components/filters.md index 7ef12664..3b3a0898 100644 --- a/docs/src/docs/components/filters.md +++ b/docs/src/docs/components/filters.md @@ -34,6 +34,43 @@ This method accepts _three_ arguments: For reference, see [available filter types](../../reference/types/filter.md). +### Column filters + +You can also add a filter directly to a column, which will display the filter in the column header. +To do this, use the `filter` and (optionally) `filter_options` keys in the column definition: + +```php +$builder + ->addColumn('name', TextColumnType::class, [ + 'label' => 'Full name', + 'sort' => true, + 'filter' => StringFilterType::class, + ]); +``` + +You can pass options to the filter using the `filter_options` array: + +```php +$builder + ->addColumn('name', TextColumnType::class, [ + 'label' => 'Full name', + 'sort' => true, + 'filter' => StringFilterType::class, + 'filter_options' => [ + 'operator_selectable' => true, + ], + ]); +``` + +- `filter`: The filter type class (e.g. `StringFilterType::class`) +- `filter_options`: An array of options passed to the filter (e.g. `operator_selectable`, `default_operator`, etc.) + +Column filters are rendered inside the column header and are scoped to the column they are defined on. + +> **Note:** +> A field cannot have both a header filter (via `addFilter()`) and a column filter (via the column's `filter` option) at the same time. +> Attempting to add both types of filters for the same field will result in an `InvalidArgumentException`. + ### Case-insensitive filters for Postgres or MySQL with a case sensitive collation If your database does `LIKE` comparisons in a case-sensitive manner (aka `A` is not the same as `a`), but you want diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 119f8bdc..0aba51ea 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -8,6 +8,7 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnHeaderView; use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView; +use Kreyu\Bundle\DataTableBundle\Filter\Type\AbstractFilterType; use Kreyu\Bundle\DataTableBundle\Util\StringUtil; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -73,6 +74,8 @@ public function buildHeaderView(ColumnHeaderView $view, ColumnInterface $column, 'sort_direction' => $sortColumnData?->getDirection(), 'sortable' => $column->getConfig()->isSortable(), 'export' => $column->getConfig()->isExportable(), + 'filter' => $options['filter'], + 'filter_options' => $options['filter_options'], ]); } @@ -330,6 +333,25 @@ public function configureOptions(OptionsResolver $resolver): void ->allowedTypes('bool') ->info('Defines whether the column can be personalized by the user in personalization feature.') ; + + $resolver->define('filter') + ->default(null) + ->allowedTypes('string', 'null') + ->allowedValues(function (?string $value): bool { + if (null === $value) { + return true; + } + + return is_subclass_of($value, AbstractFilterType::class); + }) + ->info('Provide a Filter Type FQCN (extending AbstractFilterType) to render a per-column filter.') + ; + + $resolver->define('filter_options') + ->default([]) + ->allowedTypes('array') + ->info('Options passed to the per-column filter type when rendering the inline filter in the header.') + ; } public function getBlockPrefix(): string diff --git a/src/DataTable.php b/src/DataTable.php index 230abf1e..abb02720 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -771,6 +771,23 @@ public function createFiltrationFormBuilder(?DataTableView $view = null): FormBu ); } + public function createColumnFiltrationFormBuilder(?DataTableView $view = null, array $filters = []): FormBuilderInterface + { + if (!$this->config->isFiltrationEnabled()) { + throw new RuntimeException('The data table has filtration feature disabled.'); + } + + return $this->config->getFiltrationFormFactory()->createNamedBuilder( + name: $this->config->getColumnFiltrationParameterName(), + type: FiltrationDataType::class, + options: [ + 'data_table' => $this, + 'data_table_view' => $view, + 'is_header_form' => false, + ], + ); + } + public function createPersonalizationFormBuilder(?DataTableView $view = null): FormBuilderInterface { if (!$this->config->isPersonalizationEnabled()) { diff --git a/src/DataTableBuilder.php b/src/DataTableBuilder.php index 8f49c975..716378de 100755 --- a/src/DataTableBuilder.php +++ b/src/DataTableBuilder.php @@ -262,13 +262,18 @@ public function getFilter(string $name): FilterBuilderInterface } if (isset($this->unresolvedFilters[$name])) { - return $this->resolveFilter($name); + return $this->resolveHeaderFilter($name); } if (isset($this->filters[$name])) { return $this->filters[$name]; } + $filter = $this->resolveColumnFilter($name); + if (null !== $filter) { + return $filter; + } + throw new InvalidArgumentException(sprintf('The filter with the name "%s" does not exist.', $name)); } @@ -794,7 +799,7 @@ private function resolveColumns(): void } } - private function resolveFilter(string $name): FilterBuilderInterface + private function resolveHeaderFilter(string $name): FilterBuilderInterface { [$type, $options] = $this->unresolvedFilters[$name]; @@ -803,10 +808,40 @@ private function resolveFilter(string $name): FilterBuilderInterface return $this->filters[$name] = $this->getFilterFactory()->createNamedBuilder($name, $type, $options); } + private function resolveColumnFilter(string $name): ?FilterBuilderInterface + { + if (isset($this->filters[$name])) { + throw new InvalidArgumentException(sprintf('A filter named "%s" is already registered as a header filter. Only one filter with the same name (column + header) can be active at a time. Remove or replace the existing filter before adding a new one.', $name)); + } + + $column = $this->columns[$name] ?? null; + + if (null === $column) { + return null; + } + + $type = $column->getOption('filter'); + + if (null === $type) { + return null; + } + + $options = $column->getOption('filter_options'); + $options['is_header_filter'] = false; + + return $this->filters[$name] = $this->getFilterFactory()->createNamedBuilder($name, $type, $options); + } + private function resolveFilters(): void { foreach (array_keys($this->unresolvedFilters) as $filter) { - $this->resolveFilter($filter); + $this->resolveHeaderFilter($filter); + } + + foreach ($this->columns as $column) { + if (null !== $column->getOptions()['filter']) { + $this->resolveColumnFilter($column->getName()); + } } } diff --git a/src/DataTableConfigBuilder.php b/src/DataTableConfigBuilder.php index 4e65af54..dd778dfa 100755 --- a/src/DataTableConfigBuilder.php +++ b/src/DataTableConfigBuilder.php @@ -845,6 +845,11 @@ public function getFiltrationParameterName(): string return $this->getParameterName(static::FILTRATION_PARAMETER); } + public function getColumnFiltrationParameterName(): string + { + return $this->getParameterName(static::COLUMN_FILTRATION_PARAMETER); + } + public function getPersonalizationParameterName(): string { return $this->getParameterName(static::PERSONALIZATION_PARAMETER); diff --git a/src/DataTableConfigInterface.php b/src/DataTableConfigInterface.php index c78eba6a..2a4727f9 100755 --- a/src/DataTableConfigInterface.php +++ b/src/DataTableConfigInterface.php @@ -26,6 +26,7 @@ interface DataTableConfigInterface public const PER_PAGE_PARAMETER = 'limit'; public const SORT_PARAMETER = 'sort'; public const FILTRATION_PARAMETER = 'filter'; + public const COLUMN_FILTRATION_PARAMETER = 'filter_column'; public const PERSONALIZATION_PARAMETER = 'personalization'; public const EXPORT_PARAMETER = 'export'; @@ -131,6 +132,8 @@ public function getSortParameterName(): string; public function getFiltrationParameterName(): string; + public function getColumnFiltrationParameterName(): string; + public function getPersonalizationParameterName(): string; public function getExportParameterName(): string; diff --git a/src/DataTableInterface.php b/src/DataTableInterface.php index 0a5d7464..38b39d4c 100755 --- a/src/DataTableInterface.php +++ b/src/DataTableInterface.php @@ -202,6 +202,8 @@ public function getExportData(): ?ExportData; public function createFiltrationFormBuilder(?DataTableView $view = null): FormBuilderInterface; + public function createColumnFiltrationFormBuilder(?DataTableView $view = null, array $filters = []): FormBuilderInterface; + public function createPersonalizationFormBuilder(?DataTableView $view = null): FormBuilderInterface; public function createExportFormBuilder(?DataTableView $view = null): FormBuilderInterface; diff --git a/src/Filter/FilterClearUrlGenerator.php b/src/Filter/FilterClearUrlGenerator.php index cf02a4d7..27dcb1fe 100644 --- a/src/Filter/FilterClearUrlGenerator.php +++ b/src/Filter/FilterClearUrlGenerator.php @@ -60,7 +60,7 @@ private function getFilterClearQueryParameters(FilterView $filterView): array $dataTableView = $filterView->parent; return [ - $dataTableView->vars['filtration_parameter_name'] => [ + $dataTableView->vars[$filterView->vars['is_header_filter'] ? 'filtration_parameter_name' : 'column_filtration_parameter_name'] => [ $filterView->vars['name'] => $parameters, ], ]; diff --git a/src/Filter/FilterConfigBuilder.php b/src/Filter/FilterConfigBuilder.php index 23692187..0d66b2c0 100755 --- a/src/Filter/FilterConfigBuilder.php +++ b/src/Filter/FilterConfigBuilder.php @@ -25,6 +25,7 @@ class FilterConfigBuilder implements FilterConfigBuilderInterface private array $supportedOperators = []; private Operator $defaultOperator = Operator::Equals; private bool $operatorSelectable = false; + private bool $isHeaderFilter = true; private FilterData $emptyData; public function __construct( @@ -291,6 +292,22 @@ public function getFilterConfig(): FilterConfigInterface return $config; } + public function isHeaderFilter(): bool + { + return $this->isHeaderFilter; + } + + public function setIsHeaderFilter(bool $isHeaderFilter): self + { + if ($this->locked) { + throw $this->createBuilderLockedException(); + } + + $this->isHeaderFilter = $isHeaderFilter; + + return $this; + } + private function createBuilderLockedException(): BadMethodCallException { return new BadMethodCallException('FilterConfigBuilder methods cannot be accessed anymore once the builder is turned into a FilterConfigInterface instance.'); diff --git a/src/Filter/FilterConfigBuilderInterface.php b/src/Filter/FilterConfigBuilderInterface.php index 6247267e..43ec5efc 100755 --- a/src/Filter/FilterConfigBuilderInterface.php +++ b/src/Filter/FilterConfigBuilderInterface.php @@ -46,4 +46,6 @@ public function setDefaultOperator(Operator $defaultOperator): static; public function setOperatorSelectable(bool $operatorSelectable): static; public function getFilterConfig(): FilterConfigInterface; + + public function setIsHeaderFilter(bool $isHeaderFilter): self; } diff --git a/src/Filter/FilterConfigInterface.php b/src/Filter/FilterConfigInterface.php index b5ae3594..90823b3e 100755 --- a/src/Filter/FilterConfigInterface.php +++ b/src/Filter/FilterConfigInterface.php @@ -52,4 +52,6 @@ public function getSupportedOperators(): array; public function getDefaultOperator(): Operator; public function isOperatorSelectable(): bool; + + public function isHeaderFilter(): bool; } diff --git a/src/Filter/Form/Type/FiltrationDataType.php b/src/Filter/Form/Type/FiltrationDataType.php index d4a94515..83508030 100755 --- a/src/Filter/Form/Type/FiltrationDataType.php +++ b/src/Filter/Form/Type/FiltrationDataType.php @@ -25,6 +25,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $dataTable = $options['data_table']; foreach ($dataTable->getFilters() as $filter) { + if ($filter->getConfig()->isHeaderFilter() !== $options['is_header_form']) { + continue; + } + $builder->add($filter->getFormName(), FilterDataType::class, $filter->getFormOptions() + [ 'getter' => fn (FiltrationData $filtrationData) => $filtrationData->getFilterData($filter), 'setter' => fn (FiltrationData $filtrationData, FilterData $filterData) => $filtrationData->setFilterData($filter, $filterData), @@ -83,10 +87,12 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => FiltrationData::class, 'csrf_protection' => false, 'data_table_view' => null, + 'is_header_form' => true, ]) ->setRequired('data_table') ->setAllowedTypes('data_table', DataTableInterface::class) ->setAllowedTypes('data_table_view', ['null', DataTableView::class]) + ->setAllowedTypes('is_header_form', ['bool']) ; } diff --git a/src/Filter/Type/FilterType.php b/src/Filter/Type/FilterType.php index 46b8b2ba..906e7b68 100755 --- a/src/Filter/Type/FilterType.php +++ b/src/Filter/Type/FilterType.php @@ -27,6 +27,7 @@ public function buildFilter(FilterBuilderInterface $builder, array $options): vo 'default_operator' => $builder->setDefaultOperator(...), 'supported_operators' => $builder->setSupportedOperators(...), 'operator_selectable' => $builder->setOperatorSelectable(...), + 'is_header_filter' => $builder->setIsHeaderFilter(...), ]; foreach ($setters as $option => $setter) { @@ -62,6 +63,7 @@ public function buildView(FilterView $view, FilterInterface $filter, FilterData 'operator_selectable' => $filter->getConfig()->isOperatorSelectable(), 'default_operator' => $filter->getConfig()->getDefaultOperator(), 'supported_operators' => $filter->getConfig()->getSupportedOperators(), + 'is_header_filter' => $filter->getConfig()->isHeaderFilter(), 'data' => $view->data, 'value' => $view->value, ]); @@ -83,6 +85,7 @@ public function configureOptions(OptionsResolver $resolver): void 'supported_operators' => [], 'operator_selectable' => false, 'active_filter_formatter' => null, + 'is_header_filter' => true, ]) ->setAllowedTypes('label', ['null', 'bool', 'string', TranslatableInterface::class]) ->setAllowedTypes('label_translation_parameters', 'array') @@ -96,6 +99,7 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('supported_operators', Operator::class.'[]') ->setAllowedTypes('operator_selectable', 'bool') ->setAllowedTypes('active_filter_formatter', ['null', 'callable']) + ->setAllowedTypes('is_header_filter', ['bool']) ->setAllowedValues('translation_domain', function (mixed $value): bool { return is_null($value) || false === $value || is_string($value); }) diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index 8ec2cf86..f5915af4 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -54,6 +54,16 @@ private function filter(DataTableInterface $dataTable, Request $request): void if ($form->isSubmitted() && $form->isValid()) { $dataTable->filter($form->getData()); } + + $columnForm = $dataTable->createColumnFiltrationFormBuilder()->getForm(); + + if ($data = $request->get($columnForm->getName())) { + $columnForm->submit($data); + } + + if ($columnForm->isSubmitted() && $columnForm->isValid()) { + $dataTable->filter($columnForm->getData()); + } } private function sort(DataTableInterface $dataTable, Request $request): void diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 53dcf297..eabb673b 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -15,6 +15,7 @@ data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}" > {{ block('action_bar', theme) }} + {{ block('kreyu_data_table_column_filters_form', theme) }} {{ block('table', theme) }} {% if pagination_enabled %} @@ -367,6 +368,27 @@ {% endif %} {% endblock %} +{% block kreyu_data_table_column_filters_form %} + {% set form = data_table.vars.column_filtration_form|default(null) %} + {% if form %} + {% form_theme form with form_themes|default([_self]) %} + {{ form_start(form, { attr: { 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', 'hidden': 'hidden' } }) }} + {{ form_end(form, { render_rest: false }) }} + + {% set data_table = form.vars.data_table_view %} + {% if data_table.vars.pagination_enabled %} + {% set url_query_parameters = [] %} + {% if should_reset_to_first_page ?? true %} + {% set url_query_parameters = url_query_parameters|merge({ (data_table.vars.page_parameter_name): 1 }) %} + {% endif %} + {% if should_keep_per_page ?? true %} + {% set url_query_parameters = url_query_parameters|merge({ (data_table.vars.per_page_parameter_name): data_table.vars.pagination.vars.item_number_per_page }) %} + {% endif %} + {{ _self.array_to_form_inputs(url_query_parameters, { form: form.vars.id }) }} + {% endif %} + {% endif %} +{% endblock %} + {% block filtration_widget %}
{{ block('filtration_form', theme) }}
{% endblock %} @@ -405,43 +427,55 @@ {% block column_header %} {% set label_attr = label_attr|default({}) %} - {% if data_table.vars.sorting_enabled and sortable %} - {% set active_attr = active_attr|default({}) %} - {% set inactive_attr = inactive_attr|default({}) %} - {% set sorted_attr = sorted ? active_attr : inactive_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) %} - {# Merge the sorted attr with column header attr, but merge its classes. #} - {# The column header attr class is added after the sorted attr class. #} - {% set attr = attr|merge(sorted_attr|merge({ - class: (sorted_attr.class|default('') ~ ' ' ~ attr.class|default(''))|trim - })) %} + {% if data_table.vars.sorting_enabled and sortable %} + {% set active_attr = active_attr|default({}) %} + {% set inactive_attr = inactive_attr|default({}) %} - - {% 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) %} + {% set sorted_attr = sorted ? active_attr : inactive_attr %} - - {{- block('column_header_label', theme, _context) -}} + {# Merge the sorted attr with column header attr, but merge its classes. #} + {# The column header attr class is added after the sorted attr class. #} + {% set attr = attr|merge(sorted_attr|merge({ + class: (sorted_attr.class|default('') ~ ' ' ~ attr.class|default(''))|trim + })) %} + + {{- block('column_header_label', theme, _context) -}} - {% if sorted %} - {% if sort_direction == 'asc' %} - {{ block('sort_arrow_asc', theme, _context) }} + {% if sorted %} + {% if sort_direction == 'asc' %} + {{ block('sort_arrow_asc', theme, _context) }} + {% else %} + {{ block('sort_arrow_desc', theme, _context) }} + {% endif %} {% else %} - {{ block('sort_arrow_desc', theme, _context) }} + {{ block('sort_arrow_none', theme, _context) }} {% endif %} - {% else %} - {{ block('sort_arrow_none', theme, _context) }} - {% endif %} - - - {% else %} - + + {% else %} {{- block('column_header_label', theme, _context) -}} - - {% endif %} + {% endif %} + + {% if data_table.vars.filtration_enabled and filter %} + {% set column_form = data_table.vars.column_filtration_form|default(null) %} + {% if column_form %} + {% set filter_name = header_filter_name|default(name) %} + {% if attribute(column_form, filter_name, [], 'any', true, true) is defined %} + {% set child = attribute(column_form, filter_name) %} + {% if child.operator is defined %} + {{ form_widget(child.operator) }} + {% endif %} + {{ form_widget(child.value, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', onchange: 'this.form.requestSubmit()' } }) }} + {% endif %} + {% endif %} + {% endif %} + {% endblock %} {# @see Kreyu\Bundle\DataTableBundle\Column\Type\ColumnType #} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index 19f67d95..80ed5a6d 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -99,6 +99,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'per_page_parameter_name' => $dataTable->getConfig()->getPerPageParameterName(), 'sort_parameter_name' => $dataTable->getConfig()->getSortParameterName(), 'filtration_parameter_name' => $dataTable->getConfig()->getFiltrationParameterName(), + 'column_filtration_parameter_name' => $dataTable->getConfig()->getColumnFiltrationParameterName(), 'personalization_parameter_name' => $dataTable->getConfig()->getPersonalizationParameterName(), 'export_parameter_name' => $dataTable->getConfig()->getExportParameterName(), 'has_active_filters' => $dataTable->hasActiveFilters(), @@ -128,6 +129,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar if ($dataTable->getConfig()->isFiltrationEnabled()) { $view->vars['filtration_form'] = $this->createFiltrationFormView($view, $dataTable); + $view->vars['column_filtration_form'] = $this->createFiltrationColumnView($view, $dataTable); } if ($dataTable->getConfig()->isPersonalizationEnabled()) { @@ -375,6 +377,22 @@ private function createFiltrationFormView(DataTableView $view, DataTableInterfac return $this->createFormView($form, $view, $dataTable); } + private function createFiltrationColumnView(DataTableView $view, DataTableInterface $dataTable): FormView + { + $columnFilters = []; + foreach ($view->headerRow->children as $name => $columnHeaderView) { + $filterType = $columnHeaderView->vars['filter'] ?? false; + if ($filterType) { + $filterOptions = $columnHeaderView->vars['filter_options'] ?? []; + $columnFilters[] = $dataTable->getConfig()->getFilterFactory()->createNamed($name, $filterType, $filterOptions); + } + } + $columnForm = $dataTable->createColumnFiltrationFormBuilder($view, $columnFilters)->getForm(); + $columnForm->setData($dataTable->getFiltrationData()); + + return $this->createFormView($columnForm, $view, $dataTable); + } + private function createPersonalizationFormView(DataTableView $view, DataTableInterface $dataTable): FormView { $form = $dataTable->createPersonalizationFormBuilder($view)->getForm(); diff --git a/tests/Unit/Filter/FilterClearUrlGeneratorTest.php b/tests/Unit/Filter/FilterClearUrlGeneratorTest.php index 75f862e7..894477fe 100644 --- a/tests/Unit/Filter/FilterClearUrlGeneratorTest.php +++ b/tests/Unit/Filter/FilterClearUrlGeneratorTest.php @@ -159,6 +159,7 @@ private function createFilterViewMock(string $name, bool $operatorSelectable): M $filterView = $this->createMock(FilterView::class); $filterView->vars['name'] = $name; $filterView->vars['operator_selectable'] = $operatorSelectable; + $filterView->vars['is_header_filter'] = true; $filterView->parent = $this->createDataTableViewMock();