diff --git a/src/CoreBundle/Controller/ListControllerTrait.php b/src/CoreBundle/Controller/ListControllerTrait.php index 6aff60ad2..0e9bca396 100644 --- a/src/CoreBundle/Controller/ListControllerTrait.php +++ b/src/CoreBundle/Controller/ListControllerTrait.php @@ -3,7 +3,7 @@ /** * This file is part of MetaModels/core. * - * (c) 2012-2025 The MetaModels team. + * (c) 2012-2026 The MetaModels team. * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -15,7 +15,7 @@ * @author Christian Schiffler * @author Ingolf Steinhardt * @author Sven Baumann - * @copyright 2012-2025 The MetaModels team. + * @copyright 2012-2026 The MetaModels team. * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later * @filesource */ @@ -33,6 +33,7 @@ use MetaModels\Filter\FilterUrl; use MetaModels\Filter\FilterUrlBuilder; use MetaModels\Filter\Setting\IFilterSettingFactory; +use MetaModels\FrontendIntegration\FrontendFilterOptions; use MetaModels\Helper\SortingLinkGenerator; use MetaModels\IFactory; use MetaModels\IItem; @@ -341,9 +342,19 @@ private function getResponseInternal(Template $template, Model $model, Request $ */ private function getFilterParameters(FilterUrl $filterUrl, ItemList $itemRenderer): array { + $filterSetting = $itemRenderer->getFilterSettings(); + /** @var array $wantedByType */ + $wantedByType = []; + // FIXME: improve this call - it does too much. + foreach ( + $filterSetting->getParameterFilterWidgets([], [], new FrontendFilterOptions()) as $widgetName => $widget + ) { + $wantedByType[$widgetName] = (string) ($widget['param_type'] ?? 'slugNget'); + } + $result = []; - foreach ($itemRenderer->getFilterSettings()->getParameters() as $name) { - if (null !== $value = $this->tryReadFromSlugOrGet($filterUrl, $name, 'slugNget')) { + foreach ($filterSetting->getParameters() as $name) { + if (null !== $value = $this->tryReadFromSlugOrGet($filterUrl, $name, $wantedByType[$name] ?? 'slugNget')) { $result[$name] = $value; } } @@ -354,31 +365,32 @@ private function getFilterParameters(FilterUrl $filterUrl, ItemList $itemRendere /** * Get parameter from get or slug. * - * @param FilterUrl $filterUrl The filter URL to obtain parameters from. - * @param string $sortParam The sort parameter name to obtain. - * @param string $sortType The sort URL type. + * @param FilterUrl $filterUrl The filter URL to obtain parameters from. + * @param string $name The parameter name to obtain. + * @param string $paramSource The source in the URL. * * @return string|null */ - private function tryReadFromSlugOrGet(FilterUrl $filterUrl, string $sortParam, string $sortType): ?string + private function tryReadFromSlugOrGet(FilterUrl $filterUrl, string $name, string $paramSource): ?string { $result = null; - switch ($sortType) { + switch ($paramSource) { case 'get': - $result = $filterUrl->getGet($sortParam); + $result = $filterUrl->getGet($name); break; case 'slug': - $result = $filterUrl->getSlug($sortParam); + $result = $filterUrl->getSlug($name); break; case 'slugNget': - $result = ($filterUrl->getGet($sortParam) ?? $filterUrl->getSlug($sortParam)); + $result = ($filterUrl->getGet($name) ?? $filterUrl->getSlug($name)); break; default: } - // Mark the parameter as used (otherwise, a 404 is thrown) - Input::get($sortParam); + // Mark the parameter as used — InputEnhancer registers route parameters via setUnusedRouteParameters(); + // FrontendTemplate::compile() throws UnusedArgumentsException (→ 404) for any that remain unconsumed. + Input::get($name); return $result; } diff --git a/src/CoreBundle/EventListener/DcGeneral/Table/FilterSetting/FilterSettingParamTypeHintListener.php b/src/CoreBundle/EventListener/DcGeneral/Table/FilterSetting/FilterSettingParamTypeHintListener.php new file mode 100644 index 000000000..e04c6102d --- /dev/null +++ b/src/CoreBundle/EventListener/DcGeneral/Table/FilterSetting/FilterSettingParamTypeHintListener.php @@ -0,0 +1,95 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +namespace MetaModels\CoreBundle\EventListener\DcGeneral\Table\FilterSetting; + +use Contao\Message; +use ContaoCommunityAlliance\DcGeneral\Contao\RequestScopeDeterminator; +use ContaoCommunityAlliance\DcGeneral\Contao\View\Contao2BackendView\Event\BuildWidgetEvent; +use ContaoCommunityAlliance\DcGeneral\DataDefinition\ContainerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Displays a deprecation hint when a filter rule uses param_type "slugNget". + * + * The value "slugNget" is kept for backward compatibility with legacy MetaModels but will be removed in a future + * version. This listener informs the editor to switch to either "slug" or "get". + * + * The hint fires on BuildWidgetEvent (not PreEditModelEvent) so it only appears when the param_type field is + * actually visible in the active palette — i.e. for filter types that expose this setting. + */ +final class FilterSettingParamTypeHintListener +{ + /** + * The scope determinator. + * + * @var RequestScopeDeterminator + */ + private RequestScopeDeterminator $scopeDeterminator; + + /** + * The translator. + * + * @var TranslatorInterface + */ + private TranslatorInterface $translator; + + /** + * Create a new instance. + * + * @param RequestScopeDeterminator $scopeDeterminator The scope determinator. + * @param TranslatorInterface $translator The translator. + */ + public function __construct(RequestScopeDeterminator $scopeDeterminator, TranslatorInterface $translator) + { + $this->scopeDeterminator = $scopeDeterminator; + $this->translator = $translator; + } + + /** + * Add a deprecation hint when the param_type widget is rendered with value "slugNget". + * + * @param BuildWidgetEvent $event The event. + * + * @return void + */ + public function handle(BuildWidgetEvent $event): void + { + if (!$this->scopeDeterminator->currentScopeIsBackend()) { + return; + } + + $dataDefinition = $event->getEnvironment()->getDataDefinition(); + assert($dataDefinition instanceof ContainerInterface); + + if ('tl_metamodel_filtersetting' !== $dataDefinition->getName()) { + return; + } + + if ('param_type' !== $event->getProperty()->getName()) { + return; + } + + if ('slugNget' !== $event->getModel()->getProperty('param_type')) { + return; + } + + Message::addInfo($this->translator->trans('hint_param_type_slugNget', [], 'tl_metamodel_filtersetting')); + } +} diff --git a/src/CoreBundle/Migration/FilterSettingParamTypeMigration.php b/src/CoreBundle/Migration/FilterSettingParamTypeMigration.php new file mode 100644 index 000000000..216daf483 --- /dev/null +++ b/src/CoreBundle/Migration/FilterSettingParamTypeMigration.php @@ -0,0 +1,110 @@ + + * @copyright 2012-2026 The MetaModels team. + * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later + * @filesource + */ + +declare(strict_types=1); + +namespace MetaModels\CoreBundle\Migration; + +use Contao\CoreBundle\Migration\AbstractMigration; +use Contao\CoreBundle\Migration\MigrationResult; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception; + +use function array_keys; +use function in_array; + +/** + * Adds the column `param_type` to `tl_metamodel_filtersetting` if it does not yet exist and pre-fills all existing + * rows with `slugNget`. + * + * Background: the DCA default for new filter rules is `slug`. Existing (legacy) filter rules stored no value for this + * column and must therefore receive `slugNget` so their behaviour stays unchanged after the column is introduced. + */ +final class FilterSettingParamTypeMigration extends AbstractMigration +{ + private const TABLE = 'tl_metamodel_filtersetting'; + private const COLUMN = 'param_type'; + + /** + * The database connection. + * + * @var Connection + */ + private Connection $connection; + + /** + * Create a new instance. + * + * @param Connection $connection The database connection. + */ + public function __construct(Connection $connection) + { + $this->connection = $connection; + } + + /** + * {@inheritDoc} + */ + #[\Override] + public function getName(): string + { + return 'Add column param_type to tl_metamodel_filtersetting and pre-fill legacy rows with slugNget.'; + } + + /** + * {@inheritDoc} + * + * @throws Exception + */ + #[\Override] + public function shouldRun(): bool + { + $schemaManager = $this->connection->createSchemaManager(); + if (!$schemaManager->tablesExist([self::TABLE])) { + return false; + } + + $columns = array_keys($schemaManager->listTableColumns(self::TABLE)); + + return !in_array(self::COLUMN, $columns, true); + } + + /** + * {@inheritDoc} + * + * @throws Exception + */ + #[\Override] + public function run(): MigrationResult + { + $this->connection->executeStatement( + "ALTER TABLE `" . self::TABLE . "` + ADD COLUMN `" . self::COLUMN . "` varchar(10) NOT NULL default 'slug'" + ); + + $this->connection->executeStatement( + "UPDATE `" . self::TABLE . "` SET `" . self::COLUMN . "` = 'slugNget'" + ); + + return new MigrationResult( + true, + 'Added column ' . self::TABLE . '.' . self::COLUMN . ' and pre-filled existing rows with slugNget.' + ); + } +} diff --git a/src/CoreBundle/Resources/config/dc-general/table/tl_filtersetting.yml b/src/CoreBundle/Resources/config/dc-general/table/tl_filtersetting.yml index 4bd4529e5..6d9ddb6dc 100644 --- a/src/CoreBundle/Resources/config/dc-general/table/tl_filtersetting.yml +++ b/src/CoreBundle/Resources/config/dc-general/table/tl_filtersetting.yml @@ -97,3 +97,12 @@ services: tags: - name: kernel.event_listener event: 'dc-general.factory.build-data-definition' + + MetaModels\CoreBundle\EventListener\DcGeneral\Table\FilterSetting\FilterSettingParamTypeHintListener: + arguments: + - '@cca.dc-general.scope-matcher' + - '@translator' + tags: + - name: kernel.event_listener + event: dc-general.view.contao2backend.build-widget + method: handle diff --git a/src/CoreBundle/Resources/config/services.yml b/src/CoreBundle/Resources/config/services.yml index 5205668d6..8e86ed4e3 100644 --- a/src/CoreBundle/Resources/config/services.yml +++ b/src/CoreBundle/Resources/config/services.yml @@ -296,6 +296,12 @@ services: tags: - name: contao.migration + MetaModels\CoreBundle\Migration\FilterSettingParamTypeMigration: + arguments: + $connection: '@database_connection' + tags: + - name: contao.migration + MetaModels\CoreBundle\Migration\JumpToMigration: arguments: $connection: '@database_connection' diff --git a/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php b/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php index b9263dace..bd53a1b26 100644 --- a/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php +++ b/src/CoreBundle/Resources/contao/dca/tl_metamodel_filtersetting.php @@ -249,6 +249,7 @@ ], '+fefilter' => [ 'urlparam', + 'param_type', 'predef_param', 'fe_widget', 'allow_empty', @@ -404,6 +405,22 @@ ], 'sql' => "varchar(255) NOT NULL default ''" ], + 'param_type' => [ + 'label' => 'param_type.label', + 'description' => 'param_type.description', + 'exclude' => true, + 'inputType' => 'select', + 'options' => ['slug', 'get', 'slugNget'], + 'reference' => [ + 'slug' => 'param_type_options.slug', + 'get' => 'param_type_options.get', + 'slugNget' => 'param_type_options.slugNget', + ], + 'eval' => [ + 'tl_class' => 'w50', + ], + 'sql' => "varchar(10) NOT NULL default 'slug'", + ], 'predef_param' => [ 'label' => 'predef_param.label', 'description' => 'predef_param.description', diff --git a/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.de.xlf b/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.de.xlf index a06049db1..bad8a6feb 100644 --- a/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.de.xlf +++ b/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.de.xlf @@ -155,6 +155,30 @@ URL parameter URL-Parameter + + URL type for parameter + URL-Typ für den Parameter + + + Define whether the filter parameter is placed as a URL path segment (slug) or as a query string parameter (GET). + Legt fest, ob der Filterparameter als URL-Pfadsegment (Slug) oder als Query-String-Parameter (GET) in die URL eingebaut wird. + + + Slug only + Nur Slug + + + GET only + Nur GET + + + Slug or GET allowed + Slug oder GET erlaubt - Deprecated: Slug ODER GET verwenden! + + + Set the URL type for the parameter to "Slug" OR "GET" - the "Slug or Get" setting will be removed in a future version. + URL-Typ für den Parameter auf "Slug" ODER "GET" einstellen - Einstellung "Slug oder Get" entfällt in künftiger Version. + The URL parameter that shall get mapped to the selected attribute. The special <em>"auto_item"</em> parameter can also be used, this is especially useful for alias columns. @@ -613,4 +637,4 @@ wäre das Query: "SELECT t.id FROM mm_demo AS t WHERE t.catname=\'defa - \ No newline at end of file + diff --git a/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.en.xlf b/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.en.xlf index 7b857f796..86c4ad18e 100644 --- a/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.en.xlf +++ b/src/CoreBundle/Resources/translations/tl_metamodel_filtersetting.en.xlf @@ -125,6 +125,24 @@ parameter can also be used, this is especially useful for alias columns. + + URL type for parameter + + + Define whether the filter parameter is placed as a URL path segment (slug) or as a query string parameter (GET). + + + Slug only + + + GET only + + + Slug or GET allowed - deprecated: use slug OR GET! + + + Set the URL type for the parameter to "Slug" OR "GET" - the "Slug or GET" setting will be removed in a future version. + Static parameter diff --git a/src/Filter/Setting/Simple.php b/src/Filter/Setting/Simple.php index f4f76ee99..e19f7df75 100644 --- a/src/Filter/Setting/Simple.php +++ b/src/Filter/Setting/Simple.php @@ -399,6 +399,7 @@ protected function prepareFrontendFilterOptions($arrWidget, $arrFilterUrl, $arrJ $filterUrl->setSlug($name, (string) $value); } $parameterName = $arrWidget['eval']['urlparam'] ?? ''; + $paramType = $this->get('param_type') ?: 'slug'; if ((bool) ($arrWidget['eval']['includeBlankOption'] ?? false)) { $blnActive = $this->isActiveFrontendFilterValue($arrWidget, $arrFilterUrl, ''); @@ -421,12 +422,19 @@ protected function prepareFrontendFilterOptions($arrWidget, $arrFilterUrl, $arrJ $strValue = $this->getFrontendFilterValue($arrWidget, $arrFilterUrl, $strKeyOption); $blnActive = $this->isActiveFrontendFilterValue($arrWidget, $arrFilterUrl, $strKeyOption); + $optionFilterUrl = match ($paramType) { + 'get' => $filterUrl->clone()->setGet($parameterName, $strValue)->setSlug($parameterName, ''), + 'slugNget' => $filterUrl->clone()->setSlug($parameterName, $strValue)->setGet( + $parameterName, + $strValue + ), + default => $filterUrl->clone()->setSlug($parameterName, $strValue)->setGet($parameterName, ''), + }; + $arrOptions[] = [ 'key' => $strKeyOption, 'value' => $strOption, - 'href' => $this->filterUrlBuilder->generate( - $filterUrl->clone()->setSlug($parameterName, $strValue)->setGet($parameterName, '') - ), + 'href' => $this->filterUrlBuilder->generate($optionFilterUrl), 'active' => $blnActive, 'class' => StringUtil::standardize($strKeyOption) . ($blnActive ? ' active' : '') ]; @@ -531,6 +539,7 @@ protected function prepareFrontendFilterWidget( 'formfield' => $strField, 'raw' => $arrWidget, 'urlparam' => $arrWidget['eval']['urlparam'], + 'param_type' => $this->get('param_type') ?: 'slug', 'options' => $this->prepareFrontendFilterOptions( $arrWidget, $arrFilterUrl, diff --git a/src/FrontendIntegration/FrontendFilter.php b/src/FrontendIntegration/FrontendFilter.php index 7d7b43647..c37f88a47 100644 --- a/src/FrontendIntegration/FrontendFilter.php +++ b/src/FrontendIntegration/FrontendFilter.php @@ -32,6 +32,7 @@ use ContaoCommunityAlliance\Contao\Bindings\ContaoEvents; use ContaoCommunityAlliance\Contao\Bindings\Events\Controller\RedirectEvent; use Contao\CoreBundle\Csrf\ContaoCsrfTokenManager; +use Contao\CoreBundle\Exception\PageNotFoundException; use Contao\CoreBundle\Exception\RedirectResponseException; use Contao\FrontendTemplate; use Contao\Input; @@ -414,11 +415,23 @@ protected function getFilters() $jumpToInformation = $this->objFilterConfig->getJumpTo(); $filterSetting = $this->objFilterConfig->getFilterCollection(); $wantedNames = $this->getWantedNames(); + $wantedByType = ['get' => [], 'slug' => []]; + // FIXME: improve this call - it does too much. + foreach ($filterSetting->getParameterFilterWidgets([], [], $filterOptions) as $widgetName => $widget) { + $type = ($widget['param_type'] ?? 'slugNget'); + if ($type === 'slugNget') { + $wantedByType['slug'][] = $widgetName; + $wantedByType['get'][] = $widgetName; + continue; + } + + $wantedByType[$type][] = $widgetName; + } $this->buildParameters( $other = new FilterUrl($jumpToInformation), $all = new FilterUrl($jumpToInformation), - $wantedNames + $wantedByType ); // DAMN Contao - we have to "mark" the keys in the Input class as used as we get an 404 otherwise. @@ -437,15 +450,27 @@ protected function getFilters() $filterOptions ); + // 404 if a get-only filter parameter is accessed via slug. + foreach ($arrWidgets as $widgetName => $widget) { + if ('get' === ($widget['param_type'] ?? 'slug') && $all->hasSlug($widgetName)) { + throw new PageNotFoundException(); + } + } + // If we have POST data, we need to redirect now. if (Input::post('FORM_SUBMIT') === $this->formId) { foreach ($wantedNames as $widgetName) { if (empty($arrWidgets[$widgetName])) { continue; } - $filter = $arrWidgets[$widgetName]; - if (null !== $filter['urlvalue']) { - $other->setSlug($widgetName, $filter['urlvalue']); + $filter = $arrWidgets[$widgetName]; + $paramValue = $filter['urlvalue']; + if (null !== $paramValue) { + match ($filter['param_type'] ?? 'slug') { + 'get' => $other->setGet($widgetName, $paramValue), + 'slug' => $other->setSlug($widgetName, $paramValue), + default => $other->setSlug($widgetName, $paramValue)->setGet($widgetName, $paramValue), + }; } } @@ -485,30 +510,33 @@ protected function getFilters() * * @param FilterUrl $other Destination for "other" parameters (not originating from current filter module). * @param FilterUrl $all Destination for "all" parameters. - * @param list $wantedNames The wanted parameter names. + * @param list|array{get: list, slug: list} $wantedNames The wanted parameter names. * * @return void */ protected function buildParameters(FilterUrl $other, FilterUrl $all, array $wantedNames): void { - $current = $this->filterUrlBuilder->getCurrentFilterUrl( + $wantedNames = $this->ensureWantedNamesAreSortedByType($wantedNames); + $current = $this->filterUrlBuilder->getCurrentFilterUrl( [ - 'postAsSlug' => $wantedNames, - 'postAsGet' => [], + 'postAsSlug' => $wantedNames['slug'], + 'postAsGet' => $wantedNames['get'], 'preserveGet' => true ] ); foreach ($current->getSlugParameters() as $name => $value) { - $all->setSlug($name, $value); - if (!\in_array($name, $wantedNames)) { + if (!\in_array($name, $wantedNames['slug'], true)) { $other->setSlug($name, $value); + continue; } + $all->setSlug($name, $value); } foreach ($current->getGetParameters() as $name => $value) { - $all->setGet($name, $value); - if (!\in_array($name, $wantedNames)) { + if (!\in_array($name, $wantedNames['get'], true)) { $other->setGet($name, $value); + continue; } + $all->setGet($name, $value); } } @@ -668,4 +696,25 @@ private function getBaseParameters(array $values): array return $processed; } + + /** + * @param list|array{get: list, slug: list} $wantedNames The wanted parameter names. + * + * @return array{get: list, slug: list} + */ + private function ensureWantedNamesAreSortedByType(array $wantedNames): array + { + if (array_keys($wantedNames) !== ['get', 'slug']) { + trigger_deprecation( + 'metamodels/core', + '2.4', + 'Passing a list of strings to "\MetaModels\FrontendIntegration\FrontendFilter::buildParameters()"' . + ' is deprecated since version 2.4, please pass an array [\'get\' => [], \'slug\' => []] instead.', + ); + /** @var list $wantedNames */ + return ['slug' => $wantedNames, 'get' => []]; + } + /** @var array{get: list, slug: list} $wantedNames */ + return $wantedNames; + } } diff --git a/tests/Filter/Setting/SimpleTest.php b/tests/Filter/Setting/SimpleTest.php index 9b87c6622..99b429b0e 100644 --- a/tests/Filter/Setting/SimpleTest.php +++ b/tests/Filter/Setting/SimpleTest.php @@ -3,7 +3,7 @@ /** * This file is part of MetaModels/core. * - * (c) 2012-2024 The MetaModels team. + * (c) 2012-2025 The MetaModels team. * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. @@ -15,13 +15,14 @@ * @author Sven Baumann * @author David Molineus * @author Ingolf Steinhardt - * @copyright 2012-2024 The MetaModels team. + * @copyright 2012-2025 The MetaModels team. * @license https://github.com/MetaModels/core/blob/master/LICENSE LGPL-3.0-or-later * @filesource */ namespace MetaModels\Test\Filter\Setting; +use MetaModels\Filter\FilterUrl; use MetaModels\Filter\FilterUrlBuilder; use MetaModels\Filter\Setting\ICollection; use MetaModels\Filter\Setting\Simple; @@ -40,16 +41,29 @@ class SimpleTest extends TestCase /** * Mock a Simple filter setting. * - * @param array $properties The initialization data. + * @param array $properties The initialization data. + * @param FilterUrlBuilder|null $filterUrlBuilder Optional pre-configured filter URL builder mock. + * @param TranslatorInterface|null $translator Optional pre-configured translator mock. * * @return Simple|MockObject */ - protected function mockSimpleFilterSetting($properties = []) - { - $filterSetting = $this->getMockForAbstractClass(ICollection::class); - $eventDispatcher = $this->getMockForAbstractClass(EventDispatcherInterface::class); - $filterUrlBuilder = $this->getMockBuilder(FilterUrlBuilder::class)->disableOriginalConstructor()->getMock(); - $translator = $this->getMockForAbstractClass(TranslatorInterface::class); + protected function mockSimpleFilterSetting( + $properties = [], + ?FilterUrlBuilder $filterUrlBuilder = null, + ?TranslatorInterface $translator = null + ) { + $filterSetting = $this->getMockForAbstractClass(ICollection::class); + $eventDispatcher = $this->getMockForAbstractClass(EventDispatcherInterface::class); + + if (null === $filterUrlBuilder) { + $filterUrlBuilder = $this->getMockBuilder(FilterUrlBuilder::class) + ->disableOriginalConstructor() + ->getMock(); + } + + if (null === $translator) { + $translator = $this->getMockForAbstractClass(TranslatorInterface::class); + } $setting = $this ->getMockBuilder(Simple::class) @@ -92,6 +106,74 @@ protected function buildFilterUrl($instance, $params, $paramName) return $reflection->invoke($instance, $params, $paramName); } + /** + * Call the protected prepareFrontendFilterOptions method via reflection. + * + * @param Simple $instance The filter setting instance. + * @param array $arrWidget Widget configuration. + * @param array $arrFilterUrl Current filter URL parameters. + * @param array $arrJumpTo Jump-to page information. + * @param bool $blnAutoSubmit Whether to auto-submit. + * + * @return array + */ + protected function callPrepareFrontendFilterOptions( + Simple $instance, + array $arrWidget, + array $arrFilterUrl, + array $arrJumpTo, + bool $blnAutoSubmit + ): array { + $reflection = new \ReflectionMethod($instance, 'prepareFrontendFilterOptions'); + $reflection->setAccessible(true); + return $reflection->invoke($instance, $arrWidget, $arrFilterUrl, $arrJumpTo, $blnAutoSubmit); + } + + /** + * Create a FilterUrlBuilder mock that records slug/GET parameters for each generate() call. + * + * Each entry in $capturedParams will be ['slug' => [...], 'get' => [...]]. + * + * @param array $capturedParams Reference to the array that collects captured data. + * + * @return FilterUrlBuilder&MockObject + */ + protected function mockCapturingFilterUrlBuilder(array &$capturedParams): FilterUrlBuilder + { + $filterUrlBuilder = $this->getMockBuilder(FilterUrlBuilder::class) + ->disableOriginalConstructor() + ->getMock(); + + $filterUrlBuilder->method('generate')->willReturnCallback( + static function (FilterUrl $filterUrl) use (&$capturedParams): string { + $capturedParams[] = [ + 'slug' => $filterUrl->getSlugParameters(), + 'get' => $filterUrl->getGetParameters(), + ]; + return 'http://example.com/test'; + } + ); + + return $filterUrlBuilder; + } + + /** + * Default widget array for param_type tests (two options, no blank option). + * + * @return array + */ + protected function defaultWidget(): array + { + return [ + 'inputType' => 'select', + 'options' => ['A' => 'Option A', 'B' => 'Option B'], + 'eval' => [ + 'urlparam' => 'my_param', + 'includeBlankOption' => false, + ], + ]; + } + /** * Test adding of filter url parameters. * @@ -168,4 +250,113 @@ public function testBuildFilterUrl() 'auto_item 4' ); } + + /** + * Without explicit param_type the default is 'slug': option URLs use slug, GET is cleared. + */ + public function testPrepareFrontendFilterOptionsDefaultIsSlug(): void + { + $capturedParams = []; + $filterUrlBuilder = $this->mockCapturingFilterUrlBuilder($capturedParams); + $setting = $this->mockSimpleFilterSetting([], $filterUrlBuilder); + + $result = $this->callPrepareFrontendFilterOptions($setting, $this->defaultWidget(), [], [], false); + + self::assertCount(2, $result); + self::assertSame('A', $capturedParams[0]['slug']['my_param'] ?? null, 'Option A slug'); + self::assertArrayNotHasKey('my_param', $capturedParams[0]['get'], 'Option A GET absent'); + self::assertSame('B', $capturedParams[1]['slug']['my_param'] ?? null, 'Option B slug'); + self::assertArrayNotHasKey('my_param', $capturedParams[1]['get'], 'Option B GET absent'); + } + + /** + * param_type='slug': option URLs use slug, GET is cleared. + */ + public function testPrepareFrontendFilterOptionsSlugType(): void + { + $capturedParams = []; + $filterUrlBuilder = $this->mockCapturingFilterUrlBuilder($capturedParams); + $setting = $this->mockSimpleFilterSetting(['param_type' => 'slug'], $filterUrlBuilder); + + $result = $this->callPrepareFrontendFilterOptions($setting, $this->defaultWidget(), [], [], false); + + self::assertCount(2, $result); + self::assertSame('A', $capturedParams[0]['slug']['my_param'] ?? null, 'Option A slug'); + self::assertArrayNotHasKey('my_param', $capturedParams[0]['get'], 'Option A GET absent'); + self::assertSame('B', $capturedParams[1]['slug']['my_param'] ?? null, 'Option B slug'); + self::assertArrayNotHasKey('my_param', $capturedParams[1]['get'], 'Option B GET absent'); + } + + /** + * param_type='get': option URLs use GET query string, slug is cleared. + */ + public function testPrepareFrontendFilterOptionsGetType(): void + { + $capturedParams = []; + $filterUrlBuilder = $this->mockCapturingFilterUrlBuilder($capturedParams); + $setting = $this->mockSimpleFilterSetting(['param_type' => 'get'], $filterUrlBuilder); + + $result = $this->callPrepareFrontendFilterOptions($setting, $this->defaultWidget(), [], [], false); + + self::assertCount(2, $result); + self::assertSame('A', $capturedParams[0]['get']['my_param'] ?? null, 'Option A GET'); + self::assertArrayNotHasKey('my_param', $capturedParams[0]['slug'], 'Option A slug absent'); + self::assertSame('B', $capturedParams[1]['get']['my_param'] ?? null, 'Option B GET'); + self::assertArrayNotHasKey('my_param', $capturedParams[1]['slug'], 'Option B slug absent'); + } + + /** + * param_type='slugNget': option URLs set both slug and GET. + */ + public function testPrepareFrontendFilterOptionsSlugNgetType(): void + { + $capturedParams = []; + $filterUrlBuilder = $this->mockCapturingFilterUrlBuilder($capturedParams); + $setting = $this->mockSimpleFilterSetting(['param_type' => 'slugNget'], $filterUrlBuilder); + + $result = $this->callPrepareFrontendFilterOptions($setting, $this->defaultWidget(), [], [], false); + + self::assertCount(2, $result); + self::assertSame('A', $capturedParams[0]['slug']['my_param'] ?? null, 'Option A slug'); + self::assertSame('A', $capturedParams[0]['get']['my_param'] ?? null, 'Option A GET'); + self::assertSame('B', $capturedParams[1]['slug']['my_param'] ?? null, 'Option B slug'); + self::assertSame('B', $capturedParams[1]['get']['my_param'] ?? null, 'Option B GET'); + } + + /** + * The blank "do not filter" option always clears both slug and GET, regardless of param_type. + */ + public function testPrepareFrontendFilterOptionsBlankOptionAlwaysClears(): void + { + $translator = $this->getMockForAbstractClass(TranslatorInterface::class); + $translator->method('trans')->willReturn('- no filter -'); + + $widget = $this->defaultWidget(); + $widget['eval']['includeBlankOption'] = true; + + foreach (['slug', 'get', 'slugNget'] as $paramType) { + $capturedParams = []; + $filterUrlBuilder = $this->mockCapturingFilterUrlBuilder($capturedParams); + $setting = $this->mockSimpleFilterSetting( + ['param_type' => $paramType], + $filterUrlBuilder, + $translator + ); + + $result = $this->callPrepareFrontendFilterOptions($setting, $widget, [], [], false); + + self::assertCount(3, $result, "param_type={$paramType}: expected 3 options (blank + A + B)"); + // Blank option must clear the param from both slug and GET. + self::assertArrayNotHasKey( + 'my_param', + $capturedParams[0]['slug'], + "param_type={$paramType}: blank option must not set slug" + ); + self::assertArrayNotHasKey( + 'my_param', + $capturedParams[0]['get'], + "param_type={$paramType}: blank option must not set GET" + ); + } + } }