You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/09/03 21:15:01 UTC

(juneau) branch master updated: WORK-J0509 + WORK-J0511: RowAction.endpoint {property} substitution and five row-action icon sprite glyphs.

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 1bd786689c WORK-J0509 + WORK-J0511: RowAction.endpoint {property} substitution and five row-action icon sprite glyphs.
1bd786689c is described below

commit 1bd786689c9eab15feb82b5038523e837c5f7f0e
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Sep 3 17:14:54 2026 -0400

    WORK-J0509 + WORK-J0511: RowAction.endpoint {property} substitution and five row-action icon sprite glyphs.
    
    J0509 gives RowAction.endpoint the same generic {property} token substitution Column.href already has (client-side interpolateHref in juneau-views.js; values encodeURIComponent-escaped; null/missing -> empty string). Literal endpoints round-trip unchanged and pre-existing calls stay backward-compatible; endpoint result is a fetch URL so it intentionally does not get Column.href's HTML-attribute escaping. Adds Node VM harness row-action-endpoint.cjs plus source/behavioral tests.
    
    J0511 adds five hand-authored, self-licensed (juneau-original) icon glyphs -- pause, stop, forceStop, push, openPr -- to juneau-symbols.svg with registerIcon wiring in juneau-icons.js (JS-only; no Java icon allowlist), regenerated symbols key, and provenance rows. Surfaced by Foundry WORK-P0072/WORK-P0070.
---
 .../apache/juneau/rest/server/views/RowAction.java |  31 ++-
 .../org/apache/juneau/views/juneau-icons.js        |   8 +
 .../org/apache/juneau/views/juneau-symbols-key.svg |  17 +-
 .../juneau/views/juneau-symbols-provenance.md      |  20 +-
 .../org/apache/juneau/views/juneau-symbols.svg     |  15 ++
 .../org/apache/juneau/views/juneau-views.js        |  51 +++--
 .../server/views/SymbolSprite_StemIds_Test.java    |   7 +-
 .../rest/server/views/ViewsJs_RowActions_Test.java | 219 ++++++++++++++++++++-
 .../src/test/js/row-action-endpoint.cjs            | 116 +++++++++++
 9 files changed, 458 insertions(+), 26 deletions(-)

diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
index 8546c9ebd3..0b25a196d2 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowAction.java
@@ -43,7 +43,8 @@ import org.apache.juneau.rest.server.widgets.Op;
  * 	<tr><td>{@code id}</td><td>string</td><td>Stable action id (menu-item key; also the submit's logical name).</td></tr>
  * 	<tr><td>{@code label}</td><td>string</td><td>The menu-item text.</td></tr>
  * 	<tr><td>{@code icon}</td><td>string</td><td>Optional glyph name (resolved by the shared icon registry).</td></tr>
- * 	<tr><td>{@code endpoint}</td><td>string</td><td>The URL the action submits to.</td></tr>
+ * 	<tr><td>{@code endpoint}</td><td>string</td><td>The URL the action submits to; may carry a {@code {property}}
+ * 		token substituted from the current row (see {@link #endpoint(String) endpoint}).</td></tr>
  * 	<tr><td>{@code method}</td><td>{@code POST}|{@code PUT}|{@code PATCH}|{@code DELETE}</td>
  * 		<td>The non-safe HTTP method (see {@link Method}).</td></tr>
  * 	<tr><td>{@code confirm}</td><td>string</td><td>Optional confirmation prompt shown before the submit.</td></tr>
@@ -72,6 +73,14 @@ import org.apache.juneau.rest.server.widgets.Op;
  * only to non-safe methods.  A mutating action bound to {@code GET} would be a CSRF-able write; the type system
  * forbids expressing one.
  *
+ * <h5 class='section'>Per-row {@code {property}} substitution in {@link #endpoint}</h5>
+ * <p>
+ * {@link #endpoint(String) endpoint} may carry a {@code {property}} token (e.g. {@code {id}}, as in the example
+ * below) that the client-side runtime substitutes from the row before submitting &mdash; the SAME mechanism, same
+ * escaping, and same no-value behavior as {@link Column#href(String) Column.href}'s {@code linked} renderer
+ * (WORK-J0509); see {@link #endpoint(String) endpoint}'s javadoc for the full contract.  An endpoint with no token
+ * is unaffected and submits exactly as it always has.
+ *
  * <h5 class='section'>Example:</h5>
  * <p class='bjava'>
  * 	RowAction <jv>ack</jv> = RowAction.<jsm>create</jsm>(<js>"ack"</js>)
@@ -204,7 +213,10 @@ public class RowAction {
 	/** Optional glyph/icon name (resolved by the shared icon registry). */
 	public String icon;
 
-	/** The URL this action submits to. */
+	/**
+	 * The URL this action submits to (see {@link #endpoint(String) endpoint} for the {@code {property}}
+	 * substitution grammar).
+	 */
 	public String endpoint;
 
 	/** The non-safe HTTP method wire token (see {@link Method#wire()}). */
@@ -273,6 +285,21 @@ public class RowAction {
 	/**
 	 * Sets the URL this action submits to.
 	 *
+	 * <p>
+	 * May contain one or more {@code {property}} tokens (e.g. {@code "servlet:/incidents/{id}/ack"}), substituted
+	 * from the CURRENT ROW's own already-fetched data at submit time &mdash; the client-side ({@code
+	 * juneau-views.js}) runtime, not this Java layer, performs the substitution, by delegating to the identical
+	 * {@code interpolateHref} helper {@link Column#href(String) Column.href}'s {@code linked} renderer uses
+	 * (juneau-renders.js): the SAME token grammar (any {@code {property}}, not a hardcoded {@code {id}}), the SAME
+	 * per-value {@code encodeURIComponent} escaping, and the SAME "row lacks the field, or its value is
+	 * <jk>null</jk>" &rarr; substitutes to an empty string behavior &mdash; never a request-time exception, and
+	 * never a literal {@code {property}} left in the issued URL.
+	 *
+	 * <p>
+	 * <b>Backward compatible by construction:</b> an {@code endpoint} with no {@code {...}} token is issued
+	 * byte-identical to how it always was &mdash; substitution is a no-op replace over a template with nothing to
+	 * match.
+	 *
 	 * @param value The new value.
 	 * @return This object.
 	 */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
index 3171d4d11a..c95695bc47 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-icons.js
@@ -148,6 +148,14 @@
 	registerIcon("new", host("new"));
 	registerIcon("toggle-deleted", host("toggle-deleted"));
 
+	// Row-action icons (WORK-J0511): referenced by row-action buttons (e.g. Foundry) but not previously
+	// registered, so those actions rendered a blank glyph before these five were added to the sprite.
+	registerIcon("pause", host("pause"));
+	registerIcon("stop", host("stop"));
+	registerIcon("forceStop", host("forceStop"));
+	registerIcon("push", host("push"));
+	registerIcon("openPr", host("openPr"));
+
 	if (typeof document !== "undefined") {
 		if (document.readyState === "loading")
 			document.addEventListener("DOMContentLoaded", loadSymbolSprite);
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-key.svg b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-key.svg
index f443b4135c..348c077350 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-key.svg
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-key.svg
@@ -58,10 +58,19 @@
 <symbol id="juneau-sym-filter" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M3.75 5.25H18.75L12.75 12.75V18.75L9.75 17.25V12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-forceStop" viewBox="0 0 24 24" fill="none">
+	<path fill="currentColor" d="M9 4.5H15L19.5 9V15L15 19.5H9L4.5 15V9Z"/>
+</symbol>
 <symbol id="juneau-sym-new" viewBox="0 0 24 24" fill="none">
 	<circle cx="11.25" cy="11.25" r="7.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M11.25 6.75V15.75M6.75 11.25H15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-openPr" viewBox="0 0 24 24" fill="none">
+	<path fill="none" d="M9.75 8.25H5.25V18.75H15.75V12.75M14.25 3.75H20.25V9.75M9.75 14.25L20.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
+</symbol>
+<symbol id="juneau-sym-pause" viewBox="0 0 24 24" fill="none">
+	<path fill="currentColor" d="M7.5 4.5H10.5V19.5H7.5ZM13.5 4.5H16.5V19.5H13.5Z"/>
+</symbol>
 <symbol id="juneau-sym-pdf" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M5.25 3.75H12.75L17.25 8.25V18.75H5.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="currentColor" d="M7.5 9H15V10.5H7.5ZM7.5 12H15V13.5H7.5ZM7.5 15H12V16.5H7.5Z"/>
@@ -71,6 +80,9 @@
 	<rect x="3.75" y="9.75" width="16.5" height="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M8.25 17.25V20.25H15.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-push" viewBox="0 0 24 24" fill="none">
+	<path fill="none" d="M11.25 12.75V3.75M8.25 6.75L11.25 3.75L14.25 6.75M5.25 15.75V18.75H17.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
+</symbol>
 <symbol id="juneau-sym-refresh" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M11.25 3.75A7.5 7.5 0 0 1 15 17.75M11.25 18.75A7.5 7.5 0 0 1 7.5 4.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="currentColor" d="M13.05 18.87L14.8 14.4L17.8 19.59ZM9.45 3.63L7.7 8.1L4.7 2.91Z"/>
@@ -86,6 +98,9 @@
 	<path fill="none" d="M5.25 3.75H12.75L17.25 8.25V18.75H5.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="none" d="M8.25 9.75H14.25V15.75H8.25ZM11.25 9.75V15.75M8.25 12.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-stop" viewBox="0 0 24 24" fill="none">
+	<rect x="6" y="6" width="12" height="12" fill="currentColor"/>
+</symbol>
 <symbol id="juneau-sym-toggle-deleted" viewBox="0 0 24 24" fill="none">
 	<circle cx="11.25" cy="11.25" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M6.75 6.75L15.75 15.75M15.75 6.75L6.75 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
@@ -100,5 +115,5 @@
   .cell use { color: #16325c; }
 </style>
 <text x="516" y="28" font-size="16" font-weight="600">Juneau symbols</text>
-<g class="cell"><rect x="16" y="52" width="100" height="72" fill="none" stroke="#d8dde6" stroke-width="0.4"/><svg x="52.0" y="60" width="28" height="28" viewBox="0 0 24 24"><use href="#juneau-sym-cancel"/></svg><text x="66" y="112" font-size="10">cancel</text></g><g class="cell"><rect x="116" y="52" width="100" height="72" fill="none" stroke="#d8dde6" stroke-width="0.4"/><svg x="152.0" y="60" width="28" height="28" viewBox="0 0 24 24"><use href="#juneau-sym-check"/></svg><text x="166" y= [...]
+<g class="cell"><rect x="16" y="52" width="100" height="72" fill="none" stroke="#d8dde6" stroke-width="0.4"/><svg x="52.0" y="60" width="28" height="28" viewBox="0 0 24 24"><use href="#juneau-sym-cancel"/></svg><text x="66" y="112" font-size="10">cancel</text></g><g class="cell"><rect x="116" y="52" width="100" height="72" fill="none" stroke="#d8dde6" stroke-width="0.4"/><svg x="152.0" y="60" width="28" height="28" viewBox="0 0 24 24"><use href="#juneau-sym-check"/></svg><text x="166" y= [...]
 </svg>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-provenance.md b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-provenance.md
index 467e85699e..73f95eccc0 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-provenance.md
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols-provenance.md
@@ -2,7 +2,7 @@
 
 This file pins the **approved artwork** of every glyph in `juneau-symbols.svg`.
 
-Its job is not attribution. All twenty-one glyphs are Juneau-original, so no third-party attribution is incurred and
+Its job is not attribution. All twenty-six glyphs are Juneau-original, so no third-party attribution is incurred and
 neither `LICENSE` nor `NOTICE` names anything on account of this sprite. Its job is to be a **guard**: to make a
 future paste of foreign path data over one of these glyphs fail the build until someone deliberately edits this
 file. `SymbolSprite_Provenance_Test` reads it and asserts it against the sprite on every default-profile run.
@@ -84,9 +84,11 @@ crisp pixels at 16px:
 
 ## Glyphs
 
-`origin` is `juneau-original` for all twenty-one: eighteen redrawn from scratch under `READY-J0451`, one
-(`print`) drawn from scratch under `WORK-J0507` (Foundry `WORK-P0063` toolbar print/collapse-all follow-up), and
-`check` and `collapse_all`, which were already Juneau-original and are **byte-unchanged** by that work.
+`origin` is `juneau-original` for all twenty-six: eighteen redrawn from scratch under `READY-J0451`, one
+(`print`) drawn from scratch under `WORK-J0507` (Foundry `WORK-P0063` toolbar print/collapse-all follow-up), five
+(`pause`, `stop`, `forceStop`, `push`, `openPr`) drawn from scratch under `WORK-J0511` (Foundry
+`WORK-P0072`/`WORK-P0070` row-action icon follow-up), and `check` and `collapse_all`, which were already
+Juneau-original and are **byte-unchanged** by that work.
 
 | Stem | Origin | Fingerprint (SHA-256 of the `<symbol>` element) |
 |---|---|---|
@@ -102,13 +104,18 @@ crisp pixels at 16px:
 | `download` | `juneau-original` | `cc9b5776f4ad7f1302c7af2e1ab71b34f994073ca615be3b19726ae10b4e3bf9` |
 | `edit` | `juneau-original` | `4d89fef533d8930afaa124dbd73ed34f3dc8fcf542e624d3e589bdda4b91fc3d` |
 | `filter` | `juneau-original` | `498dd4f95d4a0d1c3208098bac405b6ff5e6b46ce132a418afc0c3f813ff3847` |
+| `forceStop` | `juneau-original` | `3732dbae1b32bc6b53144ee03469e87893b6d1d9686c3c7a53720040703458d3` |
 | `new` | `juneau-original` | `703a4403c820d2d6b6a21e578eaeb410c262572ea0d0f60aef9ffef7bb726754` |
+| `openPr` | `juneau-original` | `2ab421c35b2e22ce4e50100ead41f183f310fd129b021928b230fa4257683470` |
+| `pause` | `juneau-original` | `c4abca2faff8f1b59cc48550e5c7e66dfac9303e05914388eb99f627bb99d212` |
 | `pdf` | `juneau-original` | `a1cdef16b4c57ff0d9da9af5d3fd93416bc8752fdd5388345d601986d4c7667a` |
 | `print` | `juneau-original` | `236eb8a210324db18c969faaf9623765586b84464850f026d2e5918d3650dc9e` |
+| `push` | `juneau-original` | `11500ea634ea1ad14cca0b1d0a08973c5d2f0973b7245bea3872114767be0d59` |
 | `refresh` | `juneau-original` | `33073e39fa8e58ab295b13dc18a8b7c28cbc7bf3c91ddb6ccc8290cb45d720cc` |
 | `search` | `juneau-original` | `03f04247d886a8f95fbf48e21968957c7115b002d770f6d67b39b2ea0296ab7f` |
 | `settings` | `juneau-original` | `d1fa0d3ebe2c1638425c35593379320634d11ae83a58c5b415d9e4d4fc12bc2a` |
 | `spreadsheet` | `juneau-original` | `3da479a6614c03da0adf222bb9f52f63ce423fe5f191a5922284c6305beec7b8` |
+| `stop` | `juneau-original` | `dea9a934b0ff9bf814d422fa6288d82486b1fbb6d2c5959585388975067a2605` |
 | `toggle-deleted` | `juneau-original` | `2c12c07f4f1208ff61abb62a198f73d32315a168007c01bf6dd7224a6ca2f349` |
 | `toggle_column_search` | `juneau-original` | `c1a6ef076a8d226f544402edd862e868ea51909fed52f58dea345e56e2eba3b5` |
 
@@ -132,13 +139,18 @@ its coordinates".
 | `download` | a vertical shaft, a 90° arrowhead at its foot, and a detached tray below. |
 | `edit` | a 45° pencil: a parallelogram body, a symmetric point at the lower left, and a ferrule line across the body 3 units back from the cap. |
 | `filter` | a funnel: a full-width mouth, two symmetric slopes to a 3-unit throat, and an offset spout. |
+| `forceStop` | a filled regular octagon inscribed between (4.5, 4.5) and (19.5, 19.5), drawn as one closed H/L/V path — a stop-sign silhouette, used where `stop`'s plain square reads as insufficiently forceful. |
 | `new` | the `cancel` circle with an inscribed plus instead of a cross. |
+| `openPr` | a page frame open at the top-right corner, plus a diagonal shaft from its interior to a right-angle bracket beyond the gap — the standard "external link" construction, generic for "open elsewhere". |
+| `pause` | two filled vertical bars, 3 units wide, on either side of a 3-unit gap, both 4.5 to 19.5 tall — the `columns` bar rhythm reduced to two members. |
 | `pdf` | family frame, plus three filled rules on the declared mark rhythm, the last one short. |
 | `print` | a printer: an open-bottomed paper flap peeking above the body, a stroked body box, and an open-topped output-tray flap below it — three axis-aligned rectilinear shapes, all edges on the 24×24 lattice, symmetric about both the horizontal and vertical centreline. |
+| `push` | the `download` construction (shaft, arrowhead, tray), with the shaft-and-arrowhead group mirrored within its own band to point up instead of down; the tray is held fixed at the foot, unflipped, as the base being pushed from, with the same 3-unit gap separating it from the arrow. |
 | `refresh` | two 150° arcs of r=7.5 in 180° rotational symmetry, with a 30° gap at each end, each terminating in a filled triangular arrowhead whose base is radial and whose apex points along the direction of travel. |
 | `search` | circle r=6 about (9.75, 9.75) with a 45° handle from its lower-right quadrant. |
 | `settings` | a filled 6-tooth gear about (11.25, 11.25): outer radius 7.5, root radius 5.25, tooth half-angles 13°/22°, with an `evenodd` hole of r=2.25 knocked out of the centre. |
 | `spreadsheet` | family frame, plus a stroked 2 × 2 grid on the declared mark rhythm. |
+| `stop` | a filled 12 × 12 square, axis-aligned, centred on (12, 12). |
 | `toggle-deleted` | the `cancel` construction at r=9. |
 | `toggle_column_search` | the `search` construction at r=4.5 — deliberately the same idiom at a smaller weight, which is the relationship the two glyphs had before. |
 
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols.svg b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols.svg
index 3bb8b454cd..3782d6554c 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols.svg
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-symbols.svg
@@ -57,10 +57,19 @@
 <symbol id="juneau-sym-filter" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M3.75 5.25H18.75L12.75 12.75V18.75L9.75 17.25V12.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-forceStop" viewBox="0 0 24 24" fill="none">
+	<path fill="currentColor" d="M9 4.5H15L19.5 9V15L15 19.5H9L4.5 15V9Z"/>
+</symbol>
 <symbol id="juneau-sym-new" viewBox="0 0 24 24" fill="none">
 	<circle cx="11.25" cy="11.25" r="7.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M11.25 6.75V15.75M6.75 11.25H15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-openPr" viewBox="0 0 24 24" fill="none">
+	<path fill="none" d="M9.75 8.25H5.25V18.75H15.75V12.75M14.25 3.75H20.25V9.75M9.75 14.25L20.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
+</symbol>
+<symbol id="juneau-sym-pause" viewBox="0 0 24 24" fill="none">
+	<path fill="currentColor" d="M7.5 4.5H10.5V19.5H7.5ZM13.5 4.5H16.5V19.5H13.5Z"/>
+</symbol>
 <symbol id="juneau-sym-pdf" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M5.25 3.75H12.75L17.25 8.25V18.75H5.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="currentColor" d="M7.5 9H15V10.5H7.5ZM7.5 12H15V13.5H7.5ZM7.5 15H12V16.5H7.5Z"/>
@@ -70,6 +79,9 @@
 	<rect x="3.75" y="9.75" width="16.5" height="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M8.25 17.25V20.25H15.75V17.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-push" viewBox="0 0 24 24" fill="none">
+	<path fill="none" d="M11.25 12.75V3.75M8.25 6.75L11.25 3.75L14.25 6.75M5.25 15.75V18.75H17.25V15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
+</symbol>
 <symbol id="juneau-sym-refresh" viewBox="0 0 24 24" fill="none">
 	<path fill="none" d="M11.25 3.75A7.5 7.5 0 0 1 15 17.75M11.25 18.75A7.5 7.5 0 0 1 7.5 4.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="currentColor" d="M13.05 18.87L14.8 14.4L17.8 19.59ZM9.45 3.63L7.7 8.1L4.7 2.91Z"/>
@@ -85,6 +97,9 @@
 	<path fill="none" d="M5.25 3.75H12.75L17.25 8.25V18.75H5.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 	<path fill="none" d="M8.25 9.75H14.25V15.75H8.25ZM11.25 9.75V15.75M8.25 12.75H14.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
 </symbol>
+<symbol id="juneau-sym-stop" viewBox="0 0 24 24" fill="none">
+	<rect x="6" y="6" width="12" height="12" fill="currentColor"/>
+</symbol>
 <symbol id="juneau-sym-toggle-deleted" viewBox="0 0 24 24" fill="none">
 	<circle cx="11.25" cy="11.25" r="9" fill="none" stroke="currentColor" stroke-width="1.5"/>
 	<path fill="none" d="M6.75 6.75L15.75 15.75M15.75 6.75L6.75 15.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"/>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
index 3dbd12979d..3f6352de86 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.js
@@ -345,21 +345,44 @@
 	}
 
 	/**
-	 * Builds the fail-closed row-action request descriptor from a RowAction intent, a token, and a header name -
-	 * pure, DOM/fetch-free (plain data in, plain data out) so the fail-closed contract is unit-testable without a
-	 * browser.  Returns EITHER a `{refuse:true, reason}` marker (the caller renders a VISIBLE refusal) OR a
-	 * ready-to-issue `{url, method, headers, body}`:
+	 * Substitutes `{property}` tokens in a RowAction's `endpoint` against the row's own already-fetched data
+	 * (WORK-J0509) - delegating to the EXACT SAME `interpolateHref` helper {@code Column.href}'s `linked` renderer
+	 * uses (juneau-renders.js; also reused by the row-detail field-grid `href` slot).  Same token grammar (any
+	 * `{property}`, not a special-cased `{id}`), same per-value `encodeURIComponent` escaping, and the same
+	 * "row lacks the field, or its value is null" -> empty-string substitution - never a throw, never a literal
+	 * `{property}` left in the URL.  A template with no `{...}` token round-trips byte-identical (`interpolateHref`'s
+	 * `replace` is a no-op without a match), which is the backward-compatibility guarantee for every `endpoint`
+	 * declared before this substitution existed.  A `null`/`undefined` endpoint is returned as-is (never
+	 * stringified to `"undefined"`/`"null"`).  When `juneau-renders.js` is not on the page (a caller shipping
+	 * juneau-views.js alone), this degrades to the endpoint verbatim - the same graceful-degradation shape as
+	 * {@link #viewEscAttr} above; the plain-fetch-URL destination means NO extra HTML-attribute escaping layer is
+	 * needed here (unlike `Column.href`'s `escAttr(interpolateHref(...))`, which exists only because that result is
+	 * written into an `<a href="...">` DOM attribute).
+	 */
+	function substituteRowActionEndpoint(endpoint, rowData) {
+		if (endpoint == null) return endpoint;
+		return typeof NS._render?.interpolateHref === "function" ? NS._render.interpolateHref(endpoint, rowData) : endpoint;
+	}
+
+	/**
+	 * Builds the fail-closed row-action request descriptor from a RowAction intent, a token, a header name, and
+	 * the row's own data - pure, DOM/fetch-free (plain data in, plain data out) so the fail-closed contract is
+	 * unit-testable without a browser.  Returns EITHER a `{refuse:true, reason}` marker (the caller renders a
+	 * VISIBLE refusal) OR a ready-to-issue `{url, method, headers, body}`:
 	 *   - a missing or SAFE method refuses (`reason:"safe-method"`) - HIGH-7;
 	 *   - a blank/absent/whitespace token refuses (`reason:"missing-token"`) - HIGH-1 fail-closed;
-	 *   - otherwise the body is JSON and the headers carry `Content-Type: application/json` (so the write passes
-	 *     `LoopbackBoundary.isJson`) plus the CSRF token under `headerName` (defaulting to DEFAULT_CSRF_HEADER).
+	 *   - otherwise `url` is `action.endpoint` with any `{property}` token substituted via
+	 *     {@link #substituteRowActionEndpoint} (WORK-J0509), and the body is JSON with the headers carrying
+	 *     `Content-Type: application/json` (so the write passes `LoopbackBoundary.isJson`) plus the CSRF token
+	 *     under `headerName` (defaulting to DEFAULT_CSRF_HEADER).
 	 *
 	 * The optional `extra` object is merged into the JSON body - the declarative-modal submit path uses it to carry
 	 * the server-minted `idempotencyKey` and the `targetId`, so a double-click/re-submit/browser-retry all carry the
 	 * same key and the server can check the key's `(action, targetId)` binding.  A bare submit (no `extra`) sends
-	 * exactly `{action}` as before.
+	 * exactly `{action}` as before.  `rowData` is optional (absent/`null` mirrors a Column-href row with no id: every
+	 * `{property}` token substitutes to `""`), so every pre-WORK-J0509 caller of this pure function keeps working.
 	 */
-	function buildActionRequest(action, token, headerName, extra) {
+	function buildActionRequest(action, token, headerName, extra, rowData) {
 		if (!action || isSafeMethod(action.method) || !action.method)
 			return { refuse: true, reason: "safe-method" };
 		if (isBlankToken(token))
@@ -369,7 +392,7 @@
 		const payload = { action: action.id };
 		if (extra) for (const k in extra) if (Object.hasOwn(extra, k) && extra[k] != null) payload[k] = extra[k];
 		return {
-			url: action.endpoint,
+			url: substituteRowActionEndpoint(action.endpoint, rowData),
 			method: action.method,
 			headers: headers,
 			body: JSON.stringify(payload)
@@ -3360,8 +3383,10 @@
 	}
 
 	/**
-	 * Issues one row action, FAIL-CLOSED.  Resolves the table's token + header name, asks the pure
-	 * buildActionRequest(...) for a request descriptor, and:
+	 * Issues one row action, FAIL-CLOSED.  Resolves the table's token + header name and this row's own
+	 * already-fetched data (rowDataForTr - the SAME lookup the `enabledWhen` gate uses, so no second DataTables
+	 * read), asks the pure buildActionRequest(...) for a request descriptor - which substitutes any `{property}`
+	 * token in `action.endpoint` against that row data (WORK-J0509, mirroring Column.href) - and:
 	 *   - on a refusal marker (safe method, or blank/absent/whitespace token) renders a VISIBLE refusal and sends
 	 *     NOTHING - no silent degradation, and never an empty-header request the server would 403;
 	 *   - otherwise marks the row in-flight and issues the JSON fetch with the CSRF header, then settles
@@ -3373,7 +3398,8 @@
 	 * real control.
 	 */
 	function submitRowAction(action, table, tr, ctx, extra) {
-		const req = buildActionRequest(action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra);
+		const req = buildActionRequest(
+			action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra, rowDataForTr(ctx, tr));
 		if (req.refuse) {
 			renderRowActionRefusal(tr, action, req.reason);
 			return;
@@ -5479,6 +5505,7 @@
 		DEFAULT_CSRF_HEADER: DEFAULT_CSRF_HEADER,
 		isSafeMethod: isSafeMethod,
 		isBlankToken: isBlankToken,
+		substituteRowActionEndpoint: substituteRowActionEndpoint,
 		buildActionRequest: buildActionRequest,
 		actionRefusalMessage: actionRefusalMessage,
 		resolveCsrfToken: resolveCsrfToken,
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SymbolSprite_StemIds_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SymbolSprite_StemIds_Test.java
index 7b2ca69b0d..00f7f36e2b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SymbolSprite_StemIds_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SymbolSprite_StemIds_Test.java
@@ -47,9 +47,10 @@ class SymbolSprite_StemIds_Test extends TestBase {
 	private static final Set<String> EXPECTED_IDS = Set.of(
 		"juneau-sym-cancel", "juneau-sym-check", "juneau-sym-chevrondown", "juneau-sym-chevronright",
 		"juneau-sym-close", "juneau-sym-collapse_all", "juneau-sym-columns", "juneau-sym-copy", "juneau-sym-csv",
-		"juneau-sym-download", "juneau-sym-edit", "juneau-sym-filter", "juneau-sym-new", "juneau-sym-pdf",
-		"juneau-sym-print", "juneau-sym-refresh", "juneau-sym-search", "juneau-sym-settings",
-		"juneau-sym-spreadsheet", "juneau-sym-toggle-deleted", "juneau-sym-toggle_column_search"
+		"juneau-sym-download", "juneau-sym-edit", "juneau-sym-filter", "juneau-sym-forceStop", "juneau-sym-new",
+		"juneau-sym-openPr", "juneau-sym-pause", "juneau-sym-pdf", "juneau-sym-print", "juneau-sym-push",
+		"juneau-sym-refresh", "juneau-sym-search", "juneau-sym-settings", "juneau-sym-spreadsheet",
+		"juneau-sym-stop", "juneau-sym-toggle-deleted", "juneau-sym-toggle_column_search"
 	);
 
 	private static final Pattern SYMBOL_ID_PATTERN = Pattern.compile("<symbol\\s+id=\"([^\"]+)\"");
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
index 8235216275..61c919f3ae 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
@@ -16,9 +16,17 @@
  */
 package org.apache.juneau.rest.server.views;
 
+import static java.nio.charset.StandardCharsets.*;
 import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.concurrent.*;
 
 import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
 import org.apache.juneau.rest.mock.classic.*;
 import org.apache.juneau.rest.server.*;
 import org.apache.juneau.rest.server.filter.*;
@@ -26,10 +34,13 @@ import org.apache.juneau.rest.server.servlet.*;
 import org.junit.jupiter.api.*;
 
 /**
- * Always-on source-shape coverage for the {@code juneau-views.js} row-action + fail-closed CSRF plumbing.
- * Mirrors {@code ViewsMixin_Serving_Test}'s served-script substring style: proves the load-bearing
- * pieces of the runtime's row-menu/submit contract are present in the shipped asset, without booting a browser
- * (the behavioral proof lives in the opt-in {@code RowActionCsrf_BrowserTest} canary).
+ * Always-on source-shape coverage for the {@code juneau-views.js} row-action + fail-closed CSRF plumbing, plus
+ * (WORK-J0509) the {@code RowAction.endpoint} {@code {property}} substitution.  Source-shape always runs;
+ * the substitution's behavioral proof runs via a Node harness when {@code node} is on {@code PATH} (no
+ * {@code -Pjs-tests}/browser required - {@code buildActionRequest} is pure).  Mirrors
+ * {@code ViewsMixin_Serving_Test}'s served-script substring style: proves the load-bearing pieces of the
+ * runtime's row-menu/submit contract are present in the shipped asset, without booting a browser (the
+ * fail-closed-CSRF behavioral proof lives in the opt-in {@code RowActionCsrf_BrowserTest} canary).
  *
  * <p>
  * The client refusal these tests pin is <b>defense-against-consumer-omission</b>, not the security control: the
@@ -160,10 +171,210 @@ class ViewsJs_RowActions_Test extends TestBase {
 		assertTrue(fn.contains("aria-describedby"), fn);
 	}
 
+	// -----------------------------------------------------------------------------------------------------------
+	// WORK-J0509: RowAction.endpoint `{property}` substitution, mirroring Column.href's `linked`-renderer
+	// mechanism exactly (same interpolateHref helper, same token grammar, same escaping, same no-value behavior).
+	// -----------------------------------------------------------------------------------------------------------
+
+	@Test void a13_buildActionRequestSubstitutesEndpointViaTheSameHelperColumnHrefUses() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function buildActionRequest(");
+		// The 5th param + the substitution call - not `action.endpoint` issued verbatim anymore.
+		assertTrue(fn.contains("buildActionRequest(action, token, headerName, extra, rowData)"), fn);
+		assertTrue(fn.contains("url: substituteRowActionEndpoint(action.endpoint, rowData)"), fn);
+		assertFalse(fn.contains("url: action.endpoint"), fn);
+	}
+
+	@Test void a14_substituteRowActionEndpointDelegatesToTheSharedColumnHrefHelper() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function substituteRowActionEndpoint(");
+		// The SAME interpolateHref helper juneau-renders.js exposes for Column.href's `linked` renderer - not a
+		// second, divergent template implementation.
+		assertTrue(fn.contains("NS._render?.interpolateHref"), fn);
+		assertTrue(fn.contains("NS._render.interpolateHref(endpoint, rowData)"), fn);
+		// A null/undefined endpoint is returned as-is, never stringified.
+		assertTrue(fn.contains("if (endpoint == null) return endpoint;"), fn);
+	}
+
+	@Test void a15_submitRowActionResolvesTheClickedRowsOwnDataForSubstitution() throws Exception {
+		var body = viewsJs();
+		var fn = functionBody(body, "function submitRowAction(");
+		// Must pass THIS row's own already-fetched data (the same lookup the enabledWhen gate uses) - never a
+		// stale/global row, and never re-implementing its own DataTables lookup.
+		assertTrue(fn.contains("rowDataForTr(ctx, tr)"), fn);
+		assertTrue(fn.contains("buildActionRequest("), fn);
+	}
+
 	private static String functionBody(String body, String signature) {
 		var start = body.indexOf(signature);
 		assertTrue(start >= 0, () -> signature + " not found:\n" + body);
 		var end = body.indexOf("\n\t}", start);
 		return body.substring(start, end < 0 ? body.length() : end);
 	}
+
+	// -----------------------------------------------------------------------------------------------------------
+	// Node behavioral harness (row-action-endpoint.cjs) - pure, DOM/browser-free; runs when `node` is on PATH.
+	// -----------------------------------------------------------------------------------------------------------
+
+	private static String rendersJs() throws Exception {
+		try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.RENDERS_JS_RESOURCE)) {
+			assertNotNull(in, () -> "missing classpath resource: " + ViewsMixin.RENDERS_JS_RESOURCE);
+			return new String(in.readAllBytes(), UTF_8);
+		}
+	}
+
+	private static Map<?,?> report;
+	private static Map<?,?> reportNoRendersJs;
+
+	@BeforeAll
+	static void probeIfNodeAvailable() throws Exception {
+		if (!nodeAvailable())
+			return;
+		var harness = locateHarness();
+		if (harness == null)
+			return;
+		var viewsFile = Files.createTempFile("juneau-views-", ".js");
+		var rendersFile = Files.createTempFile("juneau-renders-", ".js");
+		try {
+			Files.writeString(viewsFile, viewsJs(), UTF_8);
+			Files.writeString(rendersFile, rendersJs(), UTF_8);
+			report = Json.to(runNode(harness, viewsFile, rendersFile), Map.class);
+			reportNoRendersJs = Json.to(runNode(harness, viewsFile, null), Map.class);
+		} finally {
+			Files.deleteIfExists(viewsFile);
+			Files.deleteIfExists(rendersFile);
+		}
+	}
+
+	private static boolean nodeAvailable() {
+		try {
+			var p = new ProcessBuilder("node", "--version").redirectErrorStream(true).start();
+			if (!p.waitFor(5, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				return false;
+			}
+			return p.exitValue() == 0;
+		} catch (Exception e) {
+			return false;
+		}
+	}
+
+	private static Path locateHarness() {
+		var basedir = System.getProperty("basedir");
+		if (basedir != null) {
+			var p = Path.of(basedir, "src/test/js/row-action-endpoint.cjs");
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		for (var rel : List.of(
+			"src/test/js/row-action-endpoint.cjs",
+			"juneau-rest/juneau-rest-server-views/src/test/js/row-action-endpoint.cjs"
+		)) {
+			var p = Path.of(rel);
+			if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+		}
+		return null;
+	}
+
+	private static String runNode(Path harness, Path viewsJs, Path rendersJs) throws Exception {
+		var stdout = Files.createTempFile("row-action-endpoint-stdout-", ".json");
+		var stderr = Files.createTempFile("row-action-endpoint-stderr-", ".txt");
+		try {
+			var args = new ArrayList<String>(List.of("node", harness.toString(), viewsJs.toString()));
+			if (rendersJs != null) args.add(rendersJs.toString());
+			var pb = new ProcessBuilder(args)
+				.redirectOutput(stdout.toFile())
+				.redirectError(stderr.toFile());
+			var p = pb.start();
+			if (!p.waitFor(30, TimeUnit.SECONDS)) {
+				p.destroyForcibly();
+				fail("row-action-endpoint.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+			}
+			if (p.exitValue() != 0)
+				fail("row-action-endpoint.cjs exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr)
+					+ "\nstdout:\n" + quietRead(stdout));
+			return Files.readString(stdout, UTF_8);
+		} finally {
+			Files.deleteIfExists(stdout);
+			Files.deleteIfExists(stderr);
+		}
+	}
+
+	private static String quietRead(Path p) {
+		try { return Files.readString(p, UTF_8); }
+		catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+	}
+
+	private static Map<?,?> report() {
+		assumeTrue(report != null, "node not available or row-action-endpoint.cjs not found — behavioral layer skipped");
+		return report;
+	}
+
+	private static Map<?,?> reportNoRendersJs() {
+		assumeTrue(reportNoRendersJs != null, "node not available or row-action-endpoint.cjs not found — behavioral layer skipped");
+		return reportNoRendersJs;
+	}
+
+	@Test void b01_idTemplateResolvesAgainstTheCurrentRow_exactlyLikeColumnHref() {
+		assertEquals("servlet:/incidents/a1/ack", report().get("idTemplate_resolved"));
+	}
+
+	@Test void b02_literalEndpointWithNoTokenIsPreservedByteIdentical_backwardCompat() {
+		var r = report();
+		// With rowData, without rowData, and via the pre-J0509 4-argument call signature - all three unaffected.
+		assertEquals("servlet:/incidents/ack", r.get("literal_withRowData"));
+		assertEquals("servlet:/incidents/ack", r.get("literal_noRowData"));
+		assertEquals("servlet:/incidents/ack", r.get("literal_preFeatureCallSignature"));
+	}
+
+	@Test void b03_noIdOrNullIdRowSubstitutesToEmptyString_matchingColumnHref() {
+		var r = report();
+		// Missing key, explicit null, absent rowData, and undefined rowData all substitute `{id}` to "" - the
+		// SAME behavior Column.href's interpolateHref has for a row with no id - never a thrown error.
+		assertEquals("servlet:/incidents//ack", r.get("noId_missingKey"));
+		assertEquals("servlet:/incidents//ack", r.get("noId_explicitNull"));
+		assertEquals("servlet:/incidents//ack", r.get("noId_absentRowData"));
+		assertEquals("servlet:/incidents//ack", r.get("noId_undefinedRowData"));
+	}
+
+	@Test void b04_substitutedValueIsUrlEncodedPerToken_matchingColumnHref() {
+		assertEquals("servlet:/incidents/a%2F1%20b/ack", report().get("encoded_slashAndSpace"));
+	}
+
+	@Test void b05_genericPropertyGrammar_notAHardcodedIdOnlyToken() {
+		// Column.href's template is `{property}` (any row key), not a hardcoded `{id}` - proving RowAction.endpoint
+		// resolves a second, unrelated token in the same template confirms this mirrors the generic grammar.
+		assertEquals("/x/a1/status/open", report().get("multiToken_resolved"));
+	}
+
+	@Test void b06_nullOrUndefinedEndpointIsReturnedAsIs_neverStringified() {
+		var r = report();
+		assertNull(r.get("nullEndpoint_helper"));
+		assertEquals(true, r.get("undefinedEndpointIsUndefined_helper"));
+	}
+
+	@Test void b07_refusalNeverReachesSubstitution() {
+		var r = report();
+		assertEquals(true, ((Map<?,?>) r.get("refusal_safeMethod")).get("refuse"));
+		assertEquals("safe-method", ((Map<?,?>) r.get("refusal_safeMethod")).get("reason"));
+		assertEquals(true, ((Map<?,?>) r.get("refusal_blankToken")).get("refuse"));
+		assertEquals("missing-token", ((Map<?,?>) r.get("refusal_blankToken")).get("reason"));
+		assertFalse(((Map<?,?>) r.get("refusal_safeMethod")).containsKey("url"));
+		assertFalse(((Map<?,?>) r.get("refusal_blankToken")).containsKey("url"));
+	}
+
+	@Test void b08_substituteRowActionEndpointHelperDirect() {
+		var r = report();
+		assertEquals("/x/a1", r.get("helper_direct"));
+		assertEquals("/x/ack", r.get("helper_noToken"));
+	}
+
+	@Test void b09_gracefullyDegradesToVerbatimEndpoint_whenRendersJsIsNotLoaded() {
+		// If a caller ships juneau-views.js without its juneau-renders.js peer, substitution is unavailable -
+		// the SAME graceful-degradation shape as viewEscAttr - and the endpoint is issued verbatim (the token
+		// left in place) rather than throwing or silently corrupting the URL.
+		var r = reportNoRendersJs();
+		assertEquals(false, r.get("hasInterpolateHref"));
+		assertEquals("servlet:/incidents/{id}/ack", r.get("idTemplate_resolved"));
+		assertEquals("servlet:/incidents/ack", r.get("literal_withRowData"));
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/row-action-endpoint.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/row-action-endpoint.cjs
new file mode 100644
index 0000000000..62e0adc68d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/row-action-endpoint.cjs
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * row-action-endpoint.cjs - always-on Node harness for the WORK-J0509 RowAction.endpoint `{property}`
+ * substitution: buildActionRequest(action, token, headerName, extra, rowData) and its helper
+ * substituteRowActionEndpoint(endpoint, rowData), both pure/DOM-fetch-free.
+ *
+ *   Usage:  node row-action-endpoint.cjs <path-to-juneau-views.js> [path-to-juneau-renders.js]
+ *
+ * Loads juneau-views.js (and, when given, juneau-renders.js - the module substituteRowActionEndpoint delegates
+ * to for the actual `interpolateHref` token replace) into an isolated vm sandbox with a minimal window/document
+ * stub, exactly like row-detail.cjs does for the sibling row-detail helpers.  No browser, no jQuery, no
+ * DataTables - buildActionRequest never touches any of them.  Prints ONE JSON object to stdout; every assertion
+ * lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const viewsJsPath = process.argv[2];
+if (!viewsJsPath) {
+	console.error('usage: node row-action-endpoint.cjs <juneau-views.js> [juneau-renders.js]');
+	process.exit(2);
+}
+const rendersJsPath = process.argv[3];
+
+const document = {
+	readyState: 'loading',
+	addEventListener: function () {},
+	querySelectorAll: function () { return []; },
+	querySelector: function () { return null; },
+	getElementById: function () { return null; },
+	createElement: function () { return {}; },
+	body: { appendChild: function () {}, querySelectorAll: function () { return []; } }
+};
+const window = { document: document, console: console };
+const sandbox = { window: window, document: document, console: console };
+sandbox.globalThis = sandbox;
+
+// NOSONAR javascript:S1523 -- this harness's entire purpose is to load the production runtime under test (a
+// repo-local file path from argv, not attacker-controlled input) into an isolated VM sandbox; that IS the test.
+if (rendersJsPath)
+	vm.runInNewContext(fs.readFileSync(path.resolve(rendersJsPath), 'utf8'), sandbox, { filename: 'juneau-renders.js' });
+// NOSONAR javascript:S1523 -- same rationale: loading the production juneau-views.js under test into the sandbox.
+vm.runInNewContext(fs.readFileSync(path.resolve(viewsJsPath), 'utf8'), sandbox, { filename: 'juneau-views.js' });
+
+const NS = window.JuneauViews;
+const I = NS?.init;
+const out = { hasInit: !!(typeof I?.buildActionRequest === 'function') };
+if (!out.hasInit) {
+	process.stdout.write(JSON.stringify(out));
+	process.exit(0);
+}
+out.hasSubstituteHelper = typeof I.substituteRowActionEndpoint === 'function';
+out.hasInterpolateHref = typeof NS._render?.interpolateHref === 'function';
+
+const TOKEN = 'tok-123';
+const ackAction = { id: 'ack', label: 'Acknowledge', endpoint: 'servlet:/incidents/{id}/ack', method: 'POST' };
+const literalAction = { id: 'ack', label: 'Acknowledge', endpoint: 'servlet:/incidents/ack', method: 'POST' };
+const multiAction = { id: 'ack', endpoint: '/x/{id}/status/{status}', method: 'POST' };
+
+// Case 1: a `{id}` template resolves against the current row - same token grammar as Column.href.
+out.idTemplate_resolved = I.buildActionRequest(ackAction, TOKEN, null, null, { id: 'a1' }).url;
+
+// Case 2: backward compatibility - a literal endpoint with NO `{...}` token is preserved byte-identical,
+// with or without a rowData argument at all.
+out.literal_withRowData = I.buildActionRequest(literalAction, TOKEN, null, null, { id: 'a1' }).url;
+out.literal_noRowData = I.buildActionRequest(literalAction, TOKEN, null, null).url;
+out.literal_preFeatureCallSignature = I.buildActionRequest(literalAction, TOKEN, null).url;   // pre-J0509 4-arg call
+
+// Case 3: no-id / null-id row behavior mirrors Column.href's interpolateHref exactly - missing key, explicit
+// null, and no rowData at all every substitute to "" (never a thrown error, never a literal `{id}` surviving).
+out.noId_missingKey = I.buildActionRequest(ackAction, TOKEN, null, null, {}).url;
+out.noId_explicitNull = I.buildActionRequest(ackAction, TOKEN, null, null, { id: null }).url;
+out.noId_absentRowData = I.buildActionRequest(ackAction, TOKEN, null, null, null).url;
+out.noId_undefinedRowData = I.buildActionRequest(ackAction, TOKEN, null, null, undefined).url;
+
+// Case 4: the substituted value is URL-encoded per-token, exactly like Column.href's interpolateHref.
+out.encoded_slashAndSpace = I.buildActionRequest(ackAction, TOKEN, null, null, { id: 'a/1 b' }).url;
+
+// Case 5: more than one `{property}` token resolves - proves this is the SAME generic `{property}` grammar
+// Column.href uses, not a hardcoded `{id}`-only special case.
+out.multiToken_resolved = I.buildActionRequest(multiAction, TOKEN, null, null, { id: 'a1', status: 'open' }).url;
+
+// Case 6: a null/undefined `action.endpoint` itself is returned as-is by the helper - never stringified to
+// the literal text "undefined"/"null".
+out.nullEndpoint_helper = I.substituteRowActionEndpoint(null, { id: 'a1' });
+out.undefinedEndpointIsUndefined_helper = I.substituteRowActionEndpoint(undefined, { id: 'a1' }) === undefined;
+
+// Case 7: a refusal (safe method / blank token) never even reaches substitution - the refusal marker carries
+// no `url` at all, so a refused action can never leak a substituted endpoint.
+out.refusal_safeMethod = I.buildActionRequest({ id: 'g', endpoint: '/x/{id}', method: 'GET' }, TOKEN, null, null, { id: 'a1' });
+out.refusal_blankToken = I.buildActionRequest(ackAction, '   ', null, null, { id: 'a1' });
+
+// Case 8: substituteRowActionEndpoint itself, direct - the exact helper buildActionRequest delegates to.
+out.helper_direct = I.substituteRowActionEndpoint('/x/{id}', { id: 'a1' });
+out.helper_noToken = I.substituteRowActionEndpoint('/x/ack', { id: 'a1' });
+
+console.log(JSON.stringify(out));