Support pagination with search parameters

Ensure the parameters are passed on to new generated links.
Cover things with tests.

Relates: #11574
This commit is contained in:
Daniel Siepmann (Codappix) 2025-01-15 12:03:25 +01:00
parent 56d922cb3a
commit 4e1d158923
24 changed files with 757 additions and 17 deletions
Classes
Configuration
Documentation
Tests/Functional/Frontend
Fixtures
Database
Extensions
ce_filter
Configuration/TypoScript
Resources/Private/Templates/Date
composer.jsonext_emconf.phpext_localconf.php
ce_list
Configuration/TypoScript
Resources/Private
Partials
Templates/Date
composer.jsonext_emconf.phpext_localconf.php
example
Sites/default
SearchTest.php
composer.json

View file

@ -7,6 +7,7 @@ namespace WerkraumMedia\Events\Controller;
use Exception;
use Psr\Http\Message\ResponseInterface;
use Throwable;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Extbase\Annotation as Extbase;
use TYPO3\CMS\Extbase\Service\ExtensionService;
use WerkraumMedia\Events\Domain\Model\Date;
@ -38,13 +39,13 @@ final class DateController extends AbstractController
{
parent::initializeAction();
$this->handlePostRequests();
$contentObject = $this->request->getAttribute('currentContentObject');
if ($contentObject !== null) {
$this->demandFactory->setContentObjectRenderer($contentObject);
}
$this->dataProcessing->setConfigurationManager($this->configurationManager);
$this->handlePostRequest();
}
public function listAction(
@ -127,22 +128,34 @@ final class DateController extends AbstractController
*
* @see: https://en.wikipedia.org/wiki/Post/Redirect/Get
*/
private function handlePostRequest(): void
private function handlePostRequests(): void
{
if (
$this->request->getMethod() === 'POST'
&& $this->request->hasArgument('search')
&& is_array($this->request->getArgument('search'))
) {
$namespace = $this->extensionService->getPluginNamespace(null, null);
if ($this->request->getMethod() !== 'POST') {
return;
}
$searchArguments = [];
if ($this->request->hasArgument('search')) {
$searchArguments = $this->request->getArgument('search');
}
if (is_array($searchArguments) === false) {
$searchArguments = [];
}
$searchArguments = array_filter($searchArguments);
$parameter = [];
if ($searchArguments !== []) {
$parameter['search'] = $searchArguments;
}
$namespace = $this->extensionService->getPluginNamespace(null, null);
throw new PropagateResponseException(
$this->redirectToUri($this->request->getAttribute('currentContentObject')->typoLink_URL([
'parameter' => 't3://page?uid=current',
'additionalParams' => '&' . http_build_query([
$namespace => [
'search' => array_filter($this->request->getArgument('search')),
],
]),
]));
}
'additionalParams' => '&' . http_build_query([$namespace => $parameter]),
])),
303
);
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2025 Daniel Siepmann <daniel.siepmann@codappix.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\Events\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Routing\PageArguments;
use TYPO3\CMS\Core\Utility\ArrayUtility;
/**
* Right now I couldn't find any way to add the search parameter to the pagination via addQueryString.
* The issue is that these are not part of TYPO3 routing. Nested lists like categories also seem impossible to configure for routing.
*
* See corresponding core issue: https://forge.typo3.org/issues/105941
*/
final class AddSearchArgumentsToRouteArgumentsMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly string $routingPath = 'events/search',
) {
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$previousResult = $request->getAttribute('routing', null);
if ($previousResult instanceof PageArguments) {
$request = $request->withAttribute('routing', $this->extendPageArguments($previousResult));
}
return $handler->handle($request);
}
private function extendPageArguments(PageArguments $pageArguments): PageArguments
{
$dynamicArguments = $pageArguments->getDynamicArguments();
if (ArrayUtility::isValidPath($dynamicArguments, $this->routingPath) === false) {
return $pageArguments;
}
$routeArguments = ArrayUtility::setValueByPath(
$pageArguments->getRouteArguments(),
$this->routingPath,
ArrayUtility::getValueByPath($dynamicArguments, $this->routingPath)
);
$remainingArguments = ArrayUtility::removeByPath($dynamicArguments, $this->routingPath);
return new PageArguments(
$pageArguments->getPageId(),
$pageArguments->getPageType(),
$routeArguments,
$pageArguments->getStaticArguments(),
$remainingArguments,
);
}
}

View file

@ -17,7 +17,9 @@ routeEnhancers:
EventsPagination:
type: Plugin
namespace: events
routePath: '/{localizedPage}-{currentPage}'
routePath: '/{localizedPage}-{currentPage}/{controller}'
defaults:
controller: 'Date'
aspects:
localizedPage:
type: LocaleModifier

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use WerkraumMedia\Events\Middleware\AddSearchArgumentsToRouteArgumentsMiddleware;
return [
'frontend' => [
'werkraummedia/events/add-search-arguments-to-route-arguments' => [
'target' => AddSearchArgumentsToRouteArgumentsMiddleware::class,
'after' => [
'typo3/cms-frontend/page-resolver',
],
'before' => [
'typo3/cms-frontend/page-argument-validator',
],
],
],
];

View file

@ -44,6 +44,9 @@ Fixes
* Ensure pagination settings are provided in expected type (int).
TypoScript settings will always be strings, so we fix this with a proper type cast.
* Fix broken pagination routing in combination with active search for TYPO3 12.4 and onwards.
See: :ref:`searchPagination`.
Tasks
-----

View file

@ -0,0 +1,25 @@
.. index:: single: Search
.. _searchPagination:
Search Pagination
=================
The extension supports pagination of search results.
Please properly configure the system. Add the following `TYPO3_CONF_VARS` configuration:
.. code-block:: php
'FE' => [
'cacheHash' => [
'excludedParameters' => [
'^events[search]',
],
],
],
Adopt the configuration to your own setup, e.g. change the `pluginNamespace` from `events` to whatever you've configured.
And ensure the involved plugins are excluded from caching (`USER_INT`).
The extension will assume `events[search]` as default namespace for search arguments.
Please make use of Services files and Dependency Injection to configure the custom
`AddSearchArgumentsToRouteArgumentsMiddleware` middleware with your own namespace.

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
return [
'tt_content' => [
[
'pid' => '1',
'uid' => '1',
'CType' => 'cefilter_filter',
'header' => 'Search Form',
],
[
'pid' => '1',
'uid' => '2',
'CType' => 'celist_list',
'header' => 'Search Results',
],
],
'tx_events_domain_model_event' => [
[
'uid' => '1',
'pid' => '2',
'title' => 'Event one',
'teaser' => 'Some Teaser',
],
[
'uid' => '2',
'pid' => '2',
'title' => 'Event two',
'teaser' => 'Another teaser',
],
],
'tx_events_domain_model_date' => [
[
'uid' => '1',
'pid' => '2',
'event' => '1',
'start' => '1661626800',
'end' => '1661632200',
],
[
'uid' => '2',
'pid' => '2',
'event' => '1',
'start' => '1660158000',
'end' => '1660163400',
],
[
'uid' => '3',
'pid' => '2',
'event' => '2',
'start' => '1661194800',
'end' => '1661200200',
],
],
];

View file

@ -0,0 +1,9 @@
tt_content.cefilter_filter.20 {
view {
pluginNamespace = events
}
persistence {
storagePid = 2
}
}

View file

@ -0,0 +1,53 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<div class="row">
<div class="col-12 col-md-8 offset-md-4">
<div class="panel panel-default">
<div class="panel-body">
<f:form method="post" name="search" object="{demand}">
<div class="row mb-3">
<div class="col-md-12">
<div class="form-group">
<label for="searchword">Searchword</label>
<f:form.textfield type="text" class="form-control" id="searchword" property="searchword" value="{searchword}" />
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-6">
<div class="form-group">
<label for="start">From:</label>
<f:form.textfield type="date" class="form-control" id="start" property="start" />
</div>
</div>
<div class="col col-md-6">
<div class="form-group">
<label for="end">To:</label>
<f:form.textfield type="date" class="form-control" id="end" property="end" />
</div>
</div>
</div>
<f:if condition="{categories}">
<div class="row mb-3">
<f:for each="{categories}" as="category">
<div class="col-md-4 d-none d-lg-block">
<div class="form-check">
<f:form.checkbox class="form-check-input" property="userCategories" value="{category.uid}" id="check_{category.uid}"/>
<label class="form-check-label" for="check_{category.uid}">{category.title} {category.amountOfEvents}</label>
</div>
</div>
</f:for>
</div>
</f:if>
<div class="form-group mb-3">
<f:form.submit value="Search" class="btn btn-primary" />
</div>
</f:form>
</div>
</div>
</div>
</div>
</html>

View file

@ -0,0 +1,16 @@
{
"name": "werkraummedia/ce-events-filter",
"type": "typo3-cms-extension",
"description": "Content element: EXT:events filter",
"license": "GPL-2.0-or-later",
"version": "v1.0.0",
"require": {
"typo3/cms-core": "*",
"typo3/cms-extbase": "*"
},
"extra": {
"typo3/cms": {
"extension-key": "ce_filter"
}
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
$EM_CONF['ce_filter'] = [
'title' => 'Events filter',
'description' => 'Content Element for \'Events\'',
'category' => 'plugin',
'version' => '1.0.0',
'constraints' => [
'depends' => [
'typo3' => '*',
'events' => '*',
],
'conflicts' => [
],
'suggests' => [
],
],
];

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTypoScriptSetup(
'@import "EXT:ce_filter/Configuration/TypoScript/Setup.typoscript"'
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
'CeFilter',
'filter',
[
\WerkraumMedia\Events\Controller\DateController::class => 'search',
],
[
\WerkraumMedia\Events\Controller\DateController::class => 'search',
],
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);

View file

@ -0,0 +1,15 @@
tt_content.celist_list.20 {
view {
pluginNamespace = events
}
persistence {
storagePid = 2
}
settings {
sortByDate = start
sortOrder = ASC
itemsPerPage = 1
}
}

View file

@ -0,0 +1,94 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
data-namespace-typo3-fluid="true">
<nav role="navigation" aria-label="Pagination Navigation">
<ul class="pagination">
<f:if condition="{pagination.previousPageNumber}">
<li class="page-item">
<f:if condition="{pagination.previousPageNumber} > 1">
<f:then>
<a class="page-link"
href="{f:uri.action(addQueryString: 1, arguments: {currentPage: pagination.previousPageNumber})}"
>
<span aria-hidden="true">&laquo;</span>
</a>
</f:then>
<f:else>
<a class="page-link" href="{f:uri.action(addQueryString: 1)}">
<span aria-hidden="true">&laquo;</span>
</a>
</f:else>
</f:if>
</li>
</f:if>
<f:if condition="{pagination.displayRangeStart} > 1">
<li class="page-item">
<a class="page-link"
href="{f:uri.action(addQueryString: 1)}"
aria-label="Goto Page 1"
>
1
</a>
</li>
</f:if>
<f:if condition="{pagination.hasLessPages}">
<li class="page-item">
<span class="page-link"></span>
</li>
</f:if>
<f:for each="{pagination.allPageNumbers}" as="page">
<f:if condition="{page} == {pagination.paginator.currentPageNumber}">
<f:then>
<li class="page-item active">
<span class="page-link"
aria-label="Current Page {page}"
aria-current="true"
>{page}</span>
</li>
</f:then>
<f:else>
<li class="page-item">
<a class="page-link"
href="{f:uri.action(addQueryString: 1, arguments: {currentPage: page})}"
aria-label="Goto Page {page}"
>
{page}
</a>
</li>
</f:else>
</f:if>
</f:for>
<f:if condition="{pagination.hasMorePages}">
<li class="page-item">
<span class="page-link"></span>
</li>
</f:if>
<f:if condition="{pagination.displayRangeEnd} < {pagination.lastPageNumber}">
<li class="page-item">
<a class="page-link"
href="{f:uri.action(addQueryString: 1, arguments: {currentPage: pagination.lastPageNumber})}"
aria-label="Goto Page {pagination.lastPageNumber}"
>
{pagination.lastPageNumber}
</a>
</li>
</f:if>
<f:if condition="{pagination.nextPageNumber}">
<li class="page-item">
<a class="page-link"
href="{f:uri.action(addQueryString: 1, arguments: {currentPage: pagination.nextPageNumber})}"
>
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</f:if>
</ul>
</nav>
</html>

View file

@ -0,0 +1,29 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:if condition="{pagination.paginator.paginatedItems}">
<section class="events-list" data-uniform="height">
<f:for each="{pagination.paginator.paginatedItems}" as="date" iteration="index">
<div class="row mb-5">
<h3>
{date.event.title}
</h3>
<p>
<f:format.crop maxCharacters="160" respectHtml="true"
respectWordBoundaries="true">
{date.event.teaser}</f:format.crop>
</p>
</div>
</f:for>
</section>
<section class="events-list-pagination">
{f:render(partial: 'Pagination', arguments: {
pagination: pagination
})}
</section>
</f:if>
</html>

View file

@ -0,0 +1,16 @@
{
"name": "werkraummedia/ce-list",
"type": "typo3-cms-extension",
"description": "Content element: EXT:events list",
"license": "GPL-2.0-or-later",
"version": "v1.0.0",
"require": {
"typo3/cms-core": "*",
"typo3/cms-extbase": "*"
},
"extra": {
"typo3/cms": {
"extension-key": "ce_list"
}
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
$EM_CONF['ce_list'] = [
'title' => 'Events list view',
'description' => 'Content Elements for \'Events\'',
'category' => 'plugin',
'version' => '1.0.0',
'constraints' => [
'depends' => [
'typo3' => '*',
'events' => '*',
],
'conflicts' => [
],
'suggests' => [
],
],
];

View file

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
\TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addTypoScriptSetup(
'@import "EXT:ce_list/Configuration/TypoScript/Setup.typoscript"'
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
'CeList',
'list',
[
\WerkraumMedia\Events\Controller\DateController::class => 'list',
],
[
\WerkraumMedia\Events\Controller\DateController::class => 'list',
],
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/*
* Copyright (C) 2025 Daniel Siepmann <daniel.siepmann@codappix.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
* 02110-1301, USA.
*/
namespace WerkraumMedia\EventsExample;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\DateTimeAspect;
/**
* Allows us to set a specific DateTimeAspect for requests within tests.
*/
final class TestingDateTimeAspectMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Context $context,
) {
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$testingDateAspect = $request->getAttribute('testingDateAspect');
if ($testingDateAspect instanceof DateTimeAspect) {
$this->context->setAspect('date', $testingDateAspect);
}
return $handler->handle($request);
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
use WerkraumMedia\EventsExample\TestingDateTimeAspectMiddleware;
return [
'frontend' => [
'werkraummedia/events/testing-date-time-aspect' => [
'target' => TestingDateTimeAspectMiddleware::class,
'before' => [
'typo3/cms-frontend/timetracker',
],
],
],
];

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return static function (ContainerConfigurator $containerConfigurator) {
$services = $containerConfigurator->services()
->defaults()
->autowire()
->autoconfigure()
;
$services->load('WerkraumMedia\\EventsExample\\', '../Classes/*');
};

View file

@ -14,3 +14,6 @@ languages:
flag: us
websiteTitle: ''
rootPageId: 1
imports:
-
resource: 'EXT:events/Configuration/CeRouting.yaml'

View file

@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace WerkraumMedia\Events\Tests\Functional\Frontend;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\CMS\Core\Context\DateTimeAspect;
use TYPO3\CMS\Core\Http\StreamFactory;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
final class SearchTest extends AbstractFrontendTestCase
{
protected function setUp(): void
{
$this->testExtensionsToLoad = [
...$this->testExtensionsToLoad,
'typo3conf/ext/events/Tests/Functional/Frontend/Fixtures/Extensions/ce_filter/',
'typo3conf/ext/events/Tests/Functional/Frontend/Fixtures/Extensions/ce_list/',
];
ArrayUtility::mergeRecursiveWithOverrule($this->configurationToUseInTestInstance, [
'FE' => [
'cacheHash' => [
'excludedParameters' => [
'^events[search]',
],
],
],
]);
parent::setUp();
$this->importPHPDataSet(__DIR__ . '/Fixtures/Database/SearchSetup.php');
}
#[Test]
public function submittingPostWithoutSearchArgumentsRedirectsToGet(): void
{
$request = new InternalRequest('https://example.com/');
$request = $request->withMethod('POST');
$request = $request->withPageId(1);
$response = $this->executeFrontendSubRequest($request);
self::assertSame(303, $response->getStatusCode());
self::assertSame('http://example.com/', $response->getHeaderLine('location'));
}
#[Test]
public function submittingPostWithSearchArgumentsRedirectsToGet(): void
{
$request = new InternalRequest('https://example.com/');
$request = $request->withMethod('POST');
$request = $request->withPageId(1);
$request = $request->withBody((new StreamFactory())->createStream(http_build_query([
'events' => [
'search' => [
'searchword' => 'Event',
],
],
])));
$response = $this->executeFrontendSubRequest($request);
self::assertSame(303, $response->getStatusCode());
self::assertSame('http://example.com/?events%5Bsearch%5D%5Bsearchword%5D=Event', $response->getHeaderLine('location'));
}
#[Test]
public function submittedInputShownInForm(): void
{
$request = new InternalRequest('https://example.com/');
$request = $request->withQueryParams([
'events' => [
'search' => [
'searchword' => 'Event',
],
],
]);
$request = $request->withPageId(1);
$response = $this->executeFrontendSubRequest($request);
$html = $response->getBody()->__toString();
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('value="Event"', $html, 'Submitted value is not rendered within form');
}
#[Test]
public function submittedInputIsKeptWithinPagination(): void
{
$request = new InternalRequest('https://example.com/');
$request = $request->withAttribute('testingDateAspect', new DateTimeAspect(new DateTimeImmutable('2022-08-10')));
$request = $request->withQueryParams([
'events' => [
'search' => [
'searchword' => 'Event',
],
],
]);
$request = $request->withPageId(1);
$response = $this->executeFrontendSubRequest($request);
$html = $response->getBody()->__toString();
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('Event one', $html);
self::assertStringContainsString('Current Page 1', $html);
self::assertStringContainsString('/page-2?events%5Bsearch%5D%5Bsearchword%5D=Event&amp;cHash=41711281293c1c3a3aa161e96bbd4e98', $html);
self::assertStringNotContainsString('Event two', $html);
self::assertStringContainsString('value="Event"', $html, 'Submitted value is not rendered within form');
// Ensure going to 2nd page works (make sure it is available after warming up cache for first page)
$request = new InternalRequest('https://example.com/');
$request = $request->withAttribute('testingDateAspect', new DateTimeAspect(new DateTimeImmutable('2022-08-10')));
$request = $request->withQueryParams([
'events' => [
'search' => [
'searchword' => 'Event',
],
'controller' => 'Date',
'currentPage' => '2',
],
'cHash' => '41711281293c1c3a3aa161e96bbd4e98',
]);
$request = $request->withPageId(1);
$response = $this->executeFrontendSubRequest($request);
$html = $response->getBody()->__toString();
self::assertSame(200, $response->getStatusCode());
self::assertStringContainsString('Event two', $html);
self::assertStringContainsString('Current Page 2', $html);
self::assertStringContainsString('/page-1?events%5Bsearch%5D%5Bsearchword%5D=Event&amp;cHash=13c33adfef09ccb19da7d399ada25c4c', $html);
self::assertStringNotContainsString('Event one', $html);
self::assertStringContainsString('value="Event"', $html, 'Submitted value is not rendered within form');
}
}

View file

@ -38,6 +38,7 @@
"autoload-dev": {
"psr-4": {
"WerkraumMedia\\Events\\Tests\\": "Tests",
"WerkraumMedia\\EventsExample\\": "Tests/Functional/Frontend/Fixtures/Extensions/example/Classes/",
"WerkraumMedia\\CustomCategories\\": "Tests/Functional/Psr14Events/DestinationDataImport/Fixtures/Extensions/custom_categories/Classes/",
"WerkraumMedia\\CustomEvent\\": "Tests/Functional/Psr14Events/DestinationDataImport/Fixtures/Extensions/custom_event/Classes/"
}