diff --git a/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl b/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl new file mode 100644 index 00000000000..b0507cd43c3 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_dateRangeFormField.tpl @@ -0,0 +1,23 @@ +getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if} + {if $field->isAutofocused()} autofocus{/if} + {if $field->isRequired()} required{/if} + {if $field->isImmutable()} disabled{/if} + {foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach} +> +getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if} + {if $field->isRequired()} required{/if} + {if $field->isImmutable()} disabled{/if} + {foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach} +> diff --git a/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl b/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl new file mode 100644 index 00000000000..2e87d96359d --- /dev/null +++ b/com.woltlab.wcf/templates/shared_exceptionLogDetails.tpl @@ -0,0 +1,63 @@ +
+
+
+ + +
+
+
+
{lang}wcf.acp.exceptionLog.search.exceptionID{/lang}
+
{$exceptionID}
+
+
+
{lang}wcf.acp.exceptionLog.exception.date{/lang}
+
{$exception[date]|plainTime}
+
+
+
{lang}wcf.acp.exceptionLog.exception.requestURI{/lang}
+
{$exception[requestURI]}
+
+
+
{lang}wcf.acp.exceptionLog.exception.referrer{/lang}
+
{$exception[referrer]}
+
+
+
{lang}wcf.acp.exceptionLog.exception.userAgent{/lang}
+
{$exception[userAgent]}
+
+
+
{lang}wcf.acp.exceptionLog.exception.memory{/lang}
+
{$exception[peakMemory]|filesizeBinary} / {if $exception[maxMemory] == -1}∞{else}{$exception[maxMemory]|filesizeBinary}{/if}
+
+{foreach from=$exception[chain] item=chain} +
+
{lang}wcf.acp.exceptionLog.exception.message{/lang}
+
{$chain[message]}
+
+
+
{lang}wcf.acp.exceptionLog.exception.class{/lang}
+
{$chain[class]}
+
+
+
{lang}wcf.acp.exceptionLog.exception.file{/lang}
+
{$chain[file]} ({$chain[line]})
+
+ {if !$chain[information]|empty} + {foreach from=$chain[information] item=extraInformation} +
+
{$extraInformation[0]}
+
{$extraInformation[1]}
+
+ {/foreach} + {/if} +
+
{lang}wcf.acp.exceptionLog.exception.stacktrace{/lang}
+
+
    + {foreach from=$chain[stack] item=stack} +
  1. {$stack[file]} ({$stack[line]}): {$stack[class]}{$stack[type]}{$stack[function]}(…)
  2. + {/foreach} +
+
+
+{/foreach} diff --git a/com.woltlab.wcf/templates/shared_gridView.tpl b/com.woltlab.wcf/templates/shared_gridView.tpl new file mode 100644 index 00000000000..370238ea28f --- /dev/null +++ b/com.woltlab.wcf/templates/shared_gridView.tpl @@ -0,0 +1,73 @@ +
+ {if $view->isFilterable()} +
+
+ {foreach from=$view->getActiveFilters() item='value' key='key'} + + {/foreach} +
+
+ +
+
+ {/if} + + countRows()} hidden{/if}> + + + {foreach from=$view->getVisibleColumns() item='column'} + + {/foreach} + {if $view->hasActions()} + + {/if} + + + + {unsafe:$view->renderRows()} + +
+ {if $column->isSortable()} + + {else} + {unsafe:$column->getLabel()} + {/if} +
+ +
+ +
+ + countRows()} hidden{/if}>{lang}wcf.global.noItems{/lang} +
+ + +{unsafe:$view->renderActionInitialization()} diff --git a/com.woltlab.wcf/templates/shared_gridViewRows.tpl b/com.woltlab.wcf/templates/shared_gridViewRows.tpl new file mode 100644 index 00000000000..e51236c0a54 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_gridViewRows.tpl @@ -0,0 +1,25 @@ + +{foreach from=$view->getRows() item='row'} + + {foreach from=$view->getVisibleColumns() item='column'} + + {unsafe:$view->renderColumn($column, $row)} + + {/foreach} + {if $view->hasActions()} + + + + {/if} + +{/foreach} diff --git a/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts b/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts new file mode 100644 index 00000000000..0edc37cf86a --- /dev/null +++ b/ts/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.ts @@ -0,0 +1,38 @@ +/** + * Shows the dialog that shows exception details. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + */ + +import { renderException } from "WoltLabSuite/Core/Api/Exceptions/RenderException"; +import { copyTextToClipboard } from "WoltLabSuite/Core/Clipboard"; +import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog"; +import { promiseMutex } from "WoltLabSuite/Core/Helper/PromiseMutex"; +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; +import { getPhrase } from "WoltLabSuite/Core/Language"; + +async function showDialog(button: HTMLElement): Promise { + const response = await renderException(button.closest("tr")!.dataset.objectId!); + if (!response.ok) { + return; + } + + const dialog = dialogFactory().fromHtml(response.value.template).withoutControls(); + dialog.content.querySelector(".jsCopyButton")?.addEventListener("click", () => { + void copyTextToClipboard(dialog.content.querySelector(".jsCopyException")!.value); + }); + + dialog.show(getPhrase("wcf.acp.exceptionLog.exception.message")); +} + +export function setup(): void { + wheneverFirstSeen(".jsExceptionLogEntry", (button) => { + button.addEventListener( + "click", + promiseMutex(() => showDialog(button)), + ); + }); +} diff --git a/ts/WoltLabSuite/Core/Api/DeleteObject.ts b/ts/WoltLabSuite/Core/Api/DeleteObject.ts new file mode 100644 index 00000000000..1ae28ef6da6 --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/DeleteObject.ts @@ -0,0 +1,22 @@ +/** + * Deletes an object. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + * @woltlabExcludeBundle tiny + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "./Result"; + +export async function deleteObject(endpoint: string): Promise> { + try { + await prepareRequest(endpoint).delete().fetchAsJson(); + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue([]); +} diff --git a/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts b/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts new file mode 100644 index 00000000000..fc309d071ac --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/Exceptions/RenderException.ts @@ -0,0 +1,28 @@ +/** + * Gets the html code for the rendering of a exception log entry. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ + +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + template: string; +}; + +export async function renderException(exceptionId: string): Promise> { + const url = new URL(`${window.WSC_RPC_API_URL}core/exceptions/${exceptionId}/render`); + + let response: Response; + try { + response = (await prepareRequest(url).get().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts new file mode 100644 index 00000000000..a262a51b57c --- /dev/null +++ b/ts/WoltLabSuite/Core/Api/GridViews/GetRows.ts @@ -0,0 +1,43 @@ +import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend"; +import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result"; + +type Response = { + template: string; + pages: number; + totalRows: number; + filterLabels: ArrayLike; +}; + +export async function getRows( + gridViewClass: string, + pageNo: number, + sortField: string = "", + sortOrder: string = "ASC", + filters?: Map, + gridViewParameters?: Map, +): Promise> { + 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); + }); + } + if (gridViewParameters) { + gridViewParameters.forEach((value, key) => { + url.searchParams.set(`gridViewParameters[${key}]`, value); + }); + } + + let response: Response; + try { + response = (await prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()) as Response; + } catch (e) { + return apiResultFromError(e); + } + + return apiResultFromValue(response); +} diff --git a/ts/WoltLabSuite/Core/Component/GridView.ts b/ts/WoltLabSuite/Core/Component/GridView.ts new file mode 100644 index 00000000000..d94577cbb0e --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/GridView.ts @@ -0,0 +1,278 @@ +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 #table: HTMLTableElement; + readonly #pagination: WoltlabCorePaginationElement; + readonly #baseUrl: string; + readonly #filterButton: HTMLButtonElement; + readonly #filterPills: HTMLElement; + readonly #noItemsNotice: HTMLElement; + #pageNo: number; + #sortField: string; + #sortOrder: string; + #defaultSortField: string; + #defaultSortOrder: string; + #filters: Map; + #gridViewParameters?: Map; + + constructor( + gridId: string, + gridClassName: string, + pageNo: number, + baseUrl: string = "", + sortField = "", + sortOrder = "ASC", + gridViewParameters?: Map, + ) { + this.#gridClassName = gridClassName; + this.#table = document.getElementById(`${gridId}_table`) as HTMLTableElement; + this.#pagination = document.getElementById(`${gridId}_pagination`) as WoltlabCorePaginationElement; + this.#filterButton = document.getElementById(`${gridId}_filterButton`) as HTMLButtonElement; + this.#filterPills = document.getElementById(`${gridId}_filters`) as HTMLElement; + this.#noItemsNotice = document.getElementById(`${gridId}_noItemsNotice`) as HTMLElement; + this.#pageNo = pageNo; + this.#baseUrl = baseUrl; + this.#sortField = sortField; + this.#defaultSortField = sortField; + this.#sortOrder = sortOrder; + this.#defaultSortOrder = sortOrder; + this.#gridViewParameters = gridViewParameters; + + this.#initPagination(); + this.#initSorting(); + this.#initActions(); + this.#initFilters(); + + window.addEventListener("popstate", () => { + this.#handlePopState(); + }); + } + + #initPagination(): void { + this.#pagination.addEventListener("switchPage", (event: CustomEvent) => { + void this.#switchPage(event.detail); + }); + } + + #initSorting(): void { + this.#table + .querySelectorAll('.gridView__headerColumn[data-sortable="1"]') + .forEach((element) => { + const button = element.querySelector(".gridView__headerColumn__button"); + button?.addEventListener("click", () => { + this.#sort(element.dataset.id!); + }); + }); + + this.#renderActiveSorting(); + } + + #sort(sortField: string): void { + if (this.#sortField == sortField && this.#sortOrder == "ASC") { + this.#sortOrder = "DESC"; + } else { + this.#sortField = sortField; + this.#sortOrder = "ASC"; + } + + this.#switchPage(1); + this.#renderActiveSorting(); + } + + #renderActiveSorting(): void { + this.#table.querySelectorAll('th[data-sortable="1"]').forEach((element) => { + element.classList.remove("active", "ASC", "DESC"); + + if (element.dataset.id == this.#sortField) { + element.classList.add("active", this.#sortOrder); + } + }); + } + + #switchPage(pageNo: number, updateQueryString: boolean = true): void { + this.#pagination.page = pageNo; + this.#pageNo = pageNo; + + void this.#loadRows(updateQueryString); + } + + async #loadRows(updateQueryString: boolean = true): Promise { + const response = ( + await getRows( + this.#gridClassName, + this.#pageNo, + this.#sortField, + this.#sortOrder, + this.#filters, + this.#gridViewParameters, + ) + ).unwrap(); + DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.template); + + this.#table.hidden = response.totalRows == 0; + this.#noItemsNotice.hidden = response.totalRows != 0; + this.#pagination.count = response.pages; + + if (updateQueryString) { + this.#updateQueryString(); + } + + this.#renderFilters(response.filterLabels); + this.#initActions(); + } + + #updateQueryString(): void { + if (!this.#baseUrl) { + return; + } + + const url = new URL(this.#baseUrl); + + const parameters: [string, string][] = []; + if (this.#pageNo > 1) { + parameters.push(["pageNo", this.#pageNo.toString()]); + } + if (this.#sortField) { + parameters.push(["sortField", this.#sortField]); + parameters.push(["sortOrder", this.#sortOrder]); + } + if (this.#filters) { + this.#filters.forEach((value, key) => { + parameters.push([`filters[${key}]`, value]); + }); + } + + if (parameters.length > 0) { + url.search += url.search !== "" ? "&" : "?"; + url.search += new URLSearchParams(parameters).toString(); + } + + window.history.pushState({}, document.title, url.toString()); + } + + #initActions(): void { + this.#table.querySelectorAll("tbody tr").forEach((row) => { + row.querySelectorAll(".gridViewActions").forEach((element) => { + const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!)!; + dropdown?.querySelectorAll("[data-action]").forEach((element) => { + element.addEventListener("click", () => { + row.dispatchEvent( + new CustomEvent("action", { + detail: element.dataset, + bubbles: true, + }), + ); + }); + }); + }); + }); + } + + #initFilters(): void { + if (!this.#filterButton) { + return; + } + + this.#filterButton.addEventListener( + "click", + 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(): Promise { + 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)); + this.#switchPage(1); + } + } + + #renderFilters(labels: ArrayLike): void { + if (!this.#filterPills) { + return; + } + this.#filterPills.innerHTML = ""; + if (!this.#filters) { + return; + } + + this.#filters.forEach((value, key) => { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + const icon = document.createElement("fa-icon"); + icon.setIcon("circle-xmark"); + button.append(icon, 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(); + + const url = new URL(window.location.href); + url.searchParams.forEach((value, key) => { + if (key === "pageNo") { + pageNo = parseInt(value, 10); + return; + } + + if (key === "sortField") { + this.#sortField = value; + } + + 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); + } +} diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts new file mode 100644 index 00000000000..e3a81d1ace0 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts @@ -0,0 +1,28 @@ +import { deleteObject } from "WoltLabSuite/Core/Api/DeleteObject"; +import { confirmationFactory } from "../../Confirmation"; +import * as UiNotification from "WoltLabSuite/Core/Ui/Notification"; + +async function handleDelete(row: HTMLTableRowElement, objectName: string, endpoint: string): Promise { + const confirmationResult = await confirmationFactory().delete(objectName); + if (!confirmationResult) { + return; + } + + const result = await deleteObject(endpoint); + if (!result.ok) { + return; + } + + row.remove(); + + // TODO: This shows a generic success message and should be replaced with a more specific message. + UiNotification.show(); +} + +export function setup(table: HTMLTableElement): void { + table.addEventListener("action", (event: CustomEvent) => { + if (event.detail.action === "delete") { + void handleDelete(event.target as HTMLTableRowElement, event.detail.objectName, event.detail.endpoint); + } + }); +} diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts new file mode 100644 index 00000000000..3da01732f87 --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/DateRange.ts @@ -0,0 +1,43 @@ +/** + * Data handler for a date range form builder field in an Ajax form. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +import Field from "./Field"; +import { FormBuilderData } from "../Data"; +import DatePicker from "../../../Date/Picker"; + +class DateRange extends Field { + #fromField: HTMLElement | null; + #toField: HTMLElement | null; + + constructor(fieldId: string) { + super(fieldId); + + this.#fromField = document.getElementById(this._fieldId + "_from"); + if (this.#fromField === null) { + throw new Error("Unknown field with id '" + this._fieldId + "'."); + } + + this.#toField = document.getElementById(this._fieldId + "_to"); + if (this.#toField === null) { + throw new Error("Unknown field with id '" + this._fieldId + "'."); + } + } + + protected _getData(): FormBuilderData { + return { + [this._fieldId]: { + from: DatePicker.getValue(this.#fromField as HTMLInputElement), + to: DatePicker.getValue(this.#toField as HTMLInputElement), + }, + }; + } + + protected _readField(): void {} +} + +export = DateRange; diff --git a/wcfsetup/install/files/acp/js/WCF.ACP.js b/wcfsetup/install/files/acp/js/WCF.ACP.js index d559e5869da..b2d23e5a848 100644 --- a/wcfsetup/install/files/acp/js/WCF.ACP.js +++ b/wcfsetup/install/files/acp/js/WCF.ACP.js @@ -96,12 +96,6 @@ WCF.ACP.Cronjob.ExecutionHandler = Class.extend({ * Handles the cronjob log list. */ WCF.ACP.Cronjob.LogList = Class.extend({ - /** - * error message dialog - * @var jQuery - */ - _dialog: null, - /** * Initializes WCF.ACP.Cronjob.LogList object. */ @@ -123,29 +117,6 @@ WCF.ACP.Cronjob.LogList = Class.extend({ } }); }); - - // bind event listeners to error badges - $('.jsCronjobError').click($.proxy(this._showError, this)); - }, - - /** - * Shows certain error message - * - * @param object event - */ - _showError: function(event) { - var $errorBadge = $(event.currentTarget); - - if (this._dialog === null) { - this._dialog = $('
' + $errorBadge.next().html() + '
').hide().appendTo(document.body); - this._dialog.wcfDialog({ - title: WCF.Language.get('wcf.acp.cronjob.log.error.details') - }); - } - else { - this._dialog.html('
' + $errorBadge.next().html() + '
'); - this._dialog.wcfDialog('open'); - } } }); diff --git a/wcfsetup/install/files/acp/templates/cronjobLogList.tpl b/wcfsetup/install/files/acp/templates/cronjobLogList.tpl index af482fc14c2..980e7e5bf88 100644 --- a/wcfsetup/install/files/acp/templates/cronjobLogList.tpl +++ b/wcfsetup/install/files/acp/templates/cronjobLogList.tpl @@ -13,14 +13,14 @@
-

{lang}wcf.acp.cronjob.log{/lang}{if $items} {#$items}{/if}

+

{lang}wcf.acp.cronjob.log{/lang}{if $gridView->countRows()} {#$gridView->countRows()}{/if}

{hascontent}
-
-
-

{lang}wcf.global.filter{/lang}

- -
-
-
-
- -
-
- -
-
-
- -
-
- - {event name='filterFields'} -
- -
- - {csrfToken} -
-
-
- -{assign var='linkParameters' value=''} -{if $cronjobID}{capture append=linkParameters}&cronjobID={@$cronjobID}{/capture}{/if} -{if $success != -1}{capture append=linkParameters}&success={@$success}{/capture}{/if} - -{hascontent} -
- {content}{pages print=true assign=pagesLinks controller="CronjobLogList" link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder$linkParameters"}{/content} -
-{/hascontent} - -{if $objects|count} -
- - - - - - - - - - {event name='columnHeads'} - - - - - {foreach from=$objects item=cronjobLog} - - - - - - - - - {event name='columns'} - - {/foreach} - -
{lang}wcf.global.objectID{/lang}{lang}wcf.acp.cronjob.className{/lang}{lang}wcf.acp.cronjob.description{/lang}{lang}wcf.acp.cronjob.log.execTime{/lang}{lang}wcf.acp.cronjob.log.status{/lang}
{@$cronjobLog->cronjobID}{$cronjobLog->className}{$cronjobLog->description|phrase}{if $cronjobLog->execTime}{@$cronjobLog->execTime|time}{/if} - {if $cronjobLog->success} - {lang}wcf.acp.cronjob.log.success{/lang} - {elseif $cronjobLog->error} - {lang}wcf.acp.cronjob.log.error{/lang} - {$cronjobLog->error} - {/if} -
-
- - -{else} - {lang}wcf.global.noItems{/lang} -{/if} +{unsafe:$gridView->render()} {include file='footer'} diff --git a/wcfsetup/install/files/acp/templates/exceptionLogView.tpl b/wcfsetup/install/files/acp/templates/exceptionLogView.tpl index f33ad72f1a8..b8776950077 100644 --- a/wcfsetup/install/files/acp/templates/exceptionLogView.tpl +++ b/wcfsetup/install/files/acp/templates/exceptionLogView.tpl @@ -1,22 +1,4 @@ {include file='header' pageTitle='wcf.acp.exceptionLog'} -
@@ -32,127 +14,13 @@ {/hascontent}
-{include file='shared_formError'} - -{if !$logFiles|empty} -
-
-

{lang}wcf.acp.exceptionLog.search{/lang}

- -
-
-
-
- -
-
- -
-
-
- -
-
-
-
- -
- -
-
-{/if} - -{hascontent} -
- {content}{pages print=true assign=pagesLinks controller="ExceptionLogView" link="pageNo=%d&logFile=$logFile"}{/content} -
-{/hascontent} - -{if !$logFiles|empty} - {if $logFile} - {foreach from=$exceptions item='exception' key='exceptionKey'} -
- - {$exception[message]} - - -
-
-
{lang}wcf.acp.exceptionLog.exception.date{/lang}
-
{$exception[date]|plainTime}
-
- -
-
{lang}wcf.acp.exceptionLog.exception.requestURI{/lang}
-
{$exception[requestURI]}
-
-
-
{lang}wcf.acp.exceptionLog.exception.referrer{/lang}
-
{$exception[referrer]}
-
-
-
{lang}wcf.acp.exceptionLog.exception.userAgent{/lang}
-
{$exception[userAgent]}
-
-
-
{lang}wcf.acp.exceptionLog.exception.memory{/lang}
-
{$exception[peakMemory]|filesizeBinary} / {if $exception[maxMemory] == -1}∞{else}{$exception[maxMemory]|filesizeBinary}{/if}
-
- {foreach from=$exception[chain] item=chain} -
-
{lang}wcf.acp.exceptionLog.exception.message{/lang}
-
{$chain[message]}
-
-
-
{lang}wcf.acp.exceptionLog.exception.class{/lang}
-
{$chain[class]}
-
-
-
{lang}wcf.acp.exceptionLog.exception.file{/lang}
-
{$chain[file]} ({$chain[line]})
-
- {if !$chain[information]|empty} - {foreach from=$chain[information] item=extraInformation} -
-
{$extraInformation[0]}
-
{$extraInformation[1]}
-
- {/foreach} - {/if} -
-
{lang}wcf.acp.exceptionLog.exception.stacktrace{/lang}
-
-
    - {foreach from=$chain[stack] item=stack} -
  1. {$stack[file]} ({$stack[line]}): {$stack[class]}{$stack[type]}{$stack[function]}(…)
  2. - {/foreach} -
-
-
- {/foreach} -
-
-
-
-
-
- {/foreach} +{unsafe:$gridView->render()} -
- {hascontent} -
- {content}{@$pagesLinks}{/content} -
- {/hascontent} -
- {elseif $exceptionID} - {lang}wcf.acp.exceptionLog.exceptionNotFound{/lang} - {/if} -{else} - {lang}wcf.global.noItems{/lang} -{/if} + {include file='footer'} diff --git a/wcfsetup/install/files/acp/templates/userRankList.tpl b/wcfsetup/install/files/acp/templates/userRankList.tpl index b9dbcbce21d..e4917f8cf80 100644 --- a/wcfsetup/install/files/acp/templates/userRankList.tpl +++ b/wcfsetup/install/files/acp/templates/userRankList.tpl @@ -19,7 +19,7 @@
-

{lang}wcf.acp.user.rank.list{/lang} {#$items}

+

{lang}wcf.acp.user.rank.list{/lang} {#$gridView->countRows()}

-{hascontent} -
- {content}{pages print=true assign=pagesLinks controller="UserRankList" link="pageNo=%d&sortField=$sortField&sortOrder=$sortOrder"}{/content} -
-{/hascontent} - -{if $objects|count} -
- - - - - - - - - - - {event name='columnHeads'} - - - - - {foreach from=$objects item=userRank} - - - - - - - - - - {event name='columns'} - - {/foreach} - -
{lang}wcf.global.objectID{/lang}{lang}wcf.acp.user.rank.title{/lang}{lang}wcf.acp.user.rank.image{/lang}{lang}wcf.user.group{/lang}{lang}wcf.user.option.gender{/lang}{lang}wcf.acp.user.rank.requiredPoints{/lang}
- {icon name='pencil'} - {objectAction action="delete" objectTitle=$userRank->getTitle()} - - {event name='rowButtons'} - {@$userRank->rankID}{$userRank->getTitle()}{if $userRank->rankImage}{@$userRank->getImage()}{/if}{$userRank->groupName|phrase} - {if $userRank->requiredGender} - {if $userRank->requiredGender == 1} - {lang}wcf.user.gender.male{/lang} - {elseif $userRank->requiredGender == 2} - {lang}wcf.user.gender.female{/lang} - {else} - {lang}wcf.user.gender.other{/lang} - {/if} - {/if} - {#$userRank->requiredPoints}
-
- - -{else} - {lang}wcf.global.noItems{/lang} -{/if} +{unsafe:$gridView->render()} {include file='footer'} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.js new file mode 100644 index 00000000000..f8632d89ca1 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Controller/ExceptionLog/View.js @@ -0,0 +1,29 @@ +/** + * Shows the dialog that shows exception details. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + */ +define(["require", "exports", "WoltLabSuite/Core/Api/Exceptions/RenderException", "WoltLabSuite/Core/Clipboard", "WoltLabSuite/Core/Component/Dialog", "WoltLabSuite/Core/Helper/PromiseMutex", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Language"], function (require, exports, RenderException_1, Clipboard_1, Dialog_1, PromiseMutex_1, Selector_1, Language_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + async function showDialog(button) { + const response = await (0, RenderException_1.renderException)(button.closest("tr").dataset.objectId); + if (!response.ok) { + return; + } + const dialog = (0, Dialog_1.dialogFactory)().fromHtml(response.value.template).withoutControls(); + dialog.content.querySelector(".jsCopyButton")?.addEventListener("click", () => { + void (0, Clipboard_1.copyTextToClipboard)(dialog.content.querySelector(".jsCopyException").value); + }); + dialog.show((0, Language_1.getPhrase)("wcf.acp.exceptionLog.exception.message")); + } + function setup() { + (0, Selector_1.wheneverFirstSeen)(".jsExceptionLogEntry", (button) => { + button.addEventListener("click", (0, PromiseMutex_1.promiseMutex)(() => showDialog(button))); + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js new file mode 100644 index 00000000000..3c003f0fc97 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js @@ -0,0 +1,23 @@ +/** + * Deletes an object. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.1 + * @woltlabExcludeBundle tiny + */ +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 = deleteObject; + async function deleteObject(endpoint) { + try { + await (0, Backend_1.prepareRequest)(endpoint).delete().fetchAsJson(); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)([]); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Exceptions/RenderException.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Exceptions/RenderException.js new file mode 100644 index 00000000000..360833b2c2c --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Exceptions/RenderException.js @@ -0,0 +1,24 @@ +/** + * Gets the html code for the rendering of a exception log entry. + * + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.2 + */ +define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.renderException = renderException; + async function renderException(exceptionId) { + const url = new URL(`${window.WSC_RPC_API_URL}core/exceptions/${exceptionId}/render`); + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).get().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js new file mode 100644 index 00000000000..3299f1af960 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/GridViews/GetRows.js @@ -0,0 +1,30 @@ +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 = getRows; + async function getRows(gridViewClass, pageNo, sortField = "", sortOrder = "ASC", filters, gridViewParameters) { + 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); + }); + } + if (gridViewParameters) { + gridViewParameters.forEach((value, key) => { + url.searchParams.set(`gridViewParameters[${key}]`, value); + }); + } + let response; + try { + response = (await (0, Backend_1.prepareRequest)(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()); + } + catch (e) { + return (0, Result_1.apiResultFromError)(e); + } + return (0, Result_1.apiResultFromValue)(response); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js new file mode 100644 index 00000000000..79df19ffe02 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js @@ -0,0 +1,219 @@ +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; + Util_1 = tslib_1.__importDefault(Util_1); + Simple_1 = tslib_1.__importDefault(Simple_1); + class GridView { + #gridClassName; + #table; + #pagination; + #baseUrl; + #filterButton; + #filterPills; + #noItemsNotice; + #pageNo; + #sortField; + #sortOrder; + #defaultSortField; + #defaultSortOrder; + #filters; + #gridViewParameters; + constructor(gridId, gridClassName, pageNo, baseUrl = "", sortField = "", sortOrder = "ASC", gridViewParameters) { + this.#gridClassName = gridClassName; + this.#table = document.getElementById(`${gridId}_table`); + this.#pagination = document.getElementById(`${gridId}_pagination`); + this.#filterButton = document.getElementById(`${gridId}_filterButton`); + this.#filterPills = document.getElementById(`${gridId}_filters`); + this.#noItemsNotice = document.getElementById(`${gridId}_noItemsNotice`); + this.#pageNo = pageNo; + this.#baseUrl = baseUrl; + this.#sortField = sortField; + this.#defaultSortField = sortField; + this.#sortOrder = sortOrder; + this.#defaultSortOrder = sortOrder; + this.#gridViewParameters = gridViewParameters; + this.#initPagination(); + this.#initSorting(); + this.#initActions(); + this.#initFilters(); + window.addEventListener("popstate", () => { + this.#handlePopState(); + }); + } + #initPagination() { + this.#pagination.addEventListener("switchPage", (event) => { + void this.#switchPage(event.detail); + }); + } + #initSorting() { + this.#table + .querySelectorAll('.gridView__headerColumn[data-sortable="1"]') + .forEach((element) => { + const button = element.querySelector(".gridView__headerColumn__button"); + button?.addEventListener("click", () => { + this.#sort(element.dataset.id); + }); + }); + this.#renderActiveSorting(); + } + #sort(sortField) { + if (this.#sortField == sortField && this.#sortOrder == "ASC") { + this.#sortOrder = "DESC"; + } + else { + this.#sortField = sortField; + this.#sortOrder = "ASC"; + } + this.#switchPage(1); + this.#renderActiveSorting(); + } + #renderActiveSorting() { + this.#table.querySelectorAll('th[data-sortable="1"]').forEach((element) => { + element.classList.remove("active", "ASC", "DESC"); + if (element.dataset.id == this.#sortField) { + element.classList.add("active", this.#sortOrder); + } + }); + } + #switchPage(pageNo, updateQueryString = true) { + this.#pagination.page = pageNo; + this.#pageNo = pageNo; + void this.#loadRows(updateQueryString); + } + async #loadRows(updateQueryString = true) { + const response = (await (0, GetRows_1.getRows)(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder, this.#filters, this.#gridViewParameters)).unwrap(); + Util_1.default.setInnerHtml(this.#table.querySelector("tbody"), response.template); + this.#table.hidden = response.totalRows == 0; + this.#noItemsNotice.hidden = response.totalRows != 0; + this.#pagination.count = response.pages; + if (updateQueryString) { + this.#updateQueryString(); + } + this.#renderFilters(response.filterLabels); + this.#initActions(); + } + #updateQueryString() { + if (!this.#baseUrl) { + return; + } + const url = new URL(this.#baseUrl); + const parameters = []; + if (this.#pageNo > 1) { + parameters.push(["pageNo", this.#pageNo.toString()]); + } + if (this.#sortField) { + parameters.push(["sortField", this.#sortField]); + parameters.push(["sortOrder", this.#sortOrder]); + } + if (this.#filters) { + this.#filters.forEach((value, key) => { + parameters.push([`filters[${key}]`, value]); + }); + } + if (parameters.length > 0) { + url.search += url.search !== "" ? "&" : "?"; + url.search += new URLSearchParams(parameters).toString(); + } + window.history.pushState({}, document.title, url.toString()); + } + #initActions() { + this.#table.querySelectorAll("tbody tr").forEach((row) => { + row.querySelectorAll(".gridViewActions").forEach((element) => { + const dropdown = Simple_1.default.getDropdownMenu(element.dataset.target); + dropdown?.querySelectorAll("[data-action]").forEach((element) => { + element.addEventListener("click", () => { + row.dispatchEvent(new CustomEvent("action", { + detail: element.dataset, + bubbles: true, + })); + }); + }); + }); + }); + } + #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) { + if (!this.#filterPills) { + return; + } + this.#filterPills.innerHTML = ""; + if (!this.#filters) { + return; + } + this.#filters.forEach((value, key) => { + const button = document.createElement("button"); + button.type = "button"; + button.classList.add("button", "small"); + const icon = document.createElement("fa-icon"); + icon.setIcon("circle-xmark"); + button.append(icon, 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") { + pageNo = parseInt(value, 10); + return; + } + if (key === "sortField") { + this.#sortField = value; + } + 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); + } + } + exports.GridView = GridView; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js new file mode 100644 index 00000000000..751f8ebc35e --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js @@ -0,0 +1,26 @@ +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 = setup; + UiNotification = tslib_1.__importStar(UiNotification); + async function handleDelete(row, objectName, endpoint) { + const confirmationResult = await (0, Confirmation_1.confirmationFactory)().delete(objectName); + if (!confirmationResult) { + return; + } + const result = await (0, DeleteObject_1.deleteObject)(endpoint); + if (!result.ok) { + return; + } + row.remove(); + // TODO: This shows a generic success message and should be replaced with a more specific message. + UiNotification.show(); + } + function setup(table) { + table.addEventListener("action", (event) => { + if (event.detail.action === "delete") { + void handleDelete(event.target, event.detail.objectName, event.detail.endpoint); + } + }); + } +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/DateRange.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/DateRange.js new file mode 100644 index 00000000000..b4d8d2d11df --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/DateRange.js @@ -0,0 +1,30 @@ +define(["require", "exports", "tslib", "./Field", "../../../Date/Picker"], function (require, exports, tslib_1, Field_1, Picker_1) { + "use strict"; + Field_1 = tslib_1.__importDefault(Field_1); + Picker_1 = tslib_1.__importDefault(Picker_1); + class DateRange extends Field_1.default { + #fromField; + #toField; + constructor(fieldId) { + super(fieldId); + this.#fromField = document.getElementById(this._fieldId + "_from"); + if (this.#fromField === null) { + throw new Error("Unknown field with id '" + this._fieldId + "'."); + } + this.#toField = document.getElementById(this._fieldId + "_to"); + if (this.#toField === null) { + throw new Error("Unknown field with id '" + this._fieldId + "'."); + } + } + _getData() { + return { + [this._fieldId]: { + from: Picker_1.default.getValue(this.#fromField), + to: Picker_1.default.getValue(this.#toField), + }, + }; + } + _readField() { } + } + return DateRange; +}); diff --git a/wcfsetup/install/files/lib/acp/page/CronjobLogListPage.class.php b/wcfsetup/install/files/lib/acp/page/CronjobLogListPage.class.php index c3d6f0f92e5..74548218b63 100755 --- a/wcfsetup/install/files/lib/acp/page/CronjobLogListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/CronjobLogListPage.class.php @@ -2,22 +2,20 @@ namespace wcf\acp\page; -use wcf\data\cronjob\CronjobList; -use wcf\data\cronjob\I18nCronjobList; -use wcf\data\cronjob\log\CronjobLogList; -use wcf\page\SortablePage; -use wcf\system\WCF; +use wcf\page\AbstractGridViewPage; +use wcf\system\view\grid\AbstractGridView; +use wcf\system\view\grid\CronjobLogGridView; /** * Shows cronjob log information. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License * - * @property CronjobLogList $objectList + * @property CronjobLogGridView $gridView */ -class CronjobLogListPage extends SortablePage +class CronjobLogListPage extends AbstractGridViewPage { /** * @inheritDoc @@ -29,114 +27,9 @@ class CronjobLogListPage extends SortablePage */ public $neededPermissions = ['admin.management.canManageCronjob']; - /** - * @inheritDoc - */ - public $itemsPerPage = 100; - - /** - * @inheritDoc - */ - public $defaultSortField = 'execTime'; - - /** - * @inheritDoc - */ - public $defaultSortOrder = 'DESC'; - - /** - * @inheritDoc - */ - public $validSortFields = ['cronjobID', 'className', 'description', 'execTime', 'success']; - - /** - * @inheritDoc - */ - public $objectListClassName = CronjobLogList::class; - - /** - * @var int - */ - public $cronjobID = 0; - - /** - * @var int - */ - public $success = -1; - - /** - * @var CronjobList - */ - public $availableCronjobs; - - /** - * @inheritDoc - */ - public function readParameters() - { - parent::readParameters(); - - if (!empty($_REQUEST['cronjobID'])) { - $this->cronjobID = \intval($_REQUEST['cronjobID']); - } - if (isset($_REQUEST['success'])) { - $this->success = \intval($_REQUEST['success']); - } - } - - /** - * @inheritDoc - */ - protected function initObjectList() - { - parent::initObjectList(); - - $this->objectList->sqlSelects = "cronjob.*"; - $this->objectList->sqlJoins = " - LEFT JOIN wcf" . WCF_N . "_cronjob cronjob - ON cronjob.cronjobID = cronjob_log.cronjobID"; - - if ($this->cronjobID) { - $this->objectList->getConditionBuilder()->add('cronjob_log.cronjobID = ?', [$this->cronjobID]); - } - if ($this->success != -1) { - $this->objectList->getConditionBuilder()->add('cronjob_log.success = ?', [$this->success]); - } - } - - /** - * @inheritDoc - */ - protected function readObjects() + #[\Override] + protected function createGridViewController(): AbstractGridView { - $this->sqlOrderBy = (($this->sortField == 'className' || $this->sortField == 'description') ? 'cronjob.' : 'cronjob_log.') . $this->sortField . " " . $this->sortOrder; - - parent::readObjects(); - } - - /** - * @inheritDoc - */ - public function readData() - { - parent::readData(); - - $this->availableCronjobs = new I18nCronjobList(); - $this->availableCronjobs->sqlOrderBy = 'descriptionI18n'; - $this->availableCronjobs->readObjects(); - } - - /** - * @inheritDoc - */ - public function assignVariables() - { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'cronjobID' => $this->cronjobID, - 'success' => $this->success, - 'availableCronjobs' => $this->availableCronjobs, - ]); + return new CronjobLogGridView(); } } diff --git a/wcfsetup/install/files/lib/acp/page/ExceptionLogViewPage.class.php b/wcfsetup/install/files/lib/acp/page/ExceptionLogViewPage.class.php index 5495a24425c..8f3dc20cc58 100644 --- a/wcfsetup/install/files/lib/acp/page/ExceptionLogViewPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/ExceptionLogViewPage.class.php @@ -2,17 +2,10 @@ namespace wcf\acp\page; -use wcf\page\AbstractPage; -use wcf\page\MultipleLinkPage; -use wcf\system\event\EventHandler; -use wcf\system\exception\IllegalLinkException; -use wcf\system\Regex; +use wcf\page\AbstractGridViewPage; use wcf\system\registry\RegistryHandler; -use wcf\system\request\LinkHandler; -use wcf\system\WCF; -use wcf\util\DirectoryUtil; -use wcf\util\ExceptionLogUtil; -use wcf\util\StringUtil; +use wcf\system\view\grid\AbstractGridView; +use wcf\system\view\grid\ExceptionLogGridView; /** * Shows the exception log. @@ -21,7 +14,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class ExceptionLogViewPage extends MultipleLinkPage +class ExceptionLogViewPage extends AbstractGridViewPage { /** * @inheritDoc @@ -33,193 +26,22 @@ class ExceptionLogViewPage extends MultipleLinkPage */ public $neededPermissions = ['admin.management.canViewLog']; - /** - * @inheritDoc - */ - public $itemsPerPage = 10; - - /** - * given exceptionID - * @var string - */ - public $exceptionID = ''; - - /** - * @inheritDoc - */ - public $forceCanonicalURL = true; - - /** - * active logfile - * @var string - */ - public $logFile = ''; - - /** - * available logfiles - * @var string[] - */ - public $logFiles = []; - - /** - * exceptions shown - * @var array - */ - public $exceptions = []; - - /** - * @inheritDoc - */ - public function readParameters() - { - parent::readParameters(); - - if (isset($_REQUEST['exceptionID'])) { - $this->exceptionID = StringUtil::trim($_REQUEST['exceptionID']); - } - if (isset($_REQUEST['logFile'])) { - $this->logFile = StringUtil::trim($_REQUEST['logFile']); - } - - $parameters = []; - if ($this->exceptionID !== '') { - $parameters['exceptionID'] = $this->exceptionID; - } elseif ($this->logFile !== '') { - $parameters['logFile'] = $this->logFile; - } - - $this->canonicalURL = LinkHandler::getInstance()->getControllerLink(self::class, $parameters); - } - - /** - * @inheritDoc - */ + #[\Override] public function readData() { - AbstractPage::readData(); - - // mark notifications as read - RegistryHandler::getInstance()->set('com.woltlab.wcf', 'exceptionMailerTimestamp', TIME_NOW); - - $fileNameRegex = new Regex('(?:^|/)\d{4}-\d{2}-\d{2}\.txt$'); - $logFiles = DirectoryUtil::getInstance(WCF_DIR . 'log/', false)->getFiles(\SORT_DESC, $fileNameRegex); - foreach ($logFiles as $logFile) { - $pathname = WCF_DIR . 'log/' . $logFile; - $this->logFiles[$pathname] = $pathname; - } - - if ($this->exceptionID) { - // search the appropriate file - foreach ($this->logFiles as $logFile) { - $contents = \file_get_contents($logFile); - - if (\str_contains($contents, '<<<<<<<<' . $this->exceptionID . '<<<<')) { - $fileNameRegex->match($logFile); - $matches = $fileNameRegex->getMatches(); - $this->logFile = $matches[0]; - break; - } - - unset($contents); - } - - if (!isset($contents)) { - $this->logFile = ''; + $this->markNotificationsAsRead(); - return; - } - } elseif ($this->logFile) { - if (!$fileNameRegex->match(\basename($this->logFile))) { - throw new IllegalLinkException(); - } - if (!\file_exists(WCF_DIR . 'log/' . $this->logFile)) { - throw new IllegalLinkException(); - } - - $contents = \file_get_contents(WCF_DIR . 'log/' . $this->logFile); - } else { - return; - } - - try { - $this->exceptions = ExceptionLogUtil::splitLog($contents); - } catch (\Exception $e) { - return; - } - - // show latest exceptions first - $this->exceptions = \array_reverse($this->exceptions, true); - - if ($this->exceptionID) { - $this->searchPage($this->exceptionID); - } - $this->calculateNumberOfPages(); - - $i = 0; - $seenHashes = []; - foreach ($this->exceptions as $key => $val) { - $i++; - - $parsed = ExceptionLogUtil::parseException($val); - if (isset($seenHashes[$parsed['stackHash']])) { - $parsed['collapsed'] = true; - } - $seenHashes[$parsed['stackHash']] = true; - - if ($i < $this->startIndex || $i > $this->endIndex) { - unset($this->exceptions[$key]); - continue; - } - try { - $this->exceptions[$key] = $parsed; - } catch (\InvalidArgumentException $e) { - unset($this->exceptions[$key]); - } - } + parent::readData(); } - /** - * @inheritDoc - */ - public function countItems() - { - // call countItems event - EventHandler::getInstance()->fireAction($this, 'countItems'); - - return \count($this->exceptions); - } - - /** - * Switches to the page containing the exception with the given ID. - * - * @param string $exceptionID - */ - public function searchPage($exceptionID) + private function markNotificationsAsRead(): void { - $i = 1; - - foreach ($this->exceptions as $key => $val) { - if ($key == $exceptionID) { - break; - } - $i++; - } - - $this->pageNo = \ceil($i / $this->itemsPerPage); + RegistryHandler::getInstance()->set('com.woltlab.wcf', 'exceptionMailerTimestamp', TIME_NOW); } - /** - * @inheritDoc - */ - public function assignVariables() + #[\Override] + protected function createGridViewController(): AbstractGridView { - parent::assignVariables(); - - WCF::getTPL()->assign([ - 'exceptionID' => $this->exceptionID, - 'logFiles' => \array_flip(\array_map('basename', $this->logFiles)), - 'logFile' => $this->logFile, - 'exceptions' => $this->exceptions, - ]); + return new ExceptionLogGridView(true); } } diff --git a/wcfsetup/install/files/lib/acp/page/UserRankListPage.class.php b/wcfsetup/install/files/lib/acp/page/UserRankListPage.class.php index ddd3e9f893f..a487e7e0f26 100644 --- a/wcfsetup/install/files/lib/acp/page/UserRankListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/UserRankListPage.class.php @@ -2,19 +2,20 @@ namespace wcf\acp\page; -use wcf\data\user\rank\I18nUserRankList; -use wcf\page\SortablePage; +use wcf\page\AbstractGridViewPage; +use wcf\system\view\grid\AbstractGridView; +use wcf\system\view\grid\UserRankGridView; /** * Lists available user ranks. * - * @author Marcel Werk - * @copyright 2001-2019 WoltLab GmbH - * @license GNU Lesser General Public License + * @author Marcel Werk + * @copyright 2001-2024 WoltLab GmbH + * @license GNU Lesser General Public License * - * @property I18nUserRankList $objectList + * @property UserRankGridView $gridView */ -class UserRankListPage extends SortablePage +class UserRankListPage extends AbstractGridViewPage { /** * @inheritDoc @@ -31,31 +32,9 @@ class UserRankListPage extends SortablePage */ public $neededModules = ['MODULE_USER_RANK']; - /** - * @inheritDoc - */ - public $objectListClassName = I18nUserRankList::class; - - /** - * @inheritDoc - */ - public $defaultSortField = 'rankTitleI18n'; - - /** - * @inheritDoc - */ - public $validSortFields = ['rankID', 'groupID', 'requiredPoints', 'rankTitleI18n', 'rankImage', 'requiredGender']; - - /** - * @inheritDoc - */ - protected function initObjectList() + #[\Override] + protected function createGridViewController(): AbstractGridView { - parent::initObjectList(); - - $this->objectList->sqlSelects .= (!empty($this->objectList->sqlSelects) ? ', ' : '') . 'user_group.groupName'; - $this->objectList->sqlJoins .= ' - LEFT JOIN wcf' . WCF_N . '_user_group user_group - ON user_group.groupID = user_rank.groupID'; + return new UserRankGridView(); } } diff --git a/wcfsetup/install/files/lib/action/GridViewFilterAction.class.php b/wcfsetup/install/files/lib/action/GridViewFilterAction.class.php new file mode 100644 index 00000000000..f3579f8c26d --- /dev/null +++ b/wcfsetup/install/files/lib/action/GridViewFilterAction.class.php @@ -0,0 +1,94 @@ +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; + } +} diff --git a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php index f3554ed8ecd..9cefa279607 100644 --- a/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php +++ b/wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php @@ -134,8 +134,11 @@ static function (\wcf\event\endpoint\ControllerCollecting $event) { $event->register(new \wcf\system\endpoint\controller\core\comments\responses\RenderResponse); $event->register(new \wcf\system\endpoint\controller\core\comments\responses\RenderResponses); $event->register(new \wcf\system\endpoint\controller\core\comments\responses\UpdateResponse); + $event->register(new \wcf\system\endpoint\controller\core\exceptions\RenderException); + $event->register(new \wcf\system\endpoint\controller\core\gridViews\GetRows); $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions); $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession); + $event->register(new \wcf\system\endpoint\controller\core\users\ranks\DeleteUserRank); } ); diff --git a/wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php new file mode 100644 index 00000000000..07012b24137 --- /dev/null +++ b/wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php @@ -0,0 +1,19 @@ + + * @since 6.2 + */ +final class CronjobLogGridViewInitialized implements IPsr14Event +{ + public function __construct(public readonly CronjobLogGridView $gridView) {} +} diff --git a/wcfsetup/install/files/lib/event/gridView/ExceptionLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/ExceptionLogGridViewInitialized.class.php new file mode 100644 index 00000000000..5e10db0a7b5 --- /dev/null +++ b/wcfsetup/install/files/lib/event/gridView/ExceptionLogGridViewInitialized.class.php @@ -0,0 +1,19 @@ + + * @since 6.2 + */ +final class ExceptionLogGridViewInitialized implements IPsr14Event +{ + public function __construct(public readonly ExceptionLogGridView $gridView) {} +} diff --git a/wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php new file mode 100644 index 00000000000..4eb06b42471 --- /dev/null +++ b/wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php @@ -0,0 +1,19 @@ + + * @since 6.2 + */ +final class UserRankGridViewInitialized implements IPsr14Event +{ + public function __construct(public readonly UserRankGridView $gridView) {} +} diff --git a/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php b/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php new file mode 100644 index 00000000000..fcc7029e474 --- /dev/null +++ b/wcfsetup/install/files/lib/page/AbstractGridViewPage.class.php @@ -0,0 +1,74 @@ +pageNo = \intval($_REQUEST['pageNo']); + } + if (isset($_REQUEST['sortField'])) { + $this->sortField = $_REQUEST['sortField']; + } + 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] + public function readData() + { + parent::readData(); + + $this->initGridView(); + } + + #[\Override] + public function assignVariables() + { + parent::assignVariables(); + + WCF::getTPL()->assign([ + 'gridView' => $this->gridView, + ]); + } + + protected function initGridView(): void + { + $this->gridView = $this->createGridViewController(); + + if ($this->sortField) { + $this->gridView->setSortField($this->sortField); + } + if ($this->sortOrder) { + $this->gridView->setSortOrder($this->sortOrder); + } + if ($this->filters !== []) { + $this->gridView->setActiveFilters($this->filters); + } + if ($this->pageNo != 1) { + $this->gridView->setPageNo($this->pageNo); + } + $this->gridView->setBaseUrl(LinkHandler::getInstance()->getControllerLink(static::class)); + } + + protected abstract function createGridViewController(): AbstractGridView; +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/exceptions/RenderException.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/exceptions/RenderException.class.php new file mode 100644 index 00000000000..39afb20410a --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/exceptions/RenderException.class.php @@ -0,0 +1,84 @@ + + * @since 6.1 + */ +#[GetRequest('/core/exceptions/{id:[a-f0-9]{40}}/render')] +final class RenderException implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + $this->assertExceptionLogIsAccessible(); + + $exception = $this->getException($variables['id']); + if (!$exception) { + throw new IllegalLinkException(); + } + + return new JsonResponse([ + 'template' => WCF::getTPL()->fetch('shared_exceptionLogDetails', 'wcf', [ + 'exception' => $exception, + 'exceptionID' => $variables['id'], + ]) + ]); + } + + private function assertExceptionLogIsAccessible(): void + { + if (!WCF::getSession()->getPermission('admin.management.canViewLog')) { + throw new PermissionDeniedException(); + } + } + + private function getException(string $exceptionID): ?array + { + $logFile = $this->getLogFile($exceptionID); + if (!$logFile) { + return null; + } + + try { + $exceptions = ExceptionLogUtil::splitLog(\file_get_contents($logFile)); + + return ExceptionLogUtil::parseException($exceptions[$exceptionID]); + } catch (\Exception $e) { + return null; + } + } + + private function getLogFile(string $exceptionID): ?string + { + $fileNameRegex = new Regex('(?:^|/)\d{4}-\d{2}-\d{2}\.txt$'); + $logFiles = DirectoryUtil::getInstance(WCF_DIR . 'log/', false)->getFiles(\SORT_DESC, $fileNameRegex); + foreach ($logFiles as $logFile) { + $pathname = WCF_DIR . 'log/' . $logFile; + $contents = \file_get_contents($pathname); + + if (\str_contains($contents, '<<<<<<<<' . $exceptionID . '<<<<')) { + return $pathname; + } + } + + return null; + } +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php new file mode 100644 index 00000000000..ffcb153ba99 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/gridViews/GetRows.class.php @@ -0,0 +1,74 @@ +gridView, AbstractGridView::class)) { + throw new UserInputException('gridView', 'invalid'); + } + + $view = new $parameters->gridView(...$parameters->gridViewParameters); + \assert($view instanceof AbstractGridView); + + if (!$view->isAccessible()) { + throw new PermissionDeniedException(); + } + + $view->setPageNo($parameters->pageNo); + if ($parameters->sortField) { + $view->setSortField($parameters->sortField); + } + if ($parameters->sortOrder) { + $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(), + 'totalRows' => $view->countRows(), + 'filterLabels' => $filterLabels, + ]); + } +} + +/** @internal */ +final class GetRowsParameters +{ + public function __construct( + /** @var non-empty-string */ + public readonly string $gridView, + public readonly int $pageNo, + public readonly string $sortField, + public readonly string $sortOrder, + /** @var string[] */ + public readonly array $filters, + /** @var string[] */ + public readonly array $gridViewParameters, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php new file mode 100644 index 00000000000..fa61fec8767 --- /dev/null +++ b/wcfsetup/install/files/lib/system/endpoint/controller/core/users/ranks/DeleteUserRank.class.php @@ -0,0 +1,38 @@ + + * @since 6.1 + */ +#[DeleteRequest('/core/users/ranks/{id:\d+}')] +final class DeleteUserRank implements IController +{ + #[\Override] + public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface + { + WCF::getSession()->checkPermissions(['admin.user.rank.canManageRank']); + + $rank = Helper::fetchObjectFromRequestParameter($variables['id'], UserRank::class); + + $action = new UserRankAction([$rank], 'delete'); + $action->executeAction(); + + return new JsonResponse([]); + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php new file mode 100644 index 00000000000..96d8a26666c --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/DateRangeFormField.class.php @@ -0,0 +1,161 @@ + + * @since 6.2 + */ +class DateRangeFormField extends AbstractFormField implements + IAttributeFormField, + IAutoFocusFormField, + ICssClassFormField, + IImmutableFormField, + INullableFormField +{ + use TAttributeFormField; + use TAutoFocusFormField; + use TCssClassFormField; + use TImmutableFormField; + use TNullableFormField; + + /** + * is `true` if not only the date, but also the time can be set + * @var bool + */ + protected $supportsTime = false; + + /** + * @inheritDoc + */ + protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/DateRange'; + + /** + * @inheritDoc + */ + protected $templateName = 'shared_dateRangeFormField'; + + const DATE_FORMAT = 'Y-m-d'; + + const TIME_FORMAT = 'Y-m-d\TH:i:sP'; + + /** + * @inheritDoc + */ + public function getSaveValue() + { + if ($this->getValue() === null && $this->isNullable()) { + return null; + } + + return $this->getFromValue() . ';' . $this->getToValue(); + } + + /** + * @inheritDoc + */ + public function readValue() + { + if ( + $this->getDocument()->hasRequestData($this->getPrefixedId()) + && \is_array($this->getDocument()->getRequestData($this->getPrefixedId())) + ) { + $this->value = $this->getDocument()->getRequestData($this->getPrefixedId()); + } + + return $this; + } + + /** + * Sets if not only the date, but also the time can be set. + */ + public function supportTime($supportsTime = true): static + { + if ($this->value !== null) { + throw new \BadFunctionCallException( + "After a value has been set, time support cannot be changed for field '{$this->getId()}'." + ); + } + + $this->supportsTime = $supportsTime; + + return $this; + } + + /** + * Returns `true` if not only the date, but also the time can be set, and + * returns `false` otherwise. + * + * By default, the time cannot be set. + */ + public function supportsTime(): bool + { + return $this->supportsTime; + } + + /** + * @inheritDoc + */ + public function validate() + { + if ($this->isRequired() && (!$this->getFromValue() || !$this->getToValue())) { + $this->addValidationError(new FormFieldValidationError('empty')); + } + + if ($this->getFromValue()) { + $dateTime = \DateTime::createFromFormat( + $this->supportsTime() ? self::TIME_FORMAT : self::DATE_FORMAT, + $this->getFromValue() + ); + if ($dateTime === false) { + $this->addValidationError(new FormFieldValidationError('invalid')); + } + } + + if ($this->getToValue()) { + $dateTime = \DateTime::createFromFormat( + $this->supportsTime() ? self::TIME_FORMAT : self::DATE_FORMAT, + $this->getToValue() + ); + if ($dateTime === false) { + $this->addValidationError(new FormFieldValidationError('invalid')); + } + } + } + + /** + * @inheritDoc + */ + public function value($value) + { + $values = \explode(';', $value); + if (\count($values) !== 2) { + throw new \InvalidArgumentException( + "Given value does not match format for field '{$this->getId()}'." + ); + } + + $this->value = [ + 'from' => $values[0], + 'to' => $values[1], + ]; + + return $this; + } + + public function getFromValue(): string + { + return $this->value['from'] ?? ''; + } + + public function getToValue(): string + { + return $this->value['to'] ?? ''; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/AbstractGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/AbstractGridView.class.php new file mode 100644 index 00000000000..c46053b607e --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/AbstractGridView.class.php @@ -0,0 +1,370 @@ +columns[] = $column; + } + + /** + * Adds a new column to the grid view at the position before the specific id. + */ + public function addColumnBefore(GridViewColumn $column, string $beforeID): void + { + $position = -1; + + foreach ($this->getColumns() as $key => $existingColumn) { + if ($existingColumn->getID() === $beforeID) { + $position = $key; + break; + } + } + + if ($position === -1) { + throw new \InvalidArgumentException("Invalid column id '{$beforeID}' given."); + } + + array_splice($this->columns, $position, 0, [ + $column, + ]); + } + + /** + * Adds a new column to the grid view at the position after the specific id. + */ + public function addColumnAfter(GridViewColumn $column, string $afterID): void + { + $position = -1; + + foreach ($this->getColumns() as $key => $existingColumn) { + if ($existingColumn->getID() === $afterID) { + $position = $key; + break; + } + } + + if ($position === -1) { + throw new \InvalidArgumentException("Invalid column id '{$afterID}' given."); + } + + array_splice($this->columns, $position + 1, 0, [ + $column, + ]); + } + + /** + * Adds multiple new columns to the grid view. + * @param GridViewColumn[] $columns + */ + public function addColumns(array $columns): void + { + foreach ($columns as $column) { + $this->addColumn($column); + } + } + + /** + * Returns all columns of the grid view. + * @return GridViewColumn[] + */ + public function getColumns(): array + { + return $this->columns; + } + + /** + * Returns all visible (non-hidden) columns of the grid view. + * @return GridViewColumn[] + */ + public function getVisibleColumns(): array + { + return \array_filter($this->getColumns(), fn($column) => !$column->isHidden()); + } + + /** + * Returns the column with the given id or null if no such column exists. + */ + public function getColumn(string $id): ?GridViewColumn + { + foreach ($this->getColumns() as $column) { + if ($column->getID() === $id) { + return $column; + } + } + + return null; + } + + /** + * @param IGridViewAction[] $columns + */ + public function addActions(array $actions): void + { + foreach ($actions as $action) { + $this->addAction($action); + } + } + + public function addAction(IGridViewAction $action): void + { + $this->actions[] = $action; + } + + /** + * @return IGridViewAction[] + */ + public function getActions(): array + { + return $this->actions; + } + + public function hasActions(): bool + { + return $this->actions !== []; + } + + public function render(): string + { + return WCF::getTPL()->fetch('shared_gridView', 'wcf', ['view' => $this], true); + } + + public function renderRows(): string + { + return WCF::getTPL()->fetch('shared_gridViewRows', 'wcf', ['view' => $this], true); + } + + public function renderColumn(GridViewColumn $column, mixed $row): string + { + $value = $column->render($this->getData($row, $column->getID()), $row); + + if (isset($this->rowLink)) { + $value = $this->rowLink->render($value, $row, $column->isTitleColumn()); + } + + return $value; + } + + public function renderAction(IGridViewAction $action, mixed $row): string + { + return $action->render($row); + } + + public function renderActionInitialization(): string + { + return implode( + "\n", + \array_map( + fn($action) => $action->renderInitialization($this), + $this->getActions() + ) + ); + } + + protected function getData(mixed $row, string $identifer): mixed + { + return $row[$identifer] ?? ''; + } + + public abstract function getRows(): array; + + public abstract function countRows(): int; + + public function countPages(): int + { + return \ceil($this->countRows() / $this->getRowsPerPage()); + } + + public function getClassName(): string + { + return \get_class($this); + } + + public function isAccessible(): bool + { + return true; + } + + public function getID(): string + { + $classNamePieces = \explode('\\', static::class); + + return \implode('-', $classNamePieces); + } + + public function setBaseUrl(string $url): void + { + $this->baseUrl = $url; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + /** + * @return GridViewColumn[] + */ + public function getSortableColumns(): array + { + 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()))) { + throw new \InvalidArgumentException("Invalid value '{$sortField}' as sort field given."); + } + + $this->sortField = $sortField; + } + + public function setSortOrder(string $sortOrder): void + { + if ($sortOrder !== 'ASC' && $sortOrder !== 'DESC') { + throw new \InvalidArgumentException("Invalid value '{$sortOrder}' as sort order given."); + } + + $this->sortOrder = $sortOrder; + } + + public function getSortField(): string + { + return $this->sortField; + } + + public function getSortOrder(): string + { + return $this->sortOrder; + } + + public function getPageNo(): int + { + return $this->pageNo; + } + + public function setPageNo(int $pageNo): void + { + $this->pageNo = $pageNo; + } + + public function getRowsPerPage(): int + { + return $this->rowsPerPage; + } + + public function setRowsPerPage(int $rowsPerPage): void + { + $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]); + } + + public function getParameters(): array + { + return []; + } + + public function addRowLink(GridViewRowLink $rowLink): void + { + $this->rowLink = $rowLink; + } + + public function getObjectID(mixed $row): mixed + { + return ''; + } + + protected function fireInitializedEvent(): void + { + $event = $this->getInitializedEvent(); + if ($event === null) { + return; + } + + EventHandler::getInstance()->fire($event); + } + + protected function getInitializedEvent(): ?IPsr14Event + { + return null; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/ArrayGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/ArrayGridView.class.php new file mode 100644 index 00000000000..4bb726d6473 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/ArrayGridView.class.php @@ -0,0 +1,67 @@ +sortRows(); + + return $this->getRowsForPage(); + } + + protected function sortRows(): void + { + $this->getDataArray(); + + \uasort($this->dataArray, function (array $a, array $b) { + if ($this->getSortOrder() === 'ASC') { + return \strcmp($a[$this->getSortField()], $b[$this->getSortField()]); + } else { + return \strcmp($b[$this->getSortField()], $a[$this->getSortField()]); + } + }); + } + + protected function getRowsForPage(): array + { + return \array_slice($this->getDataArray(), ($this->getPageNo() - 1) * $this->getRowsPerPage(), $this->getRowsPerPage()); + } + + public function countRows(): int + { + return \count($this->getDataArray()); + } + + protected function getDataArray(): array + { + if (!isset($this->dataArray)) { + $this->dataArray = $this->loadDataArray(); + $this->applyFilters(); + $this->fireInitializedEvent(); + } + + return $this->dataArray; + } + + protected function applyFilters(): void + { + foreach ($this->getActiveFilters() as $key => $value) { + $column = $this->getColumn($key); + if (!$column) { + throw new LogicException("Unknown column '" . $key . "'"); + } + + $this->dataArray = \array_filter($this->dataArray, function (array $row) use ($column, $value) { + return $column->getFilter()->matches($value, $row[$column->getID()]); + }); + } + } + + protected abstract function loadDataArray(): array; +} diff --git a/wcfsetup/install/files/lib/system/view/grid/CronjobLogGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/CronjobLogGridView.class.php new file mode 100644 index 00000000000..90277a7414f --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/CronjobLogGridView.class.php @@ -0,0 +1,126 @@ +getAvailableCronjobs(); + + $this->addColumns([ + GridViewColumn::for('cronjobLogID') + ->label('wcf.global.objectID') + ->renderer(new NumberColumnRenderer()) + ->sortable(), + GridViewColumn::for('cronjobID') + ->label('wcf.acp.cronjob') + ->sortable() + ->filter(new SelectFilter($availableCronjobs)) + ->renderer([ + new class($availableCronjobs) extends TitleColumnRenderer { + public function __construct(private readonly array $availableCronjobs) {} + + public function render(mixed $value, mixed $context = null): string + { + return $this->availableCronjobs[$value]; + } + }, + ]), + GridViewColumn::for('execTime') + ->label('wcf.acp.cronjob.log.execTime') + ->sortable() + ->filter(new TimeFilter()) + ->renderer(new TimeColumnRenderer()), + GridViewColumn::for('success') + ->label('wcf.acp.cronjob.log.status') + ->sortable() + ->filter(new SelectFilter([ + 1 => 'wcf.acp.cronjob.log.success', + 0 => 'wcf.acp.cronjob.log.error', + ])) + ->renderer([ + new class extends DefaultColumnRenderer { + public function render(mixed $value, mixed $context = null): string + { + \assert($context instanceof CronjobLog); + + if ($context->success) { + return '' . WCF::getLanguage()->get('wcf.acp.cronjob.log.success') . ''; + } + if ($context->error) { + $label = WCF::getLanguage()->get('wcf.acp.cronjob.log.error'); + $buttonId = 'cronjobLogErrorButton' . $context->cronjobLogID; + $id = 'cronjobLogError' . $context->cronjobLogID; + $error = StringUtil::encodeHTML($context->error); + $dialogTitle = StringUtil::encodeJS(WCF::getLanguage()->get('wcf.acp.cronjob.log.error.details')); + + return << + {$label} + + + + HTML; + } + + return ''; + } + }, + ]), + ]); + + $this->setSortField('execTime'); + $this->setSortOrder('DESC'); + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->getPermission('admin.management.canManageCronjob'); + } + + #[\Override] + protected function createObjectList(): DatabaseObjectList + { + return new CronjobLogList(); + } + + #[\Override] + protected function getInitializedEvent(): ?IPsr14Event + { + return new CronjobLogGridViewInitialized($this); + } + + private function getAvailableCronjobs(): array + { + $list = new I18nCronjobList(); + $list->sqlOrderBy = 'descriptionI18n'; + $list->readObjects(); + + return \array_map(fn(Cronjob $cronjob) => $cronjob->getDescription(), $list->getObjects()); + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/DatabaseObjectListGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/DatabaseObjectListGridView.class.php new file mode 100644 index 00000000000..7116ac2eb47 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/DatabaseObjectListGridView.class.php @@ -0,0 +1,84 @@ +getObjectList()->readObjects(); + + return $this->getObjectList()->getObjects(); + } + + public function countRows(): int + { + if (!isset($this->objectCount)) { + $this->objectCount = $this->getObjectList()->countObjects(); + } + + return $this->objectCount; + } + + protected function getData(mixed $row, string $identifer): mixed + { + \assert($row instanceof DatabaseObject); + + return $row->__get($identifer); + } + + protected function initObjectList(): void + { + $this->objectList = $this->createObjectList(); + $this->objectList->sqlLimit = $this->getRowsPerPage(); + $this->objectList->sqlOffset = ($this->getPageNo() - 1) * $this->getRowsPerPage(); + if ($this->getSortField()) { + $column = $this->getColumn($this->getSortField()); + if ($column && $column->getSortById()) { + $this->objectList->sqlOrderBy = $column->getSortById() . ' ' . $this->getSortOrder(); + } else { + $this->objectList->sqlOrderBy = $this->getSortField() . ' ' . $this->getSortOrder(); + } + } + $this->applyFilters(); + $this->fireInitializedEvent(); + } + + public function getObjectList(): DatabaseObjectList + { + if (!isset($this->objectList)) { + $this->initObjectList(); + } + + return $this->objectList; + } + + protected 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); + } + } + + #[\Override] + public function getObjectID(mixed $row): mixed + { + \assert($row instanceof DatabaseObject); + + return $row->getObjectID(); + } + + protected abstract function createObjectList(): DatabaseObjectList; +} diff --git a/wcfsetup/install/files/lib/system/view/grid/ExceptionLogGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/ExceptionLogGridView.class.php new file mode 100644 index 00000000000..a8da2dd2f97 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/ExceptionLogGridView.class.php @@ -0,0 +1,155 @@ +addColumns([ + GridViewColumn::for('message') + ->label('wcf.acp.exceptionLog.exception.message') + ->sortable() + ->renderer(new TitleColumnRenderer()), + GridViewColumn::for('exceptionID') + ->label('wcf.acp.exceptionLog.search.exceptionID') + ->filter(new TextFilter()) + ->sortable(), + GridViewColumn::for('date') + ->label('wcf.acp.exceptionLog.exception.date') + ->sortable() + ->renderer(new TimeColumnRenderer()), + GridViewColumn::for('logFile') + ->label('wcf.acp.exceptionLog.search.logFile') + ->filter(new SelectFilter($this->getAvailableLogFiles())) + ->hidden(true), + ]); + + $this->addRowLink(new GridViewRowLink(cssClass: 'jsExceptionLogEntry')); + $this->setSortField('date'); + $this->setSortOrder('DESC'); + + if ($applyDefaultFilter && $this->getDefaultLogFile() !== null) { + $this->setActiveFilters([ + 'logFile' => $this->getDefaultLogFile(), + ]); + } + } + + #[\Override] + public function isAccessible(): bool + { + return WCF::getSession()->getPermission('admin.management.canViewLog'); + } + + #[\Override] + public function getObjectID(mixed $row): mixed + { + return $row['exceptionID']; + } + + #[\Override] + protected function loadDataArray(): array + { + if (!empty($this->getActiveFilters()['exceptionID'])) { + $exceptionID = $this->getActiveFilters()['exceptionID']; + $contents = $logFile = ''; + foreach ($this->getAvailableLogFiles() as $logFile) { + $contents = \file_get_contents(WCF_DIR . $logFile); + + if (\str_contains($contents, '<<<<<<<<' . $exceptionID . '<<<<')) { + break; + } + + unset($contents); + } + + if ($contents === '') { + return []; + } + + $exceptions = ExceptionLogUtil::splitLog($contents); + $parsedExceptions = []; + + foreach ($exceptions as $key => $val) { + if ($key !== $exceptionID) { + continue; + } + + $parsed = ExceptionLogUtil::parseException($val); + + $parsedExceptions[$key] = [ + 'exceptionID' => $key, + 'message' => $parsed['message'], + 'date' => $parsed['date'], + 'logFile' => $logFile, + ]; + } + + return $parsedExceptions; + } elseif (!empty($this->getActiveFilters()['logFile'])) { + $contents = \file_get_contents(WCF_DIR . $this->getActiveFilters()['logFile']); + $exceptions = ExceptionLogUtil::splitLog($contents); + $parsedExceptions = []; + + foreach ($exceptions as $key => $val) { + $parsed = ExceptionLogUtil::parseException($val); + + $parsedExceptions[$key] = [ + 'exceptionID' => $key, + 'message' => $parsed['message'], + 'date' => $parsed['date'], + 'logFile' => $this->getActiveFilters()['logFile'], + ]; + } + + return $parsedExceptions; + } + + return []; + } + + #[\Override] + protected function applyFilters(): void + { + // Overwrite the default filtering, as this is already applied when the data is loaded. + } + + private function getAvailableLogFiles(): array + { + if (!isset($this->availableLogFiles)) { + $this->availableLogFiles = []; + $fileNameRegex = new Regex('(?:^|/)\d{4}-\d{2}-\d{2}\.txt$'); + $logFiles = DirectoryUtil::getInstance(WCF_DIR . 'log/', false)->getFiles(\SORT_DESC, $fileNameRegex); + foreach ($logFiles as $logFile) { + $this->availableLogFiles['log/' . $logFile] = 'log/' . $logFile; + } + } + + return $this->availableLogFiles; + } + + private function getDefaultLogFile(): ?string + { + return \array_key_first($this->getAvailableLogFiles()); + } + + #[\Override] + protected function getInitializedEvent(): ?IPsr14Event + { + return new ExceptionLogGridViewInitialized($this); + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/GridViewColumn.class.php b/wcfsetup/install/files/lib/system/view/grid/GridViewColumn.class.php new file mode 100644 index 00000000000..ea1199d9c2d --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/GridViewColumn.class.php @@ -0,0 +1,174 @@ +getRenderers() === []) { + return self::getDefaultRenderer()->render($value, $context); + } + + foreach ($this->getRenderers() as $renderer) { + $value = $renderer->render($value, $context); + } + + return $value; + } + + public function getClasses(): string + { + if ($this->getRenderers() === []) { + return self::getDefaultRenderer()->getClasses(); + } + + return \implode(' ', \array_map( + static function (IColumnRenderer $renderer) { + return $renderer->getClasses(); + }, + $this->getRenderers() + )); + } + + public function renderer(array|IColumnRenderer $renderers): static + { + if (!\is_array($renderers)) { + $renderers = [$renderers]; + } + + foreach ($renderers as $renderer) { + \assert($renderer instanceof IColumnRenderer); + $this->renderer[] = $renderer; + } + + return $this; + } + + public function label(string $languageItem): static + { + $this->label = WCF::getLanguage()->get($languageItem); + + return $this; + } + + public function sortable(bool $sortable = true): static + { + $this->sortable = $sortable; + + return $this; + } + + public function sortById(string $id): static + { + $this->sortById = $id; + + return $this; + } + + /** + * @return IColumnRenderer[] + */ + public function getRenderers(): array + { + return $this->renderer; + } + + public function getID(): string + { + return $this->id; + } + + public function getLabel(): string + { + return $this->label; + } + + public function isSortable(): bool + { + return $this->sortable; + } + + public function getSortById(): string + { + 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)) { + self::$defaultRenderer = new DefaultColumnRenderer(); + } + + return self::$defaultRenderer; + } + + public function isTitleColumn(): bool + { + foreach ($this->getRenderers() as $renderer) { + if ($renderer instanceof TitleColumnRenderer) { + return true; + } + } + + return false; + } + + public function hidden(bool $hidden = true): static + { + $this->hidden = $hidden; + + return $this; + } + + public function isHidden(): bool + { + return $this->hidden; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/GridViewRowLink.class.php b/wcfsetup/install/files/lib/system/view/grid/GridViewRowLink.class.php new file mode 100644 index 00000000000..c6485e5e80d --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/GridViewRowLink.class.php @@ -0,0 +1,47 @@ +controllerClass) { + \assert($context instanceof DatabaseObject); + $href = LinkHandler::getInstance()->getControllerLink( + $this->controllerClass, + \array_merge($this->parameters, ['object' => $context]) + ); + } + + $attributes = []; + $isButton = true; + if ($href) { + $attributes[] = 'href="' . $href . '"'; + $isButton = false; + } + $attributes[] = 'class="gridView__rowLink ' . StringUtil::encodeHTML($this->cssClass) . '"'; + $attributes[] = 'tabindex="' . ($isPrimaryColumn ? '0' : '-1') . '"'; + + if ($isButton) { + return ''; + } else { + return '' + . $value + . ''; + } + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/UserRankGridView.class.php b/wcfsetup/install/files/lib/system/view/grid/UserRankGridView.class.php new file mode 100644 index 00000000000..73bd4872532 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/UserRankGridView.class.php @@ -0,0 +1,133 @@ +addColumns([ + GridViewColumn::for('rankID') + ->label('wcf.global.objectID') + ->renderer(new NumberColumnRenderer()) + ->sortable(), + GridViewColumn::for('rankTitle') + ->label('wcf.acp.user.rank.title') + ->sortable() + ->sortById('rankTitleI18n') + ->filter(new I18nTextFilter()) + ->renderer([ + new class extends TitleColumnRenderer { + public function render(mixed $value, mixed $context = null): string + { + \assert($context instanceof UserRank); + + return '' + . StringUtil::encodeHTML($context->getTitle()) + . ''; + } + } + ]), + GridViewColumn::for('rankImage') + ->label('wcf.acp.user.rank.image') + ->sortable() + ->renderer([ + new class extends DefaultColumnRenderer { + public function render(mixed $value, mixed $context = null): string + { + \assert($context instanceof UserRank); + + return $context->rankImage ? $context->getImage() : ''; + } + }, + ]), + GridViewColumn::for('groupID') + ->label('wcf.user.group') + ->sortable() + ->filter(new SelectFilter($this->getAvailableUserGroups())) + ->renderer([ + new class extends DefaultColumnRenderer { + public function render(mixed $value, mixed $context = null): string + { + return StringUtil::encodeHTML(UserGroup::getGroupByID($value)->getName()); + } + }, + ]), + GridViewColumn::for('requiredGender') + ->label('wcf.user.option.gender') + ->sortable() + ->renderer([ + new class extends DefaultColumnRenderer { + public function render(mixed $value, mixed $context = null): string + { + if (!$value) { + return ''; + } + + return WCF::getLanguage()->get(match ($value) { + 1 => 'wcf.user.gender.male', + 2 => 'wcf.user.gender.female', + default => 'wcf.user.gender.other' + }); + } + }, + ]), + GridViewColumn::for('requiredPoints') + ->label('wcf.acp.user.rank.requiredPoints') + ->sortable() + ->renderer(new NumberColumnRenderer()), + ]); + + $this->addActions([ + new EditAction(UserRankEditForm::class), + new DeleteAction('core/users/ranks/%s'), + ]); + $this->addRowLink(new GridViewRowLink(UserRankEditForm::class)); + $this->setSortField('rankTitle'); + } + + #[\Override] + public function isAccessible(): bool + { + return \MODULE_USER_RANK && WCF::getSession()->getPermission('admin.user.rank.canManageRank'); + } + + #[\Override] + protected function createObjectList(): DatabaseObjectList + { + return new I18nUserRankList(); + } + + #[\Override] + protected function getInitializedEvent(): ?IPsr14Event + { + return new UserRankGridViewInitialized($this); + } + + private function getAvailableUserGroups(): array + { + $groups = []; + foreach (UserGroup::getSortedGroupsByType([], [UserGroup::GUESTS, UserGroup::EVERYONE]) as $group) { + $groups[$group->groupID] = $group->getName(); + } + + return $groups; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php new file mode 100644 index 00000000000..dd1c35d9f54 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php @@ -0,0 +1,55 @@ +getControllerLink(ApiAction::class, ['id' => 'rpc']) . + \sprintf($this->endpoint, $row->getObjectID()) + ); + $label = WCF::getLanguage()->get('wcf.global.button.delete'); + if ($row instanceof ITitledObject) { + $objectName = StringUtil::encodeHTML($row->getTitle()); + } else { + $objectName = ''; + } + + return << + {$label} + + HTML; + } + + #[\Override] + public function renderInitialization(AbstractGridView $gridView): ?string + { + $id = StringUtil::encodeJS($gridView->getID()); + + return << + require(['WoltLabSuite/Core/Component/GridView/Action/Delete'], ({ setup }) => { + setup(document.getElementById('{$id}_table')); + }); + + HTML; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/action/EditAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/EditAction.class.php new file mode 100644 index 00000000000..42761afa120 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/action/EditAction.class.php @@ -0,0 +1,33 @@ +getControllerLink( + $this->controllerClass, + ['object' => $row] + ); + + return '' . WCF::getLanguage()->get('wcf.global.button.edit') . ''; + } + + #[\Override] + public function renderInitialization(AbstractGridView $gridView): ?string + { + return null; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php new file mode 100644 index 00000000000..e37960a92c3 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php @@ -0,0 +1,12 @@ +getConditionBuilder()->add("($id LIKE ? OR $id IN (SELECT languageItem FROM wcf1_language_item WHERE languageID = ? AND languageItemValue LIKE ?))", [ + '%' . $value . '%', + WCF::getLanguage()->languageID, + '%' . $value . '%' + ]); + } +} 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 index 00000000000..4862acbb520 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/filter/IGridViewFilter.class.php @@ -0,0 +1,17 @@ +label($label) + ->options($this->options); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $id, string $value): void + { + $list->getConditionBuilder()->add("$id = ?", [$value]); + } + + #[\Override] + public function matches(string $filterValue, string $rowValue): bool + { + return $filterValue === $rowValue; + } + + #[\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 index 00000000000..52605e79457 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/filter/TextFilter.class.php @@ -0,0 +1,35 @@ +label($label); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $id, string $value): void + { + $list->getConditionBuilder()->add("$id LIKE ?", ['%' . $value . '%']); + } + + #[\Override] + public function matches(string $filterValue, string $rowValue): bool + { + return \str_contains(\mb_strtolower($rowValue), \mb_strtolower($filterValue)); + } + + #[\Override] + public function renderValue(string $value): string + { + return $value; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/filter/TimeFilter.class.php b/wcfsetup/install/files/lib/system/view/grid/filter/TimeFilter.class.php new file mode 100644 index 00000000000..c366dbdc316 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/filter/TimeFilter.class.php @@ -0,0 +1,138 @@ +label($label) + ->supportTime(); + } + + #[\Override] + public function applyFilter(DatabaseObjectList $list, string $id, string $value): void + { + $timestamps = $this->getTimestamps($value); + + if (!$timestamps['from'] && !$timestamps['to']) { + return; + } + + if (!$timestamps['to']) { + $list->getConditionBuilder()->add("$id >= ?", [$timestamps['from']]); + } else { + $list->getConditionBuilder()->add("$id BETWEEN ? AND ?", [$timestamps['from'], $timestamps['to']]); + } + } + + #[\Override] + public function matches(string $filterValue, string $rowValue): bool + { + $timestamps = $this->getTimestamps($filterValue); + + if (!$timestamps['from'] && !$timestamps['to']) { + return true; + } + + if (!$timestamps['to']) { + return $rowValue >= $timestamps['from']; + } else { + return $rowValue >= $timestamps['from'] && $rowValue <= $timestamps['to']; + } + } + + #[\Override] + public function renderValue(string $value): string + { + $values = explode(';', $value); + if (\count($values) !== 2) { + return ''; + } + + $locale = WCF::getLanguage()->getLocale();; + $fromString = $toString = ''; + if ($values[0] !== '') { + $fromDateTime = \DateTime::createFromFormat( + 'Y-m-d\TH:i:sP', + $values[0], + WCF::getUser()->getTimeZone() + ); + if ($fromDateTime !== false) { + $fromString = \IntlDateFormatter::formatObject( + $fromDateTime, + [ + \IntlDateFormatter::LONG, + \IntlDateFormatter::SHORT, + ], + $locale + ); + } + } + if ($values[1] !== '') { + $toDateTime = \DateTime::createFromFormat( + 'Y-m-d\TH:i:sP', + $values[1], + WCF::getUser()->getTimeZone() + ); + if ($toDateTime !== false) { + $toString = \IntlDateFormatter::formatObject( + $toDateTime, + [ + \IntlDateFormatter::LONG, + \IntlDateFormatter::SHORT, + ], + $locale + ); + } + } + + if ($fromString && $toString) { + return $fromString . ' ‐ ' . $toString; + } else if ($fromString) { + return '>= ' . $fromString; + } else if ($toString) { + return '<= ' . $toString; + } + + return ''; + } + + private function getTimestamps(string $value): array + { + $from = 0; + $to = 0; + + $values = explode(';', $value); + if (\count($values) === 2) { + $from = $this->getTimestamp($values[0]); + $to = $this->getTimestamp($values[1]); + } + + return [ + 'from' => $from, + 'to' => $to, + ]; + } + + private function getTimestamp(string $date): int + { + $dateTime = \DateTime::createFromFormat( + 'Y-m-d\TH:i:sP', + $date + ); + + if ($dateTime !== false) { + return $dateTime->getTimestamp(); + } + + return 0; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/renderer/AbstractColumnRenderer.class.php b/wcfsetup/install/files/lib/system/view/grid/renderer/AbstractColumnRenderer.class.php new file mode 100644 index 00000000000..d641d36016c --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/renderer/AbstractColumnRenderer.class.php @@ -0,0 +1,11 @@ +getControllerLink( + $this->controllerClass, + \array_merge($this->parameters, ['object' => $context]) + ); + + return 'titleLanguageItem ? ' title="' . WCF::getLanguage()->get($this->titleLanguageItem) . '"' : '') . '>' + . $value + . ''; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/renderer/NumberColumnRenderer.class.php b/wcfsetup/install/files/lib/system/view/grid/renderer/NumberColumnRenderer.class.php new file mode 100644 index 00000000000..b14bf7dc45c --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/renderer/NumberColumnRenderer.class.php @@ -0,0 +1,18 @@ +get($value)); + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/renderer/TimeColumnRenderer.class.php b/wcfsetup/install/files/lib/system/view/grid/renderer/TimeColumnRenderer.class.php new file mode 100644 index 00000000000..bbd69412e8b --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/renderer/TimeColumnRenderer.class.php @@ -0,0 +1,43 @@ +setTimezone(WCF::getUser()->getTimeZone()); + $locale = WCF::getLanguage()->getLocale(); + + $isFutureDate = $dateTime->getTimestamp() > TIME_NOW; + + $dateAndTime = \IntlDateFormatter::formatObject( + $dateTime, + [ + \IntlDateFormatter::LONG, + \IntlDateFormatter::SHORT, + ], + $locale + ); + + return \sprintf( + '%s', + $dateTime->format('c'), + $isFutureDate ? ' static' : '', + $dateAndTime + ); + } + + public function getClasses(): string + { + return 'gridView__column--date'; + } +} diff --git a/wcfsetup/install/files/lib/system/view/grid/renderer/TitleColumnRenderer.class.php b/wcfsetup/install/files/lib/system/view/grid/renderer/TitleColumnRenderer.class.php new file mode 100644 index 00000000000..f8ea52af881 --- /dev/null +++ b/wcfsetup/install/files/lib/system/view/grid/renderer/TitleColumnRenderer.class.php @@ -0,0 +1,11 @@ + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index ca0f31be040..71d910723f0 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -277,6 +277,7 @@ +