Migrate grid view actions into interactions, that can be used outside of the grid... grid-view
authorMarcel Werk <burntime@woltlab.com>
Thu, 9 Jan 2025 14:30:24 +0000 (15:30 +0100)
committerMarcel Werk <burntime@woltlab.com>
Thu, 9 Jan 2025 14:30:24 +0000 (15:30 +0100)
93 files changed:
com.woltlab.wcf/templates/shared_gridView.tpl
com.woltlab.wcf/templates/shared_gridViewRows.tpl
com.woltlab.wcf/templates/shared_interactionButton.tpl [new file with mode: 0644]
com.woltlab.wcf/templates/shared_standaloneInteractionButton.tpl [new file with mode: 0644]
ts/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/GridView.ts
ts/WoltLabSuite/Core/Component/GridView/Action/Confirmation.ts [deleted file]
ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts [deleted file]
ts/WoltLabSuite/Core/Component/GridView/Action/LegacyDboAction.ts [deleted file]
ts/WoltLabSuite/Core/Component/GridView/Action/Rpc.ts [deleted file]
ts/WoltLabSuite/Core/Component/GridView/Action/Toggle.ts [deleted file]
ts/WoltLabSuite/Core/Component/Interaction/Confirmation.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Interaction/Rpc.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Interaction/StandaloneButton.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/Interaction/Toggle.ts [new file with mode: 0644]
wcfsetup/install/files/acp/templates/userRankAdd.tpl
wcfsetup/install/files/js/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Confirmation.js [deleted file]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js [deleted file]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/LegacyDboAction.js [deleted file]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Rpc.js [deleted file]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Toggle.js [deleted file]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Confirmation.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Rpc.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/StandaloneButton.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Toggle.js [new file with mode: 0644]
wcfsetup/install/files/lib/acp/form/UserRankEditForm.class.php
wcfsetup/install/files/lib/acp/page/ACPSessionLogListPage.class.php
wcfsetup/install/files/lib/acp/page/CronjobLogListPage.class.php
wcfsetup/install/files/lib/acp/page/ExceptionLogViewPage.class.php
wcfsetup/install/files/lib/acp/page/ModificationLogListPage.class.php
wcfsetup/install/files/lib/acp/page/UserOptionListPage.class.php
wcfsetup/install/files/lib/acp/page/UserRankListPage.class.php
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/event/gridView/ACPSessionLogGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/ExceptionLogGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/ModificationLogGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/UserOptionGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php [deleted file]
wcfsetup/install/files/lib/event/gridView/admin/ACPSessionLogGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/gridView/admin/CronjobLogGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/gridView/admin/ExceptionLogGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/gridView/admin/ModificationLogGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/gridView/admin/UserOptionGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/gridView/admin/UserRankGridViewInitialized.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/interaction/admin/UserOptionInteractionCollecting.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/event/interaction/admin/UserRankInteractionCollecting.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/endpoint/controller/core/interactions/GetContextMenuOptions.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/ACPSessionLogGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/AbstractGridView.class.php
wcfsetup/install/files/lib/system/gridView/CronjobLogGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/ExceptionLogGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/ModificationLogGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/UserOptionGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/UserRankGridView.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/AbstractAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/ActionConfirmationType.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/DeleteAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/EditAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/IGridViewAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/LegacyDboAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/LinkAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/RpcAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/action/ToggleAction.class.php [deleted file]
wcfsetup/install/files/lib/system/gridView/admin/ACPSessionLogGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/admin/ExceptionLogGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/admin/ModificationLogGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/AbstractInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/AbstractInteractionProvider.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/DeleteInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/Divider.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/EditInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/FormBuilderDialogInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/IInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/IInteractionProvider.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/InteractionConfirmationType.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/InteractionContextMenuView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/LegacyDboInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/LinkInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/RpcInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/StandaloneInteractionContextMenuView.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/ToggleInteraction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/admin/UserOptionInteractions.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/interaction/admin/UserRankInteractions.class.php [new file with mode: 0644]

index 1330fe350d1a62079a69cf2f08d893fa5a99532b..111d75f918cf95a36e4ca8776813f72a92beb9e1 100644 (file)
@@ -37,7 +37,7 @@
                                                        {/if}
                                                </th>
                                        {/foreach}
-                                       {if $view->hasActions()}
+                                       {if $view->hasInteractions()}
                                                <th class="gridView__headerColumn gridView__actionColumn"></th>
                                        {/if}
                                </td>
@@ -72,4 +72,6 @@
                );
        });
 </script>
-{unsafe:$view->renderActionInitialization()}
+{if $view->hasInteractions()}
+       {unsafe:$view->renderInteractionInitialization()}
+{/if}
index d3f741d906ef5828fb88088a47d2579be29dff72..2aa6fb27fc7b8d6b33b8c224f871297205b60ce6 100644 (file)
@@ -6,38 +6,11 @@
                                {unsafe:$view->renderColumn($column, $row)}
                        </td>
                {/foreach}
-               {if $view->hasActions()}
+               {if $view->hasInteractions()}
                        <td class="gridView__column gridView__actionColumn">
                                <div class="gridView__actionColumn__buttons">
-                                       {foreach from=$view->getQuickActions() item='action'}
-                                               {unsafe:$view->renderAction($action, $row)}
-                                       {/foreach}
-
-                                       {if $view->hasDropdownActions()}
-                                               {hascontent}
-                                                       <div class="dropdown">
-                                                               <button type="button" class="gridViewActions button small dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">
-                                                                       {icon name='ellipsis-vertical'}
-                                                               </button>
-
-                                                               <ul class="dropdownMenu">
-                                                                       {content}
-                                                                               {foreach from=$view->getDropdownActions() item='action'}
-                                                                                       {if $action->isAvailable($row)}
-                                                                                               <li>
-                                                                                                       {unsafe:$view->renderAction($action, $row)}
-                                                                                               </li>
-                                                                                       {/if}
-                                                                               {/foreach}
-                                                                       {/content}
-                                                               </ul>
-                                                       </div>
-                                               {hascontentelse}
-                                                       <button type="button" disabled class="button small" aria-label="{lang}wcf.global.button.more{/lang}">
-                                                               {icon name='ellipsis-vertical'}
-                                                       </button>
-                                               {/hascontent}
-                                       {/if}
+                                       {unsafe:$view->renderQuickInteractions($row)}
+                                       {unsafe:$view->renderInteractionContextMenuButton($row)}
                                </div>
                        </td>
                {/if}
diff --git a/com.woltlab.wcf/templates/shared_interactionButton.tpl b/com.woltlab.wcf/templates/shared_interactionButton.tpl
new file mode 100644 (file)
index 0000000..1035e86
--- /dev/null
@@ -0,0 +1,15 @@
+{if $contextMenuOptions}
+       <div class="dropdown">
+               <button type="button" class="button small dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">
+                       {icon name='ellipsis-vertical'}
+               </button>
+
+               <ul class="dropdownMenu">
+                       {unsafe:$contextMenuOptions}
+               </ul>
+       </div>
+{else}
+       <button type="button" disabled class="button small" aria-label="{lang}wcf.global.button.more{/lang}">
+               {icon name='ellipsis-vertical'}
+       </button>
+{/if}
diff --git a/com.woltlab.wcf/templates/shared_standaloneInteractionButton.tpl b/com.woltlab.wcf/templates/shared_standaloneInteractionButton.tpl
new file mode 100644 (file)
index 0000000..361a62c
--- /dev/null
@@ -0,0 +1,22 @@
+<div class="dropdown" id="{$containerID}">
+       <button type="button" class="button dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">
+               {icon name='ellipsis-vertical'}
+       </button>
+
+       <ul class="dropdownMenu">
+               {unsafe:$contextMenuOptions}
+       </ul>
+</div>
+
+<script data-relocate="true">
+       require(['WoltLabSuite/Core/Component/Interaction/StandaloneButton'], ({ StandaloneButton }) => {
+               new StandaloneButton(
+                       document.getElementById('{unsafe:$containerID|encodeJS}'),
+                       '{unsafe:$providerClassName|encodeJS}',
+                       '{unsafe:$objectID|encodeJS}',
+                       '{unsafe:$redirectUrl|encodeJS}'
+               );
+       });
+</script>
+
+{unsafe:$initializationCode}
diff --git a/ts/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.ts b/ts/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.ts
new file mode 100644 (file)
index 0000000..c467dd1
--- /dev/null
@@ -0,0 +1,24 @@
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "../Result";
+
+type Response = {
+  template: string;
+};
+
+export async function getContextMenuOptions(
+  providerClassName: string,
+  objectId: number | string,
+): Promise<ApiResult<Response>> {
+  const url = new URL(`${window.WSC_RPC_API_URL}core/interactions/context-menu-options`);
+  url.searchParams.set("provider", providerClassName);
+  url.searchParams.set("objectID", objectId.toString());
+
+  let response: Response;
+  try {
+    response = (await prepareRequest(url).get().allowCaching().disableLoadingIndicator().fetchAsJson()) as Response;
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue(response);
+}
index 9162951ddaf9a5c953fed00c9d54d75d8597492b..e36f35ee520d9f8c569409d8c3e73da858d32de5 100644 (file)
@@ -3,6 +3,7 @@ import { getRows } from "../Api/Gridviews/GetRows";
 import DomChangeListener from "../Dom/Change/Listener";
 import DomUtil from "../Dom/Util";
 import { promiseMutex } from "../Helper/PromiseMutex";
+import { wheneverFirstSeen } from "../Helper/Selector";
 import UiDropdownSimple from "../Ui/Dropdown/Simple";
 import { dialogFactory } from "./Dialog";
 
@@ -47,7 +48,7 @@ export class GridView {
 
     this.#initPagination();
     this.#initSorting();
-    this.#initActions();
+    this.#initInteractions();
     this.#initFilters();
     this.#initEventListeners();
 
@@ -128,7 +129,6 @@ export class GridView {
     DomChangeListener.trigger();
 
     this.#renderFilters(response.filterLabels);
-    this.#initActions();
   }
 
   async #refreshRow(row: HTMLElement): Promise<void> {
@@ -166,18 +166,18 @@ export class GridView {
     window.history.pushState({}, document.title, url.toString());
   }
 
-  #initActions(): void {
-    this.#table.querySelectorAll<HTMLTableRowElement>("tbody tr").forEach((row) => {
-      row.querySelectorAll<HTMLElement>(".gridViewActions").forEach((element) => {
+  #initInteractions(): void {
+    wheneverFirstSeen(`#${this.#table.id} tbody tr`, (row) => {
+      row.querySelectorAll<HTMLElement>(".dropdownToggle").forEach((element) => {
         let dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!);
         if (!dropdown) {
           dropdown = element.closest(".dropdown")!.querySelector<HTMLElement>(".dropdownMenu")!;
         }
 
-        dropdown?.querySelectorAll<HTMLButtonElement>("[data-action]").forEach((element) => {
+        dropdown?.querySelectorAll<HTMLButtonElement>("[data-interaction]").forEach((element) => {
           element.addEventListener("click", () => {
             row.dispatchEvent(
-              new CustomEvent("action", {
+              new CustomEvent("interaction", {
                 detail: element.dataset,
                 bubbles: true,
               }),
@@ -295,5 +295,9 @@ export class GridView {
     this.#table.addEventListener("refresh", (event) => {
       void this.#refreshRow(event.target as HTMLElement);
     });
+
+    this.#table.addEventListener("remove", (event) => {
+      (event.target as HTMLElement).remove();
+    });
   }
 }
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Confirmation.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Confirmation.ts
deleted file mode 100644 (file)
index 69e5943..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-import { confirmationFactory } from "WoltLabSuite/Core/Component/Confirmation";
-
-export enum ConfirmationType {
-  None = "None",
-  SoftDelete = "SoftDelete",
-  SoftDeleteWithReason = "SoftDeleteWithReason",
-  Restore = "Restore",
-  Delete = "Delete",
-  Custom = "Custom",
-}
-
-type ResultConfirmationWithReason = {
-  result: boolean;
-  reason?: string;
-};
-
-export async function handleConfirmation(
-  objectName: string,
-  confirmationType: ConfirmationType,
-  customMessage: string = "",
-): Promise<ResultConfirmationWithReason> {
-  if (confirmationType == ConfirmationType.SoftDelete) {
-    return await confirmationFactory().softDelete(objectName);
-  }
-
-  if (confirmationType == ConfirmationType.SoftDeleteWithReason) {
-    return await confirmationFactory().softDelete(objectName, true);
-  }
-
-  if (confirmationType == ConfirmationType.Restore) {
-    return {
-      result: await confirmationFactory().restore(objectName ? objectName : undefined),
-    };
-  }
-
-  if (confirmationType == ConfirmationType.Delete) {
-    return {
-      result: await confirmationFactory().delete(objectName ? objectName : undefined),
-    };
-  }
-
-  if (confirmationType == ConfirmationType.Custom) {
-    return {
-      result: await confirmationFactory().custom(customMessage).withoutMessage(),
-    };
-  }
-
-  return {
-    result: true,
-  };
-}
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts
deleted file mode 100644 (file)
index ed32caa..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-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<void> {
-  const confirmationResult = await confirmationFactory().delete(objectName ? objectName : undefined);
-  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/Component/GridView/Action/LegacyDboAction.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/LegacyDboAction.ts
deleted file mode 100644 (file)
index a242bf7..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * Handles execution of DBO actions within grid views.
- *
- * @author Marcel Werk
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since 6.2
- * @deprecated 6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
- */
-
-import { dboAction } from "WoltLabSuite/Core/Ajax";
-import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification";
-import { ConfirmationType, handleConfirmation } from "./Confirmation";
-
-async function handleDboAction(
-  row: HTMLTableRowElement,
-  objectName: string,
-  className: string,
-  actionName: string,
-  confirmationType: ConfirmationType,
-  customConfirmationMessage: string = "",
-): Promise<void> {
-  const confirmationResult = await handleConfirmation(objectName, confirmationType, customConfirmationMessage);
-  if (!confirmationResult.result) {
-    return;
-  }
-
-  await dboAction(actionName, className)
-    .objectIds([parseInt(row.dataset.objectId!)])
-    .payload(confirmationResult.reason ? { reason: confirmationResult.reason } : {})
-    .dispatch();
-
-  if (confirmationType == ConfirmationType.Delete) {
-    row.remove();
-  } else {
-    row.dispatchEvent(
-      new CustomEvent("refresh", {
-        bubbles: true,
-      }),
-    );
-
-    // TODO: This shows a generic success message and should be replaced with a more specific message.
-    showNotification();
-  }
-}
-
-export function setup(table: HTMLTableElement): void {
-  table.addEventListener("action", (event: CustomEvent) => {
-    if (event.detail.action === "legacy-dbo-action") {
-      void handleDboAction(
-        event.target as HTMLTableRowElement,
-        event.detail.objectName,
-        event.detail.className,
-        event.detail.actionName,
-        event.detail.confirmationType,
-        event.detail.confirmationMessage,
-      );
-    }
-  });
-}
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Rpc.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Rpc.ts
deleted file mode 100644 (file)
index 6c09a62..0000000
+++ /dev/null
@@ -1,59 +0,0 @@
-import { deleteObject } from "WoltLabSuite/Core/Api/DeleteObject";
-import { postObject } from "WoltLabSuite/Core/Api/PostObject";
-import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification";
-import { ConfirmationType, handleConfirmation } from "./Confirmation";
-
-async function handleRpcAction(
-  row: HTMLTableRowElement,
-  objectName: string,
-  endpoint: string,
-  confirmationType: ConfirmationType,
-  customConfirmationMessage: string = "",
-): Promise<void> {
-  const confirmationResult = await handleConfirmation(objectName, confirmationType, customConfirmationMessage);
-  if (!confirmationResult.result) {
-    return;
-  }
-
-  if (confirmationType == ConfirmationType.Delete) {
-    const result = await deleteObject(endpoint);
-    if (!result.ok) {
-      return;
-    }
-  } else {
-    const result = await postObject(
-      endpoint,
-      confirmationResult.reason ? { reason: confirmationResult.reason } : undefined,
-    );
-    if (!result.ok) {
-      return;
-    }
-  }
-
-  if (confirmationType == ConfirmationType.Delete) {
-    row.remove();
-  } else {
-    row.dispatchEvent(
-      new CustomEvent("refresh", {
-        bubbles: true,
-      }),
-    );
-
-    // TODO: This shows a generic success message and should be replaced with a more specific message.
-    showNotification();
-  }
-}
-
-export function setup(table: HTMLTableElement): void {
-  table.addEventListener("action", (event: CustomEvent) => {
-    if (event.detail.action === "rpc") {
-      void handleRpcAction(
-        event.target as HTMLTableRowElement,
-        event.detail.objectName,
-        event.detail.endpoint,
-        event.detail.confirmationType,
-        event.detail.confirmationMessage,
-      );
-    }
-  });
-}
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Toggle.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Toggle.ts
deleted file mode 100644 (file)
index 9b40323..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
-import { postObject } from "WoltLabSuite/Core/Api/PostObject";
-
-async function handleToggle(checked: boolean, enableEndpoint: string, disableEndpoint: string): Promise<void> {
-  await postObject(checked ? enableEndpoint : disableEndpoint);
-}
-
-export function setup(tableId: string): void {
-  wheneverFirstSeen(`#${tableId} .gridView__row woltlab-core-toggle-button`, (toggleButton) => {
-    toggleButton.addEventListener("change", (event: CustomEvent) => {
-      void handleToggle(
-        event.detail.checked as boolean,
-        toggleButton.dataset.enableEndpoint!,
-        toggleButton.dataset.disableEndpoint!,
-      );
-    });
-  });
-}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/Confirmation.ts b/ts/WoltLabSuite/Core/Component/Interaction/Confirmation.ts
new file mode 100644 (file)
index 0000000..69e5943
--- /dev/null
@@ -0,0 +1,51 @@
+import { confirmationFactory } from "WoltLabSuite/Core/Component/Confirmation";
+
+export enum ConfirmationType {
+  None = "None",
+  SoftDelete = "SoftDelete",
+  SoftDeleteWithReason = "SoftDeleteWithReason",
+  Restore = "Restore",
+  Delete = "Delete",
+  Custom = "Custom",
+}
+
+type ResultConfirmationWithReason = {
+  result: boolean;
+  reason?: string;
+};
+
+export async function handleConfirmation(
+  objectName: string,
+  confirmationType: ConfirmationType,
+  customMessage: string = "",
+): Promise<ResultConfirmationWithReason> {
+  if (confirmationType == ConfirmationType.SoftDelete) {
+    return await confirmationFactory().softDelete(objectName);
+  }
+
+  if (confirmationType == ConfirmationType.SoftDeleteWithReason) {
+    return await confirmationFactory().softDelete(objectName, true);
+  }
+
+  if (confirmationType == ConfirmationType.Restore) {
+    return {
+      result: await confirmationFactory().restore(objectName ? objectName : undefined),
+    };
+  }
+
+  if (confirmationType == ConfirmationType.Delete) {
+    return {
+      result: await confirmationFactory().delete(objectName ? objectName : undefined),
+    };
+  }
+
+  if (confirmationType == ConfirmationType.Custom) {
+    return {
+      result: await confirmationFactory().custom(customMessage).withoutMessage(),
+    };
+  }
+
+  return {
+    result: true,
+  };
+}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.ts b/ts/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.ts
new file mode 100644 (file)
index 0000000..0f5a5b1
--- /dev/null
@@ -0,0 +1,27 @@
+import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification";
+import { dialogFactory } from "WoltLabSuite/Core/Component/Dialog";
+
+async function handleFormBuilderDialogAction(element: HTMLElement, endpoint: string): Promise<void> {
+  const { ok } = await dialogFactory().usingFormBuilder().fromEndpoint(endpoint);
+
+  if (!ok) {
+    return;
+  }
+
+  element.dispatchEvent(
+    new CustomEvent("refresh", {
+      bubbles: true,
+    }),
+  );
+
+  // TODO: This shows a generic success message and should be replaced with a more specific message.
+  showNotification();
+}
+
+export function setup(identifier: string, container: HTMLElement): void {
+  container.addEventListener("interaction", (event: CustomEvent) => {
+    if (event.detail.interaction === identifier) {
+      void handleFormBuilderDialogAction(event.target as HTMLElement, event.detail.endpoint);
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.ts b/ts/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.ts
new file mode 100644 (file)
index 0000000..4b04330
--- /dev/null
@@ -0,0 +1,67 @@
+/**
+ * Handles execution of DBO actions within grid views.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ * @deprecated 6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
+ */
+
+import { dboAction } from "WoltLabSuite/Core/Ajax";
+import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification";
+import { ConfirmationType, handleConfirmation } from "./Confirmation";
+
+async function handleDboAction(
+  element: HTMLElement,
+  objectName: string,
+  className: string,
+  actionName: string,
+  confirmationType: ConfirmationType,
+  customConfirmationMessage: string = "",
+): Promise<void> {
+  const confirmationResult = await handleConfirmation(objectName, confirmationType, customConfirmationMessage);
+  if (!confirmationResult.result) {
+    return;
+  }
+
+  await dboAction(actionName, className)
+    .objectIds([parseInt(element.dataset.objectId!)])
+    .payload(confirmationResult.reason ? { reason: confirmationResult.reason } : {})
+    .dispatch();
+
+  if (confirmationType == ConfirmationType.Delete) {
+    // TODO: This shows a generic success message and should be replaced with a more specific message.
+    showNotification(undefined, () => {
+      element.dispatchEvent(
+        new CustomEvent("remove", {
+          bubbles: true,
+        }),
+      );
+    });
+  } else {
+    element.dispatchEvent(
+      new CustomEvent("refresh", {
+        bubbles: true,
+      }),
+    );
+
+    // TODO: This shows a generic success message and should be replaced with a more specific message.
+    showNotification();
+  }
+}
+
+export function setup(identifier: string, container: HTMLElement): void {
+  container.addEventListener("interaction", (event: CustomEvent) => {
+    if (event.detail.interaction === identifier) {
+      void handleDboAction(
+        event.target as HTMLElement,
+        event.detail.objectName,
+        event.detail.className,
+        event.detail.actionName,
+        event.detail.confirmationType,
+        event.detail.confirmationMessage,
+      );
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/Rpc.ts b/ts/WoltLabSuite/Core/Component/Interaction/Rpc.ts
new file mode 100644 (file)
index 0000000..9b3edd8
--- /dev/null
@@ -0,0 +1,66 @@
+import { deleteObject } from "WoltLabSuite/Core/Api/DeleteObject";
+import { postObject } from "WoltLabSuite/Core/Api/PostObject";
+import { show as showNotification } from "WoltLabSuite/Core/Ui/Notification";
+import { ConfirmationType, handleConfirmation } from "./Confirmation";
+
+async function handleRpcInteraction(
+  element: HTMLElement,
+  objectName: string,
+  endpoint: string,
+  confirmationType: ConfirmationType,
+  customConfirmationMessage: string = "",
+): Promise<void> {
+  const confirmationResult = await handleConfirmation(objectName, confirmationType, customConfirmationMessage);
+  if (!confirmationResult.result) {
+    return;
+  }
+
+  if (confirmationType == ConfirmationType.Delete) {
+    const result = await deleteObject(endpoint);
+    if (!result.ok) {
+      return;
+    }
+  } else {
+    const result = await postObject(
+      endpoint,
+      confirmationResult.reason ? { reason: confirmationResult.reason } : undefined,
+    );
+    if (!result.ok) {
+      return;
+    }
+  }
+
+  if (confirmationType === ConfirmationType.Delete) {
+    // TODO: This shows a generic success message and should be replaced with a more specific message.
+    showNotification(undefined, () => {
+      element.dispatchEvent(
+        new CustomEvent("remove", {
+          bubbles: true,
+        }),
+      );
+    });
+  } else {
+    element.dispatchEvent(
+      new CustomEvent("refresh", {
+        bubbles: true,
+      }),
+    );
+
+    // TODO: This shows a generic success message and should be replaced with a more specific message.
+    showNotification();
+  }
+}
+
+export function setup(identifier: string, container: HTMLElement): void {
+  container.addEventListener("interaction", (event: CustomEvent) => {
+    if (event.detail.interaction === identifier) {
+      void handleRpcInteraction(
+        event.target as HTMLElement,
+        event.detail.objectName,
+        event.detail.endpoint,
+        event.detail.confirmationType,
+        event.detail.confirmationMessage,
+      );
+    }
+  });
+}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/StandaloneButton.ts b/ts/WoltLabSuite/Core/Component/Interaction/StandaloneButton.ts
new file mode 100644 (file)
index 0000000..463e932
--- /dev/null
@@ -0,0 +1,71 @@
+import { getContextMenuOptions } from "WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions";
+import UiDropdownSimple from "WoltLabSuite/Core/Ui/Dropdown/Simple";
+
+export class StandaloneButton {
+  #container: HTMLElement;
+  #providerClassName: string;
+  #objectId: string | number;
+  #redirectUrl: string;
+
+  constructor(container: HTMLElement, providerClassName: string, objectId: string | number, redirectUrl: string) {
+    this.#container = container;
+    this.#providerClassName = providerClassName;
+    this.#objectId = objectId;
+    this.#redirectUrl = redirectUrl;
+
+    this.#initInteractions();
+    this.#initEventListeners();
+  }
+
+  async #refreshContextMenu(): Promise<void> {
+    const response = (await getContextMenuOptions(this.#providerClassName, this.#objectId)).unwrap();
+
+    const dropdown = this.#getDropdownMenu();
+    if (!dropdown) {
+      return;
+    }
+
+    dropdown.innerHTML = response.template;
+
+    this.#initInteractions();
+  }
+
+  #getDropdownMenu(): HTMLElement | undefined {
+    const button = this.#container.querySelector<HTMLButtonElement>(".dropdownToggle");
+    if (!button) {
+      return undefined;
+    }
+
+    let dropdown = UiDropdownSimple.getDropdownMenu(button.dataset.target!);
+    if (!dropdown) {
+      dropdown = button.closest(".dropdown")!.querySelector<HTMLElement>(".dropdownMenu")!;
+    }
+
+    return dropdown;
+  }
+
+  #initInteractions(): void {
+    this.#getDropdownMenu()
+      ?.querySelectorAll<HTMLButtonElement>("[data-interaction]")
+      .forEach((element) => {
+        element.addEventListener("click", () => {
+          this.#container.dispatchEvent(
+            new CustomEvent("interaction", {
+              detail: element.dataset,
+              bubbles: true,
+            }),
+          );
+        });
+      });
+  }
+
+  #initEventListeners(): void {
+    this.#container.addEventListener("refresh", () => {
+      void this.#refreshContextMenu();
+    });
+
+    this.#container.addEventListener("remove", () => {
+      window.location.href = this.#redirectUrl;
+    });
+  }
+}
diff --git a/ts/WoltLabSuite/Core/Component/Interaction/Toggle.ts b/ts/WoltLabSuite/Core/Component/Interaction/Toggle.ts
new file mode 100644 (file)
index 0000000..d89d636
--- /dev/null
@@ -0,0 +1,18 @@
+import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector";
+import { postObject } from "WoltLabSuite/Core/Api/PostObject";
+
+async function handleToggle(checked: boolean, enableEndpoint: string, disableEndpoint: string): Promise<void> {
+  await postObject(checked ? enableEndpoint : disableEndpoint);
+}
+
+export function setup(identifier: string, container: HTMLElement): void {
+  wheneverFirstSeen(`#${container.id} [data-interaction="${identifier}"]`, (toggleButton) => {
+    toggleButton.addEventListener("change", (event: CustomEvent) => {
+      void handleToggle(
+        event.detail.checked as boolean,
+        toggleButton.dataset.enableEndpoint!,
+        toggleButton.dataset.disableEndpoint!,
+      );
+    });
+  });
+}
index 78c53ec55db943355f3fe1127a48bbb70c90537a..cc6f7d05dd322f4a8c11f698bcefd0a41e04eb8a 100644 (file)
        
        <nav class="contentHeaderNavigation">
                <ul>
-                       <li><a href="{link controller='UserRankList'}{/link}" class="button">{icon name='list'} <span>{lang}wcf.acp.menu.link.user.rank.list{/lang}</span></a></li>
-                       
+                       {if $action == 'edit'}
+                               <li>
+                                       {unsafe:$interactionContextMenu->render()}
+                               </li>
+                       {/if}
                        {event name='contentHeaderNavigation'}
                </ul>
        </nav>
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions.js
new file mode 100644 (file)
index 0000000..208f2b0
--- /dev/null
@@ -0,0 +1,18 @@
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "../Result"], function (require, exports, Backend_1, Result_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.getContextMenuOptions = getContextMenuOptions;
+    async function getContextMenuOptions(providerClassName, objectId) {
+        const url = new URL(`${window.WSC_RPC_API_URL}core/interactions/context-menu-options`);
+        url.searchParams.set("provider", providerClassName);
+        url.searchParams.set("objectID", objectId.toString());
+        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);
+    }
+});
index d2ac3e8f5d8d60ac26d4e7a1de5618e2cfe65532..69ad1b10fd5dadca7400aaceb60eaedb0d4a5687 100644 (file)
@@ -1,4 +1,4 @@
-define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridviews/GetRows", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Ui/Dropdown/Simple", "./Dialog"], function (require, exports, tslib_1, GetRow_1, GetRows_1, Listener_1, Util_1, PromiseMutex_1, Simple_1, Dialog_1) {
+define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridviews/GetRows", "../Dom/Change/Listener", "../Dom/Util", "../Helper/PromiseMutex", "../Helper/Selector", "../Ui/Dropdown/Simple", "./Dialog"], function (require, exports, tslib_1, GetRow_1, GetRows_1, Listener_1, Util_1, PromiseMutex_1, Selector_1, Simple_1, Dialog_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.GridView = void 0;
@@ -36,7 +36,7 @@ define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridvi
             this.#gridViewParameters = gridViewParameters;
             this.#initPagination();
             this.#initSorting();
-            this.#initActions();
+            this.#initInteractions();
             this.#initFilters();
             this.#initEventListeners();
             window.addEventListener("popstate", () => {
@@ -94,7 +94,6 @@ define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridvi
             }
             Listener_1.default.trigger();
             this.#renderFilters(response.filterLabels);
-            this.#initActions();
         }
         async #refreshRow(row) {
             const response = (await (0, GetRow_1.getRow)(this.#gridClassName, row.dataset.objectId)).unwrap();
@@ -125,16 +124,16 @@ define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridvi
             }
             window.history.pushState({}, document.title, url.toString());
         }
-        #initActions() {
-            this.#table.querySelectorAll("tbody tr").forEach((row) => {
-                row.querySelectorAll(".gridViewActions").forEach((element) => {
+        #initInteractions() {
+            (0, Selector_1.wheneverFirstSeen)(`#${this.#table.id} tbody tr`, (row) => {
+                row.querySelectorAll(".dropdownToggle").forEach((element) => {
                     let dropdown = Simple_1.default.getDropdownMenu(element.dataset.target);
                     if (!dropdown) {
                         dropdown = element.closest(".dropdown").querySelector(".dropdownMenu");
                     }
-                    dropdown?.querySelectorAll("[data-action]").forEach((element) => {
+                    dropdown?.querySelectorAll("[data-interaction]").forEach((element) => {
                         element.addEventListener("click", () => {
-                            row.dispatchEvent(new CustomEvent("action", {
+                            row.dispatchEvent(new CustomEvent("interaction", {
                                 detail: element.dataset,
                                 bubbles: true,
                             }));
@@ -229,6 +228,9 @@ define(["require", "exports", "tslib", "../Api/Gridviews/GetRow", "../Api/Gridvi
             this.#table.addEventListener("refresh", (event) => {
                 void this.#refreshRow(event.target);
             });
+            this.#table.addEventListener("remove", (event) => {
+                event.target.remove();
+            });
         }
     }
     exports.GridView = GridView;
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Confirmation.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Confirmation.js
deleted file mode 100644 (file)
index 7920ecc..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-define(["require", "exports", "WoltLabSuite/Core/Component/Confirmation"], function (require, exports, Confirmation_1) {
-    "use strict";
-    Object.defineProperty(exports, "__esModule", { value: true });
-    exports.ConfirmationType = void 0;
-    exports.handleConfirmation = handleConfirmation;
-    var ConfirmationType;
-    (function (ConfirmationType) {
-        ConfirmationType["None"] = "None";
-        ConfirmationType["SoftDelete"] = "SoftDelete";
-        ConfirmationType["SoftDeleteWithReason"] = "SoftDeleteWithReason";
-        ConfirmationType["Restore"] = "Restore";
-        ConfirmationType["Delete"] = "Delete";
-        ConfirmationType["Custom"] = "Custom";
-    })(ConfirmationType || (exports.ConfirmationType = ConfirmationType = {}));
-    async function handleConfirmation(objectName, confirmationType, customMessage = "") {
-        if (confirmationType == ConfirmationType.SoftDelete) {
-            return await (0, Confirmation_1.confirmationFactory)().softDelete(objectName);
-        }
-        if (confirmationType == ConfirmationType.SoftDeleteWithReason) {
-            return await (0, Confirmation_1.confirmationFactory)().softDelete(objectName, true);
-        }
-        if (confirmationType == ConfirmationType.Restore) {
-            return {
-                result: await (0, Confirmation_1.confirmationFactory)().restore(objectName ? objectName : undefined),
-            };
-        }
-        if (confirmationType == ConfirmationType.Delete) {
-            return {
-                result: await (0, Confirmation_1.confirmationFactory)().delete(objectName ? objectName : undefined),
-            };
-        }
-        if (confirmationType == ConfirmationType.Custom) {
-            return {
-                result: await (0, Confirmation_1.confirmationFactory)().custom(customMessage).withoutMessage(),
-            };
-        }
-        return {
-            result: true,
-        };
-    }
-});
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
deleted file mode 100644 (file)
index f22c23f..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-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 ? objectName : undefined);
-        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/Component/GridView/Action/LegacyDboAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/LegacyDboAction.js
deleted file mode 100644 (file)
index 1a917fe..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * Handles execution of DBO actions within grid views.
- *
- * @author Marcel Werk
- * @copyright 2001-2024 WoltLab GmbH
- * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since 6.2
- * @deprecated 6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
- */
-define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Ui/Notification", "./Confirmation"], function (require, exports, Ajax_1, Notification_1, Confirmation_1) {
-    "use strict";
-    Object.defineProperty(exports, "__esModule", { value: true });
-    exports.setup = setup;
-    async function handleDboAction(row, objectName, className, actionName, confirmationType, customConfirmationMessage = "") {
-        const confirmationResult = await (0, Confirmation_1.handleConfirmation)(objectName, confirmationType, customConfirmationMessage);
-        if (!confirmationResult.result) {
-            return;
-        }
-        await (0, Ajax_1.dboAction)(actionName, className)
-            .objectIds([parseInt(row.dataset.objectId)])
-            .payload(confirmationResult.reason ? { reason: confirmationResult.reason } : {})
-            .dispatch();
-        if (confirmationType == Confirmation_1.ConfirmationType.Delete) {
-            row.remove();
-        }
-        else {
-            row.dispatchEvent(new CustomEvent("refresh", {
-                bubbles: true,
-            }));
-            // TODO: This shows a generic success message and should be replaced with a more specific message.
-            (0, Notification_1.show)();
-        }
-    }
-    function setup(table) {
-        table.addEventListener("action", (event) => {
-            if (event.detail.action === "legacy-dbo-action") {
-                void handleDboAction(event.target, event.detail.objectName, event.detail.className, event.detail.actionName, event.detail.confirmationType, event.detail.confirmationMessage);
-            }
-        });
-    }
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Rpc.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Rpc.js
deleted file mode 100644 (file)
index 4cf316a..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-define(["require", "exports", "WoltLabSuite/Core/Api/DeleteObject", "WoltLabSuite/Core/Api/PostObject", "WoltLabSuite/Core/Ui/Notification", "./Confirmation"], function (require, exports, DeleteObject_1, PostObject_1, Notification_1, Confirmation_1) {
-    "use strict";
-    Object.defineProperty(exports, "__esModule", { value: true });
-    exports.setup = setup;
-    async function handleRpcAction(row, objectName, endpoint, confirmationType, customConfirmationMessage = "") {
-        const confirmationResult = await (0, Confirmation_1.handleConfirmation)(objectName, confirmationType, customConfirmationMessage);
-        if (!confirmationResult.result) {
-            return;
-        }
-        if (confirmationType == Confirmation_1.ConfirmationType.Delete) {
-            const result = await (0, DeleteObject_1.deleteObject)(endpoint);
-            if (!result.ok) {
-                return;
-            }
-        }
-        else {
-            const result = await (0, PostObject_1.postObject)(endpoint, confirmationResult.reason ? { reason: confirmationResult.reason } : undefined);
-            if (!result.ok) {
-                return;
-            }
-        }
-        if (confirmationType == Confirmation_1.ConfirmationType.Delete) {
-            row.remove();
-        }
-        else {
-            row.dispatchEvent(new CustomEvent("refresh", {
-                bubbles: true,
-            }));
-            // TODO: This shows a generic success message and should be replaced with a more specific message.
-            (0, Notification_1.show)();
-        }
-    }
-    function setup(table) {
-        table.addEventListener("action", (event) => {
-            if (event.detail.action === "rpc") {
-                void handleRpcAction(event.target, event.detail.objectName, event.detail.endpoint, event.detail.confirmationType, event.detail.confirmationMessage);
-            }
-        });
-    }
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Toggle.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Toggle.js
deleted file mode 100644 (file)
index af05f9c..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/PostObject"], function (require, exports, Selector_1, PostObject_1) {
-    "use strict";
-    Object.defineProperty(exports, "__esModule", { value: true });
-    exports.setup = setup;
-    async function handleToggle(checked, enableEndpoint, disableEndpoint) {
-        await (0, PostObject_1.postObject)(checked ? enableEndpoint : disableEndpoint);
-    }
-    function setup(tableId) {
-        (0, Selector_1.wheneverFirstSeen)(`#${tableId} .gridView__row woltlab-core-toggle-button`, (toggleButton) => {
-            toggleButton.addEventListener("change", (event) => {
-                void handleToggle(event.detail.checked, toggleButton.dataset.enableEndpoint, toggleButton.dataset.disableEndpoint);
-            });
-        });
-    }
-});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Confirmation.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Confirmation.js
new file mode 100644 (file)
index 0000000..7920ecc
--- /dev/null
@@ -0,0 +1,41 @@
+define(["require", "exports", "WoltLabSuite/Core/Component/Confirmation"], function (require, exports, Confirmation_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.ConfirmationType = void 0;
+    exports.handleConfirmation = handleConfirmation;
+    var ConfirmationType;
+    (function (ConfirmationType) {
+        ConfirmationType["None"] = "None";
+        ConfirmationType["SoftDelete"] = "SoftDelete";
+        ConfirmationType["SoftDeleteWithReason"] = "SoftDeleteWithReason";
+        ConfirmationType["Restore"] = "Restore";
+        ConfirmationType["Delete"] = "Delete";
+        ConfirmationType["Custom"] = "Custom";
+    })(ConfirmationType || (exports.ConfirmationType = ConfirmationType = {}));
+    async function handleConfirmation(objectName, confirmationType, customMessage = "") {
+        if (confirmationType == ConfirmationType.SoftDelete) {
+            return await (0, Confirmation_1.confirmationFactory)().softDelete(objectName);
+        }
+        if (confirmationType == ConfirmationType.SoftDeleteWithReason) {
+            return await (0, Confirmation_1.confirmationFactory)().softDelete(objectName, true);
+        }
+        if (confirmationType == ConfirmationType.Restore) {
+            return {
+                result: await (0, Confirmation_1.confirmationFactory)().restore(objectName ? objectName : undefined),
+            };
+        }
+        if (confirmationType == ConfirmationType.Delete) {
+            return {
+                result: await (0, Confirmation_1.confirmationFactory)().delete(objectName ? objectName : undefined),
+            };
+        }
+        if (confirmationType == ConfirmationType.Custom) {
+            return {
+                result: await (0, Confirmation_1.confirmationFactory)().custom(customMessage).withoutMessage(),
+            };
+        }
+        return {
+            result: true,
+        };
+    }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/FormBuilderDialog.js
new file mode 100644 (file)
index 0000000..9a2c7e2
--- /dev/null
@@ -0,0 +1,23 @@
+define(["require", "exports", "WoltLabSuite/Core/Ui/Notification", "WoltLabSuite/Core/Component/Dialog"], function (require, exports, Notification_1, Dialog_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setup = setup;
+    async function handleFormBuilderDialogAction(element, endpoint) {
+        const { ok } = await (0, Dialog_1.dialogFactory)().usingFormBuilder().fromEndpoint(endpoint);
+        if (!ok) {
+            return;
+        }
+        element.dispatchEvent(new CustomEvent("refresh", {
+            bubbles: true,
+        }));
+        // TODO: This shows a generic success message and should be replaced with a more specific message.
+        (0, Notification_1.show)();
+    }
+    function setup(identifier, container) {
+        container.addEventListener("interaction", (event) => {
+            if (event.detail.interaction === identifier) {
+                void handleFormBuilderDialogAction(event.target, event.detail.endpoint);
+            }
+        });
+    }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/LegacyDboAction.js
new file mode 100644 (file)
index 0000000..87821a3
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Handles execution of DBO actions within grid views.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.2
+ * @deprecated 6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax", "WoltLabSuite/Core/Ui/Notification", "./Confirmation"], function (require, exports, Ajax_1, Notification_1, Confirmation_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setup = setup;
+    async function handleDboAction(element, objectName, className, actionName, confirmationType, customConfirmationMessage = "") {
+        const confirmationResult = await (0, Confirmation_1.handleConfirmation)(objectName, confirmationType, customConfirmationMessage);
+        if (!confirmationResult.result) {
+            return;
+        }
+        await (0, Ajax_1.dboAction)(actionName, className)
+            .objectIds([parseInt(element.dataset.objectId)])
+            .payload(confirmationResult.reason ? { reason: confirmationResult.reason } : {})
+            .dispatch();
+        if (confirmationType == Confirmation_1.ConfirmationType.Delete) {
+            // TODO: This shows a generic success message and should be replaced with a more specific message.
+            (0, Notification_1.show)(undefined, () => {
+                element.dispatchEvent(new CustomEvent("remove", {
+                    bubbles: true,
+                }));
+            });
+        }
+        else {
+            element.dispatchEvent(new CustomEvent("refresh", {
+                bubbles: true,
+            }));
+            // TODO: This shows a generic success message and should be replaced with a more specific message.
+            (0, Notification_1.show)();
+        }
+    }
+    function setup(identifier, container) {
+        container.addEventListener("interaction", (event) => {
+            if (event.detail.interaction === identifier) {
+                void handleDboAction(event.target, event.detail.objectName, event.detail.className, event.detail.actionName, event.detail.confirmationType, event.detail.confirmationMessage);
+            }
+        });
+    }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Rpc.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Rpc.js
new file mode 100644 (file)
index 0000000..458293b
--- /dev/null
@@ -0,0 +1,45 @@
+define(["require", "exports", "WoltLabSuite/Core/Api/DeleteObject", "WoltLabSuite/Core/Api/PostObject", "WoltLabSuite/Core/Ui/Notification", "./Confirmation"], function (require, exports, DeleteObject_1, PostObject_1, Notification_1, Confirmation_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setup = setup;
+    async function handleRpcInteraction(element, objectName, endpoint, confirmationType, customConfirmationMessage = "") {
+        const confirmationResult = await (0, Confirmation_1.handleConfirmation)(objectName, confirmationType, customConfirmationMessage);
+        if (!confirmationResult.result) {
+            return;
+        }
+        if (confirmationType == Confirmation_1.ConfirmationType.Delete) {
+            const result = await (0, DeleteObject_1.deleteObject)(endpoint);
+            if (!result.ok) {
+                return;
+            }
+        }
+        else {
+            const result = await (0, PostObject_1.postObject)(endpoint, confirmationResult.reason ? { reason: confirmationResult.reason } : undefined);
+            if (!result.ok) {
+                return;
+            }
+        }
+        if (confirmationType === Confirmation_1.ConfirmationType.Delete) {
+            // TODO: This shows a generic success message and should be replaced with a more specific message.
+            (0, Notification_1.show)(undefined, () => {
+                element.dispatchEvent(new CustomEvent("remove", {
+                    bubbles: true,
+                }));
+            });
+        }
+        else {
+            element.dispatchEvent(new CustomEvent("refresh", {
+                bubbles: true,
+            }));
+            // TODO: This shows a generic success message and should be replaced with a more specific message.
+            (0, Notification_1.show)();
+        }
+    }
+    function setup(identifier, container) {
+        container.addEventListener("interaction", (event) => {
+            if (event.detail.interaction === identifier) {
+                void handleRpcInteraction(event.target, event.detail.objectName, event.detail.endpoint, event.detail.confirmationType, event.detail.confirmationMessage);
+            }
+        });
+    }
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/StandaloneButton.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/StandaloneButton.js
new file mode 100644 (file)
index 0000000..40e8f10
--- /dev/null
@@ -0,0 +1,61 @@
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Interactions/GetContextMenuOptions", "WoltLabSuite/Core/Ui/Dropdown/Simple"], function (require, exports, tslib_1, GetContextMenuOptions_1, Simple_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.StandaloneButton = void 0;
+    Simple_1 = tslib_1.__importDefault(Simple_1);
+    class StandaloneButton {
+        #container;
+        #providerClassName;
+        #objectId;
+        #redirectUrl;
+        constructor(container, providerClassName, objectId, redirectUrl) {
+            this.#container = container;
+            this.#providerClassName = providerClassName;
+            this.#objectId = objectId;
+            this.#redirectUrl = redirectUrl;
+            this.#initInteractions();
+            this.#initEventListeners();
+        }
+        async #refreshContextMenu() {
+            const response = (await (0, GetContextMenuOptions_1.getContextMenuOptions)(this.#providerClassName, this.#objectId)).unwrap();
+            const dropdown = this.#getDropdownMenu();
+            if (!dropdown) {
+                return;
+            }
+            dropdown.innerHTML = response.template;
+            this.#initInteractions();
+        }
+        #getDropdownMenu() {
+            const button = this.#container.querySelector(".dropdownToggle");
+            if (!button) {
+                return undefined;
+            }
+            let dropdown = Simple_1.default.getDropdownMenu(button.dataset.target);
+            if (!dropdown) {
+                dropdown = button.closest(".dropdown").querySelector(".dropdownMenu");
+            }
+            return dropdown;
+        }
+        #initInteractions() {
+            this.#getDropdownMenu()
+                ?.querySelectorAll("[data-interaction]")
+                .forEach((element) => {
+                element.addEventListener("click", () => {
+                    this.#container.dispatchEvent(new CustomEvent("interaction", {
+                        detail: element.dataset,
+                        bubbles: true,
+                    }));
+                });
+            });
+        }
+        #initEventListeners() {
+            this.#container.addEventListener("refresh", () => {
+                void this.#refreshContextMenu();
+            });
+            this.#container.addEventListener("remove", () => {
+                window.location.href = this.#redirectUrl;
+            });
+        }
+    }
+    exports.StandaloneButton = StandaloneButton;
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Toggle.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Interaction/Toggle.js
new file mode 100644 (file)
index 0000000..191b7bd
--- /dev/null
@@ -0,0 +1,15 @@
+define(["require", "exports", "WoltLabSuite/Core/Helper/Selector", "WoltLabSuite/Core/Api/PostObject"], function (require, exports, Selector_1, PostObject_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setup = setup;
+    async function handleToggle(checked, enableEndpoint, disableEndpoint) {
+        await (0, PostObject_1.postObject)(checked ? enableEndpoint : disableEndpoint);
+    }
+    function setup(identifier, container) {
+        (0, Selector_1.wheneverFirstSeen)(`#${container.id} [data-interaction="${identifier}"]`, (toggleButton) => {
+            toggleButton.addEventListener("change", (event) => {
+                void handleToggle(event.detail.checked, toggleButton.dataset.enableEndpoint, toggleButton.dataset.disableEndpoint);
+            });
+        });
+    }
+});
index 5c5df25bfff090aca700d9b5b991b4b60330b8b3..9f2c044dd1fa4f33efd35315c8b2e68265410861 100644 (file)
@@ -2,12 +2,16 @@
 
 namespace wcf\acp\form;
 
+use wcf\acp\page\UserRankListPage;
 use wcf\data\user\rank\UserRank;
 use wcf\data\user\rank\UserRankAction;
 use wcf\form\AbstractForm;
 use wcf\system\exception\IllegalLinkException;
 use wcf\system\file\upload\UploadHandler;
+use wcf\system\interaction\admin\UserRankInteractions;
+use wcf\system\interaction\StandaloneInteractionContextMenuView;
 use wcf\system\language\I18nHandler;
+use wcf\system\request\LinkHandler;
 use wcf\system\WCF;
 
 /**
@@ -136,6 +140,11 @@ class UserRankEditForm extends UserRankAddForm
             'rankID' => $this->rankID,
             'rank' => $this->rank,
             'action' => 'edit',
+            'interactionContextMenu' => new StandaloneInteractionContextMenuView(
+                new UserRankInteractions(),
+                $this->rank,
+                LinkHandler::getInstance()->getControllerLink(UserRankListPage::class)
+            ),
         ]);
     }
 }
index 0053e4ea7ff3d403de6c07e70e40ba138c1f97a7..b22fecb53d45d935b5904b3ea94d276ff93d6b6d 100755 (executable)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\ACPSessionLogGridView;
+use wcf\system\gridView\admin\ACPSessionLogGridView;
 
 /**
  * Shows a list of logged sessions.
index ecdebc25be34abcb091ddbcea1d1c19125ab71d1..501d7a25263fa69848000f07a809a1a2ac80780c 100755 (executable)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\CronjobLogGridView;
+use wcf\system\gridView\admin\CronjobLogGridView;
 
 /**
  * Shows cronjob log information.
index 5fd9ca0d0e606edbf37e0aa2008f82b44efc35ad..a7d2eabbe12e57c943ab74548fdf9fc8cea0e208 100644 (file)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\ExceptionLogGridView;
+use wcf\system\gridView\admin\ExceptionLogGridView;
 use wcf\system\registry\RegistryHandler;
 
 /**
index 4f6bf9e6ba84068c87427ef9b7695233c7843774..b535d4001989f1329dd41ac5e6c051847ebb99e8 100644 (file)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\ModificationLogGridView;
+use wcf\system\gridView\admin\ModificationLogGridView;
 
 /**
  * Shows a list of modification log items.
index b1b1ad9882d4bcbce9e99a25900376e7a87a5a3f..406aeeb15e965a7c421d443a05a57c3259a37804 100644 (file)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\UserOptionGridView;
+use wcf\system\gridView\admin\UserOptionGridView;
 
 /**
  * Shows a list of the installed user options.
index f2d80bf03652bb9ba956c273a868d271095a8990..65a464ff531eee8ade543c2d0d77403ce708ddd1 100644 (file)
@@ -4,7 +4,7 @@ namespace wcf\acp\page;
 
 use wcf\page\AbstractGridViewPage;
 use wcf\system\gridView\AbstractGridView;
-use wcf\system\gridView\UserRankGridView;
+use wcf\system\gridView\admin\UserRankGridView;
 
 /**
  * Lists available user ranks.
index c4410b54691f6f2eb5b020d2bd01688ddce43ffd..7fb3872917d5edaca6a79abec417029861b44ef1 100644 (file)
@@ -143,6 +143,7 @@ return static function (): void {
             $event->register(new \wcf\system\endpoint\controller\core\users\options\DisableOption);
             $event->register(new \wcf\system\endpoint\controller\core\users\options\EnableOption);
             $event->register(new \wcf\system\endpoint\controller\core\users\ranks\DeleteUserRank);
+            $event->register(new \wcf\system\endpoint\controller\core\interactions\GetContextMenuOptions());
         }
     );
 
diff --git a/wcfsetup/install/files/lib/event/gridView/ACPSessionLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/ACPSessionLogGridViewInitialized.class.php
deleted file mode 100644 (file)
index 43f1430..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\ACPSessionLogGridView;
-
-/**
- * Indicates that the acp session log grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ACPSessionLogGridViewInitialized implements IPsr14Event
-{
-    public function __construct(public readonly ACPSessionLogGridView $gridView) {}
-}
diff --git a/wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/CronjobLogGridViewInitialized.class.php
deleted file mode 100644 (file)
index 5338059..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\CronjobLogGridView;
-
-/**
- * Indicates that the cronjob log grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @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
deleted file mode 100644 (file)
index 3a8fa46..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\ExceptionLogGridView;
-
-/**
- * Indicates that the exception log grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ExceptionLogGridViewInitialized implements IPsr14Event
-{
-    public function __construct(public readonly ExceptionLogGridView $gridView) {}
-}
diff --git a/wcfsetup/install/files/lib/event/gridView/ModificationLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/ModificationLogGridViewInitialized.class.php
deleted file mode 100644 (file)
index 5333140..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\ModificationLogGridView;
-
-/**
- * Indicates that the modification log grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ModificationLogGridViewInitialized implements IPsr14Event
-{
-    public function __construct(public readonly ModificationLogGridView $gridView) {}
-}
diff --git a/wcfsetup/install/files/lib/event/gridView/UserOptionGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/UserOptionGridViewInitialized.class.php
deleted file mode 100644 (file)
index a5c638c..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\UserOptionGridView;
-
-/**
- * Indicates that the user option grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class UserOptionGridViewInitialized implements IPsr14Event
-{
-    public function __construct(public readonly UserOptionGridView $gridView) {}
-}
diff --git a/wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/UserRankGridViewInitialized.class.php
deleted file mode 100644 (file)
index 66f739a..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace wcf\event\gridView;
-
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\UserRankGridView;
-
-/**
- * Indicates that the user rank grid view has been initialized.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class UserRankGridViewInitialized implements IPsr14Event
-{
-    public function __construct(public readonly UserRankGridView $gridView) {}
-}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/ACPSessionLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/ACPSessionLogGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..61d2f9a
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\ACPSessionLogGridView;
+
+/**
+ * Indicates that the acp session log grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ACPSessionLogGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly ACPSessionLogGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/CronjobLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/CronjobLogGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..a9b1bf8
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\CronjobLogGridView;
+
+/**
+ * Indicates that the cronjob log grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class CronjobLogGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly CronjobLogGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/ExceptionLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/ExceptionLogGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..146c46b
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\ExceptionLogGridView;
+
+/**
+ * Indicates that the exception log grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ExceptionLogGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly ExceptionLogGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/ModificationLogGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/ModificationLogGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..fb690fd
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\ModificationLogGridView;
+
+/**
+ * Indicates that the modification log grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ModificationLogGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly ModificationLogGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/UserOptionGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/UserOptionGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..87d2832
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\UserOptionGridView;
+
+/**
+ * Indicates that the user option grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserOptionGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly UserOptionGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/gridView/admin/UserRankGridViewInitialized.class.php b/wcfsetup/install/files/lib/event/gridView/admin/UserRankGridViewInitialized.class.php
new file mode 100644 (file)
index 0000000..05427ef
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\gridView\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\admin\UserRankGridView;
+
+/**
+ * Indicates that the user rank grid view has been initialized.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserRankGridViewInitialized implements IPsr14Event
+{
+    public function __construct(public readonly UserRankGridView $gridView) {}
+}
diff --git a/wcfsetup/install/files/lib/event/interaction/admin/UserOptionInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/UserOptionInteractionCollecting.class.php
new file mode 100644 (file)
index 0000000..69c898f
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\interaction\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\interaction\admin\UserOptionInteractions;
+
+/**
+ * Indicates that the provider for user option interactions is collecting interactions.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2025 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserOptionInteractionCollecting implements IPsr14Event
+{
+    public function __construct(public readonly UserOptionInteractions $provider) {}
+}
diff --git a/wcfsetup/install/files/lib/event/interaction/admin/UserRankInteractionCollecting.class.php b/wcfsetup/install/files/lib/event/interaction/admin/UserRankInteractionCollecting.class.php
new file mode 100644 (file)
index 0000000..4d2e4c9
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace wcf\event\interaction\admin;
+
+use wcf\event\IPsr14Event;
+use wcf\system\interaction\admin\UserRankInteractions;
+
+/**
+ * Indicates that the provider for user rank interactions is collecting interactions.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2025 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserRankInteractionCollecting implements IPsr14Event
+{
+    public function __construct(public readonly UserRankInteractions $provider) {}
+}
diff --git a/wcfsetup/install/files/lib/system/endpoint/controller/core/interactions/GetContextMenuOptions.class.php b/wcfsetup/install/files/lib/system/endpoint/controller/core/interactions/GetContextMenuOptions.class.php
new file mode 100644 (file)
index 0000000..5888e4f
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+namespace wcf\system\endpoint\controller\core\interactions;
+
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use wcf\data\DatabaseObject;
+use wcf\http\Helper;
+use wcf\system\endpoint\GetRequest;
+use wcf\system\endpoint\IController;
+use wcf\system\exception\UserInputException;
+use wcf\system\interaction\IInteractionProvider;
+use wcf\system\interaction\InteractionContextMenuView;
+use wcf\system\interaction\StandaloneInteractionContextMenuView;
+
+/**
+ * Retrieves the options for an interaction context menu.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2025 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+#[GetRequest('/core/interactions/context-menu-options')]
+final class GetContextMenuOptions implements IController
+{
+    #[\Override]
+    public function __invoke(ServerRequestInterface $request, array $variables): ResponseInterface
+    {
+        $parameters = Helper::mapApiParameters($request, GetContextMenuOptionsParameters::class);
+
+        if (!\is_subclass_of($parameters->provider, IInteractionProvider::class)) {
+            throw new UserInputException('provider', 'invalid');
+        }
+
+        $provider = new $parameters->provider();
+        \assert($provider instanceof IInteractionProvider);
+
+        $object = new ($provider->getObjectClassName())($parameters->objectID);
+        \assert($object instanceof DatabaseObject);
+
+        $view = new InteractionContextMenuView($provider);
+
+        return new JsonResponse([
+            'template' => $view->renderContextMenuOptions($object),
+        ]);
+    }
+}
+
+/** @internal */
+final class GetContextMenuOptionsParameters
+{
+    public function __construct(
+        /** @var non-empty-string */
+        public readonly string $provider,
+        public readonly int|string $objectID,
+    ) {}
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/ACPSessionLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/ACPSessionLogGridView.class.php
deleted file mode 100644 (file)
index 88c45dd..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use wcf\acp\form\UserEditForm;
-use wcf\acp\page\ACPSessionLogPage;
-use wcf\data\acp\session\log\ACPSessionLogList;
-use wcf\data\DatabaseObjectList;
-use wcf\event\gridView\ACPSessionLogGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\filter\IpAddressFilter;
-use wcf\system\gridView\filter\TextFilter;
-use wcf\system\gridView\filter\TimeFilter;
-use wcf\system\gridView\filter\UserFilter;
-use wcf\system\gridView\renderer\IpAddressColumnRenderer;
-use wcf\system\gridView\renderer\NumberColumnRenderer;
-use wcf\system\gridView\renderer\TimeColumnRenderer;
-use wcf\system\gridView\renderer\TruncatedTextColumnRenderer;
-use wcf\system\gridView\renderer\UserLinkColumnRenderer;
-use wcf\system\WCF;
-
-/**
- * Grid view for the list of logged admin session.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ACPSessionLogGridView extends DatabaseObjectListGridView
-{
-    public function __construct()
-    {
-        $this->addColumns([
-            GridViewColumn::for('sessionLogID')
-                ->label('wcf.global.objectID')
-                ->renderer(new NumberColumnRenderer())
-                ->sortable(),
-            GridViewColumn::for('userID')
-                ->label('wcf.user.username')
-                ->sortable(true, 'user_table.username')
-                ->titleColumn()
-                ->renderer(new UserLinkColumnRenderer(UserEditForm::class))
-                ->filter(new UserFilter()),
-            GridViewColumn::for('ipAddress')
-                ->label('wcf.user.ipAddress')
-                ->sortable()
-                ->renderer(new IpAddressColumnRenderer())
-                ->filter(new IpAddressFilter()),
-            GridViewColumn::for('userAgent')
-                ->label('wcf.user.userAgent')
-                ->sortable()
-                ->valueEncoding(false)
-                ->renderer(new TruncatedTextColumnRenderer(50))
-                ->filter(new TextFilter()),
-            GridViewColumn::for('time')
-                ->label('wcf.acp.sessionLog.time')
-                ->sortable()
-                ->renderer(new TimeColumnRenderer())
-                ->filter(new TimeFilter()),
-            GridViewColumn::for('lastActivityTime')
-                ->label('wcf.acp.sessionLog.lastActivityTime')
-                ->sortable()
-                ->renderer(new TimeColumnRenderer())
-                ->filter(new TimeFilter()),
-            GridViewColumn::for('accesses')
-                ->label('wcf.acp.sessionLog.actions')
-                ->sortable(true, 'accesses')
-                ->renderer(new NumberColumnRenderer()),
-        ]);
-
-        $this->addRowLink(new GridViewRowLink(ACPSessionLogPage::class));
-        $this->setSortField('lastActivityTime');
-        $this->setSortOrder('DESC');
-    }
-
-    #[\Override]
-    public function isAccessible(): bool
-    {
-        return WCF::getSession()->getPermission('admin.management.canViewLog');
-    }
-
-    #[\Override]
-    protected function createObjectList(): DatabaseObjectList
-    {
-        $list = new ACPSessionLogList();
-        $list->sqlSelects .= "
-            user_table.username,
-            0 AS active,
-            (
-                SELECT  COUNT(*)
-                FROM    wcf1_acp_session_access_log
-                WHERE   sessionLogID = " . $list->getDatabaseTableAlias() . ".sessionLogID
-            ) AS accesses";
-        $list->sqlJoins = $list->sqlConditionJoins .= " LEFT JOIN wcf" . WCF_N . "_user user_table ON (user_table.userID = " . $list->getDatabaseTableAlias() . ".userID)";
-
-        return $list;
-    }
-
-    #[\Override]
-    protected function getInitializedEvent(): ?IPsr14Event
-    {
-        return new ACPSessionLogGridViewInitialized($this);
-    }
-}
index 71add3162b2dca39b8aebb1274ba6f8c9a2d4213..aa48ff9fed8a8dbc939c9ba67a804968ced5c1e1 100644 (file)
@@ -4,9 +4,12 @@ namespace wcf\system\gridView;
 
 use LogicException;
 use wcf\action\GridViewFilterAction;
+use wcf\data\DatabaseObject;
 use wcf\event\IPsr14Event;
 use wcf\system\event\EventHandler;
-use wcf\system\gridView\action\IGridViewAction;
+use wcf\system\interaction\IInteraction;
+use wcf\system\interaction\IInteractionProvider;
+use wcf\system\interaction\InteractionContextMenuView;
 use wcf\system\request\LinkHandler;
 use wcf\system\WCF;
 use wcf\util\StringUtil;
@@ -27,9 +30,9 @@ abstract class AbstractGridView
     private array $columns = [];
 
     /**
-     * @var IGridViewAction[]
+     * @var IInteraction[]
      */
-    private array $actions = [];
+    private array $quickInteractions = [];
 
     private GridViewRowLink $rowLink;
     private int $rowsPerPage = 20;
@@ -39,6 +42,8 @@ abstract class AbstractGridView
     private int $pageNo = 1;
     private array $activeFilters = [];
     private string|int|null $objectIDFilter = null;
+    private ?IInteractionProvider $interactionProvider = null;
+    private InteractionContextMenuView $interactionContextMenuView;
 
     /**
      * Adds a new column to the grid view.
@@ -156,65 +161,44 @@ abstract class AbstractGridView
     }
 
     /**
-     * Adds the given actions to the grid view.
-     * @param IGridViewAction[] $columns
+     * Sets the interaction provider that is used to render the interaction context menu.
      */
-    public function addActions(array $actions): void
+    public function setInteractionProvider(IInteractionProvider $provider): void
     {
-        foreach ($actions as $action) {
-            $this->addAction($action);
-        }
+        $this->interactionProvider = $provider;
     }
 
     /**
-     * Adds the given action to the grid view.
+     * Returns the interaction provider of the grid view.
      */
-    public function addAction(IGridViewAction $action): void
+    public function getInteractionProvider(): ?IInteractionProvider
     {
-        $this->actions[] = $action;
+        return $this->interactionProvider;
     }
 
     /**
-     * Returns all actions of the grid view.
-     * @return IGridViewAction[]
+     * Returns true, if this grid view has interactions.
      */
-    public function getActions(): array
+    public function hasInteractions(): bool
     {
-        return $this->actions;
+        return $this->interactionProvider !== null || $this->quickInteractions !== [];
     }
 
     /**
-     * Returns true, if this grid view has actions.
+     * Adds a quick interaction.
      */
-    public function hasActions(): bool
+    public function addQuickInteraction(IInteraction $interaction): void
     {
-        return $this->actions !== [];
+        $this->quickInteractions[] = $interaction;
     }
 
     /**
-     * Returns true, if this grid view has actions that should be displayed in the dropdown.
+     * Returns the quick interactions.
+     * @return IInteraction[]
      */
-    public function hasDropdownActions(): bool
+    public function getQuickInteractions(): array
     {
-        return $this->getDropdownActions() !== [];
-    }
-
-    /**
-     * Returns the actions that should be displayed in the dropdown.
-     * @return IGridViewAction[]
-     */
-    public function getDropdownActions(): array
-    {
-        return \array_filter($this->getActions(), fn($action) => !$action->isQuickAction());
-    }
-
-    /**
-     * Returns the quick actions.
-     * @return IGridViewAction[]
-     */
-    public function getQuickActions(): array
-    {
-        return \array_filter($this->getActions(), fn($action) => $action->isQuickAction());
+        return $this->quickInteractions;
     }
 
     /**
@@ -255,25 +239,67 @@ abstract class AbstractGridView
     }
 
     /**
-     * Renders the given action.
+     * Returns the view of the interaction context menu.
      */
-    public function renderAction(IGridViewAction $action, mixed $row): string
+    public function getInteractionContextMenuView(): InteractionContextMenuView
     {
-        return $action->render($row);
+        if ($this->interactionProvider === null) {
+            throw new \BadMethodCallException("Missing interaction provider.");
+        }
+
+        if (!isset($this->interactionContextMenuView)) {
+            $this->interactionContextMenuView = new InteractionContextMenuView($this->interactionProvider);
+        }
+
+        return $this->interactionContextMenuView;
     }
 
     /**
-     * Renders the initialization code for the actions of the grid view.
+     * Renders the initialization code for the interactions of the grid view.
      */
-    public function renderActionInitialization(): string
+    public function renderInteractionInitialization(): string
     {
-        return implode(
-            "\n",
-            \array_map(
-                fn($action) => $action->renderInitialization($this),
-                $this->getActions()
-            )
-        );
+        $code = '';
+        if ($this->interactionProvider !== null) {
+            $code = $this->getInteractionContextMenuView()->renderInitialization($this->getID() . '_table');
+        }
+
+        if ($this->quickInteractions !== []) {
+            $code .= "\n";
+            $code .= \implode("\n", \array_map(
+                fn($interaction) => $interaction->renderInitialization($this->getID() . '_table'),
+                $this->getQuickInteractions()
+            ));
+        }
+
+        return $code;
+    }
+
+    /**
+     * Renders the interactions for the given row.
+     */
+    public function renderInteractionContextMenuButton(mixed $row): string
+    {
+        if ($this->interactionProvider === null) {
+            return '';
+        }
+
+        \assert($row instanceof DatabaseObject);
+
+        return $this->getInteractionContextMenuView()->renderButton($row);
+    }
+
+    /**
+     * Renders the interactions for the given row.
+     */
+    public function renderQuickInteractions(mixed $row): string
+    {
+        \assert($row instanceof DatabaseObject);
+
+        return \implode("\n", \array_map(
+            static fn($interaction) => $interaction->render($row),
+            $this->getQuickInteractions()
+        ));
     }
 
     /**
diff --git a/wcfsetup/install/files/lib/system/gridView/CronjobLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/CronjobLogGridView.class.php
deleted file mode 100644 (file)
index 5a9e78e..0000000
+++ /dev/null
@@ -1,134 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use wcf\data\cronjob\Cronjob;
-use wcf\data\cronjob\I18nCronjobList;
-use wcf\data\cronjob\log\CronjobLog;
-use wcf\data\cronjob\log\CronjobLogList;
-use wcf\data\DatabaseObjectList;
-use wcf\event\gridView\CronjobLogGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\filter\SelectFilter;
-use wcf\system\gridView\filter\TimeFilter;
-use wcf\system\gridView\renderer\DefaultColumnRenderer;
-use wcf\system\gridView\renderer\NumberColumnRenderer;
-use wcf\system\gridView\renderer\TimeColumnRenderer;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Grid view for the cronjob log.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class CronjobLogGridView extends DatabaseObjectListGridView
-{
-    public function __construct()
-    {
-        $availableCronjobs = $this->getAvailableCronjobs();
-
-        $this->addColumns([
-            GridViewColumn::for('cronjobLogID')
-                ->label('wcf.global.objectID')
-                ->renderer(new NumberColumnRenderer())
-                ->sortable(),
-            GridViewColumn::for('cronjobID')
-                ->label('wcf.acp.cronjob')
-                ->sortable()
-                ->titleColumn()
-                ->filter(new SelectFilter($availableCronjobs))
-                ->renderer([
-                    new class($availableCronjobs) extends DefaultColumnRenderer {
-                        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 '<span class="badge green">' . WCF::getLanguage()->get('wcf.acp.cronjob.log.success') . '</span>';
-                            }
-                            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 <<<HTML
-                                    <button type="button" id="{$buttonId}" class="badge red">
-                                        {$label}
-                                    </button>
-                                    <template id="{$id}">{$error}</template>
-                                    <script data-relocate="true">
-                                        require(['WoltLabSuite/Core/Component/Dialog'], ({ dialogFactory }) => {
-                                            document.getElementById('{$buttonId}').addEventListener('click', () => {
-                                                const dialog = dialogFactory().fromId('{$id}').withoutControls();
-                                                dialog.show('{$dialogTitle}');
-                                            });
-                                        });
-                                    </script>
-                                    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/gridView/ExceptionLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/ExceptionLogGridView.class.php
deleted file mode 100644 (file)
index 8bf76a9..0000000
+++ /dev/null
@@ -1,162 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use wcf\event\gridView\ExceptionLogGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\Regex;
-use wcf\system\gridView\filter\SelectFilter;
-use wcf\system\gridView\filter\TextFilter;
-use wcf\system\gridView\renderer\TimeColumnRenderer;
-use wcf\system\WCF;
-use wcf\util\DirectoryUtil;
-use wcf\util\ExceptionLogUtil;
-
-/**
- * Grid view for the exception log.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ExceptionLogGridView extends DataSourceGridView
-{
-    private array $availableLogFiles;
-
-    public function __construct(bool $applyDefaultFilter = false)
-    {
-        $this->addColumns([
-            GridViewColumn::for('message')
-                ->label('wcf.acp.exceptionLog.exception.message')
-                ->sortable()
-                ->titleColumn(),
-            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 loadDataSource(): 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/gridView/ModificationLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/ModificationLogGridView.class.php
deleted file mode 100644 (file)
index a646b72..0000000
+++ /dev/null
@@ -1,269 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use LogicException;
-use wcf\data\DatabaseObject;
-use wcf\data\DatabaseObjectDecorator;
-use wcf\data\DatabaseObjectList;
-use wcf\data\modification\log\IViewableModificationLog;
-use wcf\data\modification\log\ModificationLog;
-use wcf\data\modification\log\ModificationLogList;
-use wcf\data\object\type\ObjectTypeCache;
-use wcf\data\package\Package;
-use wcf\event\gridView\ModificationLogGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\form\builder\field\AbstractFormField;
-use wcf\system\form\builder\field\SelectFormField;
-use wcf\system\gridView\filter\IGridViewFilter;
-use wcf\system\gridView\filter\TextFilter;
-use wcf\system\gridView\filter\TimeFilter;
-use wcf\system\gridView\renderer\DefaultColumnRenderer;
-use wcf\system\gridView\renderer\ILinkColumnRenderer;
-use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
-use wcf\system\gridView\renderer\TimeColumnRenderer;
-use wcf\system\gridView\renderer\UserLinkColumnRenderer;
-use wcf\system\log\modification\IExtendedModificationLogHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Grid view for the list of all modification log items.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class ModificationLogGridView extends DatabaseObjectListGridView
-{
-    /**
-     * @var IViewableModificationLog[]
-     */
-    private array $logItems;
-
-    public function __construct()
-    {
-        $this->addColumns([
-            GridViewColumn::for('logID')
-                ->label('wcf.global.objectID')
-                ->renderer(new ObjectIdColumnRenderer())
-                ->sortable(),
-            GridViewColumn::for('userID')
-                ->label('wcf.user.username')
-                ->sortable(true, 'modification_log.username')
-                ->renderer(new UserLinkColumnRenderer())
-                ->filter(new TextFilter('modification_log.username')),
-            GridViewColumn::for('action')
-                ->label('wcf.acp.modificationLog.action')
-                ->titleColumn()
-                ->renderer([
-                    new class extends DefaultColumnRenderer {
-                        #[\Override]
-                        public function render(mixed $value, mixed $context = null): string
-                        {
-                            \assert($context instanceof DatabaseObjectDecorator);
-                            $log = $context->getDecoratedObject();
-                            \assert($log instanceof ModificationLog);
-                            $objectType = ObjectTypeCache::getInstance()->getObjectType($log->objectTypeID);
-                            if (!$objectType) {
-                                return '';
-                            }
-
-                            return WCF::getLanguage()->get(
-                                'wcf.acp.modificationLog.' . $objectType->objectType . '.' . $log->action
-                            );
-                        }
-                    },
-                ])
-                ->filter(
-                    new class($this->getAvailableActions()) implements IGridViewFilter {
-                        public function __construct(private readonly array $options) {}
-
-                        #[\Override]
-                        public function getFormField(string $id, string $label): AbstractFormField
-                        {
-                            return SelectFormField::create($id)
-                                ->label($label)
-                                ->options($this->options, false, false);
-                        }
-
-                        #[\Override]
-                        public function applyFilter(DatabaseObjectList $list, string $id, string $value): void
-                        {
-                            if (\is_numeric($value)) {
-                                $list->getConditionBuilder()->add(
-                                    "objectTypeID IN (SELECT objectTypeID FROM wcf1_object_type WHERE packageID = ?)",
-                                    [$value]
-                                );
-                            } else if (\preg_match('~^(?P<objectType>.+)\-(?P<action>[^\-]+)$~', $value, $matches)) {
-                                $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName(
-                                    'com.woltlab.wcf.modifiableContent',
-                                    $matches['objectType']
-                                );
-                                if (!$objectType) {
-                                    return;
-                                }
-
-                                $list->getConditionBuilder()->add(
-                                    "objectTypeID = ? AND action = ?",
-                                    [$objectType->objectTypeID, $matches['action']]
-                                );
-                            }
-                        }
-
-                        #[\Override]
-                        public function matches(string $filterValue, string $rowValue): bool
-                        {
-                            throw new LogicException('unreachable');
-                        }
-
-                        #[\Override]
-                        public function renderValue(string $value): string
-                        {
-                            if (\is_numeric($value)) {
-                                return WCF::getLanguage()->get($this->options[$value]);
-                            }
-
-                            return \substr(WCF::getLanguage()->get($this->options[$value]), 24);
-                        }
-                    }
-                ),
-            GridViewColumn::for('affectedObject')
-                ->label('wcf.acp.modificationLog.affectedObject')
-                ->renderer([
-                    new class extends DefaultColumnRenderer implements ILinkColumnRenderer {
-                        #[\Override]
-                        public function render(mixed $value, mixed $context = null): string
-                        {
-                            \assert($context instanceof IViewableModificationLog);
-                            if ($context->getAffectedObject() === null) {
-                                return WCF::getLanguage()->get('wcf.acp.modificationLog.affectedObject.unknown');
-                            }
-
-                            return \sprintf(
-                                '<a href="%s">%s</a>',
-                                StringUtil::encodeHTML($context->getAffectedObject()->getLink()),
-                                StringUtil::encodeHTML($context->getAffectedObject()->getTitle())
-                            );
-                        }
-                    },
-                ]),
-            GridViewColumn::for('time')
-                ->label('wcf.global.date')
-                ->sortable()
-                ->renderer(new TimeColumnRenderer())
-                ->filter(new TimeFilter()),
-        ]);
-
-        $this->setSortField('time');
-        $this->setSortOrder('DESC');
-    }
-
-    #[\Override]
-    public function isAccessible(): bool
-    {
-        return WCF::getSession()->getPermission('admin.management.canViewLog');
-    }
-
-    #[\Override]
-    protected function createObjectList(): DatabaseObjectList
-    {
-        return new ModificationLogList();
-    }
-
-    #[\Override]
-    public function getRows(): array
-    {
-        if (!isset($this->logItems)) {
-            $this->logItems = [];
-            $this->getObjectList()->readObjects();
-
-            $itemsPerType = [];
-            foreach ($this->getObjectList() as $modificationLog) {
-                if (!isset($itemsPerType[$modificationLog->objectTypeID])) {
-                    $itemsPerType[$modificationLog->objectTypeID] = [];
-                }
-
-                $itemsPerType[$modificationLog->objectTypeID][] = $modificationLog;
-            }
-
-            if (!empty($itemsPerType)) {
-                foreach ($itemsPerType as $objectTypeID => $items) {
-                    $objectType = ObjectTypeCache::getInstance()->getObjectType($objectTypeID);
-                    if (!$objectType) {
-                        continue;
-                    }
-                    $processor = $objectType->getProcessor();
-                    if (!$processor) {
-                        continue;
-                    }
-                    \assert($processor instanceof IExtendedModificationLogHandler);
-
-                    $this->logItems = \array_merge(
-                        $this->logItems,
-                        $processor->processItems($items)
-                    );
-                }
-            }
-
-            DatabaseObject::sort($this->logItems, $this->getSortField(), $this->getSortOrder());
-        }
-
-        return $this->logItems;
-    }
-
-    #[\Override]
-    protected function getInitializedEvent(): ?IPsr14Event
-    {
-        return new ModificationLogGridViewInitialized($this);
-    }
-
-    private function getAvailableActions(): array
-    {
-        $packages = $actions = $availableActions = [];
-
-        foreach (ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.modifiableContent') as $objectType) {
-            $processor = $objectType->getProcessor();
-            if ($processor === null) {
-                continue;
-            }
-            \assert($processor instanceof IExtendedModificationLogHandler);
-
-            if (!$processor->includeInLogList()) {
-                continue;
-            }
-
-            if (!isset($packages[$objectType->packageID])) {
-                $actions[$objectType->packageID] = [];
-                $packages[$objectType->packageID] = $objectType->getPackage();
-            }
-
-            foreach ($processor->getAvailableActions() as $action) {
-                $actions[$objectType->packageID]["{$objectType->objectType}-{$action}"]
-                    = WCF::getLanguage()->get("wcf.acp.modificationLog.{$objectType->objectType}.{$action}");
-            }
-        }
-
-        foreach ($actions as &$actionsPerPackage) {
-            \asort($actionsPerPackage, \SORT_NATURAL);
-        }
-        \uasort($packages, static function (Package $a, Package $b) {
-            return \strnatcasecmp($a->package, $b->package);
-        });
-
-        foreach ($packages as $package) {
-            $availableActions[$package->packageID]
-                = WCF::getLanguage()->getDynamicVariable(
-                    'wcf.acp.modificationLog.action.allPackageActions',
-                    ['package' => $package]
-                );
-
-            foreach ($actions[$package->packageID] as $actionName => $actionLabel) {
-                $availableActions[$actionName] = \str_repeat('&nbsp;', 4) . $actionLabel;
-            }
-        }
-
-        return $availableActions;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/UserOptionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/UserOptionGridView.class.php
deleted file mode 100644 (file)
index d4f32f3..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use wcf\acp\form\UserOptionEditForm;
-use wcf\data\DatabaseObjectList;
-use wcf\data\user\option\UserOption;
-use wcf\data\user\option\UserOptionList;
-use wcf\event\gridView\UserOptionGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\action\DeleteAction;
-use wcf\system\gridView\action\EditAction;
-use wcf\system\gridView\action\ToggleAction;
-use wcf\system\gridView\renderer\DefaultColumnRenderer;
-use wcf\system\gridView\renderer\NumberColumnRenderer;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Grid view for the list of user options.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class UserOptionGridView extends DatabaseObjectListGridView
-{
-    public function __construct()
-    {
-        $this->addColumns([
-            GridViewColumn::for('optionID')
-                ->label('wcf.global.objectID')
-                ->renderer(new NumberColumnRenderer())
-                ->sortable(),
-            GridViewColumn::for('optionName')
-                ->label('wcf.global.name')
-                ->sortable()
-                ->titleColumn()
-                ->renderer([
-                    new class extends DefaultColumnRenderer {
-                        public function render(mixed $value, mixed $context = null): string
-                        {
-                            \assert($context instanceof UserOption);
-
-                            return StringUtil::encodeHTML($context->getTitle());
-                        }
-                    }
-                ]),
-            GridViewColumn::for('categoryName')
-                ->label('wcf.global.category')
-                ->sortable()
-                ->renderer([
-                    new class extends DefaultColumnRenderer {
-                        public function render(mixed $value, mixed $context = null): string
-                        {
-                            \assert($context instanceof UserOption);
-
-                            return StringUtil::encodeHTML(
-                                WCF::getLanguage()->get('wcf.user.option.category.' . $context->categoryName)
-                            );
-                        }
-                    }
-                ]),
-            GridViewColumn::for('optionType')
-                ->label('wcf.acp.user.option.optionType')
-                ->sortable(),
-            GridViewColumn::for('showOrder')
-                ->label('wcf.global.showOrder')
-                ->sortable()
-                ->renderer(new NumberColumnRenderer()),
-        ]);
-
-        $this->addActions([
-            new ToggleAction('core/users/options/%s/enable', 'core/users/options/%s/disable'),
-            new EditAction(UserOptionEditForm::class),
-            new DeleteAction('core/users/options/%s', fn(UserOption $row) => $row->canDelete()),
-        ]);
-        $this->addRowLink(new GridViewRowLink(UserOptionEditForm::class));
-        $this->setSortField('showOrder');
-    }
-
-    #[\Override]
-    public function isAccessible(): bool
-    {
-        return WCF::getSession()->getPermission('admin.user.canManageUserOption');
-    }
-
-    #[\Override]
-    protected function createObjectList(): DatabaseObjectList
-    {
-        $list = new UserOptionList();
-        $list->getConditionBuilder()->add(
-            "option_table.categoryName IN (
-                SELECT  categoryName
-                FROM    wcf" . WCF_N . "_user_option_category
-                WHERE   parentCategoryName = ?
-            )",
-            ['profile']
-        );
-
-        return $list;
-    }
-
-    #[\Override]
-    protected function getInitializedEvent(): ?IPsr14Event
-    {
-        return new UserOptionGridViewInitialized($this);
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/UserRankGridView.class.php b/wcfsetup/install/files/lib/system/gridView/UserRankGridView.class.php
deleted file mode 100644 (file)
index b588cfa..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-<?php
-
-namespace wcf\system\gridView;
-
-use wcf\acp\form\UserRankEditForm;
-use wcf\data\DatabaseObjectList;
-use wcf\data\user\group\UserGroup;
-use wcf\data\user\rank\I18nUserRankList;
-use wcf\data\user\rank\UserRank;
-use wcf\event\gridView\UserRankGridViewInitialized;
-use wcf\event\IPsr14Event;
-use wcf\system\gridView\action\DeleteAction;
-use wcf\system\gridView\action\EditAction;
-use wcf\system\gridView\filter\I18nTextFilter;
-use wcf\system\gridView\filter\SelectFilter;
-use wcf\system\gridView\renderer\DefaultColumnRenderer;
-use wcf\system\gridView\renderer\NumberColumnRenderer;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Grid view for the list of user ranks.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-final class UserRankGridView extends DatabaseObjectListGridView
-{
-    public function __construct()
-    {
-        $this->addColumns([
-            GridViewColumn::for('rankID')
-                ->label('wcf.global.objectID')
-                ->renderer(new NumberColumnRenderer())
-                ->sortable(),
-            GridViewColumn::for('rankTitle')
-                ->label('wcf.acp.user.rank.title')
-                ->sortable(true, 'rankTitleI18n')
-                ->titleColumn()
-                ->filter(new I18nTextFilter())
-                ->renderer([
-                    new class extends DefaultColumnRenderer {
-                        public function render(mixed $value, mixed $context = null): string
-                        {
-                            \assert($context instanceof UserRank);
-
-                            return '<span class="badge label' . ($context->cssClassName ? ' ' . $context->cssClassName : '') . '">'
-                                . StringUtil::encodeHTML($context->getTitle())
-                                . '<span>';
-                        }
-                    }
-                ]),
-            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/gridView/action/AbstractAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/AbstractAction.class.php
deleted file mode 100644 (file)
index 776b167..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\system\gridView\AbstractGridView;
-
-/**
- * Provides an abstract implementation of a grid view action.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-abstract class AbstractAction implements IGridViewAction
-{
-    public function __construct(
-        protected readonly ?Closure $isAvailableCallback = null
-    ) {}
-
-    #[\Override]
-    public function isAvailable(mixed $row): bool
-    {
-        if ($this->isAvailableCallback === null) {
-            return true;
-        }
-
-        return ($this->isAvailableCallback)($row);
-    }
-
-    #[\Override]
-    public function isQuickAction(): bool
-    {
-        return false;
-    }
-
-    #[\Override]
-    public function renderInitialization(AbstractGridView $gridView): ?string
-    {
-        return null;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/ActionConfirmationType.class.php b/wcfsetup/install/files/lib/system/gridView/action/ActionConfirmationType.class.php
deleted file mode 100644 (file)
index 7bc5e99..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-enum ActionConfirmationType
-{
-    case None;
-    case SoftDelete;
-    case SoftDeleteWithReason;
-    case Restore;
-    case Delete;
-    case Custom;
-
-    public function toString(): string
-    {
-        return match ($this) {
-            self::None => 'None',
-            self::SoftDelete => 'SoftDelete',
-            self::SoftDeleteWithReason => 'SoftDeleteWithReason',
-            self::Restore => 'Restore',
-            self::Delete => 'Delete',
-            self::Custom => 'Custom',
-        };
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/DeleteAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/DeleteAction.class.php
deleted file mode 100644 (file)
index 435e434..0000000
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\action\ApiAction;
-use wcf\data\DatabaseObject;
-use wcf\data\ITitledObject;
-use wcf\system\gridView\AbstractGridView;
-use wcf\system\request\LinkHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Represents a delete action.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-class DeleteAction extends AbstractAction
-{
-    public function __construct(
-        private readonly string $endpoint,
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($isAvailableCallback);
-    }
-
-    #[\Override]
-    public function render(mixed $row): string
-    {
-        \assert($row instanceof DatabaseObject);
-
-        $label = WCF::getLanguage()->get('wcf.global.button.delete');
-        $endpoint = StringUtil::encodeHTML(
-            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
-                \sprintf($this->endpoint, $row->getObjectID())
-        );
-        if ($row instanceof ITitledObject) {
-            $objectName = StringUtil::encodeHTML($row->getTitle());
-        } else {
-            $objectName = '';
-        }
-
-        return <<<HTML
-            <button type="button" data-action="delete" data-object-name="{$objectName}" data-endpoint="{$endpoint}">
-                {$label}
-            </button>
-            HTML;
-    }
-
-    #[\Override]
-    public function renderInitialization(AbstractGridView $gridView): ?string
-    {
-        $id = StringUtil::encodeJS($gridView->getID());
-
-        return <<<HTML
-            <script data-relocate="true">
-                require(['WoltLabSuite/Core/Component/GridView/Action/Delete'], ({ setup }) => {
-                    setup(document.getElementById('{$id}_table'));
-                });
-            </script>
-            HTML;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/EditAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/EditAction.class.php
deleted file mode 100644 (file)
index 277a07c..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-
-/**
- * Represents an edit action.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-class EditAction extends LinkAction
-{
-    public function __construct(
-        string $controllerClass,
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($controllerClass, 'wcf.global.button.edit', $isAvailableCallback);
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/IGridViewAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/IGridViewAction.class.php
deleted file mode 100644 (file)
index 3ca6c33..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use wcf\system\gridView\AbstractGridView;
-
-/**
- * Represents an action of a grid view.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-interface IGridViewAction
-{
-    /**
-     * Renders the action.
-     */
-    public function render(mixed $row): string;
-
-    /**
-     * Renders the initialization code for this action.
-     */
-    public function renderInitialization(AbstractGridView $gridView): ?string;
-
-    /**
-     * Returns true if this is a quick action.
-     */
-    public function isQuickAction(): bool;
-
-    /**
-     * Returns true if this action is available for the given row.
-     */
-    public function isAvailable(mixed $row): bool;
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/LegacyDboAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/LegacyDboAction.class.php
deleted file mode 100644 (file)
index 288f155..0000000
+++ /dev/null
@@ -1,88 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\data\DatabaseObject;
-use wcf\data\ITitledObject;
-use wcf\system\gridView\AbstractGridView;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Represents an action that executes a dbo action.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- * @deprecated  6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
- */
-class LegacyDboAction extends AbstractAction
-{
-    public function __construct(
-        protected readonly string $className,
-        protected readonly string $actionName,
-        protected readonly string|Closure $languageItem,
-        protected readonly ActionConfirmationType $confirmationType = ActionConfirmationType::None,
-        protected readonly string|Closure $confirmationMessage = '',
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($isAvailableCallback);
-    }
-
-    #[\Override]
-    public function render(mixed $row): string
-    {
-        \assert($row instanceof DatabaseObject);
-
-        if (\is_string($this->languageItem)) {
-            $label = WCF::getLanguage()->get($this->languageItem);
-        } else {
-            $label = ($this->languageItem)($row);
-        }
-
-        if (\is_string($this->confirmationMessage)) {
-            $confirmationMessage = WCF::getLanguage()->get($this->confirmationMessage);
-        } else {
-            $confirmationMessage = ($this->confirmationMessage)($row);
-        }
-
-        if ($row instanceof ITitledObject) {
-            $objectName = StringUtil::encodeHTML($row->getTitle());
-        } else {
-            $objectName = '';
-        }
-
-        $className = StringUtil::encodeHTML($this->className);
-        $actionName = StringUtil::encodeHTML($this->actionName);
-
-        return <<<HTML
-            <button
-                type="button"
-                data-action="legacy-dbo-action"
-                data-object-name="{$objectName}"
-                data-class-name="{$className}"
-                data-action-name="{$actionName}"
-                data-confirmation-type="{$this->confirmationType->toString()}"
-                data-confirmation-message="{$confirmationMessage}"
-            >
-                {$label}
-            </button>
-            HTML;
-    }
-
-    #[\Override]
-    public function renderInitialization(AbstractGridView $gridView): ?string
-    {
-        $id = StringUtil::encodeJS($gridView->getID());
-
-        return <<<HTML
-            <script data-relocate="true">
-                require(['WoltLabSuite/Core/Component/GridView/Action/LegacyDboAction'], ({ setup }) => {
-                    setup(document.getElementById('{$id}_table'));
-                });
-            </script>
-            HTML;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/LinkAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/LinkAction.class.php
deleted file mode 100644 (file)
index 2173117..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\data\DatabaseObject;
-use wcf\system\gridView\AbstractGridView;
-use wcf\system\request\LinkHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Represents an action that links to a given controller.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-class LinkAction extends AbstractAction
-{
-    public function __construct(
-        protected readonly string $controllerClass,
-        protected readonly string|Closure $languageItem,
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($isAvailableCallback);
-    }
-
-    #[\Override]
-    public function render(mixed $row): string
-    {
-        \assert($row instanceof DatabaseObject);
-        $href = LinkHandler::getInstance()->getControllerLink(
-            $this->controllerClass,
-            ['object' => $row]
-        );
-
-        if (\is_string($this->languageItem)) {
-            $title = WCF::getLanguage()->get($this->languageItem);
-        } else {
-            $title = ($this->languageItem)($row);
-        }
-
-        return \sprintf('<a href="%s">%s</a>', StringUtil::encodeHTML($href), $title);
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/RpcAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/RpcAction.class.php
deleted file mode 100644 (file)
index ba02b0a..0000000
+++ /dev/null
@@ -1,89 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\action\ApiAction;
-use wcf\data\DatabaseObject;
-use wcf\data\ITitledObject;
-use wcf\system\gridView\AbstractGridView;
-use wcf\system\request\LinkHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Represents an action that call a rpc endpoint.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-class RpcAction extends AbstractAction
-{
-    public function __construct(
-        protected readonly string $endpoint,
-        protected readonly string|Closure $languageItem,
-        protected readonly ActionConfirmationType $confirmationType = ActionConfirmationType::None,
-        protected readonly string|Closure $confirmationMessage = '',
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($isAvailableCallback);
-    }
-
-    #[\Override]
-    public function render(mixed $row): string
-    {
-        \assert($row instanceof DatabaseObject);
-
-        if (\is_string($this->languageItem)) {
-            $label = WCF::getLanguage()->get($this->languageItem);
-        } else {
-            $label = ($this->languageItem)($row);
-        }
-
-        if (\is_string($this->confirmationMessage)) {
-            $confirmationMessage = WCF::getLanguage()->get($this->confirmationMessage);
-        } else {
-            $confirmationMessage = ($this->confirmationMessage)($row);
-        }
-
-        $endpoint = StringUtil::encodeHTML(
-            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
-                \sprintf($this->endpoint, $row->getObjectID())
-        );
-
-        if ($row instanceof ITitledObject) {
-            $objectName = StringUtil::encodeHTML($row->getTitle());
-        } else {
-            $objectName = '';
-        }
-
-        return <<<HTML
-            <button
-                type="button"
-                data-action="rpc"
-                data-object-name="{$objectName}"
-                data-endpoint="{$endpoint}"
-                data-confirmation-type="{$this->confirmationType->toString()}"
-                data-confirmation-message="{$confirmationMessage}"
-            >
-                {$label}
-            </button>
-            HTML;
-    }
-
-    #[\Override]
-    public function renderInitialization(AbstractGridView $gridView): ?string
-    {
-        $id = StringUtil::encodeJS($gridView->getID());
-
-        return <<<HTML
-            <script data-relocate="true">
-                require(['WoltLabSuite/Core/Component/GridView/Action/Rpc'], ({ setup }) => {
-                    setup(document.getElementById('{$id}_table'));
-                });
-            </script>
-            HTML;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/action/ToggleAction.class.php b/wcfsetup/install/files/lib/system/gridView/action/ToggleAction.class.php
deleted file mode 100644 (file)
index fc07fa5..0000000
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-
-namespace wcf\system\gridView\action;
-
-use Closure;
-use wcf\action\ApiAction;
-use wcf\data\DatabaseObject;
-use wcf\system\gridView\AbstractGridView;
-use wcf\system\request\LinkHandler;
-use wcf\system\WCF;
-use wcf\util\StringUtil;
-
-/**
- * Represents a toggle action.
- *
- * @author      Marcel Werk
- * @copyright   2001-2024 WoltLab GmbH
- * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
- * @since       6.2
- */
-class ToggleAction extends AbstractAction
-{
-    public function __construct(
-        private readonly string $enableEndpoint,
-        private readonly string $disableEndpoint,
-        private readonly string $propertyName = 'isDisabled',
-        private readonly bool $propertyIsDisabledState = true,
-        ?Closure $isAvailableCallback = null
-    ) {
-        parent::__construct($isAvailableCallback);
-    }
-
-    #[\Override]
-    public function render(mixed $row): string
-    {
-        \assert($row instanceof DatabaseObject);
-
-        $enableEndpoint = StringUtil::encodeHTML(
-            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
-                \sprintf($this->enableEndpoint, $row->getObjectID())
-        );
-        $disableEndpoint = StringUtil::encodeHTML(
-            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
-                \sprintf($this->disableEndpoint, $row->getObjectID())
-        );
-
-        $ariaLabel = WCF::getLanguage()->get('wcf.global.button.enable');
-        $checked = (!$row->{$this->propertyName} && $this->propertyIsDisabledState)
-            || ($row->{$this->propertyName} && !$this->propertyIsDisabledState) ? 'checked' : '';
-
-        return <<<HTML
-            <woltlab-core-toggle-button aria-label="{$ariaLabel}" data-enable-endpoint="{$enableEndpoint}" data-disable-endpoint="{$disableEndpoint}" {$checked}></woltlab-core-toggle-button>
-            HTML;
-    }
-
-    #[\Override]
-    public function renderInitialization(AbstractGridView $gridView): ?string
-    {
-        $id = StringUtil::encodeJS($gridView->getID());
-
-        return <<<HTML
-            <script data-relocate="true">
-                require(['WoltLabSuite/Core/Component/GridView/Action/Toggle'], ({ setup }) => {
-                    setup('{$id}_table');
-                });
-            </script>
-            HTML;
-    }
-
-    #[\Override]
-    public function isQuickAction(): bool
-    {
-        return true;
-    }
-}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/ACPSessionLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ACPSessionLogGridView.class.php
new file mode 100644 (file)
index 0000000..48eadf6
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use wcf\acp\form\UserEditForm;
+use wcf\acp\page\ACPSessionLogPage;
+use wcf\data\acp\session\log\ACPSessionLogList;
+use wcf\data\DatabaseObjectList;
+use wcf\event\gridView\admin\ACPSessionLogGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\DatabaseObjectListGridView;
+use wcf\system\gridView\filter\IpAddressFilter;
+use wcf\system\gridView\filter\TextFilter;
+use wcf\system\gridView\filter\TimeFilter;
+use wcf\system\gridView\filter\UserFilter;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\GridViewRowLink;
+use wcf\system\gridView\renderer\IpAddressColumnRenderer;
+use wcf\system\gridView\renderer\NumberColumnRenderer;
+use wcf\system\gridView\renderer\TimeColumnRenderer;
+use wcf\system\gridView\renderer\TruncatedTextColumnRenderer;
+use wcf\system\gridView\renderer\UserLinkColumnRenderer;
+use wcf\system\WCF;
+
+/**
+ * Grid view for the list of logged admin session.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ACPSessionLogGridView extends DatabaseObjectListGridView
+{
+    public function __construct()
+    {
+        $this->addColumns([
+            GridViewColumn::for('sessionLogID')
+                ->label('wcf.global.objectID')
+                ->renderer(new NumberColumnRenderer())
+                ->sortable(),
+            GridViewColumn::for('userID')
+                ->label('wcf.user.username')
+                ->sortable(true, 'user_table.username')
+                ->titleColumn()
+                ->renderer(new UserLinkColumnRenderer(UserEditForm::class))
+                ->filter(new UserFilter()),
+            GridViewColumn::for('ipAddress')
+                ->label('wcf.user.ipAddress')
+                ->sortable()
+                ->renderer(new IpAddressColumnRenderer())
+                ->filter(new IpAddressFilter()),
+            GridViewColumn::for('userAgent')
+                ->label('wcf.user.userAgent')
+                ->sortable()
+                ->valueEncoding(false)
+                ->renderer(new TruncatedTextColumnRenderer(50))
+                ->filter(new TextFilter()),
+            GridViewColumn::for('time')
+                ->label('wcf.acp.sessionLog.time')
+                ->sortable()
+                ->renderer(new TimeColumnRenderer())
+                ->filter(new TimeFilter()),
+            GridViewColumn::for('lastActivityTime')
+                ->label('wcf.acp.sessionLog.lastActivityTime')
+                ->sortable()
+                ->renderer(new TimeColumnRenderer())
+                ->filter(new TimeFilter()),
+            GridViewColumn::for('accesses')
+                ->label('wcf.acp.sessionLog.actions')
+                ->sortable(true, 'accesses')
+                ->renderer(new NumberColumnRenderer()),
+        ]);
+
+        $this->addRowLink(new GridViewRowLink(ACPSessionLogPage::class));
+        $this->setSortField('lastActivityTime');
+        $this->setSortOrder('DESC');
+    }
+
+    #[\Override]
+    public function isAccessible(): bool
+    {
+        return WCF::getSession()->getPermission('admin.management.canViewLog');
+    }
+
+    #[\Override]
+    protected function createObjectList(): DatabaseObjectList
+    {
+        $list = new ACPSessionLogList();
+        $list->sqlSelects .= "
+            user_table.username,
+            0 AS active,
+            (
+                SELECT  COUNT(*)
+                FROM    wcf1_acp_session_access_log
+                WHERE   sessionLogID = " . $list->getDatabaseTableAlias() . ".sessionLogID
+            ) AS accesses";
+        $list->sqlJoins = $list->sqlConditionJoins .= " LEFT JOIN wcf" . WCF_N . "_user user_table ON (user_table.userID = " . $list->getDatabaseTableAlias() . ".userID)";
+
+        return $list;
+    }
+
+    #[\Override]
+    protected function getInitializedEvent(): ?IPsr14Event
+    {
+        return new ACPSessionLogGridViewInitialized($this);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CronjobLogGridView.class.php
new file mode 100644 (file)
index 0000000..4834ada
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use wcf\data\cronjob\Cronjob;
+use wcf\data\cronjob\I18nCronjobList;
+use wcf\data\cronjob\log\CronjobLog;
+use wcf\data\cronjob\log\CronjobLogList;
+use wcf\data\DatabaseObjectList;
+use wcf\event\gridView\admin\CronjobLogGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\DatabaseObjectListGridView;
+use wcf\system\gridView\filter\SelectFilter;
+use wcf\system\gridView\filter\TimeFilter;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\renderer\DefaultColumnRenderer;
+use wcf\system\gridView\renderer\NumberColumnRenderer;
+use wcf\system\gridView\renderer\TimeColumnRenderer;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Grid view for the cronjob log.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class CronjobLogGridView extends DatabaseObjectListGridView
+{
+    public function __construct()
+    {
+        $availableCronjobs = $this->getAvailableCronjobs();
+
+        $this->addColumns([
+            GridViewColumn::for('cronjobLogID')
+                ->label('wcf.global.objectID')
+                ->renderer(new NumberColumnRenderer())
+                ->sortable(),
+            GridViewColumn::for('cronjobID')
+                ->label('wcf.acp.cronjob')
+                ->sortable()
+                ->titleColumn()
+                ->filter(new SelectFilter($availableCronjobs))
+                ->renderer([
+                    new class($availableCronjobs) extends DefaultColumnRenderer {
+                        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 '<span class="badge green">' . WCF::getLanguage()->get('wcf.acp.cronjob.log.success') . '</span>';
+                            }
+                            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 <<<HTML
+                                    <button type="button" id="{$buttonId}" class="badge red">
+                                        {$label}
+                                    </button>
+                                    <template id="{$id}"><pre>{$error}</pre></template>
+                                    <script data-relocate="true">
+                                        require(['WoltLabSuite/Core/Component/Dialog'], ({ dialogFactory }) => {
+                                            document.getElementById('{$buttonId}').addEventListener('click', () => {
+                                                const dialog = dialogFactory().fromId('{$id}').withoutControls();
+                                                dialog.show('{$dialogTitle}');
+                                            });
+                                        });
+                                    </script>
+                                    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/gridView/admin/ExceptionLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ExceptionLogGridView.class.php
new file mode 100644 (file)
index 0000000..501897a
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use wcf\event\gridView\admin\ExceptionLogGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\DataSourceGridView;
+use wcf\system\Regex;
+use wcf\system\gridView\filter\SelectFilter;
+use wcf\system\gridView\filter\TextFilter;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\GridViewRowLink;
+use wcf\system\gridView\renderer\TimeColumnRenderer;
+use wcf\system\WCF;
+use wcf\util\DirectoryUtil;
+use wcf\util\ExceptionLogUtil;
+
+/**
+ * Grid view for the exception log.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ExceptionLogGridView extends DataSourceGridView
+{
+    private array $availableLogFiles;
+
+    public function __construct(bool $applyDefaultFilter = false)
+    {
+        $this->addColumns([
+            GridViewColumn::for('message')
+                ->label('wcf.acp.exceptionLog.exception.message')
+                ->sortable()
+                ->titleColumn(),
+            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 loadDataSource(): 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/gridView/admin/ModificationLogGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/ModificationLogGridView.class.php
new file mode 100644 (file)
index 0000000..b16b230
--- /dev/null
@@ -0,0 +1,271 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use LogicException;
+use wcf\data\DatabaseObject;
+use wcf\data\DatabaseObjectDecorator;
+use wcf\data\DatabaseObjectList;
+use wcf\data\modification\log\IViewableModificationLog;
+use wcf\data\modification\log\ModificationLog;
+use wcf\data\modification\log\ModificationLogList;
+use wcf\data\object\type\ObjectTypeCache;
+use wcf\data\package\Package;
+use wcf\event\gridView\admin\ModificationLogGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\form\builder\field\AbstractFormField;
+use wcf\system\form\builder\field\SelectFormField;
+use wcf\system\gridView\DatabaseObjectListGridView;
+use wcf\system\gridView\filter\IGridViewFilter;
+use wcf\system\gridView\filter\TextFilter;
+use wcf\system\gridView\filter\TimeFilter;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\renderer\DefaultColumnRenderer;
+use wcf\system\gridView\renderer\ILinkColumnRenderer;
+use wcf\system\gridView\renderer\ObjectIdColumnRenderer;
+use wcf\system\gridView\renderer\TimeColumnRenderer;
+use wcf\system\gridView\renderer\UserLinkColumnRenderer;
+use wcf\system\log\modification\IExtendedModificationLogHandler;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Grid view for the list of all modification log items.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class ModificationLogGridView extends DatabaseObjectListGridView
+{
+    /**
+     * @var IViewableModificationLog[]
+     */
+    private array $logItems;
+
+    public function __construct()
+    {
+        $this->addColumns([
+            GridViewColumn::for('logID')
+                ->label('wcf.global.objectID')
+                ->renderer(new ObjectIdColumnRenderer())
+                ->sortable(),
+            GridViewColumn::for('userID')
+                ->label('wcf.user.username')
+                ->sortable(true, 'modification_log.username')
+                ->renderer(new UserLinkColumnRenderer())
+                ->filter(new TextFilter('modification_log.username')),
+            GridViewColumn::for('action')
+                ->label('wcf.acp.modificationLog.action')
+                ->titleColumn()
+                ->renderer([
+                    new class extends DefaultColumnRenderer {
+                        #[\Override]
+                        public function render(mixed $value, mixed $context = null): string
+                        {
+                            \assert($context instanceof DatabaseObjectDecorator);
+                            $log = $context->getDecoratedObject();
+                            \assert($log instanceof ModificationLog);
+                            $objectType = ObjectTypeCache::getInstance()->getObjectType($log->objectTypeID);
+                            if (!$objectType) {
+                                return '';
+                            }
+
+                            return WCF::getLanguage()->get(
+                                'wcf.acp.modificationLog.' . $objectType->objectType . '.' . $log->action
+                            );
+                        }
+                    },
+                ])
+                ->filter(
+                    new class($this->getAvailableActions()) implements IGridViewFilter {
+                        public function __construct(private readonly array $options) {}
+
+                        #[\Override]
+                        public function getFormField(string $id, string $label): AbstractFormField
+                        {
+                            return SelectFormField::create($id)
+                                ->label($label)
+                                ->options($this->options, false, false);
+                        }
+
+                        #[\Override]
+                        public function applyFilter(DatabaseObjectList $list, string $id, string $value): void
+                        {
+                            if (\is_numeric($value)) {
+                                $list->getConditionBuilder()->add(
+                                    "objectTypeID IN (SELECT objectTypeID FROM wcf1_object_type WHERE packageID = ?)",
+                                    [$value]
+                                );
+                            } else if (\preg_match('~^(?P<objectType>.+)\-(?P<action>[^\-]+)$~', $value, $matches)) {
+                                $objectType = ObjectTypeCache::getInstance()->getObjectTypeByName(
+                                    'com.woltlab.wcf.modifiableContent',
+                                    $matches['objectType']
+                                );
+                                if (!$objectType) {
+                                    return;
+                                }
+
+                                $list->getConditionBuilder()->add(
+                                    "objectTypeID = ? AND action = ?",
+                                    [$objectType->objectTypeID, $matches['action']]
+                                );
+                            }
+                        }
+
+                        #[\Override]
+                        public function matches(string $filterValue, string $rowValue): bool
+                        {
+                            throw new LogicException('unreachable');
+                        }
+
+                        #[\Override]
+                        public function renderValue(string $value): string
+                        {
+                            if (\is_numeric($value)) {
+                                return WCF::getLanguage()->get($this->options[$value]);
+                            }
+
+                            return \substr(WCF::getLanguage()->get($this->options[$value]), 24);
+                        }
+                    }
+                ),
+            GridViewColumn::for('affectedObject')
+                ->label('wcf.acp.modificationLog.affectedObject')
+                ->renderer([
+                    new class extends DefaultColumnRenderer implements ILinkColumnRenderer {
+                        #[\Override]
+                        public function render(mixed $value, mixed $context = null): string
+                        {
+                            \assert($context instanceof IViewableModificationLog);
+                            if ($context->getAffectedObject() === null) {
+                                return WCF::getLanguage()->get('wcf.acp.modificationLog.affectedObject.unknown');
+                            }
+
+                            return \sprintf(
+                                '<a href="%s">%s</a>',
+                                StringUtil::encodeHTML($context->getAffectedObject()->getLink()),
+                                StringUtil::encodeHTML($context->getAffectedObject()->getTitle())
+                            );
+                        }
+                    },
+                ]),
+            GridViewColumn::for('time')
+                ->label('wcf.global.date')
+                ->sortable()
+                ->renderer(new TimeColumnRenderer())
+                ->filter(new TimeFilter()),
+        ]);
+
+        $this->setSortField('time');
+        $this->setSortOrder('DESC');
+    }
+
+    #[\Override]
+    public function isAccessible(): bool
+    {
+        return WCF::getSession()->getPermission('admin.management.canViewLog');
+    }
+
+    #[\Override]
+    protected function createObjectList(): DatabaseObjectList
+    {
+        return new ModificationLogList();
+    }
+
+    #[\Override]
+    public function getRows(): array
+    {
+        if (!isset($this->logItems)) {
+            $this->logItems = [];
+            $this->getObjectList()->readObjects();
+
+            $itemsPerType = [];
+            foreach ($this->getObjectList() as $modificationLog) {
+                if (!isset($itemsPerType[$modificationLog->objectTypeID])) {
+                    $itemsPerType[$modificationLog->objectTypeID] = [];
+                }
+
+                $itemsPerType[$modificationLog->objectTypeID][] = $modificationLog;
+            }
+
+            if (!empty($itemsPerType)) {
+                foreach ($itemsPerType as $objectTypeID => $items) {
+                    $objectType = ObjectTypeCache::getInstance()->getObjectType($objectTypeID);
+                    if (!$objectType) {
+                        continue;
+                    }
+                    $processor = $objectType->getProcessor();
+                    if (!$processor) {
+                        continue;
+                    }
+                    \assert($processor instanceof IExtendedModificationLogHandler);
+
+                    $this->logItems = \array_merge(
+                        $this->logItems,
+                        $processor->processItems($items)
+                    );
+                }
+            }
+
+            DatabaseObject::sort($this->logItems, $this->getSortField(), $this->getSortOrder());
+        }
+
+        return $this->logItems;
+    }
+
+    #[\Override]
+    protected function getInitializedEvent(): ?IPsr14Event
+    {
+        return new ModificationLogGridViewInitialized($this);
+    }
+
+    private function getAvailableActions(): array
+    {
+        $packages = $actions = $availableActions = [];
+
+        foreach (ObjectTypeCache::getInstance()->getObjectTypes('com.woltlab.wcf.modifiableContent') as $objectType) {
+            $processor = $objectType->getProcessor();
+            if ($processor === null) {
+                continue;
+            }
+            \assert($processor instanceof IExtendedModificationLogHandler);
+
+            if (!$processor->includeInLogList()) {
+                continue;
+            }
+
+            if (!isset($packages[$objectType->packageID])) {
+                $actions[$objectType->packageID] = [];
+                $packages[$objectType->packageID] = $objectType->getPackage();
+            }
+
+            foreach ($processor->getAvailableActions() as $action) {
+                $actions[$objectType->packageID]["{$objectType->objectType}-{$action}"]
+                    = WCF::getLanguage()->get("wcf.acp.modificationLog.{$objectType->objectType}.{$action}");
+            }
+        }
+
+        foreach ($actions as &$actionsPerPackage) {
+            \asort($actionsPerPackage, \SORT_NATURAL);
+        }
+        \uasort($packages, static function (Package $a, Package $b) {
+            return \strnatcasecmp($a->package, $b->package);
+        });
+
+        foreach ($packages as $package) {
+            $availableActions[$package->packageID]
+                = WCF::getLanguage()->getDynamicVariable(
+                    'wcf.acp.modificationLog.action.allPackageActions',
+                    ['package' => $package]
+                );
+
+            foreach ($actions[$package->packageID] as $actionName => $actionLabel) {
+                $availableActions[$actionName] = \str_repeat('&nbsp;', 4) . $actionLabel;
+            }
+        }
+
+        return $availableActions;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserOptionGridView.class.php
new file mode 100644 (file)
index 0000000..a9600cf
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use wcf\acp\form\UserOptionEditForm;
+use wcf\data\DatabaseObjectList;
+use wcf\data\user\option\UserOption;
+use wcf\data\user\option\UserOptionList;
+use wcf\event\gridView\admin\UserOptionGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\DatabaseObjectListGridView;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\GridViewRowLink;
+use wcf\system\gridView\renderer\DefaultColumnRenderer;
+use wcf\system\gridView\renderer\NumberColumnRenderer;
+use wcf\system\interaction\admin\UserOptionInteractions;
+use wcf\system\interaction\Divider;
+use wcf\system\interaction\EditInteraction;
+use wcf\system\interaction\ToggleInteraction;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Grid view for the list of user options.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserOptionGridView extends DatabaseObjectListGridView
+{
+    public function __construct()
+    {
+        $this->addColumns([
+            GridViewColumn::for('optionID')
+                ->label('wcf.global.objectID')
+                ->renderer(new NumberColumnRenderer())
+                ->sortable(),
+            GridViewColumn::for('optionName')
+                ->label('wcf.global.name')
+                ->sortable()
+                ->titleColumn()
+                ->renderer([
+                    new class extends DefaultColumnRenderer {
+                        public function render(mixed $value, mixed $context = null): string
+                        {
+                            \assert($context instanceof UserOption);
+
+                            return StringUtil::encodeHTML($context->getTitle());
+                        }
+                    }
+                ]),
+            GridViewColumn::for('categoryName')
+                ->label('wcf.global.category')
+                ->sortable()
+                ->renderer([
+                    new class extends DefaultColumnRenderer {
+                        public function render(mixed $value, mixed $context = null): string
+                        {
+                            \assert($context instanceof UserOption);
+
+                            return StringUtil::encodeHTML(
+                                WCF::getLanguage()->get('wcf.user.option.category.' . $context->categoryName)
+                            );
+                        }
+                    }
+                ]),
+            GridViewColumn::for('optionType')
+                ->label('wcf.acp.user.option.optionType')
+                ->sortable(),
+            GridViewColumn::for('showOrder')
+                ->label('wcf.global.showOrder')
+                ->sortable()
+                ->renderer(new NumberColumnRenderer()),
+        ]);
+
+        $provider = new UserOptionInteractions();
+        $provider->addInteractions([
+            new Divider(),
+            new EditInteraction(UserOptionEditForm::class)
+        ]);
+        $this->setInteractionProvider($provider);
+        $this->addQuickInteraction(
+            new ToggleInteraction('enable', 'core/users/options/%s/enable', 'core/users/options/%s/disable')
+        );
+        $this->addRowLink(new GridViewRowLink(UserOptionEditForm::class));
+        $this->setSortField('showOrder');
+    }
+
+    #[\Override]
+    public function isAccessible(): bool
+    {
+        return WCF::getSession()->getPermission('admin.user.canManageUserOption');
+    }
+
+    #[\Override]
+    protected function createObjectList(): DatabaseObjectList
+    {
+        $list = new UserOptionList();
+        $list->getConditionBuilder()->add(
+            "option_table.categoryName IN (
+                SELECT  categoryName
+                FROM    wcf" . WCF_N . "_user_option_category
+                WHERE   parentCategoryName = ?
+            )",
+            ['profile']
+        );
+
+        return $list;
+    }
+
+    #[\Override]
+    protected function getInitializedEvent(): ?IPsr14Event
+    {
+        return new UserOptionGridViewInitialized($this);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/UserRankGridView.class.php
new file mode 100644 (file)
index 0000000..3544d39
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+
+namespace wcf\system\gridView\admin;
+
+use wcf\acp\form\UserRankEditForm;
+use wcf\data\DatabaseObjectList;
+use wcf\data\user\group\UserGroup;
+use wcf\data\user\rank\I18nUserRankList;
+use wcf\data\user\rank\UserRank;
+use wcf\event\gridView\admin\UserRankGridViewInitialized;
+use wcf\event\IPsr14Event;
+use wcf\system\gridView\DatabaseObjectListGridView;
+use wcf\system\gridView\filter\I18nTextFilter;
+use wcf\system\gridView\filter\SelectFilter;
+use wcf\system\gridView\GridViewColumn;
+use wcf\system\gridView\GridViewRowLink;
+use wcf\system\gridView\renderer\DefaultColumnRenderer;
+use wcf\system\gridView\renderer\NumberColumnRenderer;
+use wcf\system\interaction\admin\UserRankInteractions;
+use wcf\system\interaction\Divider;
+use wcf\system\interaction\EditInteraction;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Grid view for the list of user ranks.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserRankGridView extends DatabaseObjectListGridView
+{
+    public function __construct()
+    {
+        $this->addColumns([
+            GridViewColumn::for('rankID')
+                ->label('wcf.global.objectID')
+                ->renderer(new NumberColumnRenderer())
+                ->sortable(),
+            GridViewColumn::for('rankTitle')
+                ->label('wcf.acp.user.rank.title')
+                ->sortable(true, 'rankTitleI18n')
+                ->titleColumn()
+                ->filter(new I18nTextFilter())
+                ->renderer([
+                    new class extends DefaultColumnRenderer {
+                        public function render(mixed $value, mixed $context = null): string
+                        {
+                            \assert($context instanceof UserRank);
+
+                            return '<span class="badge label' . ($context->cssClassName ? ' ' . $context->cssClassName : '') . '">'
+                                . StringUtil::encodeHTML($context->getTitle())
+                                . '<span>';
+                        }
+                    }
+                ]),
+            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()),
+        ]);
+
+        $provider = new UserRankInteractions();
+        $provider->addInteractions([
+            new Divider(),
+            new EditInteraction(UserRankEditForm::class)
+        ]);
+        $this->setInteractionProvider($provider);
+        $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/interaction/AbstractInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/AbstractInteraction.class.php
new file mode 100644 (file)
index 0000000..db936df
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+
+/**
+ * Provides an abstract implementation of an interaction that can be applied to a DatabaseObject.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+abstract class AbstractInteraction implements IInteraction
+{
+    public function __construct(
+        protected readonly string $identifier,
+        protected readonly ?\Closure $isAvailableCallback = null
+    ) {}
+
+    #[\Override]
+    public function isAvailable(DatabaseObject $object): bool
+    {
+        if ($this->isAvailableCallback === null) {
+            return true;
+        }
+
+        return ($this->isAvailableCallback)($object);
+    }
+
+    #[\Override]
+    public function renderInitialization(string $containerId): ?string
+    {
+        return null;
+    }
+
+    #[\Override]
+    public function getIdentifier(): string
+    {
+        return $this->identifier;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/AbstractInteractionProvider.class.php b/wcfsetup/install/files/lib/system/interaction/AbstractInteractionProvider.class.php
new file mode 100644 (file)
index 0000000..bd3d406
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Provides an abstract implementation of a provider that provides interactions
+ * that can be applied to a specific type of DatabaseObject.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+abstract class AbstractInteractionProvider implements IInteractionProvider
+{
+    /**
+     * @var (IInteraction|Divider)[]
+     */
+    private array $interactions = [];
+
+    #[\Override]
+    public function getInteractions(): array
+    {
+        return $this->interactions;
+    }
+
+    #[\Override]
+    public function addInteraction(IInteraction|Divider $interaction): void
+    {
+        $this->interactions[] = $interaction;
+    }
+
+    #[\Override]
+    public function addInteractions(array $interactions): void
+    {
+        foreach ($interactions as $interaction) {
+            $this->addInteraction($interaction);
+        }
+    }
+
+    #[\Override]
+    public function addInteractionBefore(IInteraction|Divider $interaction, string $beforeID): void
+    {
+        $position = -1;
+
+        foreach ($this->getInteractions() as $key => $existingInteraction) {
+            if ($existingInteraction->getIdentifier() === $beforeID) {
+                $position = $key;
+                break;
+            }
+        }
+
+        if ($position === -1) {
+            throw new \InvalidArgumentException("Invalid interaction id '{$beforeID}' given.");
+        }
+
+        array_splice($this->interactions, $position, 0, [
+            $interaction,
+        ]);
+    }
+
+    #[\Override]
+    public function addInteractionAfter(IInteraction|Divider $interaction, string $afterID): void
+    {
+        $position = -1;
+
+        foreach ($this->getInteractions() as $key => $existingInteraction) {
+            if ($existingInteraction->getIdentifier() === $afterID) {
+                $position = $key;
+                break;
+            }
+        }
+
+        if ($position === -1) {
+            throw new \InvalidArgumentException("Invalid interaction id '{$afterID}' given.");
+        }
+
+        array_splice($this->interactions, $position + 1, 0, [
+            $interaction,
+        ]);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/DeleteInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/DeleteInteraction.class.php
new file mode 100644 (file)
index 0000000..caae8bd
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Represents a delete interaction.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class DeleteInteraction extends RpcInteraction
+{
+    public function __construct(
+        string $endpoint,
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct(
+            'delete',
+            $endpoint,
+            'wcf.global.button.delete',
+            InteractionConfirmationType::Delete,
+            '',
+            $isAvailableCallback
+        );
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/Divider.class.php b/wcfsetup/install/files/lib/system/interaction/Divider.class.php
new file mode 100644 (file)
index 0000000..77f642f
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Represents a separator in an interaction context menu.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class Divider {}
diff --git a/wcfsetup/install/files/lib/system/interaction/EditInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/EditInteraction.class.php
new file mode 100644 (file)
index 0000000..325c41d
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Represents an interaction that links to an edit form controller.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class EditInteraction extends LinkInteraction
+{
+    public function __construct(
+        string $controllerClass,
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct('edit', $controllerClass, 'wcf.global.button.edit', $isAvailableCallback);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/FormBuilderDialogInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/FormBuilderDialogInteraction.class.php
new file mode 100644 (file)
index 0000000..e2b11eb
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Represents an interaction that call a form builder action.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class FormBuilderDialogInteraction extends AbstractInteraction
+{
+    public function __construct(
+        string $identifier,
+        protected readonly string $endpoint,
+        protected readonly string|\Closure $languageItem,
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct($identifier, $isAvailableCallback);
+    }
+
+    #[\Override]
+    public function render(DatabaseObject $object): string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+
+        if (\is_string($this->languageItem)) {
+            $label = WCF::getLanguage()->get($this->languageItem);
+        } else {
+            $label = ($this->languageItem)($object);
+        }
+
+        $endpoint = StringUtil::encodeHTML(
+            \sprintf($this->endpoint, $object->getObjectID())
+        );
+
+        return <<<HTML
+            <button
+                type="button"
+                data-interaction="{$identifier}"
+                data-endpoint="{$endpoint}"
+            >
+                {$label}
+            </button>
+            HTML;
+    }
+
+    #[\Override]
+    public function renderInitialization(string $containerId): ?string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+        $containerId = StringUtil::encodeJS($containerId);
+
+        return <<<HTML
+            <script data-relocate="true">
+                require(['WoltLabSuite/Core/Component/Interaction/FormBuilderDialog'], ({ setup }) => {
+                    setup('{$identifier}', document.getElementById('{$containerId}'));
+                });
+            </script>
+            HTML;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/IInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/IInteraction.class.php
new file mode 100644 (file)
index 0000000..71f4187
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+
+/**
+ * Represents an interaction that can be applied to a DatabaseObject.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+interface IInteraction
+{
+    /**
+     * Renders the interaction for the given object.
+     */
+    public function render(DatabaseObject $object): string;
+
+    /**
+     * Renders the initialization code for this interaction.
+     */
+    public function renderInitialization(string $containerId): ?string;
+
+    /**
+     * Returns true if this interaction is available for the given object
+     */
+    public function isAvailable(DatabaseObject $object): bool;
+
+    /**
+     * Returns the identifier of this interaction.
+     */
+    public function getIdentifier(): string;
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/IInteractionProvider.class.php b/wcfsetup/install/files/lib/system/interaction/IInteractionProvider.class.php
new file mode 100644 (file)
index 0000000..22e71a7
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Represents a provider that provides interactions that can be applied to a specific type of DatabaseObject.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+interface IInteractionProvider
+{
+    /**
+     * Returns the interactions provided by this provider.
+     * @return (IInteraction|Divider)[]
+     */
+    public function getInteractions(): array;
+
+    /**
+     * Adds the given interaction to the provider.
+     */
+    public function addInteraction(IInteraction|Divider $interaction): void;
+
+    /**
+     * Adds the given interactions to the provider.
+     * @param (IInteraction|Divider)[] $interactions
+     */
+    public function addInteractions(array $interactions): void;
+
+    /**
+     * Adds a new interaction at the position before the given id.
+     */
+    public function addInteractionBefore(IInteraction|Divider $interaction, string $beforeID): void;
+
+    /**
+     * Adds a new interaction at the position after the given id.
+     */
+    public function addInteractionAfter(IInteraction|Divider $interaction, string $afterID): void;
+
+    /**
+     * Returns the class name of the object that the interactions can be applied to.
+     */
+    public function getObjectClassName(): string;
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/InteractionConfirmationType.class.php b/wcfsetup/install/files/lib/system/interaction/InteractionConfirmationType.class.php
new file mode 100644 (file)
index 0000000..30854d4
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace wcf\system\interaction;
+
+/**
+ * Represents a confirmation type used in interactions.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+enum InteractionConfirmationType
+{
+    case None;
+    case SoftDelete;
+    case SoftDeleteWithReason;
+    case Restore;
+    case Delete;
+    case Custom;
+
+    public function toString(): string
+    {
+        return match ($this) {
+            self::None => 'None',
+            self::SoftDelete => 'SoftDelete',
+            self::SoftDeleteWithReason => 'SoftDeleteWithReason',
+            self::Restore => 'Restore',
+            self::Delete => 'Delete',
+            self::Custom => 'Custom',
+        };
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/InteractionContextMenuView.class.php b/wcfsetup/install/files/lib/system/interaction/InteractionContextMenuView.class.php
new file mode 100644 (file)
index 0000000..c6e1a5a
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+use wcf\system\WCF;
+
+class InteractionContextMenuView
+{
+    public function __construct(
+        protected readonly IInteractionProvider $provider
+    ) {}
+
+    public function renderContextMenuOptions(DatabaseObject $object): string
+    {
+        $html = '';
+
+        $interactions = $this->getInteractionsForObject($object);
+
+        foreach ($interactions as $interaction) {
+            if ($interaction instanceof Divider) {
+                $html .= '<li class="dropdownDivider"></li>';
+            } else {
+                $html .= '<li>' . $interaction->render($object) . '</li>';
+            }
+        }
+
+        return $html;
+    }
+
+    public function renderButton(DatabaseObject $object): string
+    {
+        return WCF::getTPL()->fetch(
+            'shared_interactionButton',
+            'wcf',
+            ['contextMenuOptions' => $this->renderContextMenuOptions($object)],
+            true
+        );
+    }
+
+    /**
+     * Renders the initialization code for the interactions.
+     */
+    public function renderInitialization(string $containerId): string
+    {
+        return implode(
+            "\n",
+            \array_map(
+                fn($interaction) => $interaction->renderInitialization($containerId),
+                \array_filter(
+                    $this->getInteractions(),
+                    fn(IInteraction|Divider $interaction) => $interaction instanceof IInteraction
+                )
+            )
+        );
+    }
+
+    public function getInteractionsForObject(DatabaseObject $object): array
+    {
+        $interactions = \array_filter(
+            $this->getInteractions(),
+            fn(IInteraction|Divider $interaction) => $interaction instanceof Divider || $interaction->isAvailable($object)
+        );
+
+        return $this->removeObsoleteDividers($interactions);
+    }
+
+    public function getInteractions(): array
+    {
+        return $this->provider->getInteractions();
+    }
+
+    private function removeObsoleteDividers(array $interactions): array
+    {
+        $previousElementIsDivider = true;
+        $interactions = \array_filter(
+            $interactions,
+            static function (IInteraction|Divider $interaction) use (&$previousElementIsDivider) {
+                if ($interaction instanceof Divider) {
+                    if ($previousElementIsDivider) {
+                        return false;
+                    }
+
+                    $previousElementIsDivider = true;
+                } else {
+                    $previousElementIsDivider = false;
+                }
+
+                return true;
+            }
+        );
+
+        $lastKey = \array_key_last($interactions);
+        if ($lastKey !== null && $interactions[$lastKey] instanceof Divider) {
+            \array_pop($interactions);
+        }
+
+        return $interactions;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/LegacyDboInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/LegacyDboInteraction.class.php
new file mode 100644 (file)
index 0000000..ca956dc
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledObject;
+use wcf\system\interaction\AbstractInteraction;
+use wcf\system\interaction\InteractionConfirmationType;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Represents an interaction that executes a dbo action.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ * @deprecated  6.2 DBO actions are considered outdated and should be migrated to RPC endpoints.
+ */
+class LegacyDboInteraction extends AbstractInteraction
+{
+    public function __construct(
+        string $identifier,
+        protected readonly string $className,
+        protected readonly string $actionName,
+        protected readonly string|\Closure $languageItem,
+        protected readonly InteractionConfirmationType $confirmationType = InteractionConfirmationType::None,
+        protected readonly string|\Closure $confirmationMessage = '',
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct($identifier, $isAvailableCallback);
+    }
+
+    #[\Override]
+    public function render(DatabaseObject $object): string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+
+        if (\is_string($this->languageItem)) {
+            $label = WCF::getLanguage()->get($this->languageItem);
+        } else {
+            $label = ($this->languageItem)($object);
+        }
+
+        if (\is_string($this->confirmationMessage)) {
+            $confirmationMessage = WCF::getLanguage()->get($this->confirmationMessage);
+        } else {
+            $confirmationMessage = ($this->confirmationMessage)($object);
+        }
+
+        if ($object instanceof ITitledObject) {
+            $objectName = StringUtil::encodeHTML($object->getTitle());
+        } else {
+            $objectName = '';
+        }
+
+        $className = StringUtil::encodeHTML($this->className);
+        $actionName = StringUtil::encodeHTML($this->actionName);
+
+        return <<<HTML
+            <button
+                type="button"
+                data-interaction="{$identifier}"
+                data-object-name="{$objectName}"
+                data-class-name="{$className}"
+                data-action-name="{$actionName}"
+                data-confirmation-type="{$this->confirmationType->toString()}"
+                data-confirmation-message="{$confirmationMessage}"
+            >
+                {$label}
+            </button>
+            HTML;
+    }
+
+    #[\Override]
+    public function renderInitialization(string $containerId): ?string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+        $containerId = StringUtil::encodeJS($containerId);
+
+        return <<<HTML
+            <script data-relocate="true">
+                require(['WoltLabSuite/Core/Component/Interaction/LegacyDboAction'], ({ setup }) => {
+                    setup('{$identifier}', document.getElementById('{$containerId}'));
+                });
+            </script>
+            HTML;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/LinkInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/LinkInteraction.class.php
new file mode 100644 (file)
index 0000000..ff32cc6
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+use wcf\system\request\LinkHandler;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Represents an interaction that links to a given controller.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class LinkInteraction extends AbstractInteraction
+{
+    public function __construct(
+        string $identifier,
+        protected readonly string $controllerClass,
+        protected readonly string|\Closure $languageItem,
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct($identifier, $isAvailableCallback);
+    }
+
+    #[\Override]
+    public function render(DatabaseObject $object): string
+    {
+        $href = LinkHandler::getInstance()->getControllerLink(
+            $this->controllerClass,
+            ['object' => $object]
+        );
+
+        if (\is_string($this->languageItem)) {
+            $title = WCF::getLanguage()->get($this->languageItem);
+        } else {
+            $title = ($this->languageItem)($object);
+        }
+
+        return \sprintf('<a href="%s">%s</a>', StringUtil::encodeHTML($href), $title);
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/RpcInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/RpcInteraction.class.php
new file mode 100644 (file)
index 0000000..d9ae46d
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\action\ApiAction;
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledObject;
+use wcf\system\request\LinkHandler;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Represents an interaction that call a rpc endpoint.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class RpcInteraction extends AbstractInteraction
+{
+    public function __construct(
+        string $identifier,
+        protected readonly string $endpoint,
+        protected readonly string|\Closure $languageItem,
+        protected readonly InteractionConfirmationType $confirmationType = InteractionConfirmationType::None,
+        protected readonly string|\Closure $confirmationMessage = '',
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct($identifier, $isAvailableCallback);
+    }
+
+    #[\Override]
+    public function render(DatabaseObject $object): string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+
+        if (\is_string($this->languageItem)) {
+            $label = WCF::getLanguage()->get($this->languageItem);
+        } else {
+            $label = ($this->languageItem)($object);
+        }
+
+        if (\is_string($this->confirmationMessage)) {
+            $confirmationMessage = WCF::getLanguage()->get($this->confirmationMessage);
+        } else {
+            $confirmationMessage = ($this->confirmationMessage)($object);
+        }
+
+        $endpoint = StringUtil::encodeHTML(
+            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
+                \sprintf($this->endpoint, $object->getObjectID())
+        );
+
+        if ($object instanceof ITitledObject) {
+            $objectName = StringUtil::encodeHTML($object->getTitle());
+        } else {
+            $objectName = '';
+        }
+
+        return <<<HTML
+            <button
+                type="button"
+                data-interaction="{$identifier}"
+                data-object-name="{$objectName}"
+                data-endpoint="{$endpoint}"
+                data-confirmation-type="{$this->confirmationType->toString()}"
+                data-confirmation-message="{$confirmationMessage}"
+            >
+                {$label}
+            </button>
+            HTML;
+    }
+
+    #[\Override]
+    public function renderInitialization(string $containerId): ?string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+        $containerId = StringUtil::encodeJS($containerId);
+
+        return <<<HTML
+            <script data-relocate="true">
+                require(['WoltLabSuite/Core/Component/Interaction/Rpc'], ({ setup }) => {
+                    setup('{$identifier}', document.getElementById('{$containerId}'));
+                });
+            </script>
+            HTML;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/StandaloneInteractionContextMenuView.class.php b/wcfsetup/install/files/lib/system/interaction/StandaloneInteractionContextMenuView.class.php
new file mode 100644 (file)
index 0000000..bc729a1
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\data\DatabaseObject;
+use wcf\system\WCF;
+
+class StandaloneInteractionContextMenuView extends InteractionContextMenuView
+{
+    public function __construct(
+        IInteractionProvider $provider,
+        protected readonly DatabaseObject $object,
+        protected readonly string $redirectUrl
+    ) {
+        parent::__construct($provider);
+    }
+
+    public function render(): string
+    {
+        return WCF::getTPL()->fetch(
+            'shared_standaloneInteractionButton',
+            'wcf',
+            [
+                'contextMenuOptions' => $this->renderContextMenuOptions($this->object),
+                'initializationCode' => $this->renderInitialization($this->getContainerID()),
+                'containerID' => $this->getContainerID(),
+                'providerClassName' => \get_class($this->provider),
+                'objectID' => $this->object->getObjectID(),
+                'redirectUrl' => $this->redirectUrl,
+            ],
+            true
+        );
+
+        return '';
+    }
+
+    public function getContainerID(): string
+    {
+        $classNamePieces = \explode('\\', \get_class($this->object));
+
+        return \implode('-', $classNamePieces) . '-' . $this->object->getObjectID();
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/ToggleInteraction.class.php b/wcfsetup/install/files/lib/system/interaction/ToggleInteraction.class.php
new file mode 100644 (file)
index 0000000..c53e141
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace wcf\system\interaction;
+
+use wcf\action\ApiAction;
+use wcf\data\DatabaseObject;
+use wcf\system\request\LinkHandler;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+/**
+ * Represents a toggle interaction.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+class ToggleInteraction extends AbstractInteraction
+{
+    public function __construct(
+        string $identifier,
+        private readonly string $enableEndpoint,
+        private readonly string $disableEndpoint,
+        private readonly string $propertyName = 'isDisabled',
+        private readonly bool $propertyIsDisabledState = true,
+        ?\Closure $isAvailableCallback = null
+    ) {
+        parent::__construct($identifier, $isAvailableCallback);
+    }
+
+    #[\Override]
+    public function render(DatabaseObject $object): string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+
+        $enableEndpoint = StringUtil::encodeHTML(
+            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
+                \sprintf($this->enableEndpoint, $object->getObjectID())
+        );
+        $disableEndpoint = StringUtil::encodeHTML(
+            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
+                \sprintf($this->disableEndpoint, $object->getObjectID())
+        );
+
+        $ariaLabel = WCF::getLanguage()->get('wcf.global.button.enable');
+        $checked = (!$object->{$this->propertyName} && $this->propertyIsDisabledState)
+            || ($object->{$this->propertyName} && !$this->propertyIsDisabledState) ? 'checked' : '';
+
+        return <<<HTML
+            <woltlab-core-toggle-button
+                data-interaction="{$identifier}"
+                aria-label="{$ariaLabel}"
+                data-enable-endpoint="{$enableEndpoint}"
+                data-disable-endpoint="{$disableEndpoint}"
+                {$checked}
+            ></woltlab-core-toggle-button>
+            HTML;
+    }
+
+    #[\Override]
+    public function renderInitialization(string $containerId): ?string
+    {
+        $identifier = StringUtil::encodeJS($this->getIdentifier());
+        $containerId = StringUtil::encodeJS($containerId);
+
+        return <<<HTML
+            <script data-relocate="true">
+                require(['WoltLabSuite/Core/Component/Interaction/Toggle'], ({ setup }) => {
+                    setup('{$identifier}', document.getElementById('{$containerId}'));
+                });
+            </script>
+            HTML;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserOptionInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserOptionInteractions.class.php
new file mode 100644 (file)
index 0000000..3fe595c
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\system\interaction\admin;
+
+use wcf\data\user\option\UserOption;
+use wcf\event\interaction\admin\UserOptionInteractionCollecting;
+use wcf\system\event\EventHandler;
+use wcf\system\interaction\AbstractInteractionProvider;
+use wcf\system\interaction\DeleteInteraction;
+
+/**
+ * Interaction provider for user options.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserOptionInteractions extends AbstractInteractionProvider
+{
+    public function __construct()
+    {
+        $this->addInteractions([
+            new DeleteInteraction('core/users/options/%s', static fn(UserOption $object) => $object->canDelete())
+        ]);
+
+        EventHandler::getInstance()->fire(
+            new UserOptionInteractionCollecting($this)
+        );
+    }
+
+    #[\Override]
+    public function getObjectClassName(): string
+    {
+        return UserOption::class;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/interaction/admin/UserRankInteractions.class.php b/wcfsetup/install/files/lib/system/interaction/admin/UserRankInteractions.class.php
new file mode 100644 (file)
index 0000000..56630d5
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace wcf\system\interaction\admin;
+
+use wcf\data\user\rank\UserRank;
+use wcf\event\interaction\admin\UserRankInteractionCollecting;
+use wcf\system\event\EventHandler;
+use wcf\system\interaction\AbstractInteractionProvider;
+use wcf\system\interaction\DeleteInteraction;
+
+/**
+ * Interaction provider for user ranks.
+ *
+ * @author      Marcel Werk
+ * @copyright   2001-2024 WoltLab GmbH
+ * @license     GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since       6.2
+ */
+final class UserRankInteractions extends AbstractInteractionProvider
+{
+    public function __construct()
+    {
+        $this->addInteractions([
+            new DeleteInteraction('core/users/ranks/%s'),
+        ]);
+
+        EventHandler::getInstance()->fire(
+            new UserRankInteractionCollecting($this)
+        );
+    }
+
+    #[\Override]
+    public function getObjectClassName(): string
+    {
+        return UserRank::class;
+    }
+}