{"id":3484,"date":"2026-09-05T11:42:34","date_gmt":"2026-09-05T16:42:34","guid":{"rendered":"https:\/\/xlinesoft.com\/blog\/?p=3484"},"modified":"2026-09-05T11:42:34","modified_gmt":"2026-09-05T16:42:34","slug":"add-a-custom-row-actions-menu-to-a-data-grid","status":"publish","type":"post","link":"https:\/\/xlinesoft.com\/blog\/2026\/09\/05\/add-a-custom-row-actions-menu-to-a-data-grid\/","title":{"rendered":"Add a Custom Row Actions Menu to a Data Grid"},"content":{"rendered":"<p>List pages often contain several actions for every record. Displaying each action as a separate button can make the grid unnecessarily wide, especially on smaller screens.<\/p>\n<p>In this tutorial, we will add one menu button to each grid row. The menu will open View, Edit, Copy, and related-record pages, process Delete with confirmation, and include an Archive placeholder for project-specific code. The same approach works in PHPRunner and ASPRunner.NET.<\/p>\n<p><img loading=\"lazy\" decoding=\"async\" class=\"alignnone size-full wp-image-3485\" src=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/09\/row-actions-menu-data-grid.png\" alt=\"Custom row actions menu on a data grid\" width=\"1160\" height=\"625\" srcset=\"https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/09\/row-actions-menu-data-grid.png 1160w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/09\/row-actions-menu-data-grid-600x323.png 600w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/09\/row-actions-menu-data-grid-1024x552.png 1024w, https:\/\/xlinesoft.com\/blog\/wp-content\/uploads\/2026\/09\/row-actions-menu-data-grid-768x414.png 768w\" sizes=\"auto, (max-width: 1160px) 100vw, 1160px\" \/><\/p>\n<p><!--more--><\/p>\n<h3>1. Adding the row actions button<\/h3>\n<p>Open the required List page in <strong>Page Designer<\/strong> and switch the grid to Advanced Grid mode. Select <strong>Insert &#8211; Custom button &#8211; New button<\/strong>, then place the button inside the grid so every record receives its own copy.<\/p>\n<p>Clear the button label, select the bars icon, and set its Item ID to <strong>row_actions<\/strong>. The Item ID is important because the JavaScript uses it to find the button belonging to the current record.<\/p>\n<p>This example uses the <strong>customers<\/strong> table, <strong>id<\/strong> as its key field, and <strong>CustomerID<\/strong> to open related orders. Replace those names where necessary. The table must have a key field selected on the Choose pages screen.<\/p>\n<p>Keep the standard Edit button in the grid with the Item ID <strong>grid_edit<\/strong>. The menu will trigger that button, preserving the Edit behavior configured in Page Designer, including popup mode.<\/p>\n<h3>2. Building the menu in Client Before<\/h3>\n<p>Open the custom button code and add the following JavaScript to <strong>Client Before<\/strong>:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"javascript\" name=\"mshighlighter\" >\n$(\".phprunner-row-actions-menu\").remove();\n\nvar btn = pageObj.getItemButton(\"row_actions\", rowData.id);\n\nvar menu = $(\n    '<div class=\"phprunner-row-actions-menu\">' +\n        '<div class=\"row-action\" data-action=\"view\">View<\/div>' +\n        '<div class=\"row-action\" data-action=\"edit\">Edit<\/div>' +\n        '<div class=\"row-action\" data-action=\"orders\">View orders<\/div>' +\n        '<div class=\"row-action\" data-action=\"delete\">Delete<\/div>' +\n        '<div class=\"row-action\" data-action=\"copy\">Create a copy<\/div>' +\n        '<div class=\"row-action\" data-action=\"archive\">Archive<\/div>' +\n    '<\/div>'\n);\n\n$(\"body\").append(menu);\n\nvar pos = btn.offset();\nvar top = pos.top;\nvar left = pos.left + btn.outerWidth() + 4;\n\nif (\n    pos.top - $(window).scrollTop() + menu.outerHeight()\n        > $(window).height()\n) {\n    top = pos.top - menu.outerHeight() + btn.outerHeight();\n}\n\nif (\n    left + menu.outerWidth()\n        > $(window).scrollLeft() + $(window).width()\n) {\n    left = pos.left - menu.outerWidth() - 4;\n}\n\nmenu.css({\n    top: top,\n    left: left\n});\n\nmenu.on(\"click\", \".row-action\", function(event) {\n    event.stopPropagation();\n\n    var action = $(this).data(\"action\");\n    menu.remove();\n\n    switch (action) {\n        case \"view\":\n            Runner.displayPopup({\n                url: Runner.pages.getUrl(\"customers\", \"view\")\n                    + \"?editid1=\"\n                    + encodeURIComponent(rowData.keys[0]),\n                width: 800,\n                height: 600,\n                header: \"View customer\"\n            });\n            break;\n\n        case \"edit\":\n            pageObj.getItemButton(\n                \"grid_edit\",\n                rowData.id\n            ).click();\n            break;\n\n        case \"orders\":\n            var url = Runner.pages.getUrl(\"orders\", \"list\");\n            url += \"?masterkey1=\"\n                + encodeURIComponent(\n                    row.getFieldValue(\"CustomerID\")\n                )\n                + \"&mastertable=customers\";\n            location.href = url;\n            break;\n\n        case \"delete\":\n            swal(\"Would you like to delete this record?\", {\n                buttons: {\n                    cancel: \"No\",\n                    proceed: {\n                        text: \"Yes, delete it\",\n                        value: \"proceed\"\n                    }\n                }\n            }).then(function(value) {\n                if (value === \"proceed\") {\n                    params[\"action\"] = \"delete\";\n                    submit();\n                }\n            });\n            break;\n\n        case \"copy\":\n            location.href = Runner.pages.getUrl(\n                \"customers\",\n                \"add\"\n            ) + \"?copyid1=\"\n                + encodeURIComponent(rowData.keys[0]);\n            break;\n\n        case \"archive\":\n            \/\/ Add the code that archives the current record here.\n            break;\n    }\n});\n\nvar closeTimer;\n\nmenu.on(\"mouseleave\", function() {\n    closeTimer = setTimeout(function() {\n        menu.remove();\n    }, 300);\n});\n\nmenu.on(\"mouseenter\", function() {\n    clearTimeout(closeTimer);\n});\n\nreturn false;<\/textarea><\/pre>\n<\/div>\n<p>The button and the current record are identified through <strong>rowData.id<\/strong> and <strong>rowData.keys<\/strong>. If the menu would extend beyond the bottom or right side of the browser window, the positioning code moves it to the opposite side of the button.<\/p>\n<p>The Copy action opens the Add page in Copy mode. This example has one key field, so it uses <strong>copyid1<\/strong>. Tables with composite keys also require <strong>copyid2<\/strong>, <strong>copyid3<\/strong>, and so on.<\/p>\n<h3>3. Processing Delete on the server<\/h3>\n<p>The Delete action is the only menu command that changes the database. Add the appropriate code to the custom button&#8217;s <strong>Server<\/strong> section.<\/p>\n<p>The code verifies the current user&#8217;s Delete permission and obtains the record from the server-side button context. It does not trust a record ID supplied by the browser.<\/p>\n<p><strong>PHP<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"php\" name=\"mshighlighter\" >\n$result[\"action\"] = \"\";\n\nif ($params[\"action\"] === \"delete\") {\n    $rights = Security::getPermissions(\"customers\");\n\n    if (empty($rights[\"D\"])) {\n        $result[\"message\"] =\n            \"You do not have permission to delete this record.\";\n    } else {\n        $record = $button->getCurrentRecord();\n\n        if (!$record) {\n            $result[\"message\"] = \"The record could not be found.\";\n        } else {\n            $sql = DB::PrepareSQL(\n                \"DELETE FROM customers WHERE id=:1\",\n                $record[\"id\"]\n            );\n\n            if (DB::Exec($sql)) {\n                $result[\"action\"] = \"delete\";\n            } else {\n                $result[\"message\"] =\n                    \"The record could not be deleted.\";\n            }\n        }\n    }\n}<\/textarea><\/pre>\n<\/div>\n<p><strong>C#<\/strong><\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"\" name=\"mshighlighter\" >\nresult[\"action\"] = \"\";\n\nif (Convert.ToString(parameters[\"action\"]) == \"delete\")\n{\n    dynamic rights = Security.getPermissions(\"customers\");\n\n    if (!XVar.Pack(rights[\"D\"]))\n    {\n        result[\"message\"] =\n            \"You do not have permission to delete this record.\";\n    }\n    else\n    {\n        dynamic record = button.getCurrentRecord();\n\n        if (!XVar.Pack(record))\n        {\n            result[\"message\"] = \"The record could not be found.\";\n        }\n        else\n        {\n            dynamic sql = DB.PrepareSQL(\n                \"DELETE FROM customers WHERE id=:1\",\n                record[\"id\"]\n            );\n\n            if (XVar.Pack(DB.Exec(sql)))\n            {\n                result[\"action\"] = \"delete\";\n            }\n            else\n            {\n                result[\"message\"] =\n                    \"The record could not be deleted.\";\n            }\n        }\n    }\n}<\/textarea><\/pre>\n<\/div>\n<p>Replace <strong>customers<\/strong> and <strong>id<\/strong> with the actual table and key field. If the database prevents deleting records that have related rows, either configure the required cascade behavior or replace Delete with the business rule appropriate for your application.<\/p>\n<h3>4. Refreshing the List page in Client After<\/h3>\n<p>Add the following JavaScript to <strong>Client After<\/strong>:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"javascript\" name=\"mshighlighter\" >\nif (result[\"message\"]) {\n    swal(result[\"message\"]);\n}\n\nif (result[\"action\"] === \"delete\") {\n    pageObj.reload({\n        a: \"reload\"\n    });\n}<\/textarea><\/pre>\n<\/div>\n<p>The grid reloads only after the server confirms that the record was deleted.<\/p>\n<h3>5. Styling the actions menu<\/h3>\n<p>Open <strong>Style Editor<\/strong>, click &#8216;Modify CSS&#8217;, and add the following code:<\/p>\n<div class=\"my-syntax-highlighter\">\n<pre><textarea id=\"mshighlighter\" class=\"mshighlighter\" language=\"\" name=\"mshighlighter\" >\n.phprunner-row-actions-menu {\n    position: absolute;\n    z-index: 10000;\n    min-width: 180px;\n    padding: 6px 0;\n    background: #fff;\n    border: 1px solid #ddd;\n    border-radius: 6px;\n    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);\n}\n\n.phprunner-row-actions-menu .row-action {\n    padding: 8px 14px;\n    cursor: pointer;\n    white-space: nowrap;\n}\n\n.phprunner-row-actions-menu .row-action:hover {\n    background: #f5f5f5;\n}<\/textarea><\/pre>\n<\/div>\n<p>After rebuilding the project, open the Customers List page and test the menu on several rows. Confirm that View opens in a popup, Edit follows the configured Edit-button behavior, Copy opens a prefilled Add page, related orders open for the correct customer, and Delete refreshes the grid only after confirmation.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Create a compact per-record actions menu in PHPRunner and ASPRunner.NET with View, Edit, Copy, related-record navigation, permission-checked Delete, and room for custom actions.<\/p>\n","protected":false},"author":3,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[16,1,8],"tags":[],"class_list":["post-3484","post","type-post","status-publish","format-standard","hentry","category-asp-net","category-php-category","category-tutorials"],"_links":{"self":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3484","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/comments?post=3484"}],"version-history":[{"count":1,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3484\/revisions"}],"predecessor-version":[{"id":3486,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/posts\/3484\/revisions\/3486"}],"wp:attachment":[{"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/media?parent=3484"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/categories?post=3484"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/xlinesoft.com\/blog\/wp-json\/wp\/v2\/tags?post=3484"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}