diff --git a/com.woltlab.wcf/templates/searchResultList.tpl b/com.woltlab.wcf/templates/searchResultList.tpl index 32dc0b216c4..681e9940341 100644 --- a/com.woltlab.wcf/templates/searchResultList.tpl +++ b/com.woltlab.wcf/templates/searchResultList.tpl @@ -1,43 +1,41 @@ -
-
+ {lang}wcf.search.object.{$message->getObjectTypeName()}{/lang} + +
{unsafe:$message->getFormattedMessage()}
+ + {/foreach} + diff --git a/ts/WoltLabSuite/Core/Ui/Search/Extended.ts b/ts/WoltLabSuite/Core/Ui/Search/Extended.ts index c4155d82e7e..dc1aa408185 100644 --- a/ts/WoltLabSuite/Core/Ui/Search/Extended.ts +++ b/ts/WoltLabSuite/Core/Ui/Search/Extended.ts @@ -10,22 +10,15 @@ import { dboAction } from "../../Ajax"; import DatePicker from "../../Date/Picker"; import * as DomUtil from "../../Dom/Util"; -import { ucfirst } from "../../StringUtil"; import UiSearchInput from "./Input"; -import * as UiScroll from "../Scroll"; import { setValues as setItemListValues } from "../ItemList"; type ResponseSearch = { count: number; title: string; - pages?: number; pageNo?: number; - searchID?: number; template?: string; -}; - -type ResponseSearchResults = { - template: string; + containerCssClassName?: string; }; type SearchParameters = string[][]; @@ -42,11 +35,8 @@ export class UiSearchExtended { private readonly typeInput: HTMLSelectElement; private readonly delimiter: HTMLDivElement; private readonly filtersContainer: HTMLDetailsElement; - private searchID: number | undefined = undefined; - private pages = 0; private activePage = 1; private lastSearchRequest: AbortController | undefined = undefined; - private lastSearchResultRequest: AbortController | undefined = undefined; private searchParameters: SearchParameters = []; constructor() { @@ -117,17 +107,15 @@ export class UiSearchExtended { const request = dboAction("search", "wcf\\data\\search\\SearchAction").payload(this.getFormData()); this.lastSearchRequest = request.getAbortController(); - const { count, searchID, title, pages, pageNo, template } = (await request.dispatch()) as ResponseSearch; + const { count, title, pageNo, template, containerCssClassName } = (await request.dispatch()) as ResponseSearch; document.querySelector(".contentTitle")!.textContent = title; - this.searchID = searchID; this.removeSearchResults(); if (count > 0) { - this.pages = pages!; this.activePage = pageNo!; - this.showSearchResults(template!); + this.showSearchResults(template!, containerCssClassName!); } else if (Object.keys(this.getFormData()).length > 4) { // Show the advanced filters when there are no results // but advanced filters are applied. @@ -237,81 +225,23 @@ export class UiSearchExtended { void this.search(SearchAction.Init); } - private initPagination(position: "top" | "bottom"): void { - const wrapperDiv = document.createElement("div"); - wrapperDiv.classList.add("pagination" + ucfirst(position)); - this.form.parentElement!.insertBefore(wrapperDiv, this.delimiter); - - const pagination = document.createElement("woltlab-core-pagination"); - pagination.page = this.activePage; - pagination.count = this.pages; - pagination.behavior = "button"; - pagination.url = this.getPaginationUrl(); - pagination.addEventListener("switchPage", (event: CustomEvent) => { - void this.changePage(event.detail).then(() => { - if (position === "bottom") { - UiScroll.element(this.form.nextElementSibling as HTMLElement, undefined, "auto"); - } - }); - }); - - wrapperDiv.append(pagination); - } - - private getPaginationUrl(): string { - const url = new URL(this.form.action); - url.search += url.search !== "" ? "&" : "?"; - - const searchParameters: SearchParameters = []; - new FormData(this.form).forEach((value, key) => { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const trimmed = value.toString().trim(); - if (trimmed) { - searchParameters.push([key, trimmed]); - } - }); - const parameters = searchParameters.slice(); - - if (this.activePage > 1) { - parameters.push(["pageNo", this.activePage.toString()]); - } - url.search += new URLSearchParams(parameters).toString(); - - return url.toString(); - } - - private async changePage(pageNo: number): Promise { - this.lastSearchResultRequest?.abort(); - - const request = dboAction("getSearchResults", "wcf\\data\\search\\SearchAction").payload({ - searchID: this.searchID, - pageNo, - }); - this.lastSearchResultRequest = request.getAbortController(); - const { template } = (await request.dispatch()) as ResponseSearchResults; - this.activePage = pageNo; - this.removeSearchResults(); - this.showSearchResults(template); - this.updateQueryString(SearchAction.Navigation); - } - private removeSearchResults(): void { while (this.form.nextSibling !== null && this.form.nextSibling !== this.delimiter) { this.form.parentElement!.removeChild(this.form.nextSibling); } } - private showSearchResults(template: string): void { - if (this.pages > 1) { - this.initPagination("top"); - } - - const fragment = DomUtil.createFragmentFromHtml(template); - this.form.parentElement!.insertBefore(fragment, this.delimiter); + private showSearchResults(template: string, containerCssClassName: string): void { + const container = document.createElement("div"); + container.classList.add("section", ...containerCssClassName.split(" ").filter((className) => className !== "")); + this.form.parentElement!.insertBefore(container, this.delimiter); + DomUtil.setInnerHtml(container, template); - if (this.pages > 1) { - this.initPagination("bottom"); - } + // The list view handles the pagination, but the page number is part of the search url. + container.querySelector("woltlab-core-pagination")?.addEventListener("switchPage", (event: CustomEvent) => { + this.activePage = event.detail; + this.updateQueryString(SearchAction.Navigation); + }); } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Search/Extended.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Search/Extended.js index d10249b0686..37eb749d447 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Search/Extended.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Search/Extended.js @@ -6,25 +6,21 @@ * @license GNU Lesser General Public License * @woltlabExcludeBundle all */ -define(["require", "exports", "tslib", "../../Ajax", "../../Date/Picker", "../../Dom/Util", "../../StringUtil", "./Input", "../Scroll", "../ItemList"], function (require, exports, tslib_1, Ajax_1, Picker_1, DomUtil, StringUtil_1, Input_1, UiScroll, ItemList_1) { +define(["require", "exports", "tslib", "../../Ajax", "../../Date/Picker", "../../Dom/Util", "./Input", "../ItemList"], function (require, exports, tslib_1, Ajax_1, Picker_1, DomUtil, Input_1, ItemList_1) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.UiSearchExtended = void 0; Picker_1 = tslib_1.__importDefault(Picker_1); DomUtil = tslib_1.__importStar(DomUtil); Input_1 = tslib_1.__importDefault(Input_1); - UiScroll = tslib_1.__importStar(UiScroll); class UiSearchExtended { form; queryInput; typeInput; delimiter; filtersContainer; - searchID = undefined; - pages = 0; activePage = 1; lastSearchRequest = undefined; - lastSearchResultRequest = undefined; searchParameters = []; constructor() { this.form = document.getElementById("extendedSearchForm"); @@ -85,14 +81,12 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Date/Picker", "../.. this.lastSearchRequest?.abort(); const request = (0, Ajax_1.dboAction)("search", "wcf\\data\\search\\SearchAction").payload(this.getFormData()); this.lastSearchRequest = request.getAbortController(); - const { count, searchID, title, pages, pageNo, template } = (await request.dispatch()); + const { count, title, pageNo, template, containerCssClassName } = (await request.dispatch()); document.querySelector(".contentTitle").textContent = title; - this.searchID = searchID; this.removeSearchResults(); if (count > 0) { - this.pages = pages; this.activePage = pageNo; - this.showSearchResults(template); + this.showSearchResults(template, containerCssClassName); } else if (Object.keys(this.getFormData()).length > 4) { // Show the advanced filters when there are no results @@ -191,69 +185,21 @@ define(["require", "exports", "tslib", "../../Ajax", "../../Date/Picker", "../.. this.typeInput.dispatchEvent(new Event("change")); void this.search(2 /* SearchAction.Init */); } - initPagination(position) { - const wrapperDiv = document.createElement("div"); - wrapperDiv.classList.add("pagination" + (0, StringUtil_1.ucfirst)(position)); - this.form.parentElement.insertBefore(wrapperDiv, this.delimiter); - const pagination = document.createElement("woltlab-core-pagination"); - pagination.page = this.activePage; - pagination.count = this.pages; - pagination.behavior = "button"; - pagination.url = this.getPaginationUrl(); - pagination.addEventListener("switchPage", (event) => { - void this.changePage(event.detail).then(() => { - if (position === "bottom") { - UiScroll.element(this.form.nextElementSibling, undefined, "auto"); - } - }); - }); - wrapperDiv.append(pagination); - } - getPaginationUrl() { - const url = new URL(this.form.action); - url.search += url.search !== "" ? "&" : "?"; - const searchParameters = []; - new FormData(this.form).forEach((value, key) => { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - const trimmed = value.toString().trim(); - if (trimmed) { - searchParameters.push([key, trimmed]); - } - }); - const parameters = searchParameters.slice(); - if (this.activePage > 1) { - parameters.push(["pageNo", this.activePage.toString()]); - } - url.search += new URLSearchParams(parameters).toString(); - return url.toString(); - } - async changePage(pageNo) { - this.lastSearchResultRequest?.abort(); - const request = (0, Ajax_1.dboAction)("getSearchResults", "wcf\\data\\search\\SearchAction").payload({ - searchID: this.searchID, - pageNo, - }); - this.lastSearchResultRequest = request.getAbortController(); - const { template } = (await request.dispatch()); - this.activePage = pageNo; - this.removeSearchResults(); - this.showSearchResults(template); - this.updateQueryString(1 /* SearchAction.Navigation */); - } removeSearchResults() { while (this.form.nextSibling !== null && this.form.nextSibling !== this.delimiter) { this.form.parentElement.removeChild(this.form.nextSibling); } } - showSearchResults(template) { - if (this.pages > 1) { - this.initPagination("top"); - } - const fragment = DomUtil.createFragmentFromHtml(template); - this.form.parentElement.insertBefore(fragment, this.delimiter); - if (this.pages > 1) { - this.initPagination("bottom"); - } + showSearchResults(template, containerCssClassName) { + const container = document.createElement("div"); + container.classList.add("section", ...containerCssClassName.split(" ").filter((className) => className !== "")); + this.form.parentElement.insertBefore(container, this.delimiter); + DomUtil.setInnerHtml(container, template); + // The list view handles the pagination, but the page number is part of the search url. + container.querySelector("woltlab-core-pagination")?.addEventListener("switchPage", (event) => { + this.activePage = event.detail; + this.updateQueryString(1 /* SearchAction.Navigation */); + }); } } exports.UiSearchExtended = UiSearchExtended; diff --git a/wcfsetup/install/files/lib/data/search/Search.class.php b/wcfsetup/install/files/lib/data/search/Search.class.php index 45bcaf8fc4f..e69c2ab8663 100644 --- a/wcfsetup/install/files/lib/data/search/Search.class.php +++ b/wcfsetup/install/files/lib/data/search/Search.class.php @@ -3,6 +3,7 @@ namespace wcf\data\search; use wcf\data\DatabaseObject; +use wcf\system\WCF; /** * Represents a search. @@ -17,5 +18,49 @@ * @property-read int $searchTime timestamp of the search * @property-read string $searchType type of search, like `messages` or `users` * @property-read string $searchHash hash identifying the search for the user to reuse the result within the first 30 minutes + * + * @phpstan-type SearchData array{ + * results: list, + * query: string, + * additionalData: array, + * sortField: string, + * sortOrder: string, + * objectTypeNames: string[], + * } */ -class Search extends DatabaseObject {} +class Search extends DatabaseObject +{ + /** + * @var SearchData + */ + private array $unserializedSearchData; + + /** + * Returns true if the active user can access the results of this message search. + * + * @since 6.3 + */ + public function canAccess(): bool + { + if ($this->isNil() || $this->searchType !== 'messages') { + return false; + } + + return $this->userID === null || $this->userID === WCF::getUser()->userID; + } + + /** + * Returns the unserialized data and parameters of this search. + * + * @return SearchData + * @since 6.3 + */ + public function getSearchData(): array + { + if (!isset($this->unserializedSearchData)) { + $this->unserializedSearchData = \unserialize($this->searchData); + } + + return $this->unserializedSearchData; + } +} diff --git a/wcfsetup/install/files/lib/data/search/SearchAction.class.php b/wcfsetup/install/files/lib/data/search/SearchAction.class.php index 62e030dd009..60082df0021 100644 --- a/wcfsetup/install/files/lib/data/search/SearchAction.class.php +++ b/wcfsetup/install/files/lib/data/search/SearchAction.class.php @@ -95,10 +95,9 @@ public function validateSearch(): void * @return array{ * count: int, * title: string, - * pages: int, * pageNo: int, - * searchID: int, * template: string, + * containerCssClassName: string, * }|array{count: int, title: string} * @since 5.5 */ @@ -117,34 +116,25 @@ public function search(): array ]; } - $startIndex = 0; - if ($this->parameters['pageNo'] > 1) { - $startIndex = \SEARCH_RESULTS_PER_PAGE * ($this->parameters['pageNo'] - 1); - } - $resultHandler = new SearchResultHandler($search, $startIndex); - $resultHandler->loadSearchResults(); - $templateName = $resultHandler->getTemplateName(); - SearchResultTextParser::getInstance()->setSearchQuery($resultHandler->getQuery()); + $resultHandler = new SearchResultHandler($search); + $listView = $resultHandler->getResultListView(); + $listView->setPageNo(\max(1, $this->parameters['pageNo'])); return [ - 'count' => $resultHandler->countSearchResults(), + 'count' => $listView->countItems(), 'title' => WCF::getLanguage()->getDynamicVariable('wcf.search.results.title', [ - 'count' => $resultHandler->countSearchResults(), + 'count' => $listView->countItems(), 'query' => $resultHandler->getQuery(), ]), - 'pages' => (int)\ceil($resultHandler->countSearchResults() / \SEARCH_RESULTS_PER_PAGE), - 'pageNo' => $this->parameters['pageNo'] ?: 1, - 'searchID' => $search->searchID, - 'template' => WCF::getTPL()->render($templateName['application'], $templateName['templateName'], [ - 'objects' => $resultHandler->getSearchResults(), - 'query' => $resultHandler->getQuery(), - 'customIcons' => $resultHandler->getCustomIcons(), - ]), + 'pageNo' => $listView->getPageNo(), + 'template' => $listView->render(), + 'containerCssClassName' => $listView->getContainerCssClassName(), ]; } /** * @since 5.5 + * @deprecated 6.3 The search results are paginated by `SearchResultListView`. */ public function validateGetSearchResults(): void { @@ -152,10 +142,7 @@ public function validateGetSearchResults(): void $this->readInteger('pageNo'); $search = new Search($this->parameters['searchID']); - if ($search->isNil() || $search->searchType !== 'messages') { - throw new IllegalLinkException(); - } - if ($search->userID !== null && $search->userID !== WCF::getUser()->userID) { + if (!$search->canAccess()) { throw new IllegalLinkException(); } } @@ -163,6 +150,7 @@ public function validateGetSearchResults(): void /** * @return array{template: string} * @since 5.5 + * @deprecated 6.3 The search results are paginated by `SearchResultListView`. */ public function getSearchResults(): array { diff --git a/wcfsetup/install/files/lib/event/listView/user/SearchResultListViewInitialized.class.php b/wcfsetup/install/files/lib/event/listView/user/SearchResultListViewInitialized.class.php new file mode 100644 index 00000000000..edc46ca2276 --- /dev/null +++ b/wcfsetup/install/files/lib/event/listView/user/SearchResultListViewInitialized.class.php @@ -0,0 +1,19 @@ + + * @since 6.3 + */ +final class SearchResultListViewInitialized implements IPsr14Event +{ + public function __construct(public readonly SearchResultListView $listView) {} +} diff --git a/wcfsetup/install/files/lib/system/listView/user/SearchResultListView.class.php b/wcfsetup/install/files/lib/system/listView/user/SearchResultListView.class.php new file mode 100644 index 00000000000..9954f980b16 --- /dev/null +++ b/wcfsetup/install/files/lib/system/listView/user/SearchResultListView.class.php @@ -0,0 +1,154 @@ + + * @since 6.3 + * + * @extends AbstractListView<(DatabaseObject&ISearchResultObject)|(DatabaseObjectDecorator&ISearchResultObject), DatabaseObjectList> + */ +final class SearchResultListView extends AbstractListView +{ + private readonly Search $search; + private SearchResultHandler $resultHandler; + + public function __construct( + public readonly int $searchID + ) { + $this->search = new Search($this->searchID); + + $this->setItemsPerPage(\SEARCH_RESULTS_PER_PAGE); + $this->setAllowFiltering(false); + $this->setAllowSorting(false); + $this->setAllowInteractions(false); + $this->setAllowBulkInteractions(false); + } + + #[\Override] + public function isAccessible(): bool + { + return $this->search->canAccess(); + } + + #[\Override] + protected function initObjectList(): void + { + $this->objectList = $this->createObjectList(); + $this->fireInitializedEvent(); + + $this->objectCount = new SearchResultHandler($this->search)->countSearchResults(); + if ($this->getPageNo() > 1 && $this->getPageNo() > $this->countPages()) { + $this->setPageNo($this->countPages() ?: 1); + } + + $this->resultHandler = new SearchResultHandler( + $this->search, + ($this->getPageNo() - 1) * $this->getItemsPerPage(), + $this->getItemsPerPage() + ); + $this->resultHandler->loadSearchResults(); + } + + #[\Override] + protected function applyFilters(): void + { + // Filters are applied by the search engine when the search is executed. + } + + /** + * @return list<(DatabaseObject&ISearchResultObject)|(DatabaseObjectDecorator&ISearchResultObject)> + */ + #[\Override] + public function getItems(): array + { + $items = []; + foreach ($this->getResultHandler()->getSearchResults() as $item) { + if (!($item instanceof DatabaseObject) && !($item instanceof DatabaseObjectDecorator)) { + throw new ImplementationException(\get_class($item), DatabaseObjectDecorator::class); + } + + $items[] = $item; + } + + return $items; + } + + #[\Override] + public function countItems(): int + { + $this->getObjectList(); + + return $this->objectCount; + } + + public function getQuery(): string + { + return $this->getResultHandler()->getQuery(); + } + + #[\Override] + public function renderItems(): string + { + $resultHandler = $this->getResultHandler(); + $templateName = $resultHandler->getTemplateName(); + SearchResultTextParser::getInstance()->setSearchQuery($resultHandler->getQuery()); + + return WCF::getTPL()->render($templateName['application'], $templateName['templateName'], [ + 'objects' => $resultHandler->getSearchResults(), + 'query' => $resultHandler->getQuery(), + 'customIcons' => $resultHandler->getCustomIcons(), + 'view' => $this, + ]); + } + + private function getResultHandler(): SearchResultHandler + { + $this->getObjectList(); + + return $this->resultHandler; + } + + #[\Override] + protected function getInitializedEvent(): SearchResultListViewInitialized + { + return new SearchResultListViewInitialized($this); + } + + /** + * @return DatabaseObjectList + */ + #[\Override] + protected function createObjectList(): DatabaseObjectList + { + return new class extends DatabaseObjectList {}; + } + + #[\Override] + public function getParameters(): array + { + return [ + 'searchID' => $this->searchID, + ]; + } +} diff --git a/wcfsetup/install/files/lib/system/search/AbstractSearchProvider.class.php b/wcfsetup/install/files/lib/system/search/AbstractSearchProvider.class.php index 97eeed3fbac..54205a1674a 100644 --- a/wcfsetup/install/files/lib/system/search/AbstractSearchProvider.class.php +++ b/wcfsetup/install/files/lib/system/search/AbstractSearchProvider.class.php @@ -3,7 +3,9 @@ namespace wcf\system\search; use wcf\data\object\type\AbstractObjectTypeProcessor; +use wcf\data\search\Search; use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\listView\AbstractListView; /** * This class provides default implementations for the ISearchProvider interface. @@ -100,6 +102,12 @@ public function getCustomIconName(): ?string return null; } + #[\Override] + public function getResultListView(Search $search): ?AbstractListView + { + return null; + } + /** * @deprecated 5.5 */ diff --git a/wcfsetup/install/files/lib/system/search/ISearchProvider.class.php b/wcfsetup/install/files/lib/system/search/ISearchProvider.class.php index e13ff972215..9ad6edbc507 100644 --- a/wcfsetup/install/files/lib/system/search/ISearchProvider.class.php +++ b/wcfsetup/install/files/lib/system/search/ISearchProvider.class.php @@ -3,7 +3,9 @@ namespace wcf\system\search; use wcf\data\search\ISearchResultObject; +use wcf\data\search\Search; use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\listView\AbstractListView; /** * Interface for full-text search providers. @@ -116,4 +118,14 @@ public function getFetchObjectsQuery(?PreparedStatementConditionBuilder $additio * @since 6.0 */ public function getCustomIconName(): ?string; + + /** + * Can optionally return a list view that renders the results of the given search. + * Returns `null` to use the default `SearchResultListView`. + * This method is only called, if this provider is the only provider that is searched. + * + * @return ?AbstractListView<*, *> + * @since 6.3 + */ + public function getResultListView(Search $search): ?AbstractListView; } diff --git a/wcfsetup/install/files/lib/system/search/SearchResultHandler.class.php b/wcfsetup/install/files/lib/system/search/SearchResultHandler.class.php index cb127442547..6fb482beddf 100644 --- a/wcfsetup/install/files/lib/system/search/SearchResultHandler.class.php +++ b/wcfsetup/install/files/lib/system/search/SearchResultHandler.class.php @@ -7,6 +7,8 @@ use wcf\page\SearchResultPage; use wcf\system\exception\ImplementationException; use wcf\system\exception\SystemException; +use wcf\system\listView\AbstractListView; +use wcf\system\listView\user\SearchResultListView; /** * Provides the results of a full-text search. @@ -15,18 +17,15 @@ * @copyright 2001-2021 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.5 + * + * @phpstan-import-type SearchData from Search */ final class SearchResultHandler { private readonly Search $search; /** - * @var array{ - * query: string, - * objectTypeNames: string[], - * results: list, - * additionalData: array - * } + * @var SearchData */ private array $searchData; @@ -46,7 +45,7 @@ public function __construct(Search $search, int $startIndex = 0, int $limit = \S $this->search = $search; $this->startIndex = $startIndex; $this->limit = $limit; - $this->searchData = \unserialize($this->search->searchData); + $this->searchData = $this->search->getSearchData(); } public function countSearchResults(): int @@ -139,6 +138,27 @@ public function getTemplateName(): array return $this->getLegacyTemplateName(); } + /** + * Returns the list view that renders the search results. + * + * @return AbstractListView<*, *> + * @since 6.3 + */ + public function getResultListView(): AbstractListView + { + if (\count($this->searchData['objectTypeNames']) === 1) { + $objectType = SearchEngine::getInstance()->getObjectType($this->searchData['objectTypeNames'][0]); + if ($objectType instanceof ISearchProvider) { + $listView = $objectType->getResultListView($this->search); + if ($listView !== null) { + return $listView; + } + } + } + + return new SearchResultListView($this->search->searchID); + } + /** * @return array */