Add filters
authorMarcel Werk <burntime@woltlab.com>
Mon, 30 Sep 2024 14:08:42 +0000 (16:08 +0200)
committerMarcel Werk <burntime@woltlab.com>
Mon, 30 Sep 2024 14:08:42 +0000 (16:08 +0200)
17 files changed:
com.woltlab.wcf/templates/shared_gridView.tpl
com.woltlab.wcf/templates/shared_gridViewRows.tpl
ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts
ts/WoltLabSuite/Core/Component/GridView.ts
wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js
wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js
wcfsetup/install/files/lib/action/GridViewFilterAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php
wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php
wcfsetup/install/files/lib/system/view/grid/AbstractGridView.class.php
wcfsetup/install/files/lib/system/view/grid/DatabaseObjectListGridView.class.php
wcfsetup/install/files/lib/system/view/grid/GridViewColumn.class.php
wcfsetup/install/files/lib/system/view/grid/filter/IGridViewFilter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/view/grid/filter/SelectFilter.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/view/grid/filter/TextFilter.class.php [new file with mode: 0644]

index 9ec21fc7550b8957a1377849c7054a322ca7a84d..b8ea8b45aeb66fe3d554d95d43d3df595872d744 100644 (file)
@@ -1,3 +1,12 @@
+{if $view->isFilterable()}
+       <button type="button" class="button" id="{$view->getID()}_filterButton" data-endpoint="{$view->getFilterActionEndpoint()}">Filter</button>
+       <div id="{$view->getID()}_filters">
+               {foreach from=$view->getActiveFilters() item='value' key='key'}
+                       <button type="button" class="button" data-filter="{$key}" data-filter-value="{$value}">{$view->getFilterLabel($key)}</button>
+               {/foreach}
+       </div>
+{/if}
+
 {if $view->countRows()}
        <div class="paginationTop">
                <woltlab-core-pagination id="{$view->getID()}_topPagination" page="{$view->getPageNo()}" count="{$view->countPages()}"></woltlab-core-pagination>
@@ -16,7 +25,9 @@
                                                        {unsafe:$column->getLabel()}
                                                </th>
                                        {/foreach}
-                                       <th></th>
+                                       {if $view->hasActions()}
+                                               <th></th>
+                                       {/if}
                                </td>
                        </thead>
                        <tbody>
index 5b0e3ca037b41da731cfdc1f2fac9f9a70dfd19a..f494ad22515033996c0f7fee024d446f1a4c370b 100644 (file)
@@ -6,18 +6,21 @@
                                {unsafe:$view->renderColumn($column, $row)}
                        </td>
                {/foreach}
-               <td>
-                       <div class="dropdown">
-                               <button type="button" class="gridViewActions button small dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">{icon name='ellipsis-vertical'}</button>
+               {if $view->hasActions()}
+                       <td>
+                               <div class="dropdown">
+                                       <button type="button" class="gridViewActions button small dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">{icon name='ellipsis-vertical'}</button>
 
-                               <ul class="dropdownMenu">
-                                       {foreach from=$view->getActions() item='action'}
-                                               <li>
-                                                       {unsafe:$view->renderAction($action, $row)}
-                                               </li>
-                                       {/foreach}
-                               </ul>
-                       </div>
-               </td>
+                                       <ul class="dropdownMenu">
+                                                       {foreach from=$view->getActions() item='action'}
+                                                               <li>
+                                                                       {unsafe:$view->renderAction($action, $row)}
+                                                               </li>
+                                                       {/foreach}
+                                               
+                                       </ul>
+                               </div>
+                       </td>
+               {/if}
        </tr>
 {/foreach}
index 59543979c8f4772cd020f07d6668192ad60624f7..6dc07920dccde5e913012e03fdb66312147ef85b 100644 (file)
@@ -3,6 +3,8 @@ import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
 
 type Response = {
   template: string;
+  pages: number;
+  filterLabels: ArrayLike<string>;
 };
 
 export async function getRows(
@@ -10,12 +12,18 @@ export async function getRows(
   pageNo: number,
   sortField: string = "",
   sortOrder: string = "ASC",
+  filters?: Map<string, string>,
 ): Promise<ApiResult<Response>> {
   const url = new URL(`${window.WSC_RPC_API_URL}core/gridViews/rows`);
   url.searchParams.set("gridView", gridViewClass);
   url.searchParams.set("pageNo", pageNo.toString());
   url.searchParams.set("sortField", sortField);
   url.searchParams.set("sortOrder", sortOrder);
+  if (filters) {
+    filters.forEach((value, key) => {
+      url.searchParams.set(`filters[${key}]`, value);
+    });
+  }
 
   let response: Response;
   try {
index 75e9fb3e40aa920cca0c774e0db307a178daa8d7..6730ea4cb588695075f3c1a09c9539997268a272 100644 (file)
@@ -1,6 +1,8 @@
 import { getRows } from "../Api/GridViews/GetRows";
 import DomUtil from "../Dom/Util";
+import { promiseMutex } from "../Helper/PromiseMutex";
 import UiDropdownSimple from "../Ui/Dropdown/Simple";
+import { dialogFactory } from "./Dialog";
 
 export class GridView {
   readonly #gridClassName: string;
@@ -8,11 +10,14 @@ export class GridView {
   readonly #topPagination: WoltlabCorePaginationElement;
   readonly #bottomPagination: WoltlabCorePaginationElement;
   readonly #baseUrl: string;
+  readonly #filterButton: HTMLButtonElement;
+  readonly #filterPills: HTMLElement;
   #pageNo: number;
   #sortField: string;
   #sortOrder: string;
   #defaultSortField: string;
   #defaultSortOrder: string;
+  #filters: Map<string, string>;
 
   constructor(
     gridId: string,
@@ -26,6 +31,8 @@ export class GridView {
     this.#table = document.getElementById(`${gridId}_table`) as HTMLTableElement;
     this.#topPagination = document.getElementById(`${gridId}_topPagination`) as WoltlabCorePaginationElement;
     this.#bottomPagination = document.getElementById(`${gridId}_bottomPagination`) as WoltlabCorePaginationElement;
+    this.#filterButton = document.getElementById(`${gridId}_filterButton`) as HTMLButtonElement;
+    this.#filterPills = document.getElementById(`${gridId}_filters`) as HTMLElement;
     this.#pageNo = pageNo;
     this.#baseUrl = baseUrl;
     this.#sortField = sortField;
@@ -36,6 +43,7 @@ export class GridView {
     this.#initPagination();
     this.#initSorting();
     this.#initActions();
+    this.#initFilters();
 
     window.addEventListener("popstate", () => {
       this.#handlePopState();
@@ -98,11 +106,19 @@ export class GridView {
   }
 
   async #loadRows(updateQueryString: boolean = true): Promise<void> {
-    const response = await getRows(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder);
-    DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.unwrap().template);
+    const response = (
+      await getRows(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder, this.#filters)
+    ).unwrap();
+    DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.template);
+
+    this.#topPagination.count = response.pages;
+    this.#bottomPagination.count = response.pages;
+
     if (updateQueryString) {
       this.#updateQueryString();
     }
+
+    this.#renderFilters(response.filterLabels);
     this.#initActions();
   }
 
@@ -121,6 +137,9 @@ export class GridView {
       parameters.push(["sortField", this.#sortField]);
       parameters.push(["sortOrder", this.#sortOrder]);
     }
+    this.#filters.forEach((value, key) => {
+      parameters.push([`filters[${key}]`, value]);
+    });
 
     if (parameters.length > 0) {
       url.search += url.search !== "" ? "&" : "?";
@@ -148,10 +167,79 @@ export class GridView {
     });
   }
 
+  #initFilters(): void {
+    if (!this.#filterButton) {
+      return;
+    }
+
+    this.#filterButton.addEventListener(
+      "click",
+      promiseMutex(() => this.#showFilterDialog()),
+    );
+
+    if (!this.#filterPills) {
+      return;
+    }
+
+    const filterButtons = this.#filterPills.querySelectorAll<HTMLButtonElement>("[data-filter]");
+    if (!filterButtons.length) {
+      return;
+    }
+
+    this.#filters = new Map<string, string>();
+    filterButtons.forEach((button) => {
+      this.#filters.set(button.dataset.filter!, button.dataset.filterValue!);
+      button.addEventListener("click", () => {
+        this.#removeFilter(button.dataset.filter!);
+      });
+    });
+  }
+
+  async #showFilterDialog(): Promise<void> {
+    const url = new URL(this.#filterButton.dataset.endpoint!);
+    if (this.#filters) {
+      this.#filters.forEach((value, key) => {
+        url.searchParams.set(`filters[${key}]`, value);
+      });
+    }
+
+    const { ok, result } = await dialogFactory().usingFormBuilder().fromEndpoint(url.toString());
+
+    if (ok) {
+      this.#filters = new Map(Object.entries(result as ArrayLike<string>));
+      this.#switchPage(1);
+    }
+  }
+
+  #renderFilters(labels: ArrayLike<string>): void {
+    this.#filterPills.innerHTML = "";
+    if (!this.#filters) {
+      return;
+    }
+
+    this.#filters.forEach((value, key) => {
+      const button = document.createElement("button");
+      button.type = "button";
+      button.classList.add("button");
+      button.innerText = labels[key];
+      button.addEventListener("click", () => {
+        this.#removeFilter(key);
+      });
+
+      this.#filterPills.append(button);
+    });
+  }
+
+  #removeFilter(filter: string): void {
+    this.#filters.delete(filter);
+    this.#switchPage(1);
+  }
+
   #handlePopState(): void {
     let pageNo = 1;
     this.#sortField = this.#defaultSortField;
     this.#sortOrder = this.#defaultSortOrder;
+    this.#filters = new Map<string, string>();
 
     const url = new URL(window.location.href);
     url.searchParams.forEach((value, key) => {
@@ -167,6 +255,11 @@ export class GridView {
       if (key === "sortOrder") {
         this.#sortOrder = value;
       }
+
+      const matches = key.match(/^filters\[([a-z0-9_]+)\]$/i);
+      if (matches) {
+        this.#filters.set(matches[1], value);
+      }
     });
 
     this.#switchPage(pageNo, false);
index 8c35a2b549ebdff6194cb30bd5579e20909fda01..3c003f0fc97cfb0234679a8b06d11bdf8cf597b4 100644 (file)
@@ -10,7 +10,7 @@
 define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "./Result"], function (require, exports, Backend_1, Result_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.deleteObject = void 0;
+    exports.deleteObject = deleteObject;
     async function deleteObject(endpoint) {
         try {
             await (0, Backend_1.prepareRequest)(endpoint).delete().fetchAsJson();
@@ -20,5 +20,4 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "./Result"], fun
         }
         return (0, Result_1.apiResultFromValue)([]);
     }
-    exports.deleteObject = deleteObject;
 });
index b07327102da078782955e87d9e862ca8860c9fc3..7a31864e72ceaab37cbf8999a25eff80ae07447e 100644 (file)
@@ -1,13 +1,18 @@
 define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.getRows = void 0;
-    async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC") {
+    exports.getRows = getRows;
+    async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC", filters) {
         const url = new URL(`${window.WSC_RPC_API_URL}core/gridViews/rows`);
         url.searchParams.set("gridView", gridViewClass);
         url.searchParams.set("pageNo", pageNo.toString());
         url.searchParams.set("sortField", sortField);
         url.searchParams.set("sortOrder", sortOrder);
+        if (filters) {
+            filters.forEach((value, key) => {
+                url.searchParams.set(`filters[${key}]`, value);
+            });
+        }
         let response;
         try {
             response = (await (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson());
@@ -17,5 +22,4 @@ define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], fu
         }
         return (0, Result_1.apiResultFromValue)(response);
     }
-    exports.getRows = getRows;
 });
index d65ff5e1f0a91ac40f2ed00c4971a2b9c2a3a3cf..416ef22d0c4143657710eb153d4ad7531e072394 100644 (file)
@@ -1,4 +1,4 @@
-define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util", "../Ui/Dropdown/Simple"], function (require, exports, tslib_1, GetRows_1, Util_1, Simple_1) {
+define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util", "../Helper/PromiseMutex", "../Ui/Dropdown/Simple", "./Dialog"], function (require, exports, tslib_1, GetRows_1, Util_1, PromiseMutex_1, Simple_1, Dialog_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.GridView = void 0;
@@ -10,16 +10,21 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
         #topPagination;
         #bottomPagination;
         #baseUrl;
+        #filterButton;
+        #filterPills;
         #pageNo;
         #sortField;
         #sortOrder;
         #defaultSortField;
         #defaultSortOrder;
+        #filters;
         constructor(gridId, gridClassName, pageNo, baseUrl = "", sortField = "", sortOrder = "ASC") {
             this.#gridClassName = gridClassName;
             this.#table = document.getElementById(`${gridId}_table`);
             this.#topPagination = document.getElementById(`${gridId}_topPagination`);
             this.#bottomPagination = document.getElementById(`${gridId}_bottomPagination`);
+            this.#filterButton = document.getElementById(`${gridId}_filterButton`);
+            this.#filterPills = document.getElementById(`${gridId}_filters`);
             this.#pageNo = pageNo;
             this.#baseUrl = baseUrl;
             this.#sortField = sortField;
@@ -29,6 +34,7 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
             this.#initPagination();
             this.#initSorting();
             this.#initActions();
+            this.#initFilters();
             window.addEventListener("popstate", () => {
                 this.#handlePopState();
             });
@@ -80,11 +86,14 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
             void this.#loadRows(updateQueryString);
         }
         async #loadRows(updateQueryString = true) {
-            const response = await (0, GetRows_1.getRows)(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder);
-            Util_1.default.setInnerHtml(this.#table.querySelector("tbody"), response.unwrap().template);
+            const response = (await (0, GetRows_1.getRows)(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder, this.#filters)).unwrap();
+            Util_1.default.setInnerHtml(this.#table.querySelector("tbody"), response.template);
+            this.#topPagination.count = response.pages;
+            this.#bottomPagination.count = response.pages;
             if (updateQueryString) {
                 this.#updateQueryString();
             }
+            this.#renderFilters(response.filterLabels);
             this.#initActions();
         }
         #updateQueryString() {
@@ -100,6 +109,9 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
                 parameters.push(["sortField", this.#sortField]);
                 parameters.push(["sortOrder", this.#sortOrder]);
             }
+            this.#filters.forEach((value, key) => {
+                parameters.push([`filters[${key}]`, value]);
+            });
             if (parameters.length > 0) {
                 url.search += url.search !== "" ? "&" : "?";
                 url.search += new URLSearchParams(parameters).toString();
@@ -121,10 +133,64 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
                 });
             });
         }
+        #initFilters() {
+            if (!this.#filterButton) {
+                return;
+            }
+            this.#filterButton.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => this.#showFilterDialog()));
+            if (!this.#filterPills) {
+                return;
+            }
+            const filterButtons = this.#filterPills.querySelectorAll("[data-filter]");
+            if (!filterButtons.length) {
+                return;
+            }
+            this.#filters = new Map();
+            filterButtons.forEach((button) => {
+                this.#filters.set(button.dataset.filter, button.dataset.filterValue);
+                button.addEventListener("click", () => {
+                    this.#removeFilter(button.dataset.filter);
+                });
+            });
+        }
+        async #showFilterDialog() {
+            const url = new URL(this.#filterButton.dataset.endpoint);
+            if (this.#filters) {
+                this.#filters.forEach((value, key) => {
+                    url.searchParams.set(`filters[${key}]`, value);
+                });
+            }
+            const { ok, result } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(url.toString());
+            if (ok) {
+                this.#filters = new Map(Object.entries(result));
+                this.#switchPage(1);
+            }
+        }
+        #renderFilters(labels) {
+            this.#filterPills.innerHTML = "";
+            if (!this.#filters) {
+                return;
+            }
+            this.#filters.forEach((value, key) => {
+                const button = document.createElement("button");
+                button.type = "button";
+                button.classList.add("button");
+                button.innerText = labels[key];
+                button.addEventListener("click", () => {
+                    this.#removeFilter(key);
+                });
+                this.#filterPills.append(button);
+            });
+        }
+        #removeFilter(filter) {
+            this.#filters.delete(filter);
+            this.#switchPage(1);
+        }
         #handlePopState() {
             let pageNo = 1;
             this.#sortField = this.#defaultSortField;
             this.#sortOrder = this.#defaultSortOrder;
+            this.#filters = new Map();
             const url = new URL(window.location.href);
             url.searchParams.forEach((value, key) => {
                 if (key === "pageNo") {
@@ -137,6 +203,10 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
                 if (key === "sortOrder") {
                     this.#sortOrder = value;
                 }
+                const matches = key.match(/^filters\[([a-z0-9_]+)\]$/i);
+                if (matches) {
+                    this.#filters.set(matches[1], value);
+                }
             });
             this.#switchPage(pageNo, false);
         }
index a66edf2e9f95f169d5b390faa03dc4ba786cba09..751f8ebc35eadee40c5b2f3223cd8672ec08ee19 100644 (file)
@@ -1,7 +1,7 @@
 define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/DeleteObject", "../../Confirmation", "WoltLabSuite/Core/Ui/Notification"], function (require, exports, tslib_1, DeleteObject_1, Confirmation_1, UiNotification) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
-    exports.setup = void 0;
+    exports.setup = setup;
     UiNotification = tslib_1.__importStar(UiNotification);
     async function handleDelete(row, objectName, endpoint) {
         const confirmationResult = await (0, Confirmation_1.confirmationFactory)().delete(objectName);
@@ -23,5 +23,4 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/DeleteObject", "..
             }
         });
     }
-    exports.setup = setup;
 });
diff --git a/wcfsetup/install/files/lib/action/GridViewFilterAction.class.php b/wcfsetup/install/files/lib/action/GridViewFilterAction.class.php
new file mode 100644 (file)
index 0000000..f3579f8
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+namespace wcf\action;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use wcf\http\Helper;
+use wcf\system\exception\IllegalLinkException;
+use wcf\system\exception\PermissionDeniedException;
+use wcf\system\exception\UserInputException;
+use wcf\system\form\builder\Psr15DialogForm;
+use wcf\system\view\grid\AbstractGridView;
+use wcf\system\WCF;
+
+final class GridViewFilterAction implements RequestHandlerInterface
+{
+    #[\Override]
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $parameters = Helper::mapQueryParameters(
+            $request->getQueryParams(),
+            <<<'EOT'
+                array {
+                    gridView: string,
+                    filters: string[]
+                }
+                EOT
+        );
+
+        if (!\is_subclass_of($parameters['gridView'], AbstractGridView::class)) {
+            throw new UserInputException('gridView', 'invalid');
+        }
+
+        $view = new $parameters['gridView'];
+        \assert($view instanceof AbstractGridView);
+
+        if (!$view->isAccessible()) {
+            throw new PermissionDeniedException();
+        }
+
+        if (!$view->isFilterable()) {
+            throw new IllegalLinkException();
+        }
+
+        $form = $this->getForm($view, $parameters['filters']);
+
+        if ($request->getMethod() === 'GET') {
+            return $form->toResponse();
+        } elseif ($request->getMethod() === 'POST') {
+            $response = $form->validateRequest($request);
+            if ($response !== null) {
+                return $response;
+            }
+
+            $data = $form->getData()['data'];
+            foreach ($data as $key => $value) {
+                if ($value === '' || $value === null) {
+                    unset($data[$key]);
+                }
+            }
+
+            return new JsonResponse([
+                'result' => $data
+            ]);
+        } else {
+            throw new \LogicException('Unreachable');
+        }
+    }
+
+    private function getForm(AbstractGridView $gridView, array $values): Psr15DialogForm
+    {
+        $form = new Psr15DialogForm(
+            static::class,
+            WCF::getLanguage()->get('wcf.global.filter')
+        );
+
+        foreach ($gridView->getFilterableColumns() as $column) {
+            $formField = $column->getFilterFormField();
+
+            if (isset($values[$column->getID()])) {
+                $formField->value($values[$column->getID()]);
+            }
+
+            $form->appendChild($formField);
+        }
+
+        $form->markRequiredFields(false);
+        $form->build();
+
+        return $form;
+    }
+}
index 0db619758382b4e746ca838df1c25ce0cf0d741f..0164e432cb04863a44372bf4050f2b2dbef4ebfb 100644 (file)
@@ -12,6 +12,7 @@ abstract class AbstractGridViewPage extends AbstractPage
     protected int $pageNo = 1;
     protected string $sortField = '';
     protected string $sortOrder = '';
+    protected array $filters = [];
 
     #[\Override]
     public function readParameters()
@@ -27,6 +28,9 @@ abstract class AbstractGridViewPage extends AbstractPage
         if (isset($_REQUEST['sortOrder']) && ($_REQUEST['sortOrder'] === 'ASC' || $_REQUEST['sortOrder'] === 'DESC')) {
             $this->sortOrder = $_REQUEST['sortOrder'];
         }
+        if (isset($_REQUEST['filters']) && \is_array($_REQUEST['filters'])) {
+            $this->filters = $_REQUEST['filters'];
+        }
     }
 
     #[\Override]
@@ -57,6 +61,9 @@ abstract class AbstractGridViewPage extends AbstractPage
         if ($this->sortOrder) {
             $this->gridView->setSortOrder($this->sortOrder);
         }
+        if ($this->filters !== []) {
+            $this->gridView->setActiveFilters($this->filters);
+        }
         $this->gridView->setPageNo($this->pageNo);
         $this->gridView->setBaseUrl(LinkHandler::getInstance()->getControllerLink(static::class));
     }
index 9d43b27c6eb4d7703a2e820e43531f98a5a6f0af..0aba1ecac1ed2be02afda6c8480f742027b56294 100644 (file)
@@ -39,8 +39,19 @@ final class GetRows implements IController
             $view->setSortOrder($parameters->sortOrder);
         }
 
+        if ($parameters->filters !== []) {
+            $view->setActiveFilters($parameters->filters);
+        }
+
+        $filterLabels = [];
+        foreach (\array_keys($parameters->filters) as $key) {
+            $filterLabels[$key] = $view->getFilterLabel($key);
+        }
+
         return new JsonResponse([
             'template' => $view->renderRows(),
+            'pages' => $view->countPages(),
+            'filterLabels' => $filterLabels,
         ]);
     }
 }
@@ -53,6 +64,8 @@ final class GetRowsParameters
         public readonly string $gridView,
         public readonly int $pageNo,
         public readonly string $sortField,
-        public readonly string $sortOrder
+        public readonly string $sortOrder,
+        /** @var string[] */
+        public readonly array $filters
     ) {}
 }
index 903f8ff7d196a1e7ee1ee63990334932073b4edc..ca95fc72e07842ca44053801376c6d70cb084970 100644 (file)
@@ -2,6 +2,9 @@
 
 namespace wcf\system\view\grid;
 
+use LogicException;
+use wcf\action\GridViewFilterAction;
+use wcf\system\request\LinkHandler;
 use wcf\system\view\grid\action\IGridViewAction;
 use wcf\system\WCF;
 
@@ -22,6 +25,7 @@ abstract class AbstractGridView
     private string $sortField = '';
     private string $sortOrder = 'ASC';
     private int $pageNo = 1;
+    private array $activeFilters = [];
 
     public function __construct()
     {
@@ -87,6 +91,11 @@ abstract class AbstractGridView
         return $this->actions;
     }
 
+    public function hasActions(): bool
+    {
+        return $this->actions !== [];
+    }
+
     public function render(): string
     {
         return WCF::getTPL()->fetch('shared_gridView', 'wcf', ['view' => $this], true);
@@ -167,6 +176,14 @@ abstract class AbstractGridView
         return \array_filter($this->getColumns(), fn($column) => $column->isSortable());
     }
 
+    /**
+     * @return GridViewColumn[]
+     */
+    public function getFilterableColumns(): array
+    {
+        return \array_filter($this->getColumns(), fn($column) => $column->getFilter() !== null);
+    }
+
     public function setSortField(string $sortField): void
     {
         if (!\in_array($sortField, \array_map(fn($column) => $column->getID(), $this->getSortableColumns()))) {
@@ -214,4 +231,45 @@ abstract class AbstractGridView
     {
         $this->rowsPerPage = $rowsPerPage;
     }
+
+    public function isFilterable(): bool
+    {
+        return $this->getFilterableColumns() !== [];
+    }
+
+    public function getFilterActionEndpoint(): string
+    {
+        return LinkHandler::getInstance()->getControllerLink(
+            GridViewFilterAction::class,
+            ['gridView' => \get_class($this)]
+        );
+    }
+
+    public function setActiveFilters(array $filters): void
+    {
+        $this->activeFilters = $filters;
+    }
+
+    public function getActiveFilters(): array
+    {
+        return $this->activeFilters;
+    }
+
+    public function getFilterLabel(string $id): string
+    {
+        $column = $this->getColumn($id);
+        if (!$column) {
+            throw new LogicException("Unknown column '" . $id . "'.");
+        }
+
+        if (!$column->getFilter()) {
+            throw new LogicException("Column '" . $id . "' has no filter.");
+        }
+
+        if (!isset($this->activeFilters[$id])) {
+            throw new LogicException("No value for filter '" . $id . "' found.");
+        }
+
+        return $column->getLabel() . ': ' . $column->getFilter()->renderValue($this->activeFilters[$id]);
+    }
 }
index 1d8fb364e40292102c9de3c915a4ec3245a88a39..d1ae67b3d9d6b080a7ac9aee9a2b2a8593f97548 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace wcf\system\view\grid;
 
+use LogicException;
 use wcf\data\DatabaseObject;
 use wcf\data\DatabaseObjectList;
 
@@ -46,6 +47,7 @@ abstract class DatabaseObjectListGridView extends AbstractGridView
                 $this->objectList->sqlOrderBy = $this->getSortField() . ' ' . $this->getSortOrder();
             }
         }
+        $this->applyFilters();
     }
 
     public function getObjectList(): DatabaseObjectList
@@ -57,5 +59,17 @@ abstract class DatabaseObjectListGridView extends AbstractGridView
         return $this->objectList;
     }
 
+    private function applyFilters(): void
+    {
+        foreach ($this->getActiveFilters() as $key => $value) {
+            $column = $this->getColumn($key);
+            if (!$column) {
+                throw new LogicException("Unknown column '" . $key . "'");
+            }
+
+            $column->getFilter()->applyFilter($this->getObjectList(), $column->getID(), $value);
+        }
+    }
+
     protected abstract function createObjectList(): DatabaseObjectList;
 }
index 2200deabc03f11561705760de6001c5d90457717..d8c699a82ac67dae16f42dc4e5fcf1509b7e4cb1 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace wcf\system\view\grid;
 
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\view\grid\filter\IGridViewFilter;
 use wcf\system\view\grid\renderer\DefaultColumnRenderer;
 use wcf\system\view\grid\renderer\IColumnRenderer;
 use wcf\system\WCF;
@@ -16,6 +18,7 @@ final class GridViewColumn
     private static DefaultColumnRenderer $defaultRenderer;
     private bool $sortable = false;
     private string $sortById = '';
+    private ?IGridViewFilter $filter = null;
 
     private function __construct(private readonly string $id) {}
 
@@ -114,6 +117,27 @@ final class GridViewColumn
         return $this->sortById;
     }
 
+    public function filter(?IGridViewFilter $filter): static
+    {
+        $this->filter = $filter;
+
+        return $this;
+    }
+
+    public function getFilter(): ?IGridViewFilter
+    {
+        return $this->filter;
+    }
+
+    public function getFilterFormField(): AbstractFormField
+    {
+        if ($this->getFilter() === null) {
+            throw new \LogicException('This column has no filter.');
+        }
+
+        return $this->getFilter()->getFormField($this->getID(), $this->getLabel());
+    }
+
     private static function getDefaultRenderer(): DefaultColumnRenderer
     {
         if (!isset(self::$defaultRenderer)) {
diff --git a/wcfsetup/install/files/lib/system/view/grid/filter/IGridViewFilter.class.php b/wcfsetup/install/files/lib/system/view/grid/filter/IGridViewFilter.class.php
new file mode 100644 (file)
index 0000000..aecf4cd
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+namespace wcf\system\view\grid\filter;
+
+use wcf\data\DatabaseObjectList;
+use wcf\system\form\builder\field\AbstractFormField;
+
+interface IGridViewFilter
+{
+    public function getFormField(string $id, string $label): AbstractFormField;
+
+    public function applyFilter(DatabaseObjectList $list, string $id, string $value): void;
+
+    public function renderValue(string $value): string;
+}
diff --git a/wcfsetup/install/files/lib/system/view/grid/filter/SelectFilter.class.php b/wcfsetup/install/files/lib/system/view/grid/filter/SelectFilter.class.php
new file mode 100644 (file)
index 0000000..643ab8c
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace wcf\system\view\grid\filter;
+
+use wcf\data\DatabaseObjectList;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\WCF;
+
+class SelectFilter implements IGridViewFilter
+{
+    public function __construct(private readonly array $options) {}
+
+    #[\Override]
+    public function getFormField(string $id, string $label): AbstractFormField
+    {
+        return SelectFormField::create($id)
+            ->label($label)
+            ->options($this->options);
+    }
+
+    #[\Override]
+    public function applyFilter(DatabaseObjectList $list, string $id, string $value): void
+    {
+        $list->getConditionBuilder()->add("$id = ?", [$value]);
+    }
+
+    #[\Override]
+    public function renderValue(string $value): string
+    {
+        return WCF::getLanguage()->get($this->options[$value]);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/view/grid/filter/TextFilter.class.php b/wcfsetup/install/files/lib/system/view/grid/filter/TextFilter.class.php
new file mode 100644 (file)
index 0000000..0f4b5ae
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace wcf\system\view\grid\filter;
+
+use wcf\data\DatabaseObjectList;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\TextFormField;
+
+class TextFilter implements IGridViewFilter
+{
+    #[\Override]
+    public function getFormField(string $id, string $label): AbstractFormField
+    {
+        return TextFormField::create($id)
+            ->label($label);
+    }
+
+    #[\Override]
+    public function applyFilter(DatabaseObjectList $list, string $id, string $value): void
+    {
+        $list->getConditionBuilder()->add("$id LIKE ?", ['%' . $value . '%']);
+    }
+
+    #[\Override]
+    public function renderValue(string $value): string
+    {
+        return $value;
+    }
+}