From 038db01f935f4e941da28200a9eacb4c840f63ef Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Wed, 20 Aug 2025 10:58:51 +0200 Subject: [PATCH 1/6] Add a way to create column filters --- src/Column/Type/ColumnType.php | 21 +++++++ src/DataTable.php | 32 ++++++++++ src/DataTableConfigBuilder.php | 5 ++ src/DataTableConfigInterface.php | 3 + src/DataTableInterface.php | 2 + src/Filter/Form/Type/FiltrationDataType.php | 30 +++++++--- src/Request/HttpFoundationRequestHandler.php | 49 ++++++++++++++-- src/Resources/views/themes/base.html.twig | 61 ++++++++++++++++++++ src/Type/DataTableType.php | 31 +++++++++- 9 files changed, 220 insertions(+), 14 deletions(-) diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 119f8bdc..75491bb1 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,24 @@ 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..2c65ef2e 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -574,6 +574,21 @@ public function filter(FiltrationData $data, bool $persistence = true): void $filters = $this->getFilters(); + foreach ($this->getColumns() as $column) { + $typeFqcn = $column->getConfig()->getOption('filter', false); + if ($typeFqcn && is_string($typeFqcn)) { + $name = $column->getName(); + // Avoid overriding an existing filter with the same name + if (!isset($filters[$name])) { + $options = $column->getConfig()->getOption('filter_options', []); + $filter = $this->config->getFilterFactory()->createNamed($name, $typeFqcn, $options); + // Attach data table context for consistency + $filter->setDataTable($this); + $filters[$name] = $filter; + } + } + } + $data->appendMissingFilters($filters); $data->removeRedundantFilters($filters); @@ -771,6 +786,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, + 'filters' => $filters, + ], + ); + } + public function createPersonalizationFormBuilder(?DataTableView $view = null): FormBuilderInterface { if (!$this->config->isPersonalizationEnabled()) { 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/Form/Type/FiltrationDataType.php b/src/Filter/Form/Type/FiltrationDataType.php index d4a94515..46e4ec63 100755 --- a/src/Filter/Form/Type/FiltrationDataType.php +++ b/src/Filter/Form/Type/FiltrationDataType.php @@ -24,7 +24,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void */ $dataTable = $options['data_table']; - foreach ($dataTable->getFilters() as $filter) { + $filters = $dataTable->getFilters(); + + if (null !== $options['filters']) { + $selected = []; + foreach ($options['filters'] as $filter) { + $selected[$filter->getName()] = $filter; + } + $filters = $selected; + } + + foreach ($filters as $filter) { $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), @@ -50,21 +60,23 @@ public function finishView(FormView $view, FormInterface $form, array $options): $view->vars['attr']['id'] = $id; foreach ($view as $name => $filterFormView) { - $filterView = $dataTableView->filters[$name]; - - $filterFormView->vars['label'] = $filterView->vars['label']; - $filterFormView->vars['translation_domain'] = $filterView->vars['translation_domain']; + if (isset($dataTableView->filters[$name])) { + $filterView = $dataTableView->filters[$name]; + $filterFormView->vars['label'] = $filterView->vars['label']; + $filterFormView->vars['translation_domain'] = $filterView->vars['translation_domain']; + } } $searchFields = []; foreach ($form as $child) { - try { - $filter = $dataTable->getFilter($child->getName()); - } catch (\InvalidArgumentException) { + if (!$dataTable->hasFilter($child->getName())) { + // This may be a column filter not registered in DataTable->getFilters(); skip. continue; } + $filter = $dataTable->getFilter($child->getName()); + if ($filter->getConfig()->getType()->getInnerType() instanceof SearchFilterTypeInterface) { $searchFields[] = $view[$child->getName()]; @@ -83,10 +95,12 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => FiltrationData::class, 'csrf_protection' => false, 'data_table_view' => null, + 'filters' => null, ]) ->setRequired('data_table') ->setAllowedTypes('data_table', DataTableInterface::class) ->setAllowedTypes('data_table_view', ['null', DataTableView::class]) + ->setAllowedTypes('filters', ['null', 'array']) ; } diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index 8ec2cf86..e883205d 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -6,6 +6,7 @@ use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; +use Kreyu\Bundle\DataTableBundle\Filter\FiltrationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; @@ -45,15 +46,53 @@ private function filter(DataTableInterface $dataTable, Request $request): void return; } - $form = $dataTable->createFiltrationFormBuilder()->getForm(); + $mainForm = $dataTable->createFiltrationFormBuilder()->getForm(); + + // Build ad-hoc per-column filters from column configuration (type + options) + $columnFilters = []; + foreach ($dataTable->getColumns() as $column) { + $typeFqcn = $column->getConfig()->getOption('filter', false); + if ($typeFqcn && is_string($typeFqcn)) { + $filterName = $column->getName(); + $options = $column->getConfig()->getOption('filter_options', []); + $columnFilters[] = $dataTable->getConfig()->getFilterFactory()->createNamed($filterName, $typeFqcn, $options); + } + } + $columnForm = $dataTable->createColumnFiltrationFormBuilder(null, $columnFilters)->getForm(); - if ($data = $request->get($form->getName())) { - $form->submit($data); + if ($data = $request->get($mainForm->getName())) { + $mainForm->submit($data); + } + if ($data = $request->get($columnForm->getName())) { + $columnForm->submit($data); } - if ($form->isSubmitted() && $form->isValid()) { - $dataTable->filter($form->getData()); + $submitted = ($mainForm->isSubmitted() && $mainForm->isValid()) || ($columnForm->isSubmitted() && $columnForm->isValid()); + if (!$submitted) { + return; } + + // Start from current filtration data (or defaults), then override with submitted values. + $merged = $dataTable->getFiltrationData(); + if (null === $merged) { + $merged = $dataTable->getConfig()->getDefaultFiltrationData() ?? FiltrationData::fromDataTable($dataTable); + } + + if ($mainForm->isSubmitted() && $mainForm->isValid()) { + $data = $mainForm->getData(); + foreach ($data->getFilters() as $name => $filterData) { + $merged->setFilterData($name, $filterData); + } + } + + if ($columnForm->isSubmitted() && $columnForm->isValid()) { + $data = $columnForm->getData(); + foreach ($data->getFilters() as $name => $filterData) { + $merged->setFilterData($name, $filterData); + } + } + + $dataTable->filter($merged); } 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..fe7ab243 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,28 @@ {% endif %} {% endblock %} +{# Column filtration (per-column filters), independent from header filters #} +{% 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 %} @@ -434,12 +457,50 @@ {{ block('sort_arrow_none', theme, _context) }} {% endif %} + {# Inline column filter input (optional) - also render for sortable columns #} + {% 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) %} +{# {% set filter_child_name = filter_name|replace({'.':'__'}) %}#} + {% if attribute(column_form, filter_name, [], 'any', true, true) is defined %} +
+ {# Prefer rendering the value field for compactness; if not available, render whole child #} + {% set child = attribute(column_form, filter_name) %} + {% if attribute(child, 'value', [], 'any', true, true) is defined %} + {{ form_widget(child.value, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', onchange: 'this.form.requestSubmit()' } }) }} + {% else %} + {{ form_widget(child, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self' } }) }} + {% endif %} +
+ {% endif %} + {% endif %} + {% endif %} {% else %} {{- block('column_header_label', theme, _context) -}} + {# Inline column filter input (optional) #} + {% 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) %} + {% set filter_child_name = filter_name|replace({'.':'__'}) %} + {% if attribute(column_form, filter_child_name, [], 'any', true, true) is defined %} +
+ {# Prefer rendering the value field for compactness; if not available, render whole child #} + {% set child = attribute(column_form, filter_child_name) %} + {% if attribute(child, 'value', [], 'any', true, true) is defined %} + {{ form_widget(child.value, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', onchange: 'this.form.requestSubmit()' } }) }} + {% else %} + {{ form_widget(child, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self' } }) }} + {% endif %} +
+ {% endif %} + {% endif %} + {% endif %} {% endif %} {% endblock %} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index 19f67d95..1f9098ca 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,19 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar if ($dataTable->getConfig()->isFiltrationEnabled()) { $view->vars['filtration_form'] = $this->createFiltrationFormView($view, $dataTable); + + // Build a column filtration form using filters defined per column (type + options) + $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()); + $view->vars['column_filtration_form'] = $this->createFormView($columnForm, $view, $dataTable); } if ($dataTable->getConfig()->isPersonalizationEnabled()) { @@ -270,12 +284,27 @@ private function createFilterViews(DataTableView $view, DataTableInterface $data return []; } + $filters = $dataTable->getFilters(); + + foreach ($dataTable->getColumns() as $column) { + if (null !== $column->getConfig()->getOption('filter')) { + // If the column has a filter defined, we can add it to the filters list + $filter = $dataTable->getConfig()->getFilterFactory()->createNamed( + $column->getName(), + $column->getConfig()->getOption('filter'), + $column->getConfig()->getOption('filter_options', []), + ); + $filter->setDataTable($dataTable); + $filters[$column->getName()] = $filter; + } + } + return array_map( static fn (FilterInterface $filter) => $filter->createView( $dataTable->getFiltrationData()?->getFilterData($filter) ?? new FilterData(), $view, ), - $dataTable->getFilters(), + $filters, ); } From 6ae52968f7d696c6aa9120f295d1c0af82c22359 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 09:31:14 +0200 Subject: [PATCH 2/6] Add support for column filters --- src/Column/Type/ColumnType.php | 1 + src/DataTable.php | 17 +------ src/DataTableBuilder.php | 41 +++++++++++++++-- src/Filter/FilterClearUrlGenerator.php | 2 +- src/Filter/FilterConfigBuilder.php | 17 +++++++ src/Filter/FilterConfigBuilderInterface.php | 2 + src/Filter/FilterConfigInterface.php | 2 + src/Filter/Form/Type/FiltrationDataType.php | 32 +++++-------- src/Filter/Type/FilterType.php | 4 ++ src/Request/HttpFoundationRequestHandler.php | 47 ++++---------------- src/Resources/views/themes/base.html.twig | 1 - src/Type/DataTableType.php | 47 ++++++++------------ 12 files changed, 105 insertions(+), 108 deletions(-) diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 75491bb1..0aba51ea 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -341,6 +341,7 @@ public function configureOptions(OptionsResolver $resolver): void 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.') diff --git a/src/DataTable.php b/src/DataTable.php index 2c65ef2e..abb02720 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -574,21 +574,6 @@ public function filter(FiltrationData $data, bool $persistence = true): void $filters = $this->getFilters(); - foreach ($this->getColumns() as $column) { - $typeFqcn = $column->getConfig()->getOption('filter', false); - if ($typeFqcn && is_string($typeFqcn)) { - $name = $column->getName(); - // Avoid overriding an existing filter with the same name - if (!isset($filters[$name])) { - $options = $column->getConfig()->getOption('filter_options', []); - $filter = $this->config->getFilterFactory()->createNamed($name, $typeFqcn, $options); - // Attach data table context for consistency - $filter->setDataTable($this); - $filters[$name] = $filter; - } - } - } - $data->appendMissingFilters($filters); $data->removeRedundantFilters($filters); @@ -798,7 +783,7 @@ public function createColumnFiltrationFormBuilder(?DataTableView $view = null, a options: [ 'data_table' => $this, 'data_table_view' => $view, - 'filters' => $filters, + 'is_header_form' => false, ], ); } 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/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 46e4ec63..83508030 100755 --- a/src/Filter/Form/Type/FiltrationDataType.php +++ b/src/Filter/Form/Type/FiltrationDataType.php @@ -24,17 +24,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void */ $dataTable = $options['data_table']; - $filters = $dataTable->getFilters(); - - if (null !== $options['filters']) { - $selected = []; - foreach ($options['filters'] as $filter) { - $selected[$filter->getName()] = $filter; + foreach ($dataTable->getFilters() as $filter) { + if ($filter->getConfig()->isHeaderFilter() !== $options['is_header_form']) { + continue; } - $filters = $selected; - } - foreach ($filters as $filter) { $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), @@ -60,23 +54,21 @@ public function finishView(FormView $view, FormInterface $form, array $options): $view->vars['attr']['id'] = $id; foreach ($view as $name => $filterFormView) { - if (isset($dataTableView->filters[$name])) { - $filterView = $dataTableView->filters[$name]; - $filterFormView->vars['label'] = $filterView->vars['label']; - $filterFormView->vars['translation_domain'] = $filterView->vars['translation_domain']; - } + $filterView = $dataTableView->filters[$name]; + + $filterFormView->vars['label'] = $filterView->vars['label']; + $filterFormView->vars['translation_domain'] = $filterView->vars['translation_domain']; } $searchFields = []; foreach ($form as $child) { - if (!$dataTable->hasFilter($child->getName())) { - // This may be a column filter not registered in DataTable->getFilters(); skip. + try { + $filter = $dataTable->getFilter($child->getName()); + } catch (\InvalidArgumentException) { continue; } - $filter = $dataTable->getFilter($child->getName()); - if ($filter->getConfig()->getType()->getInnerType() instanceof SearchFilterTypeInterface) { $searchFields[] = $view[$child->getName()]; @@ -95,12 +87,12 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => FiltrationData::class, 'csrf_protection' => false, 'data_table_view' => null, - 'filters' => null, + 'is_header_form' => true, ]) ->setRequired('data_table') ->setAllowedTypes('data_table', DataTableInterface::class) ->setAllowedTypes('data_table_view', ['null', DataTableView::class]) - ->setAllowedTypes('filters', ['null', 'array']) + ->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 e883205d..f5915af4 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -6,7 +6,6 @@ use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; -use Kreyu\Bundle\DataTableBundle\Filter\FiltrationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; @@ -46,53 +45,25 @@ private function filter(DataTableInterface $dataTable, Request $request): void return; } - $mainForm = $dataTable->createFiltrationFormBuilder()->getForm(); + $form = $dataTable->createFiltrationFormBuilder()->getForm(); - // Build ad-hoc per-column filters from column configuration (type + options) - $columnFilters = []; - foreach ($dataTable->getColumns() as $column) { - $typeFqcn = $column->getConfig()->getOption('filter', false); - if ($typeFqcn && is_string($typeFqcn)) { - $filterName = $column->getName(); - $options = $column->getConfig()->getOption('filter_options', []); - $columnFilters[] = $dataTable->getConfig()->getFilterFactory()->createNamed($filterName, $typeFqcn, $options); - } - } - $columnForm = $dataTable->createColumnFiltrationFormBuilder(null, $columnFilters)->getForm(); - - if ($data = $request->get($mainForm->getName())) { - $mainForm->submit($data); - } - if ($data = $request->get($columnForm->getName())) { - $columnForm->submit($data); + if ($data = $request->get($form->getName())) { + $form->submit($data); } - $submitted = ($mainForm->isSubmitted() && $mainForm->isValid()) || ($columnForm->isSubmitted() && $columnForm->isValid()); - if (!$submitted) { - return; + if ($form->isSubmitted() && $form->isValid()) { + $dataTable->filter($form->getData()); } - // Start from current filtration data (or defaults), then override with submitted values. - $merged = $dataTable->getFiltrationData(); - if (null === $merged) { - $merged = $dataTable->getConfig()->getDefaultFiltrationData() ?? FiltrationData::fromDataTable($dataTable); - } + $columnForm = $dataTable->createColumnFiltrationFormBuilder()->getForm(); - if ($mainForm->isSubmitted() && $mainForm->isValid()) { - $data = $mainForm->getData(); - foreach ($data->getFilters() as $name => $filterData) { - $merged->setFilterData($name, $filterData); - } + if ($data = $request->get($columnForm->getName())) { + $columnForm->submit($data); } if ($columnForm->isSubmitted() && $columnForm->isValid()) { - $data = $columnForm->getData(); - foreach ($data->getFilters() as $name => $filterData) { - $merged->setFilterData($name, $filterData); - } + $dataTable->filter($columnForm->getData()); } - - $dataTable->filter($merged); } 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 fe7ab243..25650451 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -462,7 +462,6 @@ {% set column_form = data_table.vars.column_filtration_form|default(null) %} {% if column_form %} {% set filter_name = header_filter_name|default(name) %} -{# {% set filter_child_name = filter_name|replace({'.':'__'}) %}#} {% if attribute(column_form, filter_name, [], 'any', true, true) is defined %}
{# Prefer rendering the value field for compactness; if not available, render whole child #} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index 1f9098ca..80ed5a6d 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -129,19 +129,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar if ($dataTable->getConfig()->isFiltrationEnabled()) { $view->vars['filtration_form'] = $this->createFiltrationFormView($view, $dataTable); - - // Build a column filtration form using filters defined per column (type + options) - $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()); - $view->vars['column_filtration_form'] = $this->createFormView($columnForm, $view, $dataTable); + $view->vars['column_filtration_form'] = $this->createFiltrationColumnView($view, $dataTable); } if ($dataTable->getConfig()->isPersonalizationEnabled()) { @@ -284,27 +272,12 @@ private function createFilterViews(DataTableView $view, DataTableInterface $data return []; } - $filters = $dataTable->getFilters(); - - foreach ($dataTable->getColumns() as $column) { - if (null !== $column->getConfig()->getOption('filter')) { - // If the column has a filter defined, we can add it to the filters list - $filter = $dataTable->getConfig()->getFilterFactory()->createNamed( - $column->getName(), - $column->getConfig()->getOption('filter'), - $column->getConfig()->getOption('filter_options', []), - ); - $filter->setDataTable($dataTable); - $filters[$column->getName()] = $filter; - } - } - return array_map( static fn (FilterInterface $filter) => $filter->createView( $dataTable->getFiltrationData()?->getFilterData($filter) ?? new FilterData(), $view, ), - $filters, + $dataTable->getFilters(), ); } @@ -404,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(); From f947e74e4cfaa47cf3f98bc2bf9ea604b09d59dc Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 09:46:47 +0200 Subject: [PATCH 3/6] Improve template --- src/Resources/views/themes/base.html.twig | 97 ++++++++--------------- 1 file changed, 34 insertions(+), 63 deletions(-) diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 25650451..18f11e43 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -368,7 +368,6 @@ {% endif %} {% endblock %} -{# Column filtration (per-column filters), independent from header filters #} {% block kreyu_data_table_column_filters_form %} {% set form = data_table.vars.column_filtration_form|default(null) %} {% if form %} @@ -428,80 +427,52 @@ {% 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) }} - {% endif %} - {% else %} - {{ block('sort_arrow_none', theme, _context) }} - {% endif %} - - {# Inline column filter input (optional) - also render for sortable columns #} - {% 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 %} -
- {# Prefer rendering the value field for compactness; if not available, render whole child #} - {% set child = attribute(column_form, filter_name) %} - {% if attribute(child, 'value', [], 'any', true, true) is defined %} - {{ form_widget(child.value, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', onchange: 'this.form.requestSubmit()' } }) }} - {% else %} - {{ form_widget(child, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self' } }) }} - {% endif %} -
+ {{ block('sort_arrow_none', theme, _context) }} {% endif %} - {% endif %} - {% endif %} - - {% else %} - + + {% else %} {{- block('column_header_label', theme, _context) -}} - {# Inline column filter input (optional) #} - {% 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) %} - {% set filter_child_name = filter_name|replace({'.':'__'}) %} - {% if attribute(column_form, filter_child_name, [], 'any', true, true) is defined %} -
- {# Prefer rendering the value field for compactness; if not available, render whole child #} - {% set child = attribute(column_form, filter_child_name) %} - {% if attribute(child, 'value', [], 'any', true, true) is defined %} - {{ form_widget(child.value, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self', onchange: 'this.form.requestSubmit()' } }) }} - {% else %} - {{ form_widget(child, { attr: { form: column_form.vars.id, 'data-turbo-action': 'advance', 'data-turbo-frame': '_self' } }) }} - {% endif %} -
- {% 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) %} + {{ 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 %} + {% endif %} + {% endblock %} {# @see Kreyu\Bundle\DataTableBundle\Column\Type\ColumnType #} From fdf16777edccd6e11a8d0eea4ef75015bd4daaed Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 10:37:55 +0200 Subject: [PATCH 4/6] Add documentation --- docs/src/docs/components/filters.md | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) 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 From 1e688dfdd37c9e9befbca6eb53e14579591fcfa2 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 11:04:06 +0200 Subject: [PATCH 5/6] Add operator --- src/Resources/views/themes/base.html.twig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 18f11e43..eabb673b 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -468,6 +468,9 @@ {% 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 %} From 417472473f143c416ecd93529e502a1a9d6d1c97 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 21 Aug 2025 11:09:12 +0200 Subject: [PATCH 6/6] Fix tests --- tests/Unit/Filter/FilterClearUrlGeneratorTest.php | 1 + 1 file changed, 1 insertion(+) 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();