Add type filter and facet counts to the search endpoint

The search JSON gains tab support: the new `filter` GET parameter
restricts results to one document type (product, news, download, story,
page, market); the response carries `filter` (active value or null) and
`facets.type` with per-type counts and active flags. Counts stay
complete while a filter is active (keepAllFacetsOnSelection), so tabs
never collapse - except on an empty filtered result, documented in spec
v1.12 clause 7.16.

The parameter is named `filter` because `type` is TYPO3's reserved
page-type parameter and crashes page resolution; like q and page it is
excluded from cHash validation. Facets come from EXT:solr's native
faceting, serialized generically by SearchJsonRenderer - a future
category facet only needs TypoScript.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 15:51:26 +02:00
parent cf7a04ea15
commit 083f0937e2
5 changed files with 162 additions and 10 deletions

View File

@@ -28,6 +28,9 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* "numFound": total hits,
* "totalPages": ceil(numFound / resultsPerPage),
* "results": [ { "title", "url", "type", "teaser" } ],
* "filter": the active type filter or null,
* "facets": { "type": [ { "value", "count", "active" } ] } -
* counts stay complete while a filter is active,
* "suggestions": spellcheck alternatives ("did you mean"), [] if none
* }
*
@@ -35,6 +38,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
* q = search terms; without it the empty shape above is returned
* (numFound 0) so the frontend always sees the same structure
* page = 1-based page number, optional
* filter = restrict results to one type from the `type` vocabulary
* (page|product|market|story|news|download); unknown values ignored
* The EXT:solr namespace (tx_solr[q], tx_solr[page]) is accepted as a
* fallback so classic solr URLs keep working.
*
@@ -132,13 +137,21 @@ class SearchJsonRenderer
// excluded from cHash - cached variants would collide (config.no_cache
// is gone in TYPO3 v14, so the cache is disabled per request here).
$request->getAttribute('frontend.cache.instruction')
?->disableCache('vitec search: response varies by q/page query parameters');
?->disableCache('vitec search: response varies by q/page/filter query parameters');
$params = $request->getQueryParams();
$solrNamespace = (array)($params['tx_solr'] ?? []);
$query = trim((string)($params['q'] ?? $solrNamespace['q'] ?? ''));
$page = max(1, (int)($params['page'] ?? $solrNamespace['page'] ?? 1));
// Optional type filter, friendly vocabulary (see TYPE_LABELS).
// Unknown values are ignored, never an error.
$activeType = trim((string)($params['filter'] ?? ''));
$typeFilterField = array_search($activeType, self::TYPE_LABELS, true);
if ($typeFilterField === false) {
$activeType = '';
}
if ($query === '') {
return (string)json_encode($this->emptyResult());
}
@@ -163,8 +176,12 @@ class SearchJsonRenderer
$typoScriptConfiguration,
$search,
);
$arguments = ['q' => $query, 'page' => $page];
if ($activeType !== '') {
$arguments['filter'] = ['type:' . $typeFilterField];
}
$searchRequest = GeneralUtility::makeInstance(SearchRequestBuilder::class, $typoScriptConfiguration)
->buildForSearch(['q' => $query, 'page' => $page], $pageId, $languageId);
->buildForSearch($arguments, $pageId, $languageId);
$resultSet = $searchService->search($searchRequest);
@@ -205,6 +222,29 @@ class SearchJsonRenderer
// spellchecking disabled or unavailable - not essential
}
$facets = [];
try {
foreach ($resultSet->getFacets() as $facet) {
$options = [];
foreach ($facet->getOptions() as $option) {
$value = (string)$option->getUriValue();
if ($facet->getName() === 'type') {
$value = self::TYPE_LABELS[$value] ?? $value;
}
$options[] = [
'value' => $value,
'count' => $option->getDocumentCount(),
'active' => $option->getSelected(),
];
}
if ($options !== []) {
$facets[$facet->getName()] = $options;
}
}
} catch (\Throwable) {
// faceting disabled or unavailable - facets stay empty
}
$numFound = $resultSet->getAllResultCount();
$resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results);
@@ -214,6 +254,8 @@ class SearchJsonRenderer
'resultsPerPage' => $resultsPerPage,
'numFound' => $numFound,
'totalPages' => $resultsPerPage > 0 ? (int)ceil($numFound / $resultsPerPage) : 0,
'filter' => $activeType !== '' ? $activeType : null,
'facets' => (object)$facets,
'results' => $results,
'suggestions' => array_values(array_unique($suggestions)),
]);
@@ -233,6 +275,8 @@ class SearchJsonRenderer
'resultsPerPage' => 0,
'numFound' => 0,
'totalPages' => 0,
'filter' => null,
'facets' => new \stdClass(),
'results' => [],
'suggestions' => [],
];