Add context menu / row actions
authorMarcel Werk <burntime@woltlab.com>
Thu, 26 Sep 2024 16:09:38 +0000 (18:09 +0200)
committerMarcel Werk <burntime@woltlab.com>
Tue, 12 Nov 2024 11:51:53 +0000 (12:51 +0100)
14 files changed:
com.woltlab.wcf/templates/shared_gridView.tpl
com.woltlab.wcf/templates/shared_gridViewRows.tpl
ts/WoltLabSuite/Core/Api/DeleteObject.ts [new file with mode: 0644]
ts/WoltLabSuite/Core/Component/GridView.ts
ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView.js
wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js [new file with mode: 0644]
wcfsetup/install/files/lib/bootstrap/com.woltlab.wcf.php
wcfsetup/install/files/lib/system/view/grid/AbstractGridView.class.php
wcfsetup/install/files/lib/system/view/grid/UserRankGridView.class.php
wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/view/grid/action/EditAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php [new file with mode: 0644]

index cd984ed4401a4eeb557383a77d43b7ee761ee020..9ec21fc7550b8957a1377849c7054a322ca7a84d 100644 (file)
@@ -16,6 +16,7 @@
                                                        {unsafe:$column->getLabel()}
                                                </th>
                                        {/foreach}
+                                       <th></th>
                                </td>
                        </thead>
                        <tbody>
@@ -40,6 +41,7 @@
                        );
                });
        </script>
+       {unsafe:$view->renderActionInitialization()}
 {else}
        <woltlab-core-notice type="info">{lang}wcf.global.noItems{/lang}</woltlab-core-notice>
 {/if}
index 13b455f973a43a8eb8ecf959898795a68e891d9b..5b0e3ca037b41da731cfdc1f2fac9f9a70dfd19a 100644 (file)
@@ -6,5 +6,18 @@
                                {unsafe:$view->renderColumn($column, $row)}
                        </td>
                {/foreach}
+               <td>
+                       <div class="dropdown">
+                               <button type="button" class="gridViewActions button small dropdownToggle" aria-label="{lang}wcf.global.button.more{/lang}">{icon name='ellipsis-vertical'}</button>
+
+                               <ul class="dropdownMenu">
+                                       {foreach from=$view->getActions() item='action'}
+                                               <li>
+                                                       {unsafe:$view->renderAction($action, $row)}
+                                               </li>
+                                       {/foreach}
+                               </ul>
+                       </div>
+               </td>
        </tr>
 {/foreach}
diff --git a/ts/WoltLabSuite/Core/Api/DeleteObject.ts b/ts/WoltLabSuite/Core/Api/DeleteObject.ts
new file mode 100644 (file)
index 0000000..1ae28ef
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * Deletes an object.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ * @woltlabExcludeBundle tiny
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "./Result";
+
+export async function deleteObject(endpoint: string): Promise<ApiResult<[]>> {
+  try {
+    await prepareRequest(endpoint).delete().fetchAsJson();
+  } catch (e) {
+    return apiResultFromError(e);
+  }
+
+  return apiResultFromValue([]);
+}
index 658d1138c7df5da7524f77b0145496a179ed2f21..40254fb7ad793a13d5291f298f8da5716ab7aa50 100644 (file)
@@ -1,5 +1,6 @@
 import { getRows } from "../Api/GridViews/GetRows";
 import DomUtil from "../Dom/Util";
+import UiDropdownSimple from "../Ui/Dropdown/Simple";
 
 export class GridView {
   readonly #gridClassName: string;
@@ -30,6 +31,7 @@ export class GridView {
 
     this.#initPagination();
     this.#initSorting();
+    this.#initActions();
   }
 
   #initPagination(): void {
@@ -91,6 +93,7 @@ export class GridView {
     const response = await getRows(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder);
     DomUtil.setInnerHtml(this.#table.querySelector("tbody")!, response.unwrap().template);
     this.#updateQueryString();
+    this.#initActions();
   }
 
   #updateQueryString(): void {
@@ -116,4 +119,22 @@ 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) => {
+        const dropdown = UiDropdownSimple.getDropdownMenu(element.dataset.target!)!;
+        dropdown?.querySelectorAll<HTMLButtonElement>("[data-action]").forEach((element) => {
+          element.addEventListener("click", () => {
+            row.dispatchEvent(
+              new CustomEvent("action", {
+                detail: element.dataset,
+                bubbles: true,
+              }),
+            );
+          });
+        });
+      });
+    });
+  }
 }
diff --git a/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts b/ts/WoltLabSuite/Core/Component/GridView/Action/Delete.ts
new file mode 100644 (file)
index 0000000..48275d4
--- /dev/null
@@ -0,0 +1,28 @@
+import { deleteObject } from "WoltLabSuite/Core/Api/DeleteObject";
+import { confirmationFactory } from "../../Confirmation";
+import * as UiNotification from "WoltLabSuite/Core/Ui/Notification";
+
+async function handleDelete(row: HTMLTableRowElement, objectName: string, endpoint: string): Promise<void> {
+  const confirmationResult = await confirmationFactory().delete(objectName);
+  if (!confirmationResult) {
+    return;
+  }
+
+  const result = await deleteObject(endpoint);
+  if (!result.ok) {
+    return;
+  }
+
+  row.remove();
+
+  // TODO: This shows a generic success message and should be replaced with a more specific message.
+  UiNotification.show();
+}
+
+export function setup(table: HTMLTableElement): void {
+  table.addEventListener("action", (event: CustomEvent) => {
+    if (event.detail.action === "delete") {
+      void handleDelete(event.target as HTMLTableRowElement, event.detail.objectName!, event.detail.endpoint!);
+    }
+  });
+}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Api/DeleteObject.js
new file mode 100644 (file)
index 0000000..8c35a2b
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * Deletes an object.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2024 WoltLab GmbH
+ * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @since 6.1
+ * @woltlabExcludeBundle tiny
+ */
+define(["require", "exports", "WoltLabSuite/Core/Ajax/Backend", "./Result"], function (require, exports, Backend_1, Result_1) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.deleteObject = void 0;
+    async function deleteObject(endpoint) {
+        try {
+            await (0, Backend_1.prepareRequest)(endpoint).delete().fetchAsJson();
+        }
+        catch (e) {
+            return (0, Result_1.apiResultFromError)(e);
+        }
+        return (0, Result_1.apiResultFromValue)([]);
+    }
+    exports.deleteObject = deleteObject;
+});
index 8e7876ed2908a021931ebe4aaab701e26344d30e..e65b94b4b0128dced5018a0448f2b44c536a5cef 100644 (file)
@@ -1,8 +1,9 @@
-define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"], function (require, exports, tslib_1, GetRows_1, Util_1) {
+define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util", "../Ui/Dropdown/Simple"], function (require, exports, tslib_1, GetRows_1, Util_1, Simple_1) {
     "use strict";
     Object.defineProperty(exports, "__esModule", { value: true });
     exports.GridView = void 0;
     Util_1 = tslib_1.__importDefault(Util_1);
+    Simple_1 = tslib_1.__importDefault(Simple_1);
     class GridView {
         #gridClassName;
         #table;
@@ -23,6 +24,7 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
             this.#sortOrder = sortOrder;
             this.#initPagination();
             this.#initSorting();
+            this.#initActions();
         }
         #initPagination() {
             this.#topPagination.addEventListener("switchPage", (event) => {
@@ -68,12 +70,13 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
             this.#topPagination.page = pageNo;
             this.#bottomPagination.page = pageNo;
             this.#pageNo = pageNo;
-            this.#loadRows();
+            void this.#loadRows();
         }
         async #loadRows() {
             const response = await (0, GetRows_1.getRows)(this.#gridClassName, this.#pageNo, this.#sortField, this.#sortOrder);
             Util_1.default.setInnerHtml(this.#table.querySelector("tbody"), response.unwrap().template);
             this.#updateQueryString();
+            this.#initActions();
         }
         #updateQueryString() {
             if (!this.#baseUrl) {
@@ -94,6 +97,21 @@ define(["require", "exports", "tslib", "../Api/GridViews/GetRows", "../Dom/Util"
             }
             window.history.pushState({}, document.title, url.toString());
         }
+        #initActions() {
+            this.#table.querySelectorAll("tbody tr").forEach((row) => {
+                row.querySelectorAll(".gridViewActions").forEach((element) => {
+                    const dropdown = Simple_1.default.getDropdownMenu(element.dataset.target);
+                    dropdown?.querySelectorAll("[data-action]").forEach((element) => {
+                        element.addEventListener("click", () => {
+                            row.dispatchEvent(new CustomEvent("action", {
+                                detail: element.dataset,
+                                bubbles: true,
+                            }));
+                        });
+                    });
+                });
+            });
+        }
     }
     exports.GridView = GridView;
 });
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/GridView/Action/Delete.js
new file mode 100644 (file)
index 0000000..a66edf2
--- /dev/null
@@ -0,0 +1,27 @@
+define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/DeleteObject", "../../Confirmation", "WoltLabSuite/Core/Ui/Notification"], function (require, exports, tslib_1, DeleteObject_1, Confirmation_1, UiNotification) {
+    "use strict";
+    Object.defineProperty(exports, "__esModule", { value: true });
+    exports.setup = void 0;
+    UiNotification = tslib_1.__importStar(UiNotification);
+    async function handleDelete(row, objectName, endpoint) {
+        const confirmationResult = await (0, Confirmation_1.confirmationFactory)().delete(objectName);
+        if (!confirmationResult) {
+            return;
+        }
+        const result = await (0, DeleteObject_1.deleteObject)(endpoint);
+        if (!result.ok) {
+            return;
+        }
+        row.remove();
+        // TODO: This shows a generic success message and should be replaced with a more specific message.
+        UiNotification.show();
+    }
+    function setup(table) {
+        table.addEventListener("action", (event) => {
+            if (event.detail.action === "delete") {
+                void handleDelete(event.target, event.detail.objectName, event.detail.endpoint);
+            }
+        });
+    }
+    exports.setup = setup;
+});
index 57d5782e4cf289b132014fc07b3909ef7a2ec722..98da328d9633fbe68cba337986a29777c7fcf2e3 100644 (file)
@@ -137,6 +137,7 @@ return static function (): void {
             $event->register(new \wcf\system\endpoint\controller\core\gridViews\GetRows);
             $event->register(new \wcf\system\endpoint\controller\core\messages\GetMentionSuggestions);
             $event->register(new \wcf\system\endpoint\controller\core\sessions\DeleteSession);
+            $event->register(new \wcf\system\endpoint\controller\core\users\ranks\DeleteUserRank);
         }
     );
 
index b3d0ea8c75513549b461b7e3e35501ca0d41d8b6..903f8ff7d196a1e7ee1ee63990334932073b4edc 100644 (file)
@@ -2,11 +2,21 @@
 
 namespace wcf\system\view\grid;
 
+use wcf\system\view\grid\action\IGridViewAction;
 use wcf\system\WCF;
 
 abstract class AbstractGridView
 {
+    /**
+     * @var GridViewColumn[]
+     */
     private array $columns = [];
+
+    /**
+     * @var IGridViewAction[]
+     */
+    private array $actions = [];
+
     private int $rowsPerPage = 20;
     private string $baseUrl = '';
     private string $sortField = '';
@@ -54,6 +64,29 @@ abstract class AbstractGridView
         return null;
     }
 
+    /**
+     * @param IGridViewAction[] $columns
+     */
+    public function addActions(array $actions): void
+    {
+        foreach ($actions as $action) {
+            $this->addAction($action);
+        }
+    }
+
+    public function addAction(IGridViewAction $action): void
+    {
+        $this->actions[] = $action;
+    }
+
+    /**
+     * @return IGridViewAction[]
+     */
+    public function getActions(): array
+    {
+        return $this->actions;
+    }
+
     public function render(): string
     {
         return WCF::getTPL()->fetch('shared_gridView', 'wcf', ['view' => $this], true);
@@ -69,6 +102,22 @@ abstract class AbstractGridView
         return $column->render($this->getData($row, $column->getID()), $row);
     }
 
+    public function renderAction(IGridViewAction $action, mixed $row): string
+    {
+        return $action->render($row);
+    }
+
+    public function renderActionInitialization(): string
+    {
+        return implode(
+            "\n",
+            \array_map(
+                fn($action) => $action->renderInitialization($this),
+                $this->getActions()
+            )
+        );
+    }
+
     protected function getData(mixed $row, string $identifer): mixed
     {
         return $row[$identifer] ?? '';
index 3b90e362a4ed619bbf5890eed8aa356dffbbfec0..a7a091a31a585981d74923eeec126081515912f6 100644 (file)
@@ -7,6 +7,8 @@ use wcf\data\DatabaseObjectList;
 use wcf\data\user\group\UserGroup;
 use wcf\data\user\rank\I18nUserRankList;
 use wcf\data\user\rank\UserRank;
+use wcf\system\view\grid\action\DeleteAction;
+use wcf\system\view\grid\action\EditAction;
 use wcf\system\view\grid\renderer\DefaultColumnRenderer;
 use wcf\system\view\grid\renderer\LinkColumnRenderer;
 use wcf\system\view\grid\renderer\NumberColumnRenderer;
@@ -90,6 +92,11 @@ final class UserRankGridView extends DatabaseObjectListGridView
                 ->renderer(new NumberColumnRenderer()),
         ]);
 
+        $this->addActions([
+            new EditAction(UserRankEditForm::class),
+            new DeleteAction('core/users/ranks/%s'),
+        ]);
+
         $this->setSortField('rankTitle');
     }
 
diff --git a/wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/DeleteAction.class.php
new file mode 100644 (file)
index 0000000..dd1c35d
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+namespace wcf\system\view\grid\action;
+
+use wcf\action\ApiAction;
+use wcf\data\DatabaseObject;
+use wcf\data\ITitledObject;
+use wcf\system\request\LinkHandler;
+use wcf\system\view\grid\AbstractGridView;
+use wcf\system\WCF;
+use wcf\util\StringUtil;
+
+class DeleteAction implements IGridViewAction
+{
+    public function __construct(
+        private readonly string $endpoint,
+    ) {}
+
+    #[\Override]
+    public function render(mixed $row): string
+    {
+        \assert($row instanceof DatabaseObject);
+
+        $endpoint = StringUtil::encodeHTML(
+            LinkHandler::getInstance()->getControllerLink(ApiAction::class, ['id' => 'rpc']) .
+                \sprintf($this->endpoint, $row->getObjectID())
+        );
+        $label = WCF::getLanguage()->get('wcf.global.button.delete');
+        if ($row instanceof ITitledObject) {
+            $objectName = StringUtil::encodeHTML($row->getTitle());
+        } else {
+            $objectName = '';
+        }
+
+        return <<<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/view/grid/action/EditAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/EditAction.class.php
new file mode 100644 (file)
index 0000000..42761af
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace wcf\system\view\grid\action;
+
+use wcf\data\DatabaseObject;
+use wcf\system\request\LinkHandler;
+use wcf\system\view\grid\AbstractGridView;
+use wcf\system\WCF;
+
+class EditAction implements IGridViewAction
+{
+    public function __construct(
+        private readonly string $controllerClass,
+    ) {}
+
+    #[\Override]
+    public function render(mixed $row): string
+    {
+        \assert($row instanceof DatabaseObject);
+        $href = LinkHandler::getInstance()->getControllerLink(
+            $this->controllerClass,
+            ['object' => $row]
+        );
+
+        return '<a href="' . $href . '">' . WCF::getLanguage()->get('wcf.global.button.edit') . '</a>';
+    }
+
+    #[\Override]
+    public function renderInitialization(AbstractGridView $gridView): ?string
+    {
+        return null;
+    }
+}
diff --git a/wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php b/wcfsetup/install/files/lib/system/view/grid/action/IGridViewAction.class.php
new file mode 100644 (file)
index 0000000..e37960a
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+namespace wcf\system\view\grid\action;
+
+use wcf\system\view\grid\AbstractGridView;
+
+interface IGridViewAction
+{
+    public function render(mixed $row): string;
+
+    public function renderInitialization(AbstractGridView $gridView): ?string;
+}