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/04 18:55:38 UTC
(juneau) branch master updated: Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.
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 e991410d79 Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.
e991410d79 is described below
commit e991410d79ecc7fa760fcdb1c295f61990510728
Author: James Bognar <ja...@apache.org>
AuthorDate: Fri Sep 4 14:55:16 2026 -0400
Add an enforceable in-dialog result receipt (ModalDef.keepOpenOnSubmit + ActionResult.resultForm) and a dialog-scoped child-action catalog (ModalDef.childActions) for stacked wizard steps.
---
.../juneau/rest/server/views/ActionResult.java | 38 +-
.../org/apache/juneau/views/juneau-views.js | 668 +++++++++++++++++++--
.../server/views/ActionResult_Contract_Test.java | 36 +-
.../views/ViewsJs_DialogChildCatalog_Test.java | 378 ++++++++++++
.../rest/server/views/ViewsJs_DialogForm_Test.java | 12 +
.../server/views/ViewsJs_DialogReceipt_Test.java | 550 +++++++++++++++++
.../server/views/ViewsJs_ModalResult_Test.java | 5 +-
.../server/views/ViewsJs_RibbonStrip_Test.java | 4 +-
.../src/test/js/dialog-child-catalog.cjs | 375 ++++++++++++
.../src/test/js/dialog-form.cjs | 13 +
.../src/test/js/dialog-receipt.cjs | 509 ++++++++++++++++
.../juneau/rest/server/widgets/ModalDef.java | 336 ++++++++++-
.../server/widgets/ModalDef_ResultHost_Test.java | 282 +++++++++
13 files changed, 3164 insertions(+), 42 deletions(-)
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
index 9d42ab20aa..39b6aed5f8 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ActionResult.java
@@ -87,7 +87,7 @@ import org.apache.juneau.commons.bean.*;
*
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row")
+@BeanType(properties="contractVersion,outcome,replay,refusalCode,message,row,resultForm")
@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class ActionResult {
@@ -171,6 +171,31 @@ public class ActionResult {
*/
public Object row;
+ /**
+ * The follow-up <b>read-only</b> form-source URL a receipt is painted from; omitted from the wire when unset.
+ *
+ * <h5 class='section'>Honored on {@link Outcome#SUCCESS success} only</h5>
+ * <p>
+ * This is the mechanical form of <i>a receipt must never look like a receipt for a write that didn't happen</i>.
+ * On a failure, a refusal, an unknown, or a transport failure this field is <b>ignored</b>, no follow-up GET is
+ * issued, and nothing is swapped — so no result host can appear for a write that did not commit.
+ *
+ * <h5 class='section'>The served payload must not carry a form</h5>
+ * <p>
+ * The URL serves a {@code ModalDef}, and that payload <b>must not</b> declare a {@code form}. A result host is
+ * terminal by definition: the write already committed, so there is nothing left to gate. A payload carrying
+ * inputs is refused <i>visibly</i> rather than rendered read-only or silently stripped, because it is a consumer
+ * authoring bug and should be visible as one.
+ *
+ * <h5 class='section'>It is a swap, not a new dialog</h5>
+ * <p>
+ * The runtime paints the receipt into the dialog that is <b>already open</b>, at the same layer depth, and only
+ * when that dialog opted in with {@code ModalDef.keepOpenOnSubmit}. Set on a result whose dialog did not opt in,
+ * the swap is dropped, the success is reported unchanged, and the runtime emits one non-alarming diagnostic
+ * rather than either opening a layer or making a successful write look failed.
+ */
+ public String resultForm;
+
/**
* Starts a {@link Outcome#SUCCESS success} result carrying the authoritative row read back from the remote system.
*
@@ -267,4 +292,15 @@ public class ActionResult {
refusalCode = value;
return this;
}
+
+ /**
+ * Sets the follow-up read-only form-source URL a receipt is painted from (see {@link #resultForm}).
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ActionResult resultForm(String value) {
+ resultForm = value;
+ return this;
+ }
}
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 91da35a399..faf487d412 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
@@ -3600,7 +3600,10 @@
const req = buildActionRequest(
action, resolveCsrfToken(table), resolveCsrfHeaderName(table), extra, rowDataForTr(ctx, tr));
if (req.refuse) {
- renderActionRefusalFor(tr, action, req.reason, ctx);
+ // T1 - the ONE terminal where nothing was even SENT: this guard returns before fetch is ever called, so
+ // a held dialog can re-enable Confirm and let the operator retry once the declaration is corrected.
+ if (settleDialogResultHold(ctx, "retryable")) renderHeldResultNotice(actionRefusalMessage(req.reason));
+ else renderActionRefusalFor(tr, action, req.reason, ctx);
return;
}
setRowInFlight(tr, true);
@@ -3612,7 +3615,11 @@
// A network-level failure is itself a terminal outcome: clear the marker (so polling resumes) and
// render a visible refusal rather than leaving the row stuck in-flight.
setRowInFlight(tr, false);
- renderActionRefusalFor(tr, action, "request-failed", ctx);
+ // T2 - AMBIGUOUS, not retryable. fetch rejects on a connection dropped AFTER the request was fully
+ // sent and processed, so this does NOT prove the write didn't land; re-enabling Confirm would hand
+ // the operator a one-click duplicate of a non-idempotent write. Terminal + Close-only instead.
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(REQUEST_UNCONFIRMED_NOTICE);
+ else renderActionRefusalFor(tr, action, "request-failed", ctx);
});
}
@@ -3643,15 +3650,26 @@
*/
function settleActionResponse(resp, action, table, tr, ctx) {
setRowInFlight(tr, false); // EVERY terminal outcome clears the marker first - polling must always resume.
- if (! resp) { renderActionOutcomeFor(tr, { outcome: "unknown" }, ctx); return; }
+ if (! resp) {
+ // T3: an absent response does not prove the write didn't land, so `unknown` never re-enables Confirm.
+ const o = { outcome: "unknown" };
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(actionOutcomeMessage(o));
+ else renderActionOutcomeFor(tr, o, ctx);
+ return;
+ }
if (! resp.ok) {
const boundaryReason = (typeof resp.headers?.get === "function")
? resp.headers.get("X-Loopback-Boundary") : null;
readBodyText(resp).then(function (text) {
const t = transportRefusal(resp.status, boundaryReason, parseJsonSafe(text));
- renderActionOutcomeFor(tr, { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message }, ctx);
- });
+ const o = { outcome: "refusal", transport: true, refusalCode: t.code, message: t.message };
+ // T4: a boundary/transport refusal is a gate saying no - nothing was written, so retry is safe and
+ // is the point. The hand-off sits INSIDE this callback, next to the paint it replaces: at the
+ // arm's outer `return` the refusal text does not exist yet and the notice would race the button.
+ if (settleDialogResultHold(ctx, "retryable")) renderHeldResultNotice(actionOutcomeMessage(o));
+ else renderActionOutcomeFor(tr, o, ctx);
+ }).catch(function () { settleBodyReadFailure(action, table, tr, ctx); }); // T9
return;
}
@@ -3663,30 +3681,75 @@
// duration (HIGH-9).
const started = parseJobStarted(text);
if (started) {
+ // T5: CLOSE FIRST, then stream. The order is load-bearing, not cosmetic - startJobStream paints
+ // setRowJobRunning/renderJobProgress into the ROW, so a still-open backdrop would hide the whole
+ // progress affordance behind a dialog that can never settle (a job pointer is not a terminal
+ // result and can never carry a resultForm), and a throw in stream setup would strand that busy
+ // modal permanently over a running job.
+ settleDialogResultHold(ctx, "closeCommitted");
startJobStream(started, action, table, tr, ctx);
return;
}
const result = parseActionResult(text);
if (! result) { // bare 2xx, no typed result -> pre-416 success behavior.
applySuccessBehavior(action, table, tr, ctx, null);
+ // T6: a bare 2xx IS a success carrying no typed body and therefore no resultForm - the matrix's
+ // "success, no resultForm" cell. Whole-stack close, and the row render is left exactly as today
+ // (closeCommitted returns false, so nothing downstream is skipped).
+ settleDialogResultHold(ctx, "closeCommitted");
return;
}
if (result.contractVersion != null && result.contractVersion !== JUNEAU_ACTION_RESULT_CONTRACT_VERSION) {
- renderActionOutcomeFor(tr, { outcome: "unknown",
+ const o = { outcome: "unknown",
message: "action-result contract mismatch (page='" + result.contractVersion +
- "', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" }, ctx);
+ "', runtime='" + JUNEAU_ACTION_RESULT_CONTRACT_VERSION + "')" };
+ // T7: same family as T3 - the mismatch text is carried into the dialog notice verbatim.
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(actionOutcomeMessage(o));
+ else renderActionOutcomeFor(tr, o, ctx);
return;
}
const outcome = normalizeOutcome(result);
- if (outcome === "success") {
+ // T8, the one terminal with more than one disposition. Keyed on the hold's ABSENCE rather than on a
+ // falsy hand-off return, because closeCommitted also returns false and must emit NO F4 diagnostic.
+ const held = ctx?._resultHold != null;
+ if (outcome === "success")
applySuccessBehavior(action, table, tr, ctx, result);
- renderActionOutcomeFor(tr, { outcome: "success", replay: !! result.replay, message: result.message }, ctx);
- } else {
- renderActionOutcomeFor(tr, {
- outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay
- }, ctx);
+ const o = (outcome === "success")
+ ? { outcome: "success", replay: !! result.replay, message: result.message }
+ : { outcome: outcome, refusalCode: result.refusalCode, message: result.message, replay: !! result.replay };
+ if (held && outcome === "success" && ! isBlankToken(result.resultForm)) {
+ // The RECEIPT branch, and it FETCHES BEFORE IT SETTLES. Settling here would clear ctx._resultHold
+ // before the GET resolves, so every arm inside fetchResultForm would take the layer-gone escape and
+ // a committed write with a perfectly good receipt payload would paint no receipt at all, silently.
+ fetchResultForm(result.resultForm, action, table, tr, ctx);
+ return;
}
- });
+ const disposition = (outcome === "success")
+ ? "closeCommitted" : (outcome === "refusal" ? "retryable" : "terminal");
+ if (settleDialogResultHold(ctx, disposition)) renderHeldResultNotice(actionOutcomeMessage(o));
+ else renderActionOutcomeFor(tr, o, ctx);
+ // F4: an UNHELD success carrying a resultForm keeps its success banner byte-identical and adds ONE
+ // non-alarming diagnostic - a consumer authoring mistake must not make a committed write look failed.
+ if (! held && outcome === "success" && ! isBlankToken(result.resultForm))
+ renderResultFormIgnored(tr, ctx);
+ }).catch(function () { settleBodyReadFailure(action, table, tr, ctx); }); // T9
+ }
+
+ /**
+ * T9 - the shared terminal for a REJECTED response-body read, on either of settleActionResponse's two
+ * readBodyText(...) chains. readBodyText is defensive about `resp.text` being absent or throwing
+ * synchronously, but it returns the real {@code Response.text()} promise for a real response, and that
+ * REJECTS on a truncated / aborted body stream, a decode error, or an already-consumed body. With no handler
+ * such a failure bypasses T4 on the non-2xx chain and T5-T8 on the 2xx chain entirely: no hand-off runs,
+ * ctx._resultHold is never cleared, and a held dialog sits disabled + busy forever.
+ *
+ * <p>Terminal, for the same reason T2 is: to have a body to read at all the response HEADERS were already
+ * received, so the request reached the server and the server answered - the write is not provably absent and
+ * Confirm must not re-enable. Unheld, it falls back to today's honest non-optimistic `unknown` render.
+ */
+ function settleBodyReadFailure(action, table, tr, ctx) {
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(BODY_UNREADABLE_NOTICE);
+ else renderActionOutcomeFor(tr, { outcome: "unknown", message: BODY_UNREADABLE_NOTICE }, ctx);
}
/** Reads a fetch Response body as text, defensively (a stubbed/absent body resolves to "" rather than throwing). */
@@ -4229,14 +4292,19 @@
const dt = document.createElement("dt");
dt.textContent = (f?.label != null) ? String(f.label) : "";
const dd = document.createElement("dd");
- dd.textContent = (f?.value != null) ? String(f.value) : "";
+ // The ONE line that changed here: the value now goes through the shared kind-aware painter, so a
+ // `code` field is monospaced and copyable in the confirmation dialog and in the receipt alike.
+ buildModalFieldValueNode(dd, f);
dl.appendChild(dt);
dl.appendChild(dd);
});
dialog.appendChild(dl);
}
- appendDialogForm(dialog, modal?.form, table, tr, ctx, seq);
+ // The dialog-scoped child-action catalog rides the form paint as a trailing optional parameter, so a
+ // `type=action` input inside this dialog's form can resolve a STACKED step that is not a row action.
+ const childCatalog = modal?.childActions ?? null;
+ appendDialogForm(dialog, modal?.form, table, tr, ctx, seq, childCatalog);
const actions = document.createElement("div");
actions.className = "juneau-view-dialog-actions";
@@ -4311,7 +4379,7 @@
// A `type=action` button: its name/id wiring is entirely its own (no shared tail post-processing), and
// a missing/non-dialog action id paints DISABLED rather than throwing (SF-1 / H-P4-S1). A resolvable action
// gated-and-failing for this row paints disabled too, with both enabledWhen reason channels.
- function buildActionFormControl(f, table, tr, ctx, row) {
+ function buildActionFormControl(f, table, tr, ctx, row, childCatalog) {
const control = document.createElement("button");
control.type = "button";
control.className = "juneau-view-dialog-form-action";
@@ -4320,16 +4388,23 @@
control.dataset.juneauFormField = String(f.name);
row.appendChild(control); // attach BEFORE gating: attachRowActionDescNode needs control.parentNode to insert the reason sibling
const actionId = f.actionId;
- if (! dialogActionIsOpenable(ctx, actionId)) {
- control.disabled = true;
- control.setAttribute("aria-disabled", "true");
- control.dataset.juneauActionMissing = "1";
- } else {
+ // Resolution order, and the order is the whole security property. (1) Today's row-action check runs FIRST
+ // and unchanged, enabledWhen included, so a served payload can never shadow - and thereby bypass the gating
+ // of - a declared row action. (2) A collision with a NON-dialog row action is a fail-closed miss and the
+ // child catalog is NOT consulted, because rescuing it there would be the same shadowing hole through a
+ // narrower door (a payload shadowing, say, a gated direct-submit Delete). (3) Only an id that resolves to
+ // nothing today reaches the dialog-scoped catalog - which is exactly the case that fails closed with no
+ // signal to its author today. (4) A miss in both stays today's fail-closed disabled+marked paint.
+ if (dialogActionIsOpenable(ctx, actionId)) {
const target = findRowActionById(ctx?.viewDef, actionId);
const failing = firstFailingRowActionRule(target, rowDataForTr(ctx, tr));
if (failing) disableRowActionControl(control, failing.reason);
+ } else if (rowActionIdExists(ctx, actionId) || ! childActionById(childCatalog, actionId)) {
+ control.disabled = true;
+ control.setAttribute("aria-disabled", "true");
+ control.dataset.juneauActionMissing = "1";
}
- control.addEventListener("click", function () { openFormActionDialog(actionId, table, tr, ctx); });
+ control.addEventListener("click", function () { openFormActionDialog(actionId, table, tr, ctx, childCatalog); });
return control;
}
@@ -4342,12 +4417,12 @@
if (f.pattern != null) control.dataset.juneauPattern = String(f.pattern);
}
- function paintFormControl(row, f, id, table, tr, ctx) {
+ function paintFormControl(row, f, id, table, tr, ctx, childCatalog) {
const type = (f.type == null || f.type === "") ? "text" : String(f.type);
if (! isTypedFormInputType(type)) return null;
// `action` has no shared id/name/value tail below - it wires its own name/dataset and returns early.
if (type === "action") {
- return buildActionFormControl(f, table, tr, ctx, row);
+ return buildActionFormControl(f, table, tr, ctx, row, childCatalog);
}
let control;
if (type === "textarea") control = document.createElement("textarea");
@@ -4422,8 +4497,21 @@
* the SAME kind of visible refusal (fresh, fail-closed re-check - never a silent no-op, never a throw): the
* button's own disabled state (buildActionFormControl) is defense in depth, not the only gate.
*/
- function openFormActionDialog(actionId, table, tr, ctx) {
- if (! dialogActionIsOpenable(ctx, actionId)) { renderDialogActionRefusal(actionId); return; }
+ function openFormActionDialog(actionId, table, tr, ctx, childCatalog) {
+ // A dialog whose write is in flight or already settled into a receipt has no live form to branch FROM, so a
+ // stale `type=action` click (a child button the hold could not reach, an enter-key replay) must not stack a
+ // child onto a committed layer. Cheapest correct answer is the no-op: the busy marker is the same one the
+ // hold paints, so there is no second piece of state to keep in sync.
+ if (topDialogEl()?.dataset?.juneauDialogBusy === "1") return;
+ if (! dialogActionIsOpenable(ctx, actionId)) {
+ // Fail-closed ordering mirrors buildActionFormControl exactly: a collision with a NON-dialog row action
+ // refuses OUTRIGHT rather than falling through to the dialog-scoped catalog, so a served child-action
+ // payload can never shadow a declared row action. See that function's comment for why.
+ const child = rowActionIdExists(ctx, actionId) ? null : childActionById(childCatalog, actionId);
+ if (! child) { renderDialogActionRefusal(actionId); return; }
+ openChildActionDialog(child, table, tr, ctx);
+ return;
+ }
let target = null;
const catalog = (ctx?.viewDef?.rowActions) || [];
for (const c of catalog) if (c?.id === actionId) { target = c; break; }
@@ -4432,6 +4520,39 @@
openActionDialog(target, table, tr, ctx);
}
+ /**
+ * Opens a STACKED step declared in the enclosing dialog's own `childActions` catalog (WORK-J0513 Scope B).
+ *
+ * <p>Deliberately NOT a RowAction: a child action is reachable only from the form of the dialog that declares it,
+ * so it never surfaces in buildRowActionMenu, never surfaces in the ribbon resolver, and needs no exclusion
+ * filter in either - the same disjoint-catalog argument that keeps J0512's ribbon dialogs out of the row menus.
+ * The ChildAction shape is then adapted to the RowAction shape openActionDialog already takes, so the child rides
+ * the SAME reviewed dialog machinery (confirmation GET, typed fields, optional form, fail-closed CSRF submit,
+ * typed-result settling) rather than a parallel implementation of it.
+ *
+ * <p>`carryDrafts` encodes the PARENT dialog's current form values onto the child's form URL, so a wizard step
+ * can prefill from what the user has typed but not yet submitted. It is capped, and an over-cap carry is a
+ * visible refusal rather than a silently truncated - and therefore silently wrong - prefill.
+ */
+ function openChildActionDialog(child, table, tr, ctx) {
+ let resolved = child;
+ // The server rejects carryDrafts on a child with no form, so the second half of this guard is defense in
+ // depth against a hand-rolled payload - not a shape the validator lets through.
+ if (child.carryDrafts && child.form != null) {
+ resolved = withDraftQuery(child, collectDialogFormFields(topDialogEl()));
+ if (! resolved) { renderDialogDraftRefusal(child.id); return; }
+ }
+ openActionDialog({
+ id: String(resolved.id),
+ label: resolved.label == null ? String(resolved.id) : String(resolved.label),
+ type: "dialog",
+ form: resolved.form == null ? null : String(resolved.form),
+ endpoint: resolved.endpoint == null ? null : String(resolved.endpoint),
+ method: resolved.method == null ? null : String(resolved.method),
+ onSuccess: resolved.onSuccess == null ? null : String(resolved.onSuccess)
+ }, table, tr, ctx);
+ }
+
/**
* Paints a visible fail-closed refusal into the current top dialog: a missing/non-dialog action id (no
* `reason`), or a resolvable action gated-and-failing for this row (`reason` is the failing enabledWhen rule's
@@ -4472,16 +4593,16 @@
* per-control error sibling. Field ids use a dialog-only sequence (`juneau-dialog-field-<seq>-<name>`) so stacked
* dialogs with the same field name do not collide and popover layers do not shift ids (N-3).
*/
- function appendDialogForm(dialog, form, table, tr, ctx, seq) {
+ function appendDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {
if (!dialog || !form) return;
// A sectioned form and a flat one are mutually exclusive on the server (FormDef.validate rejects both), so
// sections win here without needing to reconcile the two.
- if (form.sections?.length) { appendSectionedDialogForm(dialog, form, table, tr, ctx, seq); return; }
+ if (form.sections?.length) { appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog); return; }
if (!form.fields || !form.fields.length) return;
const wrap = document.createElement("div");
wrap.className = "juneau-view-dialog-form";
wrap.dataset.testid = "dialog-form";
- form.fields.forEach(function (f) { appendDialogFormRow(wrap, dialog, f, table, tr, ctx, seq); });
+ form.fields.forEach(function (f) { appendDialogFormRow(wrap, dialog, f, table, tr, ctx, seq, childCatalog); });
if (wrap.childNodes.length)
dialog.appendChild(wrap);
}
@@ -4516,7 +4637,7 @@
bindControlValidation(dialog, control);
}
- function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq) {
+ function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq, childCatalog) {
if (!f || f.name == null || String(f.name) === "") return;
const type = (f.type == null || f.type === "") ? "text" : String(f.type);
if (! isTypedFormInputType(type)) return;
@@ -4529,7 +4650,7 @@
label.textContent = f.label != null ? String(f.label) : String(f.name);
row.appendChild(label);
}
- const control = paintFormControl(row, f, id, table, tr, ctx);
+ const control = paintFormControl(row, f, id, table, tr, ctx, childCatalog);
if (! control) return; // unknown type skipped
if (type !== "action") appendDialogFormHelpAndError(row, dialog, control, f, id);
host.appendChild(row);
@@ -4548,7 +4669,7 @@
* <p>The strip opens NO layer: a sectioned dialog is ONE dialog and consumes exactly one MAX_DIALOG_DEPTH slot,
* so a `type=action` control inside a pane still stacks a real nested dialog and still refuses at the cap.
*/
- function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq) {
+ function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {
const wrap = document.createElement("div");
wrap.className = "juneau-view-dialog-form";
wrap.dataset.testid = "dialog-form";
@@ -4561,7 +4682,7 @@
pane.dataset.juneauFormSection = String(s.id);
const fields = s.fields || [];
for (const field of fields)
- appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq);
+ appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq, childCatalog);
if (! pane.childNodes.length) continue; // every field skipped -> no empty tab
items.push({ id: String(s.id), label: s.label == null ? String(s.id) : String(s.label), pane: pane });
}
@@ -4736,7 +4857,15 @@
ui.confirmBtn.addEventListener("click", function () {
if (! validateDialogForm(ui.dialog, true)) return; // fail-loud client validation before the submit
const fields = collectDialogFormFields(ui.dialog);
- close();
+ // A modal that opted in to hosting its own result KEEPS ITS LAYER and registers a hold instead of
+ // closing; the settle then decides that layer's fate from the outcome. Registration happens BEFORE the
+ // submit because the submit's client-refusal terminal can settle synchronously. The hold is passed
+ // ctx-scoped rather than as a submit parameter on purpose: submitActionDialog stays byte-identical, so
+ // J0512's targetId precedence and its tripwire keep covering the only submit path there is.
+ // No ctx means no hold registry AND no dialog stack to check liveness against, so it closes as today -
+ // the fail-safe direction: today's behavior, not a dialog nothing can ever settle.
+ if (modal?.keepOpenOnSubmit && ctx) beginDialogResultHold(ui, action, modal, ctx);
+ else close();
submitActionDialog(modal, action, table, tr, ctx, fields);
});
if (ctx) {
@@ -4795,6 +4924,459 @@
submitRowAction(action, table, tr, ctx, extra);
}
+ // ==================================================================================================================
+ // THE NON-SUBMITTABLE IN-DIALOG RESULT HOST (WORK-J0513 Scope A): one mechanism, terminal by construction
+ // ==================================================================================================================
+ //
+ // A dialog that opted in with ModalDef.keepOpenOnSubmit is NOT closed at confirm-click time; instead its Confirm
+ // is disabled, the dialog is marked busy, and a HOLD is registered on ctx. Every terminal of the submit graph
+ // then settles that hold to exactly one of five named dispositions, so a held dialog can always be exited: it
+ // either re-enables Confirm, or offers a Close-only surface, or is removed. There is no fourth state.
+ //
+ // The hold is registered on `ctx` rather than passed as a callback parameter ON PURPOSE: submitActionDialog is a
+ // shared submit seam with its own pinned target-precedence rule, and threading a completion callback through it
+ // would couple this feature's diff to that one. Coupling through ctx._resultHold leaves it byte-identical.
+ //
+ // WHY THE RESULT HOST IS NOT THE PREVIOUSLY-REJECTED "open a follow-up dialog from the response" shape: the
+ // distinguishing property is POSITION RELATIVE TO THE COMMIT, and it is enforced by three independently-testable
+ // absences rather than by documentation. (1) The host never opens a new dialog - it swaps the body of the dialog
+ // that is already open, at the same layer depth, so the layer count is unchanged across the swap. (2) It is
+ // reached ONLY after outcome === "success", i.e. only after a committed write, so no pre-commit path can produce
+ // it and there is nothing for a dry-run response mode to serve. (3) The host it paints CANNOT submit -
+ // paintDialogReceipt creates no confirm control, references no submit function, and refuses outright to paint a
+ // payload that carries a `form`. A pre-write follow-up step is a different thing entirely: it is a stacked child
+ // dialog opened BEFORE submit (Scope B, below), and the two are structurally disjoint.
+
+ /** The in-dialog held-outcome notice: ONE node per dialog, overwritten - never a growing pile of siblings. */
+ const DIALOG_RESULT_NOTICE_CLASS = "juneau-view-dialog-result-notice";
+ const DIALOG_RESULT_NOTICE_TESTID = "dialog-result-notice";
+
+ /** T2's wording: the request WAS sent, so the outcome is ambiguous rather than a plain "try again". */
+ const REQUEST_UNCONFIRMED_NOTICE =
+ "The request was sent but no response came back. It may or may not have been applied - re-check before retrying.";
+
+ /** T9's wording: headers arrived, so the server answered; only the body was unreadable. */
+ const BODY_UNREADABLE_NOTICE =
+ "The server answered but the response body could not be read. The action may or may not have been applied.";
+
+ /** F12: the write COMMITTED and only the follow-up read failed - nothing here casts doubt on the write. */
+ const RESULT_FORM_UNAVAILABLE_NOTICE =
+ "The action completed, but its result could not be loaded.";
+
+ /** F1c: a well-formed payload of the FORBIDDEN shape - a consumer authoring bug, diagnosed as one. */
+ const RESULT_FORM_REFUSED_NOTICE =
+ "The action completed, but its result was not shown: a result form must not carry inputs.";
+
+ /** Paints (or OVERWRITES) the one in-dialog held-outcome notice, through the shared top-dialog notice painter. */
+ function renderHeldResultNotice(text) {
+ renderTopDialogNotice(DIALOG_RESULT_NOTICE_CLASS, DIALOG_RESULT_NOTICE_TESTID, text);
+ }
+
+ /** The `.juneau-view-dialog` element of the CURRENT top layer, or null when no layer is open. */
+ function topDialogEl() {
+ const el = topLayer()?.el;
+ return (el?.querySelector?.(".juneau-view-dialog")) || el || null;
+ }
+
+ /** Removes a dialog's actions row outright - which detaches Confirm AND its listener, not merely disables it. */
+ function removeDialogActionsRow(dialog) {
+ if (! dialog?.querySelectorAll) return;
+ for (const el of dialog.querySelectorAll(".juneau-view-dialog-actions")) removeEl(el);
+ }
+
+ /**
+ * Tears down a COMMITTED write's body before a terminal post-commit surface is painted over it.
+ *
+ * <p>Removing the actions row is not sufficient teardown: the submitted inputs live in a SEPARATE
+ * `.juneau-view-dialog-form` wrapper (and, for a sectioned form, a live section ribbon), which nothing else
+ * here touches and the receipt paint does not repaint. Without this a receipt would render the server's
+ * result UNDERNEATH the still-populated, still-editable form the operator just submitted - a terminal surface
+ * carrying live inputs for a write that already committed, i.e. the forbidden shape leaking back in through
+ * sloppy teardown rather than through a submit control. The old field list goes too, since the receipt
+ * repaints one from its own payload and a surviving `dl` would double it.
+ */
+ function teardownCommittedWriteBody(dialog) {
+ if (! dialog?.querySelectorAll) return;
+ for (const el of dialog.querySelectorAll(".juneau-view-dialog-form")) removeEl(el);
+ for (const el of dialog.querySelectorAll(".juneau-view-dialog-fields")) removeEl(el);
+ }
+
+ /**
+ * The ONE Close-only action row every non-receipt `terminal` surface uses (T2, T3, T7, T9, T8-failure,
+ * T8-unknown, plus the two post-commit cells F12 and F1c). Its only listener is the WHOLE-STACK teardown, so
+ * the operator's exit from a terminal held dialog is identical everywhere and lives in exactly one place.
+ *
+ * <p>Factoring it here is also what makes the terminal dispositions buildable at all: the automatic-teardown
+ * paths are forbidden from calling closeActionDialog themselves, so they delegate their Close button to this
+ * helper. A Close button the operator has to press is that operator's exit, not an automatic teardown.
+ */
+ function appendDialogCloseOnlyRow(dialog, ctx) {
+ if (! dialog?.appendChild) return null;
+ const actions = document.createElement("div");
+ actions.className = "juneau-view-dialog-actions";
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = "juneau-view-dialog-close";
+ close.dataset.testid = "dialog-result-close";
+ close.textContent = "Close";
+ close.addEventListener("click", function () { closeActionDialog(ctx); });
+ actions.appendChild(close);
+ dialog.appendChild(actions);
+ return close;
+ }
+
+ /**
+ * Registers the click-time hold: disable Confirm, mark the dialog busy, and disable every child-action button
+ * in it - RECORDING which were already disabled, so the re-enable path restores the PRIOR state and can never
+ * enable a child that was gated before the submit started.
+ *
+ * <p>Cancel deliberately stays enabled. Escape pops the top layer through the shared stack regardless, so the
+ * design has to tolerate "dialog gone before settle" anyway; given that, disabling Cancel would only remove the
+ * operator's obvious exit. The layer-gone case is handled at settle time.
+ *
+ * <p>Child-action buttons are disabled because the row in-flight marker cannot help here (it disables a trigger
+ * inside a `tr`, and a ribbon-hosted dialog has no row), and because a live child button during the in-flight
+ * parent write lets an operator push a child layer on top of a dialog that is about to be swapped into a
+ * receipt - the parent settles, the body is torn down under a live child, and the operator is typing into a
+ * step whose parent already committed.
+ */
+ function beginDialogResultHold(ui, action, modal, ctx) {
+ ui.confirmBtn.disabled = true;
+ ui.dialog.dataset.juneauDialogBusy = "1";
+ const disabledChildren = [];
+ if (ui.dialog.querySelectorAll)
+ for (const c of ui.dialog.querySelectorAll(".juneau-view-dialog-form-action")) {
+ if (c.disabled) { disabledChildren.push(c); continue; }
+ c.disabled = true;
+ c.setAttribute("aria-disabled", "true");
+ }
+ ctx._resultHold = {
+ backdrop: ui.backdrop, dialog: ui.dialog, confirmBtn: ui.confirmBtn,
+ action: action, modal: modal, disabledChildren: disabledChildren
+ };
+ }
+
+ /** Whether the held dialog's layer is still on this ctx's dialog stack (it is not, after Cancel or Escape). */
+ function heldLayerIsLive(ctx, hold) {
+ const stack = ctx?._dialogStack;
+ return !! (stack && stack.indexOf(hold.backdrop) >= 0);
+ }
+
+ /** Drops the busy marker a hold stamped on its dialog. */
+ function clearDialogBusyMarker(hold) {
+ if (hold?.dialog?.dataset) delete hold.dialog.dataset.juneauDialogBusy;
+ }
+
+ /** Restores child-action buttons to their PRE-HOLD state - never enabling one that was already gated. */
+ function restoreHeldChildActions(hold) {
+ const dialog = hold?.dialog;
+ if (! dialog?.querySelectorAll) return;
+ const prior = hold.disabledChildren || [];
+ for (const c of dialog.querySelectorAll(".juneau-view-dialog-form-action")) {
+ if (prior.indexOf(c) >= 0) continue;
+ c.disabled = false;
+ c.removeAttribute("aria-disabled");
+ }
+ }
+
+ /**
+ * Settles the registered hold to one named disposition, and reports whether the calling terminal's OWN row /
+ * ribbon painter was superseded.
+ *
+ * <p>The hold is cleared FIRST, which is what guarantees exactly one hand-off per submit even though the
+ * submit graph has nine terminals: they are mutually exclusive paths through one submitRowAction call, and a
+ * second settle on the same hold finds nothing.
+ *
+ * <p>Returns <code>false</code>, having done nothing, when there is no hold at all or when the registered layer
+ * is no longer on the stack (the operator pressed Cancel or Escape mid-flight). The caller then paints into
+ * the row / ribbon host exactly as it does today, which is what makes leaving Cancel enabled safe.
+ *
+ * <p>Returns <code>true</code> for `retryable` / `terminal` / `receipt` - the three dispositions that leave the
+ * dialog ON SCREEN, so the notice belongs in the dialog where the operator can read it and painting the row
+ * banner too would render one outcome twice, once behind a backdrop. Returns <code>false</code> for
+ * `closeCommitted`, because the dialog is GONE and the row / ribbon banner is then the only surviving signal -
+ * suppressing it there would leave a committed write with no visible confirmation anywhere.
+ *
+ * <p>applySuccessBehavior is never skipped under any disposition; only the BANNER moves. That is safe for an
+ * open receipt because an onSuccess=redraw is a data reload, not a DOM teardown, and a portalled dialog is a
+ * body-level layer a row repaint cannot remove.
+ */
+ function settleDialogResultHold(ctx, disposition) {
+ const hold = ctx?._resultHold ?? null;
+ if (ctx) ctx._resultHold = null; // cleared FIRST: no terminal may settle the same hold twice
+ if (! hold || ! heldLayerIsLive(ctx, hold)) return false;
+ clearDialogBusyMarker(hold);
+ if (disposition === "closeCommitted") {
+ // The ONE automatic teardown in the whole graph, and it takes the WHOLE stack: a terminal surface for a
+ // committed write must not drop the operator back onto a now-stale parent dialog underneath it. At
+ // depth 1 this is observably identical to popping the single layer.
+ closeActionDialog(ctx);
+ return false;
+ }
+ if (disposition === "retryable" || disposition === "release") {
+ // `release` is RESERVED and currently passed by nothing - a defined name for "let the caller's UI run,
+ // dialog untouched". It is deliberately NOT the no-hold escape above, which returns false.
+ if (hold.confirmBtn) hold.confirmBtn.disabled = false;
+ restoreHeldChildActions(hold);
+ return true;
+ }
+ if (disposition === "terminal") {
+ // Actions row only. The form body is left standing on purpose: T2/T3/T7/T9 are not confirmed-commit
+ // surfaces, so the operator keeps their typed values on screen to copy out before Close. The two
+ // post-commit `terminal` cells (F12, F1c) run the body teardown themselves, in fetchResultForm.
+ removeDialogActionsRow(hold.dialog);
+ appendDialogCloseOnlyRow(hold.dialog, ctx);
+ }
+ return true;
+ }
+
+ /**
+ * F4 - a result carried a resultForm but its dialog never opted in with keepOpenOnSubmit.
+ *
+ * <p>The write SUCCEEDED, so the success banner is left byte-identical and this adds exactly one non-alarming
+ * `role=status` diagnostic into the SAME outcome host the success already used. Never a new layer (that is the
+ * forbidden shape), and never a refusal (which would make a successful write look failed for what is purely a
+ * consumer-authoring mistake). One node, overwritten.
+ */
+ function renderResultFormIgnored(tr, ctx) {
+ const host = tr
+ ? (tr.querySelector ? (tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr) : tr)
+ : ribbonBannerHost(ctx);
+ if (! host?.appendChild) return;
+ let el = host.querySelector ? host.querySelector(".juneau-view-result-form-ignored") : null;
+ if (! el) {
+ el = document.createElement("div");
+ el.className = "juneau-view-result-form-ignored";
+ el.dataset.testid = "result-form-ignored";
+ el.setAttribute("role", "status");
+ host.appendChild(el);
+ }
+ el.textContent = "This action returned a result to show, but its dialog did not opt in to staying open"
+ + " (ModalDef.keepOpenOnSubmit), so the result was not shown.";
+ }
+
+ /** Whether `kind` is a legal ModalDef.Field display kind (the frozen 2-token allowlist: text, code). */
+ function isModalFieldKind(kind) {
+ return kind === "text" || kind === "code";
+ }
+
+ /**
+ * A guarded clipboard copy button for a `code`-kind field. Feature-detected, `.catch()`ed, and wrapped so it
+ * can never throw out of a click handler; the `<pre>` beside it stays selectable, so manual copy always works
+ * even where the clipboard API is absent or permission is denied.
+ */
+ function buildFieldCopyButton(value) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "juneau-view-dialog-field-copy";
+ btn.dataset.testid = "dialog-field-copy";
+ btn.textContent = "Copy";
+ btn.addEventListener("click", function () {
+ const clip = (typeof navigator === "undefined") ? null : navigator?.clipboard;
+ if (typeof clip?.writeText !== "function") return;
+ try {
+ Promise.resolve(clip.writeText(value)).catch(function () { /* denied - the <pre> stays selectable */ });
+ } catch (e) { /* a copy affordance never throws */ }
+ });
+ return btn;
+ }
+
+ /**
+ * Paints ONE confirmation field's value into its `<dd>`: plain text by default, and for `kind="code"` a
+ * monospaced `<pre>` plus a copy button. createElement / textContent ONLY - never innerHTML, for exactly the
+ * reason the whole dialog payload is painted that way (live data is attacker-influenceable and this origin
+ * holds the CSRF token). An UNRECOGNIZED kind token falls back to `text` rather than being trusted.
+ */
+ function buildModalFieldValueNode(dd, f) {
+ const value = (f?.value != null) ? String(f.value) : "";
+ const kind = (f?.kind == null || f.kind === "") ? "text" : String(f.kind);
+ if (! isModalFieldKind(kind) || kind === "text") { dd.textContent = value; return dd; }
+ const pre = document.createElement("pre");
+ pre.className = "juneau-view-dialog-field-code";
+ pre.textContent = value;
+ dd.appendChild(pre);
+ dd.appendChild(buildFieldCopyButton(value));
+ return dd;
+ }
+
+ /**
+ * Paints a `<dl>` of typed confirmation fields for the RECEIPT.
+ *
+ * <p>The confirmation overlay keeps its own `dl` shell rather than calling this: its shell is pinned where it
+ * stands, and what actually needed sharing is the per-value painter above (the `kind` dispatch), not the three
+ * lines of `dl` around it.
+ */
+ function buildModalFieldList(fields) {
+ const dl = document.createElement("dl");
+ dl.className = "juneau-view-dialog-fields";
+ dl.dataset.testid = "dialog-fields";
+ fields.forEach(function (f) {
+ const dt = document.createElement("dt");
+ dt.textContent = (f?.label != null) ? String(f.label) : "";
+ const dd = document.createElement("dd");
+ buildModalFieldValueNode(dd, f);
+ dl.appendChild(dt);
+ dl.appendChild(dd);
+ });
+ return dl;
+ }
+
+ /**
+ * Paints the RECEIPT into the dialog that is already open - the enforcement, and it enforces by ABSENCE.
+ *
+ * <p>This function creates no confirm control, names no submit function, and pushes no layer. A structural
+ * marker would enforce by veto (a later change can delete the check, and a DOM attribute a fixture can forge);
+ * an absence is what a test can pin cheaply and what a careless edit cannot silently undo. The
+ * `data-juneau-receipt` stamp is for tests and CSS - it is NOT the enforcement.
+ *
+ * <p>A payload carrying a `form` is refused outright rather than painted read-only or silently stripped: inputs
+ * on a terminal post-commit surface are the forbidden shape arriving through the receipt door, and refusing
+ * loudly makes it a visible authoring bug instead of a silently-degraded surface. That refusal is the caller's
+ * (fetchResultForm), which never reaches this function for such a payload.
+ */
+ function paintDialogReceipt(dialog, modal, ctx) {
+ if (! dialog?.appendChild) return false;
+ removeDialogActionsRow(dialog);
+ teardownCommittedWriteBody(dialog);
+ const title = dialog.querySelector ? dialog.querySelector(".juneau-view-dialog-title") : null;
+ if (title) title.textContent = isBlankToken(modal?.title) ? "Done" : String(modal.title);
+ if (modal?.fields?.length)
+ dialog.appendChild(buildModalFieldList(modal.fields));
+ const actions = document.createElement("div");
+ actions.className = "juneau-view-dialog-actions";
+ const close = document.createElement("button");
+ close.type = "button";
+ close.className = "juneau-view-dialog-close";
+ close.dataset.testid = "dialog-receipt-close";
+ close.textContent = "Close";
+ // The WHOLE stack, not this one layer: a terminal receipt for a committed write must not drop the operator
+ // back onto the now-stale parent dialog underneath, showing pre-write values for a write that committed.
+ close.addEventListener("click", function () { closeActionDialog(ctx); });
+ actions.appendChild(close);
+ dialog.appendChild(actions);
+ dialog.className = dialog.className + " juneau-view-dialog-receipt";
+ dialog.dataset.testid = "dialog-receipt";
+ dialog.dataset.juneauReceipt = "1";
+ return true;
+ }
+
+ /**
+ * Issues the follow-up READ-ONLY receipt GET and routes its four outcomes.
+ *
+ * <p>Three of them are the same F12 terminal - "the write committed, we just could not show you the result":
+ * a non-2xx, an unparseable payload, and a REJECTED promise. The third arm matters as much as the other two:
+ * without it a single dropped packet on the follow-up read leaves the promise rejected, the terminal unreached,
+ * and Confirm stuck disabled + busy on a write that had ALREADY COMMITTED.
+ *
+ * <p>The fourth is distinct and must stay distinct: a 2xx that parses fine but carries a `form` is a
+ * SUCCESSFUL read of a payload of the forbidden shape. It shares F12's disposition, teardown and Close-only
+ * row, but it carries its own wording, because folding a consumer authoring bug into a transport-failure
+ * message hides it.
+ *
+ * <p>No automatic teardown here: every arm ends with the dialog OPEN, and the Close row's listener is the
+ * operator's exit rather than a terminal closing the stack behind their back.
+ */
+ function fetchResultForm(url, action, table, tr, ctx) {
+ const dialog = ctx?._resultHold?.dialog ?? null;
+ function resultFormTerminal(text) {
+ teardownCommittedWriteBody(dialog); // post-commit surface: the submitted inputs come out
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(text);
+ }
+ fetch(url, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json" } })
+ .then(function (resp) {
+ if (! resp || ! resp.ok) { resultFormTerminal(RESULT_FORM_UNAVAILABLE_NOTICE); return null; }
+ return readBodyText(resp).then(function (text) {
+ const payload = parseJsonSafe(text);
+ if (! payload) { resultFormTerminal(RESULT_FORM_UNAVAILABLE_NOTICE); return; }
+ if (payload.form) { resultFormTerminal(RESULT_FORM_REFUSED_NOTICE); return; }
+ if (! settleDialogResultHold(ctx, "receipt")) return; // the layer went away mid-GET
+ paintDialogReceipt(dialog, payload, ctx);
+ });
+ })
+ .catch(function () {
+ // F12's third arm, written out here rather than delegated: the pin on this function slices from its
+ // last `.catch(` to the end and requires the SETTLE inside it, because a `.catch` that merely logs
+ // would satisfy a bare "has a catch" check while leaving the hold uncleared on a committed write.
+ teardownCommittedWriteBody(dialog);
+ if (settleDialogResultHold(ctx, "terminal")) renderHeldResultNotice(RESULT_FORM_UNAVAILABLE_NOTICE);
+ });
+ }
+
+ // ==================================================================================================================
+ // THE DIALOG-SCOPED CHILD-ACTION CATALOG (WORK-J0513 Scope B): reaching a stacked step from inside a dialog
+ // ==================================================================================================================
+ //
+ // A `type=action` input resolves its actionId against ctx.viewDef.rowActions and nothing else, so a stacked
+ // Review step inside an already-open dialog has nothing legal to resolve against. ModalDef.childActions is that
+ // missing catalog, and it is DIALOG-SCOPED: it arrives on the per-open payload, so buildRowActionMenu and the
+ // ribbon resolver never see it and there is no exclusion filter to maintain (or to forget).
+ //
+ // It reaches the click site as a TRAILING OPTIONAL parameter threaded through both row-painting paths. Not
+ // stamped on the dialog element (framework state on a DOM node, forgeable from the document), and not looked up
+ // from topLayer() at click time (that assumes the button's dialog is topmost - true today, but an assumption a
+ // popover push could break). Threaded and captured in the click closure, it is unforgeable; and because the
+ // parameter is trailing and optional, every existing positional call site keeps resolving exactly as today.
+
+ /** The client-side cap on the draft query, measured on the ENCODED value. See withDraftQuery. */
+ const MAX_DRAFT_QUERY_BYTES = 2048;
+
+ /** Resolves `actionId` against a dialog-scoped child catalog, or null. */
+ function childActionById(childCatalog, actionId) {
+ for (const c of (childCatalog || []))
+ if (c?.id === actionId) return c;
+ return null;
+ }
+
+ /** Whether `actionId` names ANY entry in this view's row-action catalog (dialog-presenting or not). */
+ function rowActionIdExists(ctx, actionId) {
+ for (const c of ((ctx?.viewDef?.rowActions) || []))
+ if (c?.id === actionId) return true;
+ return false;
+ }
+
+ /**
+ * The byte length of an encoded draft query value.
+ *
+ * <p>The cap is measured on the ENCODED value - the bytes that actually land in the URL, the access log and
+ * the browser's history entry - and not on the raw JSON. Percent-encoding is up to 3x expansion per byte for
+ * non-ASCII and for the braces / quotes / colons JSON is made of, so a cap applied to the raw string would let
+ * a ~2KB draft become a ~6KB query and blow a server or proxy request-line limit AFTER the client had already
+ * told the operator it was fine: a refusal arriving as an opaque transport error instead of a visible,
+ * actionable one.
+ */
+ function draftQueryByteLength(encoded) {
+ if (typeof TextEncoder === "function") return new TextEncoder().encode(encoded).length;
+ return encoded.length; // the encoded form is ASCII-only by construction, so the two agree
+ }
+
+ /**
+ * A SHALLOW COPY of `childAction` whose form URL already carries the parent's drafts as one `juneauDrafts`
+ * query parameter; null when the encoded value exceeds the cap (the caller then refuses visibly and opens
+ * nothing).
+ *
+ * <p>A copy, rather than a parameter on the dialog opener: the opener is a shared surface with its own pinned
+ * shape, and a descriptor whose `form` is already final needs nothing from it.
+ *
+ * <p>Without this channel a Review step opens EMPTY and loses everything the operator typed. Drafts land in
+ * access logs and browser history, so no secret may ride here.
+ */
+ function withDraftQuery(childAction, drafts) {
+ const encoded = encodeURIComponent(JSON.stringify(drafts || {}));
+ if (draftQueryByteLength(encoded) > MAX_DRAFT_QUERY_BYTES) return null;
+ const copy = {};
+ for (const k of Object.keys(childAction)) copy[k] = childAction[k];
+ const url = String(childAction.form);
+ copy.form = url + (url.indexOf("?") >= 0 ? "&" : "?") + "juneauDrafts=" + encoded;
+ return copy;
+ }
+
+ /** The visible over-cap refusal: the child does not open, and the operator is told why. */
+ function renderDialogDraftRefusal(actionId) {
+ renderTopDialogNotice("juneau-view-dialog-action-refusal", "dialog-action-refusal",
+ "Action '" + String(actionId) + "' was not opened: its form draft exceeds "
+ + MAX_DRAFT_QUERY_BYTES + " bytes.");
+ }
+
/** Renders a VISIBLE row-action refusal (anti-silent-degradation) into the row's actions cell. */
function renderRowActionRefusal(tr, action, reason) {
const cell = tr.querySelector(".juneau-view-actions-cell") || tr.lastElementChild || tr;
@@ -5941,6 +6523,22 @@
renderActionOutcomeFor: renderActionOutcomeFor,
renderDialogActionRefusal: renderDialogActionRefusal,
renderDialogDepthRefusal: renderDialogDepthRefusal,
+ // In-dialog result receipt + dialog-scoped child-action catalog (WORK-J0513) - exposed for the harnesses.
+ MAX_DRAFT_QUERY_BYTES: MAX_DRAFT_QUERY_BYTES,
+ isModalFieldKind: isModalFieldKind,
+ buildModalFieldValueNode: buildModalFieldValueNode,
+ buildModalFieldList: buildModalFieldList,
+ beginDialogResultHold: beginDialogResultHold,
+ settleDialogResultHold: settleDialogResultHold,
+ settleBodyReadFailure: settleBodyReadFailure,
+ appendDialogCloseOnlyRow: appendDialogCloseOnlyRow,
+ paintDialogReceipt: paintDialogReceipt,
+ fetchResultForm: fetchResultForm,
+ renderResultFormIgnored: renderResultFormIgnored,
+ childActionById: childActionById,
+ rowActionIdExists: rowActionIdExists,
+ withDraftQuery: withDraftQuery,
+ openChildActionDialog: openChildActionDialog,
pushLayer: pushLayer,
popLayer: popLayer,
topLayer: topLayer,
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
index ffc2bdbf36..6cfc1fa4c9 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ActionResult_Contract_Test.java
@@ -64,12 +64,46 @@ class ActionResult_Contract_Test extends TestBase {
}
@Test void a04_topLevelKeyOrder_fullResult_matchesBeanTypeOrder() {
- // contractVersion,outcome,replay,refusalCode,message,row (the @BeanType-pinned order).
+ // contractVersion,outcome,replay,refusalCode,message,row,resultForm (the @BeanType-pinned order).
var r = ActionResult.success(Map.of("id", "x")).replay(true).message("done");
Map<?,?> actual = Json.to(Json.of(r), Map.class);
assertEquals(List.of("contractVersion", "outcome", "replay", "message", "row"), new ArrayList<>(actual.keySet()));
}
+ //------------------------------------------------------------------------------------------------------------------
+ // resultForm - the additive receipt pointer (WORK-J0513 Scope A)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a05_resultForm_isAppendedLast_neverInterleaved() {
+ // APPENDED, not interleaved: a reader positioned on the frozen prefix keeps reading the same keys in the
+ // same order, which is what makes this additive rather than a contract change.
+ var r = ActionResult.success(Map.of("id", "x")).replay(true).message("done").resultForm("/x/receipt");
+ Map<?,?> actual = Json.to(Json.of(r), Map.class);
+ assertEquals(List.of("contractVersion", "outcome", "replay", "message", "row", "resultForm"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void a06_resultForm_omittedWhenUnset() {
+ for (var r : List.of(ActionResult.success(null), ActionResult.failure(), ActionResult.unknown())) {
+ var json = Json.of(r);
+ assertFalse(json.contains("resultForm"), () -> "unset resultForm leaked:\n" + json);
+ }
+ assertFalse(Json.of(ActionResult.success(null).resultForm(null)).contains("resultForm"));
+ }
+
+ @Test void a07_resultForm_doesNotBumpTheContractVersion() {
+ // The no-bump is the whole additive claim: an older runtime that has never heard of resultForm keeps
+ // accepting these payloads and simply ignores the key.
+ assertEquals("1", ActionResult.CONTRACT_VERSION);
+ var json = Json.of(ActionResult.success(null).resultForm("/x/receipt"));
+ assertTrue(json.contains("\"contractVersion\":\"1\""), json);
+ assertTrue(json.contains("\"resultForm\":\"/x/receipt\""), json);
+ }
+
+ @Test void a08_resultForm_isFluentAndReadable() {
+ assertEquals("/x/receipt", ActionResult.success(null).resultForm("/x/receipt").resultForm);
+ }
+
//------------------------------------------------------------------------------------------------------------------
// Outcome discriminator + reserved async terminal states (MED-8)
//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java
new file mode 100644
index 0000000000..70fb4dc992
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogChildCatalog_Test.java
@@ -0,0 +1,378 @@
+/*
+ * 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.
+ */
+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.junit.jupiter.api.*;
+
+/**
+ * The dialog-scoped child-action catalog (WORK-J0513 Scope B): {@code ModalDef.childActions} lets a
+ * {@code type="action"} input inside one dialog's form open a STACKED step that is not a row action, and therefore
+ * appears in no row's action menu and in no ribbon.
+ *
+ * <p>Two layers:
+ * <ul class='spaced-list'>
+ * <li><b>Default-gate source pins</b> (a-group): the catalog's scoping is structural rather than a filter some
+ * menu builder has to remember, the catalog reaches the click site as a threaded parameter rather than as
+ * framework state on a DOM node, and the resolution ORDER puts the row-action check first so a served
+ * payload can never shadow - and thereby bypass the gating of - a declared row action.
+ * <li><b>A behavioral harness</b> ({@code dialog-child-catalog.cjs}) driving real clicks through both
+ * row-painting paths. Runs when {@code node} is on {@code PATH}.
+ * </ul>
+ */
+class ViewsJs_DialogChildCatalog_Test extends TestBase {
+
+ private static String resource(String name) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(name)) {
+ assertNotNull(in, () -> "missing classpath resource: " + name);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String viewsJs() throws IOException { return resource(ViewsMixin.VIEWS_JS_RESOURCE); }
+ private static String rendersJs() throws IOException { return resource(ViewsMixin.RENDERS_JS_RESOURCE); }
+
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found in juneau-views.js");
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Default-gate source pins
+ //------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * The catalog is DIALOG-SCOPED structurally: it is read from the per-open payload, so the row-action menu and
+ * the ribbon resolver never see it.
+ *
+ * <p>This is the same disjoint-catalog argument WORK-J0512 made for ribbon dialogs, and it matters for the same
+ * reason: an exclusion filter in {@code buildRowActionMenu} would be a thing to maintain, and a thing to
+ * forget. Nothing to filter is stronger than a filter that works.
+ */
+ @Test void a01_theCatalogIsReadFromThePerOpenPayloadOnly() throws Exception {
+ var views = viewsJs();
+ var overlay = functionBody(views, "\tfunction buildDialogOverlay(");
+ assertTrue(overlay.contains("const childCatalog = modal?.childActions ?? null;"), overlay);
+ // The three catalogs stay disjoint: neither existing resolver was widened to look at childActions.
+ for (var fn : List.of("\tfunction dialogActionIsOpenable(", "\tfunction findRibbonDialogAction(",
+ "\tfunction buildRowActionMenu(", "\tfunction activatePillAction(")) {
+ var body = functionBody(views, fn);
+ assertFalse(body.contains("childActions"),
+ () -> fn + " must not see the dialog-scoped catalog:\n" + body);
+ assertFalse(body.contains("childActionById"),
+ () -> fn + " must not resolve against the dialog-scoped catalog:\n" + body);
+ }
+ }
+
+ /**
+ * The catalog reaches the click site as a THREADED PARAMETER.
+ *
+ * <p>Deliberately not stamped on the dialog element - framework state on a DOM node is forgeable from the
+ * document - and deliberately not looked up from {@code topLayer()} at click time, which would assume the
+ * button's own dialog is topmost. That is true today and is exactly the kind of assumption a later stacked
+ * surface breaks silently.
+ */
+ @Test void a02_theCatalogIsThreadedNotStampedOnTheDom() throws Exception {
+ var views = viewsJs();
+ // One trailing optional parameter on each of the six functions in the chain, so every existing positional
+ // call site keeps working unchanged.
+ assertTrue(views.contains("function appendDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {"), views);
+ assertTrue(views.contains("function appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog) {"), views);
+ assertTrue(views.contains("function appendDialogFormRow(host, dialog, f, table, tr, ctx, seq, childCatalog) {"), views);
+ assertTrue(views.contains("function paintFormControl(row, f, id, table, tr, ctx, childCatalog) {"), views);
+ assertTrue(views.contains("function buildActionFormControl(f, table, tr, ctx, row, childCatalog) {"), views);
+ assertTrue(views.contains("function openFormActionDialog(actionId, table, tr, ctx, childCatalog) {"), views);
+ // Both row-painting paths thread it (a sectioned form is the second one, and is easy to miss).
+ assertTrue(functionBody(views, "\tfunction appendDialogForm(")
+ .contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog)"), views);
+ assertTrue(functionBody(views, "\tfunction appendSectionedDialogForm(")
+ .contains("appendDialogFormRow(pane, dialog, field, table, tr, ctx, seq, childCatalog)"), views);
+ // It is never stamped onto an element.
+ var resolver = functionBody(views, "\tfunction childActionById(");
+ assertFalse(resolver.contains("dataset"), resolver);
+ assertFalse(resolver.contains("topLayer("), resolver);
+ }
+
+ /**
+ * The resolution ORDER, which is the whole security property.
+ *
+ * <p>The existing row-action check runs FIRST and unchanged, {@code enabledWhen} included, so a served payload
+ * can never shadow a declared row action. A collision with a NON-dialog row action is a fail-closed miss and
+ * the child catalog is not consulted at all - rescuing it there would be the same shadowing hole through a
+ * narrower door. Only an id that resolves to nothing today reaches the catalog.
+ */
+ @Test void a03_theRowActionCheckRunsFirstAndTheCatalogIsTheFallback() throws Exception {
+ var views = viewsJs();
+ for (var fn : List.of("\tfunction buildActionFormControl(", "\tfunction openFormActionDialog(")) {
+ var body = functionBody(views, fn);
+ var rowCheck = body.indexOf("dialogActionIsOpenable(ctx, actionId)");
+ var shadow = body.indexOf("rowActionIdExists(ctx, actionId)");
+ var child = body.indexOf("childActionById(childCatalog, actionId)");
+ assertTrue(rowCheck >= 0, () -> fn + " must still run the row-action check:\n" + body);
+ assertTrue(shadow >= 0, () -> fn + " must refuse a non-dialog row-action collision:\n" + body);
+ assertTrue(child >= 0, () -> fn + " must fall back to the dialog catalog:\n" + body);
+ assertTrue(rowCheck < shadow && shadow < child,
+ () -> fn + " must check the row catalog, then the shadow case, then the child catalog:\n" + body);
+ // The gate on the row-action branch was NOT relaxed to accommodate the new fallback.
+ assertTrue(body.contains("firstFailingRowActionRule(target, rowDataForTr(ctx, tr))")
+ || body.contains("firstFailingRowActionRule(target,"), body);
+ }
+ // rowActionIdExists is deliberately BROADER than dialogActionIsOpenable - it is what makes the
+ // non-dialog collision refusable.
+ var exists = functionBody(views, "\tfunction rowActionIdExists(");
+ assertTrue(exists.contains("ctx?.viewDef?.rowActions"), exists);
+ assertFalse(exists.contains("isDialogAction("),
+ () -> "rowActionIdExists must see NON-dialog row actions too, or a gated direct-submit action can be "
+ + "shadowed:\n" + exists);
+ }
+
+ /** A `type=action` click on a dialog whose write is in flight or already a receipt is a no-op. */
+ @Test void a04_aStaleChildClickOnABusyDialogIsANoOp() throws Exception {
+ var body = functionBody(viewsJs(), "\tfunction openFormActionDialog(");
+ assertTrue(body.contains("topDialogEl()?.dataset?.juneauDialogBusy === \"1\""), body);
+ // The guard is the FIRST thing it does - after any resolution work it would be checking too late.
+ var guard = body.indexOf("juneauDialogBusy");
+ var resolve = body.indexOf("dialogActionIsOpenable(ctx, actionId)");
+ assertTrue(guard >= 0 && resolve >= 0 && guard < resolve, body);
+ // The SAME marker the hold paints, so there is no second piece of state to keep in sync.
+ assertTrue(functionBody(viewsJs(), "\tfunction beginDialogResultHold(")
+ .contains("ui.dialog.dataset.juneauDialogBusy = \"1\""), body);
+ }
+
+ /**
+ * The draft cap is measured on the ENCODED value.
+ *
+ * <p>Percent-encoding is up to 3x expansion per byte, so a cap applied to the raw JSON would let a ~2KB draft
+ * become a ~6KB query and blow a server or proxy request-line limit AFTER the client had already told the
+ * operator it was fine - a refusal arriving as an opaque transport error instead of a visible, actionable one.
+ */
+ @Test void a05_theDraftCapIsMeasuredOnTheEncodedValue() throws Exception {
+ var views = viewsJs();
+ assertTrue(views.contains("const MAX_DRAFT_QUERY_BYTES = 2048;"), views);
+ var fn = functionBody(views, "\tfunction withDraftQuery(");
+ var encode = fn.indexOf("encodeURIComponent(JSON.stringify(drafts");
+ var measure = fn.indexOf("draftQueryByteLength(encoded) > MAX_DRAFT_QUERY_BYTES");
+ assertTrue(encode >= 0 && measure >= 0 && encode < measure,
+ () -> "the cap must be applied to the ENCODED value, not the raw JSON:\n" + fn);
+ // It returns a COPY rather than mutating the descriptor it was handed.
+ assertTrue(fn.contains("const copy = {};"), fn);
+ // Over the cap it returns null so the caller refuses VISIBLY - never a silently-truncated (and therefore
+ // silently-wrong) prefill.
+ assertTrue(fn.contains("return null;"), fn);
+ assertTrue(functionBody(views, "\tfunction renderDialogDraftRefusal(")
+ .contains("MAX_DRAFT_QUERY_BYTES"), views);
+ }
+
+ /** The child is adapted to the shape openActionDialog already takes, rather than reimplementing the machinery. */
+ @Test void a06_aChildRidesTheSameReviewedDialogMachinery() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction openChildActionDialog(");
+ assertTrue(fn.contains("openActionDialog({"), fn);
+ assertTrue(fn.contains("type: \"dialog\""), fn);
+ // No parallel fetch / submit / settle path.
+ for (var forbidden : List.of("fetch(", "submitRowAction(", "settleActionResponse(", "pushLayer("))
+ assertFalse(fn.contains(forbidden),
+ () -> "a child must reuse the shared opener, not re-plumb it - it grew '" + forbidden + "':\n" + fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Behavioral harness
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var rendersFile = Files.createTempFile("juneau-renders-", ".js");
+ var viewsFile = Files.createTempFile("juneau-views-", ".js");
+ try {
+ Files.writeString(rendersFile, rendersJs(), UTF_8);
+ Files.writeString(viewsFile, viewsJs(), UTF_8);
+ report = Json.to(runNode(harness, rendersFile, viewsFile), Map.class);
+ } finally {
+ Files.deleteIfExists(rendersFile);
+ Files.deleteIfExists(viewsFile);
+ }
+ }
+
+ 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/dialog-child-catalog.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/dialog-child-catalog.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path rendersJs, Path viewsJs) throws Exception {
+ var stdout = Files.createTempFile("dialog-child-catalog-stdout-", ".json");
+ var stderr = Files.createTempFile("dialog-child-catalog-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of("node", harness.toString(), rendersJs.toString(), viewsJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("dialog-child-catalog.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("dialog-child-catalog.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 dialog-child-catalog.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ private static void assertAllTrue(String... keys) {
+ var r = report();
+ for (var k : keys)
+ assertEquals(true, r.get(k), () -> k + " was " + r.get(k) + " in " + r);
+ }
+
+ @Test void b00_theCatalogSeamIsWired() {
+ assertEquals(true, report().get("hasCatalogSeam"), report()::toString);
+ }
+
+ /** The case that fails closed today: an id in neither row catalog, now reachable from its own dialog. */
+ @Test void b01_aChildActionOpensAStackedStep() {
+ assertAllTrue("open_parentOpened", "open_childButtonPainted", "open_childButtonEnabled",
+ "open_childButtonNotMarkedMissing", "open_childStackedALayer", "open_childFormWasFetched",
+ "open_noRefusalPainted");
+ }
+
+ /** Scoping, observed: the catalog rides the per-open payload, so no other resolver can see it. */
+ @Test void b02_aChildActionIsInvisibleToEveryOtherCatalog() {
+ assertAllTrue("scope_rowMenuExcludesChild", "scope_ribbonResolverExcludesChild",
+ "scope_rowResolverExcludesChild");
+ assertEquals("step1", report().get("scope_rowMenuIds"),
+ () -> "the row menu must contain the row action and nothing from the dialog catalog: " + report());
+ }
+
+ /** Precedence: a declared DIALOG row action of the same id wins, and its enabledWhen gate still applies. */
+ @Test void b03_aDeclaredRowActionWinsAnIdCollisionAndStaysGated() {
+ assertAllTrue("precedence_rowActionWinsAndStaysGated", "precedence_gateReasonSurfaced",
+ "precedence_reasonOnTheControlTitle", "precedence_notMarkedMissing");
+ }
+
+ /**
+ * A collision with a NON-dialog row action is a fail-closed refusal, not a fall-through.
+ *
+ * <p>The dangerous case is a gated direct-submit action - a Delete, say. Rescuing the collision by consulting
+ * the child catalog would let a served payload shadow it and bypass its gating through a narrower door, which
+ * is the same hole the precedence rule exists to close.
+ */
+ @Test void b04_aNonDialogRowActionCollisionIsRefusedNotRescued() {
+ assertAllTrue("shadow_buttonPaintedDisabled", "shadow_buttonMarkedMissing", "shadow_ariaDisabled",
+ "shadow_clickIsAVisibleRefusal", "shadow_refusalNamesTheId", "shadow_noChildOpened");
+ }
+
+ /** An id in neither catalog stays today's paint, and a click is a visible refusal - never a throw. */
+ @Test void b05_anUnresolvableIdStaysFailClosed() {
+ assertAllTrue("unknown_buttonDisabled", "unknown_buttonMarkedMissing", "unknown_clickIsAVisibleRefusal",
+ "unknown_noChildOpened", "unknown_absentCatalogSameAsEmpty");
+ }
+
+ /** A child consumes a real depth slot, so the cap refuses in the same place with the same visible notice. */
+ @Test void b06_aChildIsARealLayerAndTheDepthCapStillHolds() {
+ assertAllTrue("depth_childIsARealLayer", "depth_thirdIsRefused", "depth_refusalPainted");
+ }
+
+ /** carryDrafts: without it a Review step opens EMPTY and loses everything the operator typed. */
+ @Test void b07_carryDraftsCarriesTheParentsUnsubmittedValues() {
+ assertAllTrue("drafts_queryParamPresent", "drafts_carriedTheEditedValue", "drafts_childOpened");
+ assertEquals("/x/review-form?juneauDrafts=%7B%22note%22%3A%22edited-in-place%22%7D",
+ report().get("drafts_childGetUrl"), report()::toString);
+ }
+
+ @Test void b08_theDraftCapIsEnforcedOnTheEncodedValue() {
+ assertEquals(2048, report().get("cap_value"), report()::toString);
+ assertAllTrue("cap_smallDraftPasses", "cap_overCapIsRefused", "cap_returnsACopyAndDoesNotMutate",
+ "cap_appendsToAnExistingQuery");
+ // The case that distinguishes the two measurements: raw JSON under the cap whose encoded form is over it.
+ assertAllTrue("cap_rawJsonWouldHaveFit", "cap_encodedFormIsRefused");
+ }
+
+ /** Over the cap the child does NOT open, and the operator is told why rather than getting a wrong prefill. */
+ @Test void b09_anOverCapDraftIsAVisibleRefusal() {
+ assertAllTrue("capRefusal_noChildOpened", "capRefusal_visible", "capRefusal_namesTheCap");
+ }
+
+ @Test void b10_aChildClickOnABusyDialogIsANoOp() {
+ assertAllTrue("busy_noChildStacked", "busy_noRefusalPainted", "busy_worksAgainOnceCleared");
+ }
+
+ /** The sectioned form is the second row-painting path, and threading it is easy to miss. */
+ @Test void b11_aSectionedParentFormThreadsTheCatalogToo() {
+ assertAllTrue("sectioned_buttonPainted", "sectioned_buttonEnabledFromCatalog",
+ "sectioned_withoutCatalogStaysDisabled");
+ }
+
+ @Test void b12_theResolverHelpersInIsolation() {
+ assertAllTrue("helpers_childActionByIdFindsFirst", "helpers_childActionByIdMissIsNull",
+ "helpers_rowActionIdExistsSeesNonDialog", "helpers_dialogActionIsOpenableDoesNot",
+ "helpers_rowActionIdExistsMiss", "helpers_rowActionIdExistsNullSafe");
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
index 655a2d060f..d5de90e404 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogForm_Test.java
@@ -192,6 +192,18 @@ class ViewsJs_DialogForm_Test extends TestBase {
assertEquals(true, r.get("missing_marker"));
}
+ /**
+ * The trailing {@code childCatalog} parameter (WORK-J0513) is additive at the CALL SITE, not just in the
+ * signature: the six-argument calls above are byte-identical to their pre-J0513 form and still paint today's
+ * fail-closed control, while the same field resolves once a catalog is supplied.
+ */
+ @Test void b04a_theTrailingChildCatalogParameterIsOptional() {
+ var r = report();
+ assertEquals(true, r.get("catalog_enabled"), r::toString);
+ assertEquals(true, r.get("catalog_notMarkedMissing"), r::toString);
+ assertEquals(true, r.get("catalog_stillSkippedByCollect"), r::toString);
+ }
+
@Test void b05_collectReadsTypedValuesAndSkipsActions() {
var r = report();
assertEquals(true, r.get("collect_notes"));
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java
new file mode 100644
index 0000000000..6fbdb695a5
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_DialogReceipt_Test.java
@@ -0,0 +1,550 @@
+/*
+ * 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.
+ */
+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.junit.jupiter.api.*;
+
+/**
+ * The in-dialog result <b>receipt</b> (WORK-J0513 Scope A): a dialog that opted in with
+ * {@code ModalDef.keepOpenOnSubmit} keeps its layer across the submit, and the runtime paints the write's result
+ * into that already-open dialog instead of closing it and painting a row banner.
+ *
+ * <p>Two layers, the same split {@code ViewsJs_RibbonDialog_Test} uses:
+ * <ul class='spaced-list'>
+ * <li><b>Default-gate source pins</b> (a-group): the <b>coupling guard</b> that keeps
+ * {@code submitActionDialog} byte-identical, the <b>enforcement by absence</b> that makes the receipt
+ * non-submittable, the hold's one-settle discipline, and the narrow allowlist of sites permitted to tear the
+ * dialog stack down automatically.
+ * <li><b>A behavioral harness</b> ({@code dialog-receipt.cjs}) walking all nine submit terminals and checking
+ * the disposition each settles to. The interesting failures here - a stuck busy modal, a Confirm left
+ * disabled on a retryable refusal, a receipt painted over a still-populated form, one outcome rendered twice
+ * - are DOM-shaped and invisible to a source pin. Runs when {@code node} is on {@code PATH}.
+ * </ul>
+ */
+class ViewsJs_DialogReceipt_Test extends TestBase {
+
+ private static String resource(String name) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(name)) {
+ assertNotNull(in, () -> "missing classpath resource: " + name);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String viewsJs() throws IOException { return resource(ViewsMixin.VIEWS_JS_RESOURCE); }
+ private static String rendersJs() throws IOException { return resource(ViewsMixin.RENDERS_JS_RESOURCE); }
+
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found in juneau-views.js");
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ private static int countOf(String body, String needle) {
+ var n = 0;
+ for (var i = body.indexOf(needle); i >= 0; i = body.indexOf(needle, i + needle.length()))
+ n++;
+ return n;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Default-gate source pins
+ //------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * The COUPLING GUARD, and the single most load-bearing pin in this file.
+ *
+ * <p>The receipt needs to know, at settle time, which dialog to paint into - and the obvious way to arrange
+ * that is a callback parameter on the submit. {@code submitActionDialog} is the one function EVERY
+ * {@code present=dialog} submit goes through, and WORK-J0512 put its {@code targetId} precedence rule and that
+ * rule's tripwire there precisely because it is the only path. Threading a receipt parameter through it would
+ * re-open a reviewed function to add a feature that has nothing to do with what it decides. The coupling
+ * therefore runs through a ctx-scoped hold registered at CLICK time instead, which leaves this function
+ * byte-identical.
+ */
+ @Test void a01_submitActionDialogIsUntouched() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction submitActionDialog(");
+ // The J0512 precedence rule and its opt-in gate, quoted verbatim.
+ assertTrue(fn.contains("const targetId = (modal?.selfTargeted && modal?.idempotencyKey != null)"), fn);
+ assertTrue(fn.contains(": (tr?.dataset?.juneauRowId ?? null);"), fn);
+ // Nothing about the receipt reached it: no hold, no callback, no result-form parameter.
+ for (var forbidden : List.of("_resultHold", "beginDialogResultHold", "settleDialogResultHold",
+ "resultForm", "keepOpenOnSubmit", "onResult", "callback")) {
+ assertFalse(fn.contains(forbidden),
+ () -> "submitActionDialog must stay byte-identical - it grew '" + forbidden + "':\n" + fn);
+ }
+ // Its signature is unchanged too: no trailing parameter was appended.
+ assertTrue(viewsJs().contains("function submitActionDialog(modal, action, table, tr, ctx, fields) {"), fn);
+ }
+
+ /** {@code startJobStream} is likewise untouched - T5 closes the dialog BEFORE handing off to it. */
+ @Test void a02_startJobStreamIsUntouched() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction startJobStream(");
+ for (var forbidden : List.of("_resultHold", "settleDialogResultHold", "resultForm", "keepOpenOnSubmit"))
+ assertFalse(fn.contains(forbidden), () -> "startJobStream grew '" + forbidden + "':\n" + fn);
+ var settle = functionBody(viewsJs(), "\tfunction settleActionResponse(");
+ // The ORDER is load-bearing: everything startJobStream paints is row-anchored, so a surviving backdrop
+ // would hide the whole progress affordance behind a modal that can never settle.
+ var close = settle.indexOf("settleDialogResultHold(ctx, \"closeCommitted\")");
+ var stream = settle.indexOf("startJobStream(started");
+ assertTrue(close >= 0 && stream >= 0, settle);
+ assertTrue(close < stream, () -> "T5 must close the held dialog BEFORE streaming:\n" + settle);
+ }
+
+ /**
+ * The receipt painter enforces its non-submittability by ABSENCE.
+ *
+ * <p>A structural marker would enforce by veto - a later change can delete the check, and a DOM attribute a
+ * fixture can forge. An absence is what a test can pin cheaply and what a careless edit cannot silently
+ * undo: this painter creates no confirm control and names no submit function, so there is nothing to disable
+ * and nothing to re-enable.
+ */
+ @Test void a03_theReceiptPainterCreatesNoSubmitControl() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction paintDialogReceipt(");
+ for (var forbidden : List.of("juneau-view-dialog-confirm", "submitActionDialog", "submitRowAction",
+ "appendDialogForm", "validateDialogForm", "collectDialogFormFields", "innerHTML"))
+ assertFalse(fn.contains(forbidden),
+ () -> "the receipt host must be non-submittable BY CONSTRUCTION - it grew '" + forbidden + "':\n" + fn);
+ // It tears the submitted write's body down rather than painting over it: a terminal post-commit surface
+ // must not carry the live, editable inputs of a write that already landed.
+ assertTrue(fn.contains("removeDialogActionsRow(dialog)"), fn);
+ assertTrue(fn.contains("teardownCommittedWriteBody(dialog)"), fn);
+ // And it opens no layer - the forbidden shape this whole mechanism exists to avoid.
+ for (var forbidden : List.of("pushLayer(", "showActionDialog(", "buildDialogOverlay(", "openActionDialog("))
+ assertFalse(fn.contains(forbidden), () -> "a receipt is a SWAP, not a new layer - it grew '"
+ + forbidden + "':\n" + fn);
+ }
+
+ /** The body teardown must take the FORM wrapper, not merely the actions row. */
+ @Test void a04_theCommittedWriteBodyTeardownTakesTheInputsToo() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction teardownCommittedWriteBody(");
+ // Removing the actions row alone is not sufficient: the submitted inputs live in a SEPARATE wrapper, and
+ // leaving it standing renders the server's result underneath a still-editable form for a committed write.
+ assertTrue(fn.contains("juneau-view-dialog-form"), fn);
+ assertTrue(fn.contains("juneau-view-dialog-fields"), fn);
+ }
+
+ /**
+ * One hand-off per submit, guaranteed by clearing the hold FIRST.
+ *
+ * <p>The submit graph has nine terminals; they are mutually-exclusive paths through one {@code submitRowAction}
+ * call, so clearing before dispatching means a second settle on the same hold finds nothing. That ordering is
+ * the whole mechanism, so it is pinned as an ordering and not just as a presence.
+ */
+ @Test void a05_theHoldIsClearedBeforeItIsDispatched() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction settleDialogResultHold(");
+ var read = fn.indexOf("const hold = ctx?._resultHold");
+ var clear = fn.indexOf("ctx._resultHold = null");
+ var live = fn.indexOf("heldLayerIsLive(ctx, hold)");
+ assertTrue(read >= 0 && clear >= 0 && live >= 0, fn);
+ assertTrue(read < clear && clear < live,
+ () -> "the hold must be READ, then CLEARED, then dispatched - or a terminal can settle twice:\n" + fn);
+ }
+
+ /**
+ * Every terminal in the submit graph hands off exactly once, and the F19 exemption is per-disposition.
+ *
+ * <p>{@code closeCommitted} returns {@code false} on purpose: the dialog is gone, so the row / ribbon banner is
+ * the only surviving signal and suppressing it would leave a committed write with no visible confirmation
+ * anywhere. Every other disposition returns {@code true}, because the dialog is still on screen and painting
+ * the row banner too would render one outcome twice, once behind a backdrop.
+ */
+ @Test void a06_allNineTerminalsHandOffExactlyOnce() throws Exception {
+ var views = viewsJs();
+ var submit = functionBody(views, "\tfunction submitRowAction(");
+ var settle = functionBody(views, "\tfunction settleActionResponse(");
+ // T1 (client refuse) + T2 (network reject).
+ assertEquals(2, countOf(submit, "settleDialogResultHold(ctx, "),
+ () -> "submitRowAction has exactly two terminals:\n" + submit);
+ // T3..T8 - six terminals, six hand-offs, one each.
+ assertEquals(6, countOf(settle, "settleDialogResultHold(ctx, "),
+ () -> "settleActionResponse has exactly six terminals:\n" + settle);
+ // T9 is shared by BOTH readBodyText chains, so it is one function reached from two `.catch` arms.
+ assertEquals(2, countOf(settle, "settleBodyReadFailure(action, table, tr, ctx)"),
+ () -> "both body-read chains must route to T9, or a held dialog hangs forever:\n" + settle);
+ assertEquals(1, countOf(functionBody(views, "\tfunction settleBodyReadFailure("),
+ "settleDialogResultHold(ctx, "), views);
+ // The dispositions actually used, and no others.
+ for (var d : List.of("\"retryable\"", "\"terminal\"", "\"closeCommitted\"", "\"receipt\""))
+ assertTrue(views.contains("settleDialogResultHold(ctx, " + d + ")")
+ || views.contains("settleDialogResultHold(ctx, disposition)"), d);
+ var hold = functionBody(views, "\tfunction settleDialogResultHold(");
+ assertTrue(hold.contains("if (disposition === \"closeCommitted\")"), hold);
+ assertTrue(hold.contains("closeActionDialog(ctx);\n\t\t\treturn false;"),
+ () -> "closeCommitted must return FALSE so the caller's row painter still runs:\n" + hold);
+ }
+
+ /**
+ * The receipt terminal FETCHES before it settles.
+ *
+ * <p>Settling first would clear {@code ctx._resultHold} before the GET resolved, so every arm inside
+ * {@code fetchResultForm} would take the layer-gone escape and a committed write with a perfectly good receipt
+ * payload would paint no receipt at all - silently.
+ */
+ @Test void a07_theReceiptTerminalFetchesBeforeItSettles() throws Exception {
+ var settle = functionBody(viewsJs(), "\tfunction settleActionResponse(");
+ var fetch = settle.indexOf("fetchResultForm(result.resultForm");
+ assertTrue(fetch >= 0, settle);
+ // The receipt branch returns immediately after the fetch: it must not also fall through to a settle.
+ var tail = settle.substring(fetch);
+ var nextSettle = tail.indexOf("settleDialogResultHold(");
+ var ret = tail.indexOf("return;");
+ assertTrue(ret >= 0 && (nextSettle < 0 || ret < nextSettle),
+ () -> "the receipt branch must fetch and RETURN, never fetch and then settle:\n" + tail);
+ // And the fetch is gated on a HELD success carrying a resultForm - never on a failure, refusal or unknown,
+ // so no result host can appear for a write that did not commit.
+ assertTrue(settle.contains("if (held && outcome === \"success\" && ! isBlankToken(result.resultForm))"),
+ settle);
+ }
+
+ /**
+ * The automatic-teardown allowlist.
+ *
+ * <p>Exactly three NEW sites may call {@code closeActionDialog(} - the {@code closeCommitted} branch, the
+ * receipt's Close-button wiring, and the shared Close-only row helper - plus the pre-existing grandfathered
+ * {@code teardownTable} caller. Every OTHER terminal path must end with the dialog OPEN and delegate its exit
+ * to a Close button the operator presses, because a terminal that closes the stack behind the operator's back
+ * destroys the message it was about to show them.
+ */
+ @Test void a08_noTerminalPathTearsTheStackDownAutomatically() throws Exception {
+ var views = viewsJs();
+ // The BAN, stated as absences on the automatic paths: none of these may close the stack, because a
+ // terminal that closes it behind the operator's back destroys the message it was about to show them.
+ for (var fn : List.of("\tfunction settleBodyReadFailure(", "\tfunction fetchResultForm(",
+ "\tfunction renderHeldResultNotice(", "\tfunction renderResultFormIgnored(",
+ "\tfunction beginDialogResultHold(", "\tfunction removeDialogActionsRow(",
+ "\tfunction teardownCommittedWriteBody(", "\tfunction restoreHeldChildActions(")) {
+ var body = functionBody(views, fn);
+ assertFalse(body.contains("closeActionDialog("),
+ () -> fn + " must not tear the stack down automatically:\n" + body);
+ }
+ // The three permitted NEW sites. Two of them are CLICK LISTENERS - a Close button the operator presses is
+ // that operator's exit, not an automatic teardown - and the third is the one disposition whose write is
+ // known-committed and whose dialog is therefore meant to go.
+ assertTrue(functionBody(views, "\tfunction appendDialogCloseOnlyRow(")
+ .contains("close.addEventListener(\"click\", function () { closeActionDialog(ctx); });"), views);
+ var receipt = functionBody(views, "\tfunction paintDialogReceipt(");
+ assertTrue(receipt.contains("close.addEventListener(\"click\", function () { closeActionDialog(ctx); });"),
+ receipt);
+ assertEquals(1, countOf(receipt, "closeActionDialog("),
+ () -> "the receipt's ONLY teardown is its Close button's listener:\n" + receipt);
+ var settle = functionBody(views, "\tfunction settleDialogResultHold(");
+ assertEquals(1, countOf(settle, "closeActionDialog("),
+ () -> "exactly one disposition may close the stack:\n" + settle);
+ var branch = settle.indexOf("if (disposition === \"closeCommitted\")");
+ assertTrue(branch >= 0 && branch < settle.indexOf("closeActionDialog("),
+ () -> "the one teardown must sit inside the closeCommitted branch:\n" + settle);
+ }
+
+ /** Every terminal Close-only row comes from the ONE shared helper, so the operator's exit is identical. */
+ @Test void a09_terminalSurfacesShareOneCloseOnlyRow() throws Exception {
+ var views = viewsJs();
+ var helper = functionBody(views, "\tfunction appendDialogCloseOnlyRow(");
+ assertTrue(helper.contains("juneau-view-dialog-actions"), helper);
+ assertTrue(helper.contains("dialog-result-close"), helper);
+ assertFalse(helper.contains("juneau-view-dialog-confirm"),
+ () -> "a Close-only row is Close ONLY:\n" + helper);
+ // The `terminal` disposition removes the live actions row FIRST, so Confirm is detached (listener and all)
+ // rather than merely disabled, and then appends the shared row.
+ var settle = functionBody(views, "\tfunction settleDialogResultHold(");
+ var remove = settle.indexOf("removeDialogActionsRow(hold.dialog)");
+ var append = settle.indexOf("appendDialogCloseOnlyRow(hold.dialog, ctx)");
+ assertTrue(remove >= 0 && append >= 0 && remove < append, settle);
+ assertTrue(functionBody(views, "\tfunction removeDialogActionsRow(").contains("removeEl(el)"), views);
+ }
+
+ /** Painters use createElement + textContent only - live result data is attacker-influenceable. */
+ @Test void a10_noInnerHtmlInAnyNewPainter() throws Exception {
+ var views = viewsJs();
+ for (var fn : List.of("\tfunction paintDialogReceipt(", "\tfunction buildModalFieldValueNode(",
+ "\tfunction buildModalFieldList(", "\tfunction buildFieldCopyButton(",
+ "\tfunction appendDialogCloseOnlyRow(", "\tfunction renderResultFormIgnored(")) {
+ var body = functionBody(views, fn);
+ assertFalse(body.contains("innerHTML"), () -> fn + " must not assign innerHTML:\n" + body);
+ }
+ assertTrue(functionBody(views, "\tfunction buildModalFieldValueNode(").contains("pre.textContent = value"),
+ views);
+ }
+
+ /** The copy affordance is feature-detected, caught, and wrapped - it can never throw out of a click handler. */
+ @Test void a11_theCopyButtonCannotThrow() throws Exception {
+ var fn = functionBody(viewsJs(), "\tfunction buildFieldCopyButton(");
+ assertTrue(fn.contains("typeof clip?.writeText !== \"function\""), fn);
+ assertTrue(fn.contains(".catch(function ()"), fn);
+ assertTrue(fn.contains("try {") && fn.contains("catch (e)"), fn);
+ }
+
+ /** The three constants that carry the terminal wording, so a diagnosis is never an empty banner. */
+ @Test void a12_theTerminalNoticesAreNamedConstants() throws Exception {
+ var views = viewsJs();
+ for (var c : List.of("REQUEST_UNCONFIRMED_NOTICE", "BODY_UNREADABLE_NOTICE",
+ "RESULT_FORM_UNAVAILABLE_NOTICE", "RESULT_FORM_REFUSED_NOTICE"))
+ assertTrue(views.contains("const " + c + " ="), () -> "missing named notice constant: " + c);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Behavioral harness
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var rendersFile = Files.createTempFile("juneau-renders-", ".js");
+ var viewsFile = Files.createTempFile("juneau-views-", ".js");
+ try {
+ Files.writeString(rendersFile, rendersJs(), UTF_8);
+ Files.writeString(viewsFile, viewsJs(), UTF_8);
+ report = Json.to(runNode(harness, rendersFile, viewsFile), Map.class);
+ } finally {
+ Files.deleteIfExists(rendersFile);
+ Files.deleteIfExists(viewsFile);
+ }
+ }
+
+ 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/dialog-receipt.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/dialog-receipt.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path rendersJs, Path viewsJs) throws Exception {
+ var stdout = Files.createTempFile("dialog-receipt-stdout-", ".json");
+ var stderr = Files.createTempFile("dialog-receipt-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of("node", harness.toString(), rendersJs.toString(), viewsJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("dialog-receipt.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("dialog-receipt.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 dialog-receipt.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ private static void assertAllTrue(String... keys) {
+ var r = report();
+ for (var k : keys)
+ assertEquals(true, r.get(k), () -> k + " was " + r.get(k) + " in " + r);
+ }
+
+ @Test void b00_theReceiptSeamIsWired() {
+ assertEquals(true, report().get("hasReceiptSeam"), report()::toString);
+ }
+
+ /** T8-receipt: the swap. Same layer, no submit controls, the submitted form gone. */
+ @Test void b01_aHeldSuccessWithAResultFormSwapsInPlace() {
+ assertAllTrue("receipt_dialogOpened", "receipt_stillOpenAtSameDepth", "receipt_marked",
+ "receipt_titleFromPayload", "receipt_fieldsRepainted", "receipt_exactlyOneFieldList",
+ "receipt_closeButtonPresent", "receipt_closeTearsDownTheStack");
+ }
+
+ /** The enforcement, observed rather than asserted about the source: there is nothing to submit with. */
+ @Test void b02_theReceiptHostCarriesNoSubmitControls() {
+ assertAllTrue("receipt_noConfirmButton", "receipt_noInputsAtAll", "receipt_submittedFormTornDown");
+ }
+
+ @Test void b03_aReceiptSuppressesTheRowBannerAndClearsBusy() {
+ // The dialog is on screen, so the outcome belongs in it - rendering the row banner too would show one
+ // outcome twice, once behind a backdrop.
+ assertAllTrue("receipt_noRowBanner", "receipt_busyMarkerCleared");
+ }
+
+ @Test void b04_codeKindFieldsPaintAsSelectablePreWithCopy() {
+ assertAllTrue("receipt_codeFieldIsPre", "receipt_codeFieldHasCopyButton",
+ "kinds_ddCount", "kinds_plainIsText", "kinds_codeIsPre", "kinds_codeHasCopy",
+ "kinds_explicitTextIsText", "kinds_allowlistIsExactlyTwo");
+ // An UNRECOGNIZED token falls back to text rather than being trusted - the runtime half of the
+ // allowlist that ModalDef.validate enforces server-side.
+ assertAllTrue("kinds_unknownFallsBackToText");
+ }
+
+ /** T8-commit / T6: with the dialog gone, the row banner is the only surviving signal and must still render. */
+ @Test void b05_aHeldSuccessWithoutAResultFormClosesAndStillPaintsTheRow() {
+ assertAllTrue("commit_dialogClosed", "commit_noDialogInDom", "commit_rowOutcomeRendered", "commit_noReceipt");
+ assertEquals("success", report().get("commit_rowOutcomeState"), report()::toString);
+ }
+
+ /**
+ * F4: a resultForm on a dialog that never opted in.
+ *
+ * <p>The write SUCCEEDED, so the success banner stays byte-identical and this adds exactly one non-alarming
+ * {@code role=status} diagnostic. Never a new layer (the forbidden shape), and never a refusal - which would
+ * make a successful write look failed for what is purely a consumer-authoring mistake.
+ */
+ @Test void b06_anUnheldResultFormIsDiagnosedNotOpened() {
+ assertAllTrue("f4_dialogClosedAsToday", "f4_noFollowUpGet", "f4_successBannerUnchanged",
+ "f4_diagnosticPresent", "f4_diagnosticIsStatusNotAlert", "f4_noNewLayer");
+ }
+
+ /** F1c: a receipt payload carrying a form is the forbidden shape arriving through the receipt door. */
+ @Test void b07_aReceiptPayloadCarryingAFormIsRefusedWithItsOwnWording() {
+ assertAllTrue("f1c_stillOpen", "f1c_notPaintedAsReceipt", "f1c_noInputsPainted", "f1c_closeOnlyRow",
+ "f1c_noConfirm", "f1c_busyCleared");
+ var r = report();
+ // Its own wording, not folded into the transport-failure message: a consumer authoring bug that reads as
+ // a network blip is a bug nobody fixes.
+ assertEquals("The action completed, but its result was not shown: a result form must not carry inputs.",
+ r.get("f1c_noticeText"), r::toString);
+ }
+
+ /**
+ * F12: the follow-up read failed three different ways, all of them post-commit.
+ *
+ * <p>The rejected-promise arm matters as much as the other two: without it a single dropped packet on the
+ * follow-up read leaves the promise rejected, the terminal unreached, and Confirm stuck disabled + busy on a
+ * write that had ALREADY COMMITTED.
+ */
+ @Test void b08_aFailedReceiptReadIsTerminalAndNeverStuck() {
+ assertAllTrue("f12_stillOpen", "f12_closeOnlyRow", "f12_busyCleared", "f12_submittedFormTornDown",
+ "f12reject_stillOpen", "f12reject_closeOnlyRow", "f12reject_busyCleared",
+ "f12reject_noConfirmStuckDisabled", "f12parse_closeOnlyRow", "f12parse_notPaintedAsReceipt");
+ var r = report();
+ var expected = "The action completed, but its result could not be loaded.";
+ for (var k : List.of("f12_noticeText", "f12reject_noticeText", "f12parse_noticeText"))
+ assertEquals(expected, r.get(k), () -> k + " in " + r);
+ }
+
+ /** T1: nothing was even sent, so the operator can fix the declaration and press Confirm again. */
+ @Test void b09_aPreflightClientRefusalIsRetryable() {
+ assertAllTrue("t1_nothingSent", "t1_stillOpen", "t1_noticePresent", "t1_confirmReEnabled",
+ "t1_busyCleared", "t1_noCloseOnlyRow", "t1_noRowBanner", "t1_formStillStanding");
+ }
+
+ /**
+ * T2: a network reject is AMBIGUOUS, not retryable.
+ *
+ * <p>{@code fetch} rejects on a connection dropped AFTER the request was fully sent and processed, so this
+ * does not prove the write didn't land - re-enabling Confirm would hand the operator a one-click duplicate of
+ * a non-idempotent write.
+ */
+ @Test void b10_aNetworkRejectIsTerminalNotRetryable() {
+ assertAllTrue("t2_stillOpen", "t2_noConfirmButton", "t2_closeOnlyRow", "t2_busyCleared",
+ "t2_formStillStanding", "t2_noRowBanner");
+ assertEquals("The request was sent but no response came back. It may or may not have been applied - "
+ + "re-check before retrying.", report().get("t2_noticeText"), report()::toString);
+ }
+
+ /** T4 / T8-refusal: a gate said no and nothing was written, so retry is safe and is the point (F11). */
+ @Test void b11_refusalsReEnableConfirm() {
+ assertAllTrue("t4_stillOpen", "t4_noticePresent", "t4_confirmReEnabled", "t4_noCloseOnlyRow",
+ "t4_busyCleared", "t8refuse_confirmReEnabled", "t8refuse_noCloseOnlyRow", "t8refuse_noticePresent");
+ }
+
+ @Test void b12_aTypedFailureIsTerminal() {
+ assertAllTrue("t8fail_stillOpen", "t8fail_terminalCloseOnly", "t8fail_noConfirm", "t8fail_noticePresent");
+ }
+
+ /** A resultForm on a NON-success outcome is ignored outright: no receipt for a write that did not happen. */
+ @Test void b13_aResultFormOnANonSuccessOutcomeIsIgnored() {
+ assertAllTrue("failResultForm_noGetIssued", "failResultForm_noReceiptPainted",
+ "failResultForm_terminalCloseOnly", "failResultForm_noDiagnostic");
+ }
+
+ /** T5: the close happens first, so the row-anchored job affordance is not painted behind a live backdrop. */
+ @Test void b14_aJobPointerClosesTheDialogBeforeStreaming() {
+ assertAllTrue("t5_dialogClosed", "t5_noDialogInDom", "t5_rowAnchoredSignalVisible",
+ "t5_rowSignalIsNotInADialog");
+ }
+
+ /**
+ * Cancel stays enabled during the hold, and that is safe.
+ *
+ * <p>Escape pops the top layer through the shared stack regardless, so the design has to tolerate "dialog gone
+ * before settle" anyway; given that, disabling Cancel would only remove the operator's obvious exit. The
+ * layer-gone case is handled at settle time by falling back to the row banner.
+ */
+ @Test void b15_cancellingMidFlightFallsBackToTheRowBanner() {
+ assertAllTrue("cancel_layerGone", "cancel_outcomeFellBackToRow", "cancel_noThrowAndNoDialog");
+ }
+
+ @Test void b16_aHoldSettlesExactlyOnce() {
+ assertAllTrue("once_holdRegistered", "once_confirmDisabledAtClickTime", "once_busyMarked",
+ "once_firstSettleHandsOff", "once_holdCleared", "once_secondSettleIsANoOp",
+ "once_closeCommittedDoesNotSuppressRowPaint", "once_noHoldIsFalse", "once_noCtxIsFalse");
+ }
+
+ /**
+ * Child-action buttons are disabled for the duration of the hold, and restored to their PRIOR state.
+ *
+ * <p>Recording which were already disabled is what makes the restore safe: it can never enable a child that
+ * was gated before the submit started. They are disabled at all because a live child button during the
+ * in-flight parent write lets an operator push a child layer onto a dialog that is about to be swapped into a
+ * receipt.
+ */
+ @Test void b17_childActionButtonsAreHeldAndRestoredToTheirPriorState() {
+ assertAllTrue("children_liveOneDisabled", "children_gatedOneStaysDisabled", "children_liveOneRestored",
+ "children_gatedOneStillDisabled");
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
index 59e62cc638..6615d9fa64 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ModalResult_Test.java
@@ -306,7 +306,8 @@ class ViewsJs_ModalResult_Test extends TestBase {
@Test void h05_buildDialogOverlayPaintsForm() throws Exception {
var body = viewsJs();
var build = fn(body, "function buildDialogOverlay(");
- // The form paint is threaded the row context (table/tr/ctx) and the dialog-only field-id sequence.
- assertTrue(build.contains("appendDialogForm(dialog, modal?.form, table, tr, ctx, seq)"), build);
+ // The form paint is threaded the row context (table/tr/ctx), the dialog-only field-id sequence, and - since
+ // WORK-J0513 - this dialog's own child-action catalog, read from the per-open payload.
+ assertTrue(build.contains("appendDialogForm(dialog, modal?.form, table, tr, ctx, seq, childCatalog)"), build);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
index c03e77b39c..c2634dec78 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RibbonStrip_Test.java
@@ -151,7 +151,9 @@ class ViewsJs_RibbonStrip_Test extends TestBase {
@Test void a06_sectionedFormIsMutuallyExclusiveWithFlatFields() throws Exception {
var painter = bodyOf(viewsJs(), "appendDialogForm");
assertTrue(painter.contains("if (form.sections?.length)"), painter);
- assertTrue(painter.contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq); return;"), painter);
+ // The trailing `childCatalog` is threaded straight through to the sectioned painter (WORK-J0513): a
+ // `type=action` control inside a pane resolves against the same dialog-scoped catalog a flat form's does.
+ assertTrue(painter.contains("appendSectionedDialogForm(dialog, form, table, tr, ctx, seq, childCatalog); return;"), painter);
}
private static int count(String haystack, String needle) {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs
new file mode 100644
index 0000000000..91d935807b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-child-catalog.cjs
@@ -0,0 +1,375 @@
+/*
+ * 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.
+ */
+
+/*
+ * dialog-child-catalog.cjs - always-on Node harness for the DIALOG-SCOPED child-action catalog (WORK-J0513 Scope
+ * B): `ModalDef.childActions` lets a `type="action"` input inside one dialog's form open a STACKED step that is
+ * not a row action, and therefore appears in no row's action menu and in no ribbon.
+ *
+ * The properties worth a DOM harness rather than a source pin, in the order the resolver decides them:
+ *
+ * 1. Scope. A child action opens from the dialog that declares it, and is invisible to buildRowActionMenu.
+ * 2. Precedence. A declared ROW action wins an id collision, gating included - a served payload must never be
+ * able to shadow (and thereby bypass the gating of) a row action. A collision with a NON-dialog row action
+ * is a fail-closed refusal, not a fall-through to the catalog, because rescuing it there would be the same
+ * shadowing hole through a narrower door.
+ * 3. Fail-closed. An id in neither catalog stays today's disabled+marked paint, and clicking it is a visible
+ * refusal - never a throw, never a silent no-op.
+ * 4. Stacking. Opening a child pushes a real layer and consumes a real MAX_DIALOG_DEPTH slot, so the cap still
+ * refuses at the same place.
+ * 5. Drafts. `carryDrafts` carries the PARENT's typed-but-unsubmitted values on the child's form GET; over the
+ * cap it is a visible refusal and the child does not open, because a silently-truncated prefill is a
+ * silently-WRONG prefill.
+ * 6. Hold interaction. A `type=action` click on a dialog whose write is in flight or already settled into a
+ * receipt is a no-op - it must not stack a child onto a committed layer.
+ *
+ * Usage: node dialog-child-catalog.cjs <juneau-renders.js> <juneau-views.js>
+ *
+ * Prints ONE JSON object to stdout; every assertion lives in the Java test.
+ */
+'use strict';
+
+const path = require('node:path');
+const { makeEnv, loadViews, jsonResponse } = require(path.join(__dirname, 'views-dom-shim.cjs'));
+
+const rendersJsPath = process.argv[2];
+const viewsJsPath = process.argv[3];
+if (!rendersJsPath || !viewsJsPath) {
+ console.error('usage: node dialog-child-catalog.cjs <juneau-renders.js> <juneau-views.js>');
+ process.exit(2);
+}
+
+const PARENT_FORM = '/x/step1-form';
+const CHILD_FORM = '/x/review-form';
+
+function load() {
+ const env = makeEnv();
+ return loadViews(rendersJsPath, viewsJsPath, env);
+}
+
+const out = {};
+{
+ const probe = load();
+ out.hasCatalogSeam = !!(probe.I
+ && typeof probe.I.childActionById === 'function'
+ && typeof probe.I.rowActionIdExists === 'function'
+ && typeof probe.I.withDraftQuery === 'function'
+ && typeof probe.I.openChildActionDialog === 'function'
+ && typeof probe.I.MAX_DRAFT_QUERY_BYTES === 'number');
+}
+if (!out.hasCatalogSeam) { process.stdout.write(JSON.stringify(out)); process.exit(0); }
+
+function flush() {
+ return new Promise(function (r) { let n = 0; (function tick() { if (n++ >= 8) { return r(); } setTimeout(tick, 0); })(); });
+}
+
+const q = (env, sel) => env.body.querySelector(sel);
+const dialogRefusalText = (env) => {
+ const el = q(env, '.juneau-view-dialog-action-refusal');
+ return el ? el.textContent : null;
+};
+
+/**
+ * The parent dialog's served payload: a form carrying a `type=action` input plus the dialog-scoped catalog the
+ * input resolves against.
+ */
+function parentPayload(opts) {
+ const o = opts || {};
+ return {
+ contractVersion: '2',
+ title: 'Step 1',
+ form: {
+ contractVersion: '2',
+ fields: [
+ { name: 'note', type: 'text', label: 'Note', value: 'typed-by-operator' },
+ { name: 'go', type: 'action', label: 'Review', actionId: o.actionId || 'review' }
+ ]
+ },
+ childActions: o.childActions === undefined
+ ? [{ id: 'review', label: 'Review', form: CHILD_FORM, endpoint: '/x/review', method: 'POST' }]
+ : o.childActions
+ };
+}
+
+/** A table + row + ctx with a `present=dialog` row action that serves the parent payload. */
+function fixture(env, opts) {
+ const o = opts || {};
+ const table = env.el('table');
+ table.setAttribute('data-juneau-csrf', 'tok-1');
+ env.body.appendChild(table);
+
+ const tr = env.el('tr');
+ tr.setAttribute('data-juneau-row-id', 'INC-1');
+ tr.dataset.juneauRowId = 'INC-1';
+ const cell = env.el('td');
+ cell.className = 'juneau-view-actions-cell';
+ tr.appendChild(cell);
+ table.appendChild(tr);
+
+ const parent = { id: 'step1', label: 'Step 1', present: 'dialog', method: 'POST', endpoint: '/x/step1', form: PARENT_FORM };
+ const rowActions = [parent].concat(o.extraRowActions || []);
+ const ctx = { table: table, viewDef: { id: 'v1', rowActions: rowActions }, dataTable: {}, activeState: {} };
+ return { table: table, tr: tr, parent: parent, ctx: ctx };
+}
+
+/** Opens the parent dialog through the real fetch path and returns the child-action button inside it. */
+async function openParent(opts) {
+ const o = opts || {};
+ const { env, I } = load();
+ const fx = fixture(env, o);
+ const gets = [];
+ env.setFetch(function (url, init) {
+ const method = (init && init.method) || 'GET';
+ if (method === 'GET') gets.push(String(url));
+ if (method === 'GET' && url === PARENT_FORM) return Promise.resolve(jsonResponse(parentPayload(o)));
+ if (method === 'GET' && String(url).indexOf(CHILD_FORM) === 0)
+ return Promise.resolve(jsonResponse({
+ contractVersion: '2', title: 'Review',
+ form: { contractVersion: '2', fields: [{ name: 'note', type: 'text', label: 'Note' }] }
+ }));
+ return Promise.resolve(jsonResponse({ outcome: 'success' }));
+ });
+ I.openActionDialog(fx.parent, fx.table, fx.tr, fx.ctx);
+ await flush();
+ return { env: env, I: I, fx: fx, gets: gets, btn: q(env, '.juneau-view-dialog-form-action') };
+}
+
+(async function main() {
+
+ // --- 1) A child action opens a STACKED step, and never appears in a row menu -----------------------------
+ {
+ const r = await openParent();
+ const env = r.env, I = r.I;
+ out.open_parentOpened = I.dialogLayerCount() === 1;
+ out.open_childButtonPainted = r.btn != null;
+ // The resolution that fails closed today: the id is in NEITHER row catalog, so pre-J0513 this button was
+ // painted disabled+marked with no way for its author to reach it.
+ out.open_childButtonEnabled = r.btn != null && r.btn.disabled === false;
+ out.open_childButtonNotMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === undefined;
+
+ if (r.btn) r.btn.dispatch('click');
+ await flush();
+ out.open_childStackedALayer = I.dialogLayerCount() === 2;
+ out.open_childFormWasFetched = r.gets.some(function (u) { return u.indexOf(CHILD_FORM) === 0; });
+ out.open_noRefusalPainted = dialogRefusalText(env) === null;
+
+ // Scope: the catalog rides the per-open payload, so no row menu can see it.
+ const menu = I.buildRowActionMenu(r.fx.ctx.viewDef, r.fx.table, r.fx.tr, r.fx.ctx);
+ const ids = menu.querySelectorAll('.juneau-view-action-item').map(function (b) { return b.dataset.actionId; });
+ out.scope_rowMenuIds = ids.join(',');
+ out.scope_rowMenuExcludesChild = ids.indexOf('review') < 0;
+ // ...and neither can the ribbon resolver.
+ out.scope_ribbonResolverExcludesChild = I.ribbonDialogActionIsOpenable(r.fx.ctx, 'review') === false;
+ // ...nor the row-action resolver.
+ out.scope_rowResolverExcludesChild = I.dialogActionIsOpenable(r.fx.ctx, 'review') === false;
+ }
+
+ // --- 2) Precedence: a declared DIALOG row action of the same id wins, gating included --------------------
+ {
+ const rowTwin = {
+ id: 'review', label: 'Row Review', present: 'dialog', method: 'POST', endpoint: '/x/row-review',
+ enabledWhen: [{ field: 'status', op: 'eq', value: 'open', reason: 'only open records' }]
+ };
+ const r = await openParent({ extraRowActions: [rowTwin] });
+ const env = r.env;
+ // The row-action check runs FIRST and unchanged, so its enabledWhen gate still applies - which is exactly
+ // what a served payload must not be able to route around. This fixture's row carries no `status` at all,
+ // which the shared rule evaluator fails CLOSED on.
+ out.precedence_rowActionWinsAndStaysGated = r.btn != null && r.btn.disabled === true;
+ // Gated, but not silently: the failing rule's own reason is what the control announces.
+ out.precedence_gateReasonSurfaced = (function () {
+ const el = q(env, '[data-juneau-row-action-desc]');
+ return el != null && el.textContent === 'only open records';
+ })();
+ out.precedence_reasonOnTheControlTitle = r.btn != null && r.btn.getAttribute('title') === 'only open records';
+ // NOT the fail-closed "missing" paint: the id resolved, it is the GATE that closed it.
+ out.precedence_notMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === undefined;
+ }
+
+ // --- 3) Precedence: a collision with a NON-dialog row action is a fail-closed REFUSAL -------------------
+ {
+ // A gated direct-submit Delete is the dangerous case: falling through to the child catalog here would let
+ // a served payload shadow it and bypass its gating through a narrower door.
+ const directDelete = { id: 'review', label: 'Delete', method: 'DELETE', endpoint: '/x/rows/{id}' };
+ const r = await openParent({ extraRowActions: [directDelete] });
+ const env = r.env, I = r.I;
+ out.shadow_buttonPaintedDisabled = r.btn != null && r.btn.disabled === true;
+ out.shadow_buttonMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === '1';
+ out.shadow_ariaDisabled = r.btn != null && r.btn.getAttribute('aria-disabled') === 'true';
+ // Defense in depth: the click handler refuses too, rather than relying on the disabled attribute alone.
+ I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+ [{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+ await flush();
+ out.shadow_clickIsAVisibleRefusal = dialogRefusalText(env) != null;
+ out.shadow_refusalNamesTheId = (dialogRefusalText(env) || '').indexOf("'review'") >= 0;
+ out.shadow_noChildOpened = I.dialogLayerCount() === 1;
+ }
+
+ // --- 4) Fail-closed: an id in NEITHER catalog stays disabled, and a click refuses visibly ---------------
+ {
+ const r = await openParent({ actionId: 'nope', childActions: [] });
+ const env = r.env, I = r.I;
+ out.unknown_buttonDisabled = r.btn != null && r.btn.disabled === true;
+ out.unknown_buttonMarkedMissing = r.btn != null && r.btn.dataset.juneauActionMissing === '1';
+ I.openFormActionDialog('nope', r.fx.table, r.fx.tr, r.fx.ctx, []);
+ await flush();
+ out.unknown_clickIsAVisibleRefusal = dialogRefusalText(env) != null;
+ out.unknown_noChildOpened = I.dialogLayerCount() === 1;
+ // An absent catalog behaves exactly like an empty one (the parameter is optional by design, so every
+ // existing positional caller keeps working unchanged).
+ out.unknown_absentCatalogSameAsEmpty = I.childActionById(null, 'review') === null
+ && I.childActionById(undefined, 'review') === null
+ && I.childActionById([], 'review') === null;
+ }
+
+ // --- 5) Stacking: a child consumes a real depth slot, so the cap still refuses at the same place --------
+ {
+ const r = await openParent();
+ const I = r.I;
+ if (r.btn) r.btn.dispatch('click');
+ await flush();
+ out.depth_childIsARealLayer = I.dialogLayerCount() === 2 && I.MAX_DIALOG_DEPTH === 2;
+ // A third is the visible depth refusal, not a third overlay.
+ I.openChildActionDialog({ id: 'review', label: 'Review', form: CHILD_FORM }, r.fx.table, r.fx.tr, r.fx.ctx);
+ await flush();
+ out.depth_thirdIsRefused = I.dialogLayerCount() === 2;
+ out.depth_refusalPainted = q(r.env, '[data-testid="dialog-depth-refusal"]') != null;
+ }
+
+ // --- 6) carryDrafts: the parent's typed values ride the child's form GET --------------------------------
+ {
+ const r = await openParent({
+ childActions: [{ id: 'review', label: 'Review', form: CHILD_FORM, carryDrafts: true }]
+ });
+ const I = r.I;
+ // Type something the operator has NOT submitted yet - the whole reason this channel exists.
+ const note = q(r.env, '[data-juneau-form-field="note"]');
+ if (note) note.value = 'edited-in-place';
+ if (r.btn) r.btn.dispatch('click');
+ await flush();
+ const childGet = r.gets.find(function (u) { return u.indexOf(CHILD_FORM) === 0; });
+ out.drafts_childGetUrl = childGet || null;
+ out.drafts_queryParamPresent = childGet != null && childGet.indexOf('juneauDrafts=') > 0;
+ out.drafts_carriedTheEditedValue = (function () {
+ if (childGet == null) return false;
+ const raw = decodeURIComponent(childGet.slice(childGet.indexOf('juneauDrafts=') + 'juneauDrafts='.length));
+ const parsed = JSON.parse(raw);
+ return parsed.note === 'edited-in-place';
+ })();
+ out.drafts_childOpened = I.dialogLayerCount() === 2;
+ }
+
+ // --- 7) The draft cap is measured on the ENCODED value, and over it the child does NOT open ------------
+ {
+ const { env, I } = load();
+ out.cap_value = I.MAX_DRAFT_QUERY_BYTES;
+ const child = { id: 'review', label: 'Review', form: CHILD_FORM };
+ out.cap_smallDraftPasses = I.withDraftQuery(child, { note: 'hi' }) != null;
+ // A raw JSON string that fits the cap but whose ENCODED form does not: every one of these characters
+ // expands to three bytes, so measuring the raw string would let a ~2KB draft become a ~6KB query and blow
+ // a request-line limit AFTER the client had already told the operator it was fine.
+ const expands = { note: '\u00e9'.repeat(900) };
+ out.cap_rawJsonWouldHaveFit = JSON.stringify(expands).length < I.MAX_DRAFT_QUERY_BYTES;
+ out.cap_encodedFormIsRefused = I.withDraftQuery(child, expands) === null;
+ out.cap_overCapIsRefused = I.withDraftQuery(child, { note: 'x'.repeat(I.MAX_DRAFT_QUERY_BYTES + 1) }) === null;
+ // It is a COPY: the descriptor handed in is never mutated.
+ const copy = I.withDraftQuery(child, { note: 'hi' });
+ out.cap_returnsACopyAndDoesNotMutate = child.form === CHILD_FORM && copy.form !== CHILD_FORM
+ && copy.id === 'review' && copy.label === 'Review';
+ // An existing query string is appended to, not clobbered.
+ const withQuery = I.withDraftQuery({ id: 'r', label: 'R', form: '/x/f?a=1' }, { note: 'hi' });
+ out.cap_appendsToAnExistingQuery = withQuery.form.indexOf('/x/f?a=1&juneauDrafts=') === 0;
+
+ // Over the cap the child does NOT open, and the refusal is visible.
+ const fx = fixture(env);
+ const backdrop = env.el('div');
+ const dialog = env.el('div');
+ dialog.className = 'juneau-view-dialog';
+ backdrop.appendChild(dialog);
+ env.body.appendChild(backdrop);
+ I.pushLayer(backdrop, { kind: 'dialog', trapFocus: false, lightDismiss: false });
+ const field = env.el('input');
+ field.dataset.juneauFormField = 'note';
+ field.value = 'x'.repeat(I.MAX_DRAFT_QUERY_BYTES + 1);
+ dialog.appendChild(field);
+ I.openChildActionDialog({ id: 'review', label: 'Review', form: CHILD_FORM, carryDrafts: true },
+ fx.table, fx.tr, fx.ctx);
+ out.capRefusal_noChildOpened = I.dialogLayerCount() === 1;
+ out.capRefusal_visible = dialogRefusalText(env) != null;
+ out.capRefusal_namesTheCap = (dialogRefusalText(env) || '').indexOf(String(I.MAX_DRAFT_QUERY_BYTES)) >= 0;
+ }
+
+ // --- 8) A `type=action` click on a BUSY (in-flight or receipt) dialog is a no-op ----------------------
+ {
+ const r = await openParent();
+ const I = r.I;
+ const dialog = q(r.env, '.juneau-view-dialog');
+ dialog.dataset.juneauDialogBusy = '1';
+ I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+ [{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+ await flush();
+ // Not a refusal either: the operator did not ask for anything new, a stale control fired.
+ out.busy_noChildStacked = I.dialogLayerCount() === 1;
+ out.busy_noRefusalPainted = dialogRefusalText(r.env) === null;
+ delete dialog.dataset.juneauDialogBusy;
+ I.openFormActionDialog('review', r.fx.table, r.fx.tr, r.fx.ctx,
+ [{ id: 'review', label: 'Review', form: CHILD_FORM }]);
+ await flush();
+ out.busy_worksAgainOnceCleared = I.dialogLayerCount() === 2;
+ }
+
+ // --- 9) A SECTIONED parent form threads the catalog too (the second row-painting path) ---------------
+ {
+ const { env, I } = load();
+ const fx = fixture(env);
+ const dialog = env.el('div');
+ const catalog = [{ id: 'review', label: 'Review', form: CHILD_FORM }];
+ I.appendDialogForm(dialog, {
+ sections: [{ id: 's1', label: 'One', fields: [{ name: 'go', type: 'action', label: 'Review', actionId: 'review' }] }]
+ }, fx.table, fx.tr, fx.ctx, 7, catalog);
+ const btn = dialog.querySelector('.juneau-view-dialog-form-action');
+ out.sectioned_buttonPainted = btn != null;
+ out.sectioned_buttonEnabledFromCatalog = btn != null && btn.disabled === false
+ && btn.dataset.juneauActionMissing === undefined;
+ // Without the catalog the same field stays today's fail-closed paint.
+ const dialog2 = env.el('div');
+ I.appendDialogForm(dialog2, {
+ sections: [{ id: 's1', label: 'One', fields: [{ name: 'go', type: 'action', label: 'Review', actionId: 'review' }] }]
+ }, fx.table, fx.tr, fx.ctx, 8);
+ const btn2 = dialog2.querySelector('.juneau-view-dialog-form-action');
+ out.sectioned_withoutCatalogStaysDisabled = btn2 != null && btn2.disabled === true
+ && btn2.dataset.juneauActionMissing === '1';
+ }
+
+ // --- 10) The resolver helpers, in isolation ---------------------------------------------------------
+ {
+ const { I } = load();
+ const catalog = [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }];
+ out.helpers_childActionByIdFindsFirst = I.childActionById(catalog, 'b').label === 'B';
+ out.helpers_childActionByIdMissIsNull = I.childActionById(catalog, 'zz') === null;
+ const ctx = { viewDef: { rowActions: [{ id: 'del', label: 'Delete', method: 'DELETE' }] } };
+ // rowActionIdExists is deliberately BROADER than dialogActionIsOpenable: it sees non-dialog row actions
+ // too, which is what makes the shadowing refusal in step 3 possible.
+ out.helpers_rowActionIdExistsSeesNonDialog = I.rowActionIdExists(ctx, 'del') === true;
+ out.helpers_dialogActionIsOpenableDoesNot = I.dialogActionIsOpenable(ctx, 'del') === false;
+ out.helpers_rowActionIdExistsMiss = I.rowActionIdExists(ctx, 'nope') === false;
+ out.helpers_rowActionIdExistsNullSafe = I.rowActionIdExists(null, 'x') === false
+ && I.rowActionIdExists({}, 'x') === false;
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
index 299e82b563..afc86e5025 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-form.cjs
@@ -119,4 +119,17 @@ out.missing_disabled = xbtn.disabled === true;
out.missing_ariaDisabled = xbtn.getAttribute('aria-disabled') === 'true';
out.missing_marker = xbtn.dataset.juneauActionMissing === '1';
+// --- the trailing childCatalog parameter is OPTIONAL: every existing positional call is unchanged -----------
+// The call above passes six arguments, exactly as it did before WORK-J0513, and still paints today's fail-closed
+// control. With the catalog supplied, the SAME field resolves instead - so the parameter is additive at the call
+// site as well as in the signature.
+const dialog3 = env.el('div');
+I.appendDialogForm(dialog3, { fields: [{ name: 'x', type: 'action', label: 'X', actionId: 'nope' }] },
+ table, tr, ctx, 3, [{ id: 'nope', label: 'Child', form: '/x/child-form' }]);
+const cbtn = dialog3.querySelector('button');
+out.catalog_enabled = cbtn.disabled === false;
+out.catalog_notMarkedMissing = cbtn.dataset.juneauActionMissing === undefined;
+// ...and it still carries no submit value, so the collection path is untouched by any of this.
+out.catalog_stillSkippedByCollect = !Object.hasOwn(I.collectDialogFormFields(dialog3), 'x');
+
process.stdout.write(JSON.stringify(out));
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs
new file mode 100644
index 0000000000..658fa20d05
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-receipt.cjs
@@ -0,0 +1,509 @@
+/*
+ * 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.
+ */
+
+/*
+ * dialog-receipt.cjs - always-on Node harness for the in-dialog result RECEIPT (WORK-J0513 Scope A): a dialog that
+ * opted in with `ModalDef.keepOpenOnSubmit` keeps its layer across the submit, and the runtime paints the write's
+ * result into that already-open dialog rather than closing it and painting a row banner.
+ *
+ * Why a DOM harness and not source pins: the interesting failures here are not "is the right function called", they
+ * are "which surface did the outcome land on, and can the operator still submit". A stuck busy modal, a Confirm
+ * left disabled on a retryable refusal, a receipt painted over a still-populated form, an outcome rendered twice
+ * (once in the dialog and once behind the backdrop) - all of those are DOM-shaped and invisible to a source pin.
+ *
+ * The submit graph has NINE mutually-exclusive terminals, so this harness walks them one case per terminal and
+ * checks the DISPOSITION each one settles to, plus the two enforcement properties that hold across all of them:
+ *
+ * 1. T8-receipt - success + resultForm on a held dialog: swap in place, same layer depth, no submit controls.
+ * 2. T8-commit - success with NO resultForm: whole-stack close, and the row banner still renders.
+ * 3. F4 - success + resultForm on an UN-held dialog: success banner unchanged, plus one role=status
+ * diagnostic. Never a new layer, never a refusal.
+ * 4. F1c - the receipt payload carries a `form`: refused VISIBLY with its own wording, terminal.
+ * 5. F12 - the receipt GET fails (non-2xx / unparseable / REJECTED promise): terminal, Close-only, and
+ * the busy marker cleared - a committed write must never leave a permanently stuck modal.
+ * 6. T1 - a pre-flight client refusal (nothing sent): retryable, Confirm re-enabled.
+ * 7. T2 - a network reject (ambiguous): terminal, Confirm NOT re-enabled, no one-click duplicate.
+ * 8. T4 - a transport refusal (a gate said no, nothing written): retryable.
+ * 9. T5 - a job-started pointer: the dialog closes BEFORE the row-anchored progress affordance paints.
+ * 10. Cancel - the operator dismissed the dialog mid-flight: the outcome falls back to the row banner.
+ * 11. Field kinds - `code` paints a <pre> + copy button; an unrecognized kind falls back to text.
+ *
+ * Usage: node dialog-receipt.cjs <juneau-renders.js> <juneau-views.js>
+ *
+ * Prints ONE JSON object to stdout; every assertion lives in the Java test.
+ */
+'use strict';
+
+const path = require('node:path');
+const { makeEnv, loadViews, jsonResponse } = require(path.join(__dirname, 'views-dom-shim.cjs'));
+
+const rendersJsPath = process.argv[2];
+const viewsJsPath = process.argv[3];
+if (!rendersJsPath || !viewsJsPath) {
+ console.error('usage: node dialog-receipt.cjs <juneau-renders.js> <juneau-views.js>');
+ process.exit(2);
+}
+
+const FORM_URL = '/x/form';
+const RECEIPT_URL = '/x/receipt';
+const ENDPOINT = '/x/act';
+
+function load() {
+ const env = makeEnv();
+ return loadViews(rendersJsPath, viewsJsPath, env);
+}
+
+const out = {};
+{
+ const probe = load();
+ out.hasReceiptSeam = !!(probe.I
+ && typeof probe.I.beginDialogResultHold === 'function'
+ && typeof probe.I.settleDialogResultHold === 'function'
+ && typeof probe.I.paintDialogReceipt === 'function'
+ && typeof probe.I.fetchResultForm === 'function'
+ && typeof probe.I.appendDialogCloseOnlyRow === 'function');
+}
+if (!out.hasReceiptSeam) { process.stdout.write(JSON.stringify(out)); process.exit(0); }
+
+function flush() {
+ return new Promise(function (r) { let n = 0; (function tick() { if (n++ >= 8) { return r(); } setTimeout(tick, 0); })(); });
+}
+
+/**
+ * The served confirmation payload: a form-bearing ModalDef, so it must carry the runtime's dialog-form contract
+ * version at BOTH levels or openActionDialog refuses before any of this is reachable.
+ */
+function modalPayload(opts) {
+ const o = opts || {};
+ const m = {
+ contractVersion: '2',
+ title: 'Really do it?',
+ fields: o.fields || [{ label: 'Id', value: 'INC-1' }],
+ form: {
+ contractVersion: '2',
+ fields: [{ name: 'note', type: 'text', label: 'Note', value: 'typed-by-operator' }]
+ }
+ };
+ if (o.keepOpen) m.keepOpenOnSubmit = true;
+ if (o.childActions) m.childActions = o.childActions;
+ return m;
+}
+
+/** A table + row + ctx wired the way constructTable wires a real one, with a `present=dialog` row action. */
+function fixture(env, opts) {
+ const o = opts || {};
+ const table = env.el('table');
+ if (o.csrf !== false) table.setAttribute('data-juneau-csrf', 'tok-1');
+ env.body.appendChild(table);
+
+ const tr = env.el('tr');
+ tr.setAttribute('data-juneau-row-id', 'INC-1');
+ tr.dataset.juneauRowId = 'INC-1';
+ const cell = env.el('td');
+ cell.className = 'juneau-view-actions-cell';
+ tr.appendChild(cell);
+ table.appendChild(tr);
+
+ const action = {
+ id: 'act', label: 'Act', present: 'dialog', method: 'POST', endpoint: ENDPOINT, form: FORM_URL,
+ onSuccess: o.onSuccess || null
+ };
+ let redraws = 0;
+ const ctx = {
+ table: table, viewDef: { id: 'v1', rowActions: [action] }, dataTable: {}, activeState: {},
+ redraw: function () { redraws++; },
+ redrawCount: function () { return redraws; }
+ };
+ return { table: table, tr: tr, action: action, ctx: ctx };
+}
+
+const q = (env, sel) => env.body.querySelector(sel);
+const dialogEl = (env) => q(env, '.juneau-view-dialog');
+const confirmBtn = (env) => q(env, '.juneau-view-dialog-confirm');
+const heldNotice = (env) => q(env, '.juneau-view-dialog-result-notice');
+const rowOutcome = (env) => q(env, '.juneau-view-action-outcome');
+const rowRefusal = (env) => q(env, '.juneau-view-action-refusal');
+const anyRowBanner = (env) => rowOutcome(env) != null || rowRefusal(env) != null;
+
+/**
+ * Opens the dialog and presses Confirm, with `respond` answering the submit POST (and, when given, the follow-up
+ * receipt GET). Returns the loaded env / interfaces so each case can inspect the settled DOM.
+ */
+async function openAndConfirm(opts) {
+ const o = opts || {};
+ const { env, I } = load();
+ const fx = fixture(env, o);
+ env.setFetch(function (url, init) {
+ const method = (init && init.method) || 'GET';
+ if (method === 'GET' && url === FORM_URL)
+ return Promise.resolve(jsonResponse(modalPayload(o)));
+ if (method === 'GET' && String(url).indexOf(RECEIPT_URL) === 0)
+ return o.receipt();
+ return o.submit();
+ });
+ I.openActionDialog(fx.action, fx.table, fx.tr, fx.ctx);
+ await flush();
+ const opened = I.dialogLayerCount() === 1;
+ const btn = confirmBtn(env);
+ if (o.beforeConfirm) o.beforeConfirm(env, I, fx);
+ if (btn) btn.dispatch('click');
+ if (o.afterConfirm) o.afterConfirm(env, I, fx);
+ await flush();
+ return { env: env, I: I, fx: fx, opened: opened, confirm: btn };
+}
+
+const success = (extra) => () => Promise.resolve(jsonResponse(Object.assign({ outcome: 'success' }, extra || {})));
+
+(async function main() {
+
+ // --- 1) T8-receipt: the swap. Same layer, no submit controls, the submitted form torn down ---------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: success({ resultForm: RECEIPT_URL, message: 'done' }),
+ receipt: () => Promise.resolve(jsonResponse({
+ title: 'Deleted',
+ fields: [{ label: 'Id', value: 'INC-1' }, { label: 'Token', value: 'tok-9', kind: 'code' }]
+ }))
+ });
+ const env = r.env, d = dialogEl(env);
+ out.receipt_dialogOpened = r.opened;
+ out.receipt_stillOpenAtSameDepth = r.I.dialogLayerCount() === 1;
+ out.receipt_marked = d != null && d.dataset.juneauReceipt === '1' && d.dataset.testid === 'dialog-receipt';
+ // The ENFORCEMENT, and it is an absence: no confirm control anywhere in the receipt.
+ out.receipt_noConfirmButton = confirmBtn(env) === null;
+ out.receipt_noInputsAtAll = env.body.querySelectorAll('[data-juneau-form-field]').length === 0;
+ out.receipt_submittedFormTornDown = env.body.querySelector('.juneau-view-dialog-form') === null;
+ out.receipt_closeButtonPresent = q(env, '[data-testid="dialog-receipt-close"]') != null;
+ out.receipt_titleFromPayload = (function () {
+ const t = q(env, '.juneau-view-dialog-title');
+ return t != null && t.textContent === 'Deleted';
+ })();
+ out.receipt_fieldsRepainted = (function () {
+ const dl = q(env, '.juneau-view-dialog-fields');
+ return dl != null && dl.querySelectorAll('dd').length === 2 && dl.querySelectorAll('dt').length === 2;
+ })();
+ out.receipt_exactlyOneFieldList = env.body.querySelectorAll('.juneau-view-dialog-fields').length === 1;
+ out.receipt_codeFieldIsPre = q(env, '.juneau-view-dialog-field-code') != null
+ && q(env, '.juneau-view-dialog-field-code').textContent === 'tok-9';
+ out.receipt_codeFieldHasCopyButton = q(env, '[data-testid="dialog-field-copy"]') != null;
+ // The row banner is SUPPRESSED: the dialog is on screen, so rendering it too would show one outcome twice.
+ out.receipt_noRowBanner = !anyRowBanner(env);
+ out.receipt_busyMarkerCleared = d != null && d.dataset.juneauDialogBusy === undefined;
+ // Pressing Close takes the whole stack down.
+ const closeBtn = q(env, '[data-testid="dialog-receipt-close"]');
+ if (closeBtn) closeBtn.dispatch('click');
+ out.receipt_closeTearsDownTheStack = r.I.dialogLayerCount() === 0;
+ }
+
+ // --- 2) T8-commit: a held success with NO resultForm closes, and the row banner STILL renders -------------
+ {
+ const r = await openAndConfirm({ keepOpen: true, submit: success({ message: 'done' }) });
+ const env = r.env;
+ out.commit_dialogClosed = r.I.dialogLayerCount() === 0;
+ out.commit_noDialogInDom = dialogEl(env) === null;
+ // closeCommitted returns false on purpose: with the dialog gone the row banner is the ONLY signal left.
+ out.commit_rowOutcomeRendered = rowOutcome(env) != null;
+ out.commit_rowOutcomeState = rowOutcome(env) ? rowOutcome(env).dataset.state : null;
+ out.commit_noReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+ }
+
+ // --- 3) F4: an UN-held success carrying a resultForm - diagnose, never open a layer ----------------------
+ {
+ let receiptGets = 0;
+ const r = await openAndConfirm({
+ submit: success({ resultForm: RECEIPT_URL, message: 'done' }),
+ receipt: () => { receiptGets++; return Promise.resolve(jsonResponse({ title: 'Deleted', fields: [] })); }
+ });
+ const env = r.env;
+ out.f4_dialogClosedAsToday = r.I.dialogLayerCount() === 0;
+ out.f4_noFollowUpGet = receiptGets === 0;
+ out.f4_successBannerUnchanged = rowOutcome(env) != null && rowOutcome(env).dataset.state === 'success';
+ const diag = q(env, '[data-testid="result-form-ignored"]');
+ out.f4_diagnosticPresent = diag != null;
+ // role=status, not alert: the write SUCCEEDED, and a consumer authoring bug must not look like a failure.
+ out.f4_diagnosticIsStatusNotAlert = diag != null && diag.getAttribute('role') === 'status';
+ out.f4_noNewLayer = r.I.dialogLayerCount() === 0 && env.body.querySelector('[data-juneau-receipt]') === null;
+ }
+
+ // --- 4) F1c: the receipt payload carries a form - the FORBIDDEN shape, refused visibly -------------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: success({ resultForm: RECEIPT_URL }),
+ receipt: () => Promise.resolve(jsonResponse({
+ title: 'Nope', form: { contractVersion: '2', fields: [{ name: 'again', type: 'text', label: 'Again' }] }
+ }))
+ });
+ const env = r.env;
+ out.f1c_stillOpen = r.I.dialogLayerCount() === 1;
+ out.f1c_notPaintedAsReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+ out.f1c_noInputsPainted = env.body.querySelectorAll('[data-juneau-form-field]').length === 0;
+ out.f1c_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+ out.f1c_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.f1c_noConfirm = confirmBtn(env) === null;
+ out.f1c_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ }
+
+ // --- 5a) F12: the receipt GET returns a non-2xx --------------------------------------------------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: success({ resultForm: RECEIPT_URL }),
+ receipt: () => Promise.resolve(jsonResponse({ reason: 'nope' }, { status: 500 }))
+ });
+ const env = r.env;
+ out.f12_stillOpen = r.I.dialogLayerCount() === 1;
+ out.f12_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+ out.f12_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.f12_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ out.f12_submittedFormTornDown = env.body.querySelector('.juneau-view-dialog-form') === null;
+ }
+
+ // --- 5b) F12: the receipt GET PROMISE REJECTS - the arm without which a committed write hangs forever ---
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: success({ resultForm: RECEIPT_URL }),
+ receipt: () => Promise.reject(new Error('socket died'))
+ });
+ const env = r.env;
+ out.f12reject_stillOpen = r.I.dialogLayerCount() === 1;
+ out.f12reject_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+ out.f12reject_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.f12reject_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ out.f12reject_noConfirmStuckDisabled = confirmBtn(env) === null;
+ }
+
+ // --- 5c) F12: the receipt GET returns 2xx that does not parse ------------------------------------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: success({ resultForm: RECEIPT_URL }),
+ receipt: () => Promise.resolve(jsonResponse('not json at all'))
+ });
+ const env = r.env;
+ out.f12parse_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+ out.f12parse_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.f12parse_notPaintedAsReceipt = env.body.querySelector('[data-juneau-receipt]') === null;
+ }
+
+ // --- 6) T1: a pre-flight client refusal (no CSRF token) is RETRYABLE ----------------------------------
+ {
+ let posts = 0;
+ const r = await openAndConfirm({
+ keepOpen: true, csrf: false,
+ submit: () => { posts++; return Promise.resolve(jsonResponse({ outcome: 'success' })); }
+ });
+ const env = r.env;
+ out.t1_nothingSent = posts === 0;
+ out.t1_stillOpen = r.I.dialogLayerCount() === 1;
+ out.t1_noticePresent = heldNotice(env) != null;
+ // Retryable: nothing was sent, so the operator can fix the declaration and press Confirm again.
+ out.t1_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+ out.t1_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ out.t1_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+ out.t1_noRowBanner = !anyRowBanner(env);
+ out.t1_formStillStanding = env.body.querySelector('.juneau-view-dialog-form') != null;
+ }
+
+ // --- 7) T2: a network reject is AMBIGUOUS - terminal, and Confirm must NOT come back ------------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.reject(new Error('connection reset'))
+ });
+ const env = r.env;
+ out.t2_stillOpen = r.I.dialogLayerCount() === 1;
+ out.t2_noticeText = heldNotice(env) ? heldNotice(env).textContent : null;
+ // The whole point: re-enabling Confirm here would hand the operator a one-click duplicate of a write that
+ // may well have landed.
+ out.t2_noConfirmButton = confirmBtn(env) === null;
+ out.t2_closeOnlyRow = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.t2_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ // Terminal keeps the typed values on screen so the operator can copy them out before Close.
+ out.t2_formStillStanding = env.body.querySelector('.juneau-view-dialog-form') != null;
+ out.t2_noRowBanner = !anyRowBanner(env);
+ }
+
+ // --- 8) T4: a transport refusal is a gate saying no - nothing written, so RETRYABLE -------------------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.resolve(jsonResponse({ reason: 'not-armed' }, { status: 403 }))
+ });
+ const env = r.env;
+ out.t4_stillOpen = r.I.dialogLayerCount() === 1;
+ out.t4_noticePresent = heldNotice(env) != null;
+ out.t4_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+ out.t4_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+ out.t4_busyCleared = dialogEl(env).dataset.juneauDialogBusy === undefined;
+ }
+
+ // --- 9) T8-failure / T8-refusal: the two remaining dispositions of the typed-outcome terminal --------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.resolve(jsonResponse({ outcome: 'failure', message: 'boom' }))
+ });
+ const env = r.env;
+ out.t8fail_stillOpen = r.I.dialogLayerCount() === 1;
+ out.t8fail_terminalCloseOnly = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.t8fail_noConfirm = confirmBtn(env) === null;
+ out.t8fail_noticePresent = heldNotice(env) != null;
+ }
+ {
+ // F11: a named refusal did not write, so Confirm comes back.
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.resolve(jsonResponse({ outcome: 'refusal', refusalCode: 'write-guard:not-armed' }))
+ });
+ const env = r.env;
+ out.t8refuse_confirmReEnabled = confirmBtn(env) != null && confirmBtn(env).disabled === false;
+ out.t8refuse_noCloseOnlyRow = q(env, '[data-testid="dialog-result-close"]') === null;
+ out.t8refuse_noticePresent = heldNotice(env) != null;
+ }
+
+ // --- 10) resultForm is IGNORED on a non-success outcome: no receipt for a write that did not happen ---
+ {
+ let receiptGets = 0;
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.resolve(jsonResponse({ outcome: 'failure', resultForm: RECEIPT_URL })),
+ receipt: () => { receiptGets++; return Promise.resolve(jsonResponse({ title: 'Deleted' })); }
+ });
+ const env = r.env;
+ out.failResultForm_noGetIssued = receiptGets === 0;
+ out.failResultForm_noReceiptPainted = env.body.querySelector('[data-juneau-receipt]') === null;
+ out.failResultForm_terminalCloseOnly = q(env, '[data-testid="dialog-result-close"]') != null;
+ out.failResultForm_noDiagnostic = q(env, '[data-testid="result-form-ignored"]') === null;
+ }
+
+ // --- 11) T5: a job pointer closes the dialog BEFORE the row-anchored progress affordance paints -------
+ {
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => Promise.resolve(jsonResponse({ streamUrl: '/x/stream' }))
+ });
+ const env = r.env;
+ out.t5_dialogClosed = r.I.dialogLayerCount() === 0;
+ out.t5_noDialogInDom = dialogEl(env) === null;
+ // Everything startJobStream paints is ROW-anchored, so a surviving backdrop would hide the whole
+ // affordance behind a modal that can never settle (a job pointer is not a terminal result and can never
+ // carry a resultForm). This shim has no EventSource, so the path taken is that function's own
+ // no-SSE-transport fallback - which is itself a ROW paint, and therefore proves the same ordering: the
+ // close already happened, and the row was reachable when the job path ran.
+ out.t5_rowAnchoredSignalVisible = rowOutcome(env) != null;
+ out.t5_rowSignalIsNotInADialog = rowOutcome(env) != null && rowOutcome(env).closest('.juneau-view-dialog') === null;
+ }
+
+ // --- 12) Cancel mid-flight: the layer is gone, so the outcome falls back to the row banner -----------
+ {
+ let resolveSubmit = null;
+ const r = await openAndConfirm({
+ keepOpen: true,
+ submit: () => new Promise(function (res) { resolveSubmit = res; }),
+ afterConfirm: function (env, I) {
+ // Cancel stays enabled on purpose (Escape pops the layer regardless, so the design has to tolerate
+ // "dialog gone before settle" anyway).
+ const cancel = env.body.querySelector('.juneau-view-dialog-cancel');
+ if (cancel) cancel.dispatch('click');
+ }
+ });
+ out.cancel_layerGone = r.I.dialogLayerCount() === 0;
+ if (resolveSubmit) resolveSubmit(jsonResponse({ outcome: 'refusal', refusalCode: 'app:x' }));
+ await flush();
+ const env = r.env;
+ out.cancel_outcomeFellBackToRow = rowOutcome(env) != null || rowRefusal(env) != null;
+ out.cancel_noThrowAndNoDialog = dialogEl(env) === null;
+ }
+
+ // --- 13) Field kinds on the confirmation payload: `code` -> <pre>, unknown -> plain text -------------
+ {
+ const { env, I } = load();
+ const dl = I.buildModalFieldList([
+ { label: 'Plain', value: 'p' },
+ { label: 'Coded', value: 'c' , kind: 'code' },
+ { label: 'Bogus', value: 'b', kind: 'html' },
+ { label: 'Explicit', value: 'e', kind: 'text' }
+ ]);
+ env.body.appendChild(dl);
+ const dds = dl.querySelectorAll('dd');
+ out.kinds_ddCount = dds.length === 4;
+ out.kinds_plainIsText = dds[0].querySelector('pre') === null && dds[0].textContent === 'p';
+ out.kinds_codeIsPre = dds[1].querySelector('pre') !== null;
+ out.kinds_codeHasCopy = dds[1].querySelector('.juneau-view-dialog-field-copy') !== null;
+ // An unrecognized token falls BACK to text rather than being trusted.
+ out.kinds_unknownFallsBackToText = dds[2].querySelector('pre') === null && dds[2].textContent === 'b';
+ out.kinds_explicitTextIsText = dds[3].querySelector('pre') === null && dds[3].textContent === 'e';
+ out.kinds_allowlistIsExactlyTwo = I.isModalFieldKind('text') === true && I.isModalFieldKind('code') === true
+ && I.isModalFieldKind('html') === false && I.isModalFieldKind('') === false;
+ }
+
+ // --- 14) A hold settles exactly ONCE: the second hand-off on the same submit finds nothing -----------
+ {
+ const { env, I } = load();
+ const fx = fixture(env);
+ const dialog = env.el('div');
+ dialog.className = 'juneau-view-dialog';
+ const btn = env.el('button');
+ const backdrop = env.el('div');
+ env.body.appendChild(backdrop);
+ fx.ctx._dialogStack = [backdrop];
+ I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: btn }, fx.action, {}, fx.ctx);
+ out.once_holdRegistered = fx.ctx._resultHold != null;
+ out.once_confirmDisabledAtClickTime = btn.disabled === true;
+ out.once_busyMarked = dialog.dataset.juneauDialogBusy === '1';
+ out.once_firstSettleHandsOff = I.settleDialogResultHold(fx.ctx, 'terminal') === true;
+ out.once_holdCleared = fx.ctx._resultHold == null;
+ out.once_secondSettleIsANoOp = I.settleDialogResultHold(fx.ctx, 'terminal') === false;
+ // closeCommitted is the ONE disposition that returns false, so the caller's row painter still runs.
+ fx.ctx._dialogStack = [backdrop];
+ I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: btn }, fx.action, {}, fx.ctx);
+ out.once_closeCommittedDoesNotSuppressRowPaint = I.settleDialogResultHold(fx.ctx, 'closeCommitted') === false;
+ // A settle with no hold at all is also false - the un-held path renders exactly as it does today.
+ out.once_noHoldIsFalse = I.settleDialogResultHold(fx.ctx, 'terminal') === false;
+ out.once_noCtxIsFalse = I.settleDialogResultHold(null, 'terminal') === false;
+ }
+
+ // --- 15) A child-action button is disabled for the duration of the hold, and RESTORED to its prior state
+ {
+ const { env, I } = load();
+ const fx = fixture(env);
+ const dialog = env.el('div');
+ dialog.className = 'juneau-view-dialog';
+ const live = env.el('button');
+ live.className = 'juneau-view-dialog-form-action';
+ const alreadyGated = env.el('button');
+ alreadyGated.className = 'juneau-view-dialog-form-action';
+ alreadyGated.disabled = true;
+ dialog.appendChild(live);
+ dialog.appendChild(alreadyGated);
+ const backdrop = env.el('div');
+ env.body.appendChild(backdrop);
+ fx.ctx._dialogStack = [backdrop];
+ I.beginDialogResultHold({ backdrop: backdrop, dialog: dialog, confirmBtn: env.el('button') }, fx.action, {}, fx.ctx);
+ out.children_liveOneDisabled = live.disabled === true && live.getAttribute('aria-disabled') === 'true';
+ out.children_gatedOneStaysDisabled = alreadyGated.disabled === true;
+ I.settleDialogResultHold(fx.ctx, 'retryable');
+ out.children_liveOneRestored = live.disabled === false && live.getAttribute('aria-disabled') === null;
+ // The restore must never ENABLE a child that was gated before the submit started.
+ out.children_gatedOneStillDisabled = alreadyGated.disabled === true;
+ }
+
+ process.stdout.write(JSON.stringify(out));
+})();
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
index bbea4826d1..f86dcbfee5 100644
--- a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ModalDef.java
@@ -66,10 +66,43 @@ import org.apache.juneau.commons.bean.*;
* Set it <b>only</b> on a modal whose key came from the self-targeted mint helper. Setting it on a modal carrying
* an artifact-bound key silently discards the real target and makes the server's binding check vacuous.
*
+ * <h5 class='section'>{@link #keepOpenOnSubmit} — the opt-in for an in-dialog result receipt</h5>
+ * <p>
+ * By default the runtime closes a dialog at confirm-click time and then submits, so every dialog is gone before its
+ * result exists. {@link #keepOpenOnSubmit} suppresses that close, and the runtime instead paints the write's
+ * <b>result</b> into the dialog that is already open — a <i>receipt</i>.
+ * <p>
+ * A receipt host is <b>non-submittable by construction</b>, not by convention: the runtime paints it with a distinct
+ * function that creates no confirm control at all, and it refuses to paint one whose payload carries a
+ * {@link #form}. It is reached <b>only</b> after a committed write (an
+ * {@code ActionResult} whose outcome is {@code success}), so a result host can never appear for a write that did not
+ * happen. A follow-up read-only form source is named by that result's {@code resultForm}.
+ * <p>
+ * The opt-in has to live here rather than on the action bean, because the confirm handler must decide whether to
+ * close <i>before</i> any response exists. A dialog synthesized from a {@code confirm} string alone (no form-source
+ * URL) therefore cannot opt in — a receipt-bearing action must serve a real {@link ModalDef}.
+ * <p>
+ * Additive, and named degradation, accepted: an older runtime ignoring this flag closes the dialog and drops the
+ * receipt. The write still happens; the receipt is simply not shown.
+ *
+ * <h5 class='section'>{@link #childActions} — the dialog-scoped child-action catalog</h5>
+ * <p>
+ * A {@code type="action"} form input names an action id, and the runtime resolves it against the enclosing view's
+ * row-action catalog. A <b>stacked</b> step reached from inside an already-open dialog (a Review step in a wizard)
+ * has nothing legal to resolve against there: it is not a row action, and publishing it as one would surface it in
+ * every row's action menu. {@link #childActions} is that missing catalog, scoped to <b>one open dialog</b>.
+ * <p>
+ * Scoping is <b>structural</b>, not a flag some menu builder has to remember to filter on: this catalog rides on a
+ * per-open payload, so the row-action menu and the ribbon resolver never see it and there is no exclusion logic to
+ * rot. Resolution precedence is likewise fail-safe: the existing row-action check runs <b>first</b> and unchanged,
+ * so a served payload can never shadow — and thereby bypass the gating of — a declared row action.
+ *
* <h5 class='section'>Not a new row-action wire field</h5>
* <p>
* This is the response a row action's form-source URL returns, <b>not</b> a new field on the frozen row-action wire
* schema: the modal's declaration lives in this separately-served payload so the row-action contract stays frozen.
+ * The same reasoning is why {@link #keepOpenOnSubmit} and {@link #childActions} land here rather than on
+ * {@code RowAction} / {@code RibbonAction}, which stay frozen and untouched.
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
@@ -104,7 +137,7 @@ import org.apache.juneau.commons.bean.*;
*
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,selfTargeted,barSlot")
+@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,selfTargeted,barSlot,keepOpenOnSubmit,childActions")
@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class ModalDef implements Widget {
@@ -118,13 +151,19 @@ public class ModalDef implements Widget {
*/
public static final String CONTRACT_VERSION = "2";
+ /** The {@link Field#kind} token for an ordinary text value — the default when the field declares none. */
+ public static final String FIELD_KIND_TEXT = "text";
+
+ /** The {@link Field#kind} token for a monospaced, copyable value (an id, a URL, a generated token). */
+ public static final String FIELD_KIND_CODE = "code";
+
/**
* A single typed confirmation field: a label and a value, painted client-side with {@code textContent} (never
* {@code innerHTML}).
*
* @since 10.0.0
*/
- @BeanType(properties="label,value")
+ @BeanType(properties="label,value,kind")
public static class Field {
/** The field label shown to the user. */
@@ -133,6 +172,22 @@ public class ModalDef implements Widget {
/** The field value, read back from the current authoritative record. */
public String value;
+ /**
+ * How this field's value is <i>displayed</i>; omitted from the wire when unset (which reads as
+ * {@link ModalDef#FIELD_KIND_TEXT text}).
+ *
+ * <p>
+ * The allowlist is exactly {@link ModalDef#FIELD_KIND_TEXT text} and {@link ModalDef#FIELD_KIND_CODE code};
+ * {@link ModalDef#validate()} rejects anything else server-side, and the runtime falls back to
+ * {@code text} for an unrecognized token rather than trusting it.
+ *
+ * <p>
+ * This is a <b>display</b> kind on a confirmation field, deliberately <b>not</b> a new
+ * {@code FormDef.Input} type: a {@code code} field carries no submit value, so expressing it as an input
+ * would mean teaching the collection, validation, prefill and submit paths about a control that is not one.
+ */
+ public String kind;
+
/**
* Creates a typed confirmation field.
*
@@ -149,6 +204,177 @@ public class ModalDef implements Widget {
f.value = value;
return f;
}
+
+ /**
+ * Creates a typed confirmation field displayed as monospaced, copyable {@link ModalDef#FIELD_KIND_CODE code}.
+ *
+ * <p>
+ * The runtime paints the value into a {@code <pre>} with {@code textContent} (never {@code innerHTML}) and
+ * adds a copy button that is feature-detected and never throws; the {@code <pre>} stays selectable, so
+ * manual copy always works.
+ *
+ * @param label The field label. Must not be <jk>null</jk> or blank.
+ * @param value The field value. Can be <jk>null</jk> (rendered as an empty value).
+ * @return A new {@link Field}.
+ * @throws IllegalArgumentException If {@code label} is <jk>null</jk> or blank.
+ */
+ public static Field code(String label, String value) {
+ var f = of(label, value);
+ f.kind = FIELD_KIND_CODE;
+ return f;
+ }
+ }
+
+ /**
+ * One entry in a dialog's {@link ModalDef#childActions} catalog — a stacked step a {@code type="action"}
+ * input inside <i>this</i> dialog's form may open.
+ *
+ * <h5 class='section'>Dialog-presenting by construction</h5>
+ * <p>
+ * A child action carries no {@code present} field: opening a stacked dialog is the only thing it does. A child
+ * with no {@link #form} is a legal confirm-only child dialog.
+ *
+ * <h5 class='section'>No gating vocabulary, on purpose</h5>
+ * <p>
+ * There is deliberately no {@code enabledWhen} here, for three independent reasons. The rule type lives in the
+ * rich-view module, which this module must not reference. A dialog opened where there is no row has no row data
+ * to evaluate against, so a field-keyed rule would fail closed forever with no signal to its author. And the
+ * subject of a child step is the <i>draft in the open dialog</i>, not the parent row — so evaluating the
+ * parent row's data would answer a question nobody asked. Gating a child step is the <b>server's</b> job at
+ * child-form-GET time; it already owns that GET and can refuse it.
+ *
+ * @since 10.0.0
+ */
+ @BeanType(properties="id,label,form,endpoint,method,onSuccess,carryDrafts")
+ @SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+ public static class ChildAction {
+
+ /** The action id a {@code type="action"} input in this dialog's form names. Required, non-blank. */
+ public String id;
+
+ /** The label painted on the button that opens this child. Required, non-blank. */
+ public String label;
+
+ /**
+ * The child dialog's read-only form-source URL; omitted from the wire when unset (a confirm-only child).
+ *
+ * <p>
+ * Required if this child declares {@link #carryDrafts} — with no URL there is nothing for the draft
+ * query to ride on, so the drafts would vanish silently.
+ */
+ public String form;
+
+ /** The child's submit endpoint; omitted from the wire when unset. See {@link #endpoint(String)}. */
+ public String endpoint;
+
+ /** The child's submit method; omitted from the wire when unset. See {@link #method(String)}. */
+ public String method;
+
+ /** The child's on-success behaviour token; omitted from the wire when unset. */
+ public String onSuccess;
+
+ /**
+ * Whether opening this child carries the <b>parent dialog's current draft values</b> to its form-source GET
+ * as one query parameter; omitted from the wire when unset or <jk>false</jk>.
+ *
+ * <p>
+ * Without it a Review step opens <b>empty</b> and loses everything the operator typed. With it, the
+ * runtime serializes the parent's collected form values, URL-encodes them, and appends them as a single
+ * {@code juneauDrafts} parameter, refusing visibly (and not opening the child) above a client-side byte cap
+ * measured on the <i>encoded</i> value.
+ *
+ * <p>
+ * <b>Drafts land in access logs and browser history.</b> No secret may ride this channel.
+ */
+ public Boolean carryDrafts;
+
+ /**
+ * Creates a child action.
+ *
+ * @param id The action id a {@code type="action"} input names. Must not be <jk>null</jk> or blank.
+ * @param label The button label. Must not be <jk>null</jk> or blank.
+ * @return A new {@link ChildAction}.
+ * @throws IllegalArgumentException If {@code id} or {@code label} is <jk>null</jk> or blank.
+ */
+ public static ChildAction of(String id, String label) {
+ if (id == null || id.isBlank())
+ throw iaex("ModalDef.ChildAction id must not be null or blank.");
+ if (label == null || label.isBlank())
+ throw iaex("ModalDef.ChildAction label must not be null or blank.");
+ var c = new ChildAction();
+ c.id = id;
+ c.label = label;
+ return c;
+ }
+
+ /**
+ * Sets the child dialog's read-only form-source URL.
+ *
+ * @param value The URL. Can be <jk>null</jk> to unset (a confirm-only child).
+ * @return This object.
+ */
+ public ChildAction form(String value) {
+ form = value;
+ return this;
+ }
+
+ /**
+ * Sets the child's submit endpoint.
+ *
+ * <p>
+ * Optional individually; a child action whose dialog is meant to be <b>submittable</b> must declare both a
+ * non-safe {@link #method} and an {@code endpoint}, or its Confirm visibly refuses with {@code safe-method}
+ * and sends nothing. A display-only child that never submits needs neither, which is why this is not a
+ * {@link ModalDef#validate()} rule — this bean cannot know which of its children are meant to submit.
+ *
+ * @param value The endpoint. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ChildAction endpoint(String value) {
+ endpoint = value;
+ return this;
+ }
+
+ /**
+ * Sets the child's submit method.
+ *
+ * <p>
+ * Optional individually; see {@link #endpoint(String)} — a submittable child needs both, and a
+ * missing or safe method is a visible client-side refusal before any request is sent.
+ *
+ * @param value The method. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ChildAction method(String value) {
+ method = value;
+ return this;
+ }
+
+ /**
+ * Sets the child's on-success behaviour token.
+ *
+ * @param value The token. Can be <jk>null</jk> to unset.
+ * @return This object.
+ */
+ public ChildAction onSuccess(String value) {
+ onSuccess = value;
+ return this;
+ }
+
+ /**
+ * Declares that opening this child carries the parent dialog's drafts (see {@link #carryDrafts}).
+ *
+ * <p>
+ * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}, so an
+ * un-opted-in child's payload is byte-identical to one that never mentioned it.
+ *
+ * @param value <jk>true</jk> to carry the parent's drafts; <jk>false</jk> (or unset) to open empty.
+ * @return This object.
+ */
+ public ChildAction carryDrafts(boolean value) {
+ carryDrafts = value ? Boolean.TRUE : null;
+ return this;
+ }
}
/**
@@ -193,6 +419,22 @@ public class ModalDef implements Widget {
*/
public BarSlot barSlot;
+ /**
+ * Whether the runtime must <b>not</b> close this dialog at confirm-click time, so the write's result can be
+ * painted into it as a receipt (see the class Javadoc); omitted from the wire when unset or <jk>false</jk>.
+ *
+ * <p>
+ * Additive-only: like {@link #barSlot} and {@link #selfTargeted}, its presence does not bump
+ * {@link #CONTRACT_VERSION}.
+ */
+ public Boolean keepOpenOnSubmit;
+
+ /**
+ * The dialog-scoped catalog of stacked child actions a {@code type="action"} input in {@link #form} may open
+ * (see the class Javadoc); omitted from the wire when unset or empty.
+ */
+ public List<ChildAction> childActions;
+
/**
* Starts a new {@link ModalDef} with the specified title / confirmation prompt.
*
@@ -222,6 +464,52 @@ public class ModalDef implements Widget {
return this;
}
+ /**
+ * Adds one typed confirmation field displayed as monospaced, copyable {@link #FIELD_KIND_CODE code}.
+ *
+ * @param label The field label. Must not be <jk>null</jk> or blank.
+ * @param value The field value. Can be <jk>null</jk>.
+ * @return This object.
+ */
+ public ModalDef codeField(String label, String value) {
+ if (fields == null)
+ fields = l();
+ fields.add(Field.code(label, value));
+ return this;
+ }
+
+ /**
+ * Adds one entry to this dialog's child-action catalog.
+ *
+ * @param value The child action. Must not be <jk>null</jk>.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ModalDef childAction(ChildAction value) {
+ if (value == null)
+ throw iaex("ModalDef.ChildAction must not be null.");
+ if (childActions == null)
+ childActions = l();
+ childActions.add(value);
+ return this;
+ }
+
+ /**
+ * Declares that the runtime must keep this dialog open across its submit so the result can be painted into it
+ * as a receipt (see the class Javadoc).
+ *
+ * <p>
+ * Passing <jk>false</jk> <b>clears</b> the flag rather than serializing an explicit {@code false}: the wire
+ * carries this opt-in only when it is on, so an un-opted-in dialog's payload is byte-identical to today's.
+ *
+ * @param value <jk>true</jk> to opt in; <jk>false</jk> (or unset) keeps today's close-then-submit behaviour.
+ * @return This object.
+ */
+ public ModalDef keepOpenOnSubmit(boolean value) {
+ keepOpenOnSubmit = value ? Boolean.TRUE : null;
+ return this;
+ }
+
/**
* Sets the optional input form.
*
@@ -288,6 +576,14 @@ public class ModalDef implements Widget {
* (a raw-built form-bearing modal validated directly must not false-refuse on a null version —
* {@link #checked()} stamps the version first, then validates).
*
+ * <p>
+ * Also rejects, fail-closed at serve time rather than silently on the wire: a {@link Field#kind} outside the
+ * {@link #FIELD_KIND_TEXT text}/{@link #FIELD_KIND_CODE code} allowlist; a blank, duplicate or unlabelled
+ * {@link ChildAction} id; {@link ChildAction#carryDrafts} on a modal that declares no {@link #form} (nothing to
+ * collect <i>from</i>); and {@link ChildAction#carryDrafts} on a child that declares no
+ * {@link ChildAction#form} (nowhere to put them — the runtime would take its confirm-only branch, issue
+ * no GET, and the drafts would vanish silently).
+ *
* @throws IllegalArgumentException If this modal is not well-formed.
*/
@Override
@@ -300,13 +596,49 @@ public class ModalDef implements Widget {
throw iaex("ModalDef field must not be null.");
if (f.label == null || f.label.isBlank())
throw iaex("ModalDef.Field label must not be null or blank.");
+ if (f.kind != null && ! (FIELD_KIND_TEXT.equals(f.kind) || FIELD_KIND_CODE.equals(f.kind)))
+ throw iaex("ModalDef.Field kind must be '" + FIELD_KIND_TEXT + "' or '" + FIELD_KIND_CODE
+ + "', not '" + f.kind + "'.");
}
+ validateChildActions();
if (form != null)
form.validate();
if (barSlot != null)
barSlot.validate();
}
+ /**
+ * The {@link #childActions} half of {@link #validate()}.
+ *
+ * <p>
+ * {@link ChildAction#of(String,String)} already argument-checks {@code id} and {@code label}; they are
+ * re-checked here as the wire-level backstop for a bean-deserialized instance that never went through the
+ * factory.
+ */
+ private void validateChildActions() {
+ if (childActions == null)
+ return;
+ var seen = new HashSet<String>();
+ for (var c : childActions) {
+ if (c == null)
+ throw iaex("ModalDef.ChildAction must not be null.");
+ if (c.id == null || c.id.isBlank())
+ throw iaex("ModalDef.ChildAction id must not be null or blank.");
+ if (c.label == null || c.label.isBlank())
+ throw iaex("ModalDef.ChildAction label must not be null or blank.");
+ if (! seen.add(c.id))
+ throw iaex("ModalDef.ChildAction id '" + c.id + "' is declared more than once on this modal.");
+ if (Boolean.TRUE.equals(c.carryDrafts)) {
+ if (form == null)
+ throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but this modal declares "
+ + "no form - there is nothing to collect drafts from.");
+ if (c.form == null || c.form.isBlank())
+ throw iaex("ModalDef.ChildAction '" + c.id + "' declares carryDrafts, but declares no form URL "
+ + "- a confirm-only child issues no form GET, so the drafts would be dropped silently.");
+ }
+ }
+ }
+
/**
* The serving-path hook every app {@code @RestGet} that returns a {@link ModalDef} must invoke.
*
diff --git a/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java
new file mode 100644
index 0000000000..8464d9feac
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_ResultHost_Test.java
@@ -0,0 +1,282 @@
+/*
+ * 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.
+ */
+package org.apache.juneau.rest.server.widgets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Bean contract for the in-dialog <b>result host</b> opt-in ({@link ModalDef#keepOpenOnSubmit}), the dialog-scoped
+ * <b>child-action catalog</b> ({@link ModalDef#childActions} / {@link ModalDef.ChildAction}), and the confirmation
+ * field <b>display kind</b> ({@link ModalDef.Field#kind} / {@link ModalDef.Field#code(String,String)}).
+ *
+ * <p>
+ * All three are additive to an already-served payload, so the three properties this pins hardest are the ones that
+ * make that claim true rather than merely intended:
+ * <ul class='spaced-list'>
+ * <li><b>Appended, never interleaved.</b> The new keys land after the frozen prefix, in {@code @BeanType} order.
+ * <li><b>Omitted when unset.</b> An un-opted-in modal's payload is byte-identical to one served before any of
+ * this existed — which is what lets an older runtime keep reading it, and lets the golden fixtures in
+ * {@code ModalDef_FormDef_Test} / {@code ModalDef_BarSlot_Test} stay untouched.
+ * <li><b>No {@link ModalDef#CONTRACT_VERSION} bump.</b> Additive means the version does not move.
+ * </ul>
+ *
+ * <p>
+ * The remaining group is the fail-closed serve-time refusals: a payload that would produce a silently-wrong or
+ * silently-empty client surface is rejected by {@link ModalDef#validate()} at serve time, where the author sees it,
+ * rather than degrading quietly in a browser.
+ */
+class ModalDef_ResultHost_Test extends TestBase {
+
+ private static FormDef form() {
+ return FormDef.create().field(FormDef.Input.of("note", "Note", "text"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) keepOpenOnSubmit - the receipt opt-in
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_fluentSetter_storesTheFlag() {
+ assertEquals(Boolean.TRUE, ModalDef.create("Delete?").keepOpenOnSubmit(true).keepOpenOnSubmit);
+ }
+
+ @Test void a02_falseClearsRatherThanSerializingFalse() {
+ // Clearing, not storing FALSE: an un-opted-in payload must be byte-identical to one that never mentioned
+ // the flag, or every existing golden fixture would have to grow a "keepOpenOnSubmit":false.
+ assertNull(ModalDef.create("Delete?").keepOpenOnSubmit(true).keepOpenOnSubmit(false).keepOpenOnSubmit);
+ assertFalse(Json.of(ModalDef.create("Delete?").keepOpenOnSubmit(false)).contains("keepOpenOnSubmit"));
+ }
+
+ @Test void a03_omittedWhenUnset() {
+ var json = Json.of(ModalDef.create("Delete?").checked());
+ assertFalse(json.contains("keepOpenOnSubmit"), () -> "unset flag leaked:\n" + json);
+ assertFalse(json.contains("childActions"), () -> "unset catalog leaked:\n" + json);
+ }
+
+ @Test void a04_emittedWhenSet_andAppendedLast() {
+ var m = ModalDef.create("Delete?").field("Id", "INC-1").form(form()).keepOpenOnSubmit(true).checked();
+ Map<?,?> actual = Json.to(Json.of(m), Map.class);
+ assertEquals(List.of("contractVersion", "title", "fields", "form", "keepOpenOnSubmit"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void a05_noContractVersionBump() {
+ // A form-bearing modal is the one that carries a stamped version at all (checked() clears it otherwise),
+ // so it is the one that can show the version did not move.
+ assertEquals("2", ModalDef.CONTRACT_VERSION);
+ var json = Json.of(ModalDef.create("Delete?").form(form()).keepOpenOnSubmit(true).checked());
+ assertTrue(json.contains("\"contractVersion\":\"2\""), json);
+ assertTrue(json.contains("\"keepOpenOnSubmit\":true"), json);
+ }
+
+ @Test void a06_keepOpenOnSubmitAloneIsValid() {
+ // The opt-in does not require a form: a confirm-only action can still host its own receipt.
+ assertDoesNotThrow(() -> ModalDef.create("Delete?").keepOpenOnSubmit(true).validate());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Field.kind - the display kind on a confirmation field
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_kindOmittedWhenUnset() {
+ var json = Json.of(ModalDef.create("Delete?").field("Id", "INC-1").checked());
+ assertFalse(json.contains("kind"), () -> "unset kind leaked:\n" + json);
+ }
+
+ @Test void b02_codeFactorySetsTheKind() {
+ var f = ModalDef.Field.code("Token", "abc-123");
+ assertEquals("Token", f.label);
+ assertEquals("abc-123", f.value);
+ assertEquals(ModalDef.FIELD_KIND_CODE, f.kind);
+ }
+
+ @Test void b03_codeFieldOnTheModal_appendsKindLast() {
+ var m = ModalDef.create("Done").codeField("Token", "abc-123").checked();
+ var fields = (List<?>) Json.to(Json.of(m), Map.class).get("fields");
+ assertEquals(List.of("label", "value", "kind"), new ArrayList<>(((Map<?,?>) fields.get(0)).keySet()));
+ }
+
+ @Test void b04_codeFactoryArgumentChecksTheLabel() {
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.code(null, "v"));
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.Field.code(" ", "v"));
+ // A null VALUE is legal (an absent value renders empty, it is not an authoring error).
+ assertDoesNotThrow(() -> ModalDef.Field.code("Token", null));
+ }
+
+ @Test void b05_bothAllowlistTokensValidate() {
+ for (var k : List.of(ModalDef.FIELD_KIND_TEXT, ModalDef.FIELD_KIND_CODE)) {
+ var m = ModalDef.create("Done");
+ m.field("L", "v");
+ m.fields.get(0).kind = k;
+ assertDoesNotThrow(m::validate, k);
+ }
+ }
+
+ @Test void b06_offAllowlistKindIsRefusedAtServeTime() {
+ // Fail closed where the author can see it. The runtime ALSO falls back to `text` for an unknown token
+ // rather than trusting it, but a served payload should never get that far.
+ var m = ModalDef.create("Done");
+ m.field("L", "v");
+ m.fields.get(0).kind = "html";
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("kind"), e::getMessage);
+ assertTrue(e.getMessage().contains("html"), e::getMessage);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) childActions - the dialog-scoped catalog
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_factoryStoresIdAndLabel() {
+ var c = ModalDef.ChildAction.of("review", "Review");
+ assertEquals("review", c.id);
+ assertEquals("Review", c.label);
+ assertNull(c.form);
+ assertNull(c.endpoint);
+ assertNull(c.method);
+ assertNull(c.onSuccess);
+ assertNull(c.carryDrafts);
+ }
+
+ @Test void c02_factoryArgumentChecks() {
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of(null, "L"));
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of(" ", "L"));
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of("id", null));
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.ChildAction.of("id", " "));
+ }
+
+ @Test void c03_fluentSettersAndWireShape() {
+ var m = ModalDef.create("Step 1")
+ .form(form())
+ .childAction(ModalDef.ChildAction.of("review", "Review")
+ .form("/x/review-form").endpoint("/x/review").method("POST").onSuccess("redraw").carryDrafts(true))
+ .checked();
+ var child = (Map<?,?>) ((List<?>) Json.to(Json.of(m), Map.class).get("childActions")).get(0);
+ assertEquals(List.of("id", "label", "form", "endpoint", "method", "onSuccess", "carryDrafts"),
+ new ArrayList<>(child.keySet()));
+ assertEquals("review", child.get("id"));
+ assertEquals(Boolean.TRUE, child.get("carryDrafts"));
+ }
+
+ @Test void c04_childActionsAppendedAfterKeepOpenOnSubmit() {
+ var m = ModalDef.create("Step 1")
+ .form(form())
+ .keepOpenOnSubmit(true)
+ .childAction(ModalDef.ChildAction.of("review", "Review"))
+ .checked();
+ Map<?,?> actual = Json.to(Json.of(m), Map.class);
+ assertEquals(List.of("contractVersion", "title", "form", "keepOpenOnSubmit", "childActions"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void c05_unsetOptionalsOmittedFromAChild() {
+ var m = ModalDef.create("Step 1").childAction(ModalDef.ChildAction.of("review", "Review")).checked();
+ var child = (Map<?,?>) ((List<?>) Json.to(Json.of(m), Map.class).get("childActions")).get(0);
+ assertEquals(List.of("id", "label"), new ArrayList<>(child.keySet()));
+ }
+
+ @Test void c06_carryDraftsFalseClearsRatherThanSerializingFalse() {
+ var c = ModalDef.ChildAction.of("review", "Review").carryDrafts(true).carryDrafts(false);
+ assertNull(c.carryDrafts);
+ }
+
+ @Test void c07_nullChildIsRefusedByTheAdder() {
+ assertThrows(IllegalArgumentException.class, () -> ModalDef.create("Step 1").childAction(null));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) The fail-closed validate rules on the catalog
+ //------------------------------------------------------------------------------------------------------------------
+
+ // A raw list, bypassing the factory - the shape a bean-deserialized payload can actually arrive in.
+ private static ModalDef withRawChildren(ModalDef.ChildAction... children) {
+ var m = ModalDef.create("Step 1");
+ m.childActions = new ArrayList<>(Arrays.asList(children));
+ return m;
+ }
+
+ private static ModalDef.ChildAction raw(String id, String label) {
+ var c = new ModalDef.ChildAction();
+ c.id = id;
+ c.label = label;
+ return c;
+ }
+
+ @Test void d01_blankIdIsRefused() {
+ var e = assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw(" ", "Review")).validate());
+ assertTrue(e.getMessage().contains("id"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw(null, "Review")).validate());
+ }
+
+ @Test void d02_blankLabelIsRefused() {
+ // A child with no label paints a button with nothing on it - unclickable in practice, and invisible in a
+ // screen reader. Refused where the author can see it.
+ var e = assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw("review", " ")).validate());
+ assertTrue(e.getMessage().contains("label"), e::getMessage);
+ assertThrows(IllegalArgumentException.class, () -> withRawChildren(raw("review", null)).validate());
+ }
+
+ @Test void d03_duplicateIdIsRefused() {
+ // The resolver returns the FIRST match, so a duplicate id means one of the two declared children is
+ // unreachable with no signal anywhere.
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> withRawChildren(raw("review", "Review"), raw("review", "Review again")).validate());
+ assertTrue(e.getMessage().contains("more than once"), e::getMessage);
+ }
+
+ @Test void d04_nullChildInARawListIsRefused() {
+ assertThrows(IllegalArgumentException.class, () -> withRawChildren((ModalDef.ChildAction) null).validate());
+ }
+
+ @Test void d05_carryDraftsOnAFormlessModalIsRefused() {
+ // Nothing to collect FROM: the parent has no inputs, so the carry would always be an empty object.
+ var m = ModalDef.create("Step 1")
+ .childAction(ModalDef.ChildAction.of("review", "Review").form("/x/review-form").carryDrafts(true));
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("carryDrafts"), e::getMessage);
+ assertTrue(e.getMessage().contains("no form"), e::getMessage);
+ }
+
+ @Test void d06_carryDraftsOnAFormlessChildIsRefused() {
+ // Nowhere to PUT them: a confirm-only child issues no form GET, so the query parameter has no request to
+ // ride on and the drafts would vanish with no error.
+ var m = ModalDef.create("Step 1")
+ .form(form())
+ .childAction(ModalDef.ChildAction.of("review", "Review").carryDrafts(true));
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("carryDrafts"), e::getMessage);
+ assertTrue(e.getMessage().contains("form URL"), e::getMessage);
+ }
+
+ @Test void d07_theLegalCarryDraftsShapePasses() {
+ assertDoesNotThrow(() -> ModalDef.create("Step 1")
+ .form(form())
+ .childAction(ModalDef.ChildAction.of("review", "Review").form("/x/review-form").carryDrafts(true))
+ .validate());
+ }
+
+ @Test void d08_aChildWithoutCarryDraftsNeedsNoForm() {
+ assertDoesNotThrow(() -> ModalDef.create("Step 1")
+ .childAction(ModalDef.ChildAction.of("review", "Review"))
+ .validate());
+ }
+}