+{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>
{unsafe:$column->getLabel()}
</th>
{/foreach}
- <th></th>
+ {if $view->hasActions()}
+ <th></th>
+ {/if}
</td>
</thead>
<tbody>
{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}
type Response = {
template: string;
+ pages: number;
+ filterLabels: ArrayLike<string>;
};
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 {
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;
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,
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;
this.#initPagination();
this.#initSorting();
this.#initActions();
+ this.#initFilters();
window.addEventListener("popstate", () => {
this.#handlePopState();
}
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();
}
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 !== "" ? "&" : "?";
});
}
+ #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) => {
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);
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();
}
return (0, Result_1.apiResultFromValue)([]);
}
- exports.deleteObject = deleteObject;
});
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getRows = getRows;
- async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC") {
+ 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());
-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;
#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;
this.#initPagination();
this.#initSorting();
this.#initActions();
+ this.#initFilters();
window.addEventListener("popstate", () => {
this.#handlePopState();
});
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() {
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();
});
});
}
+ #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") {
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);
}
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);
}
});
}
- exports.setup = setup;
});
--- /dev/null
+<?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;
+ }
+}
protected int $pageNo = 1;
protected string $sortField = '';
protected string $sortOrder = '';
+ protected array $filters = [];
#[\Override]
public function readParameters()
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]
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));
}
$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,
]);
}
}
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
) {}
}
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;
private string $sortField = '';
private string $sortOrder = 'ASC';
private int $pageNo = 1;
+ private array $activeFilters = [];
public function __construct()
{
return $this->actions;
}
+ public function hasActions(): bool
+ {
+ return $this->actions !== [];
+ }
+
public function render(): string
{
return WCF::getTPL()->fetch('shared_gridView', 'wcf', ['view' => $this], true);
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()))) {
{
$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]);
+ }
}
namespace wcf\system\view\grid;
+use LogicException;
use wcf\data\DatabaseObject;
use wcf\data\DatabaseObjectList;
$this->objectList->sqlOrderBy = $this->getSortField() . ' ' . $this->getSortOrder();
}
}
+ $this->applyFilters();
}
public function getObjectList(): DatabaseObjectList
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;
}
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;
private static DefaultColumnRenderer $defaultRenderer;
private bool $sortable = false;
private string $sortById = '';
+ private ?IGridViewFilter $filter = null;
private function __construct(private readonly string $id) {}
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)) {
--- /dev/null
+<?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;
+}
--- /dev/null
+<?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]);
+ }
+}
--- /dev/null
+<?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;
+ }
+}