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/08/31 13:02:42 UTC
(juneau) branch master updated: Add third BarSlot host: dialog ModalDef.barSlot anchored at the dialog title, with dynamic sidecar id-minting and enhance-on-insert (reusing the row-detail host's existing functions); adds ANCHOR_DIALOG_TITLE and an examples demonstration. 37 new tests, all green.
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 4b95d4f7fd Add third BarSlot host: dialog ModalDef.barSlot anchored at the dialog title, with dynamic sidecar id-minting and enhance-on-insert (reusing the row-detail host's existing functions); adds ANCHOR_DIALOG_TITLE and an examples demonstration. 37 new tests, all green.
4b95d4f7fd is described below
commit 4b95d4f7fd1cbb3ced5895fe94bd86eafecd8396
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Aug 31 09:02:35 2026 -0400
Add third BarSlot host: dialog ModalDef.barSlot anchored at the dialog title, with dynamic sidecar id-minting and enhance-on-insert (reusing the row-detail host's existing functions); adds ANCHOR_DIALOG_TITLE and an examples demonstration. 37 new tests, all green.
---
.../juneau/examples/views/ExampleViewsRest.java | 25 +
.../examples/views/ExampleViewsEndToEnd_Test.java | 21 +
.../juneau/rest/server/views/BarSlotTable.java | 37 +-
.../org/apache/juneau/views/juneau-views.css | 10 +
.../org/apache/juneau/views/juneau-views.js | 132 ++++++
.../views/BarSlotTable_DialogAnchor_Test.java | 82 ++++
.../server/views/ViewsJs_Dialog_BarSlot_Test.java | 303 ++++++++++++
.../src/test/js/dialog-bar-slot.cjs | 511 +++++++++++++++++++++
.../juneau/rest/server/widgets/ModalDef.java | 40 +-
.../rest/server/widgets/ModalDef_BarSlot_Test.java | 132 ++++++
10 files changed, 1283 insertions(+), 10 deletions(-)
diff --git a/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsRest.java b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsRest.java
index d6fb96b9a2..a4db4af1df 100644
--- a/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsRest.java
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsRest.java
@@ -738,6 +738,13 @@ public class ExampleViewsRest extends BasicRestServlet {
* The serving-path {@link ModalDef#checked() checked()} hook stamps the contract version and fail-closed validates
* the modal/form — a malformed form fails here at serve time, not silently on the wire.
*
+ * <p>
+ * This is also the toolkit's example of the <b>third</b> named {@link BarSlot} host, {@link ModalDef#barSlot}:
+ * a static {@link BarText} for severity context beside a {@link BarBadge} whose count
+ * ({@link #countOtherOpen(Alert)}) is live, painted client-side from this same JSON by
+ * {@code insertDialogBarSlot} the moment the dialog opens — unlike {@link PageDef#barSlot}/
+ * {@link RowDetailDef#barSlot}, there is no server-rendered pass to ride into.
+ *
* @param id The alert id.
* @return The validated, version-stamped modal definition.
*/
@@ -756,10 +763,28 @@ public class ExampleViewsRest extends BasicRestServlet {
.field(FormDef.Input.of(COL_SEVERITY, "Re-assign severity", "select")
.option(SEVERITY_CRITICAL, "Critical").option("warning", "Warning").option(SEVERITY_INFO, "Info").value(a.severity))
.field(FormDef.Input.of("escalate", "Escalate instead…", INPUT_TYPE_ACTION).action(ActionRef.of(ACTION_ESC))))
+ .barSlot(BarSlot.create("ack-form-bar").widgets(
+ BarText.of("severity-note", "Severity: " + a.severity),
+ BarBadge.of("other-open").label("other open at this severity").badge(Badge.count(countOtherOpen(a))
+ .tone(SEVERITY_CRITICAL.equals(a.severity) ? Tone.DANGER : Tone.WARN))))
.idempotencyKey(key.value())
.checked();
}
+ /**
+ * Counts other {@link #STATUS_OPEN} alerts sharing {@code a}'s severity, for the {@link #ackForm(String)}
+ * dialog's live {@link BarBadge} count.
+ *
+ * @param a The alert being acknowledged (excluded from its own count).
+ * @return The number of other open alerts at the same severity.
+ */
+ private static int countOtherOpen(Alert a) {
+ var n = 0;
+ for (var o : ALERTS)
+ if (o != a && STATUS_OPEN.equals(o.status) && a.severity.equals(o.severity)) n++;
+ return n;
+ }
+
/**
* [POST /data/alerts/{id}/ack] — acknowledge an open alert.
*
diff --git a/juneau-examples/juneau-examples-views/src/test/java/org/apache/juneau/examples/views/ExampleViewsEndToEnd_Test.java b/juneau-examples/juneau-examples-views/src/test/java/org/apache/juneau/examples/views/ExampleViewsEndToEnd_Test.java
index 7d3411879f..47a52948a1 100644
--- a/juneau-examples/juneau-examples-views/src/test/java/org/apache/juneau/examples/views/ExampleViewsEndToEnd_Test.java
+++ b/juneau-examples/juneau-examples-views/src/test/java/org/apache/juneau/examples/views/ExampleViewsEndToEnd_Test.java
@@ -311,4 +311,25 @@ class ExampleViewsEndToEnd_Test extends TestBase {
assertTrue(body.contains("\"actionId\":\"esc\""), body);
assertTrue(body.contains("\"idempotencyKey\""), body);
}
+
+ @Test
+ void d07_ackForm_barSlotHost_ridesTheWireAsAnAdditiveField() throws Exception {
+ // The example's ackForm() dialog also declares ModalDef.barSlot - the THIRD named BarSlot host. Unlike
+ // PageDef/RowDetailDef (Java-only, server-rendered), a dialog is a fetched JSON payload, so the bean must
+ // actually be present on the wire for juneau-views.js to paint it client-side. BarSlot carries its OWN
+ // independently-frozen contract version (never bumped alongside ModalDef's), so this is a distinct stamp
+ // from the modal/form one already asserted in d06.
+ var res = getJson("/data/alerts/ALRT-2/ack-form");
+ assertEquals(200, res.statusCode(), res.body());
+ var body = res.body();
+ assertTrue(body.contains("\"barSlot\":{"), body);
+ assertTrue(body.contains("\"contractVersion\":\"" + BarSlot.CONTRACT_VERSION + "\""), body);
+ assertTrue(body.contains("\"id\":\"ack-form-bar\""), body);
+ // ALRT-2 (severities cycle critical/warning/info) is "info" severity, so the widget mirrors that AND the
+ // non-critical (WARN, not DANGER) tone - proving the tone/text are actually DERIVED from the alert, not
+ // a copy-pasted literal.
+ assertTrue(body.contains("\"text\":\"Severity: info\""), body);
+ assertTrue(body.contains("\"label\":\"other open at this severity\""), body);
+ assertTrue(body.contains("\"tone\":\"WARN\""), body);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BarSlotTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BarSlotTable.java
index 637b456ddb..d3b0d14af7 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BarSlotTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/BarSlotTable.java
@@ -31,14 +31,21 @@ import org.apache.juneau.rest.server.widgets.*;
* {@code data-juneau-bar-slot} region {@link PageTable} emits as a <b>trailing sibling of {@code .jc-subtab-bar}</b>
* (concept #9), plus a data-only sidecar for its dynamic counts.
*
- * <h5 class='section'>Two named hosts, one emitter:</h5>
+ * <h5 class='section'>Three named hosts, one emitter:</h5>
* <p>
* {@link PageDef#barSlot} is the page host: {@link #of(BarSlot)} + {@link #sidecar(BarSlot)}, emitted once per page
* with a document-unique sidecar {@code id}. {@link RowDetailDef#barSlot} is the row-detail host:
- * {@link #detailRegion(BarSlot, String)} + {@link #detailSidecar(BarSlot)}, emitted into the row-expand
- * {@code <template>} and therefore cloned per expanded row — so its sidecar ships {@code id}-less and the
- * runtime mints a row-qualified identity after cloning. Sharing the bean and the emitter is the point; the hosts and
- * their placements stay distinct.
+ * {@link #detailRegion(BarSlot, String)} with {@link #ANCHOR_RIBBON} or {@link #ANCHOR_SECTION_TITLE} +
+ * {@link #detailSidecar(BarSlot)}, emitted into the row-expand {@code <template>} and therefore cloned per expanded
+ * row — so its sidecar ships {@code id}-less and the runtime mints a row-qualified identity after cloning. The
+ * <i>third</i> host is a dialog's {@code ModalDef.barSlot} (in {@code juneau-rest-server-widgets}): unlike the other
+ * two, that field travels the wire (the modal itself is a fetched payload with no server-rendered pass to ride into),
+ * so this emitter's {@link #detailRegion(BarSlot, String)} with {@link #ANCHOR_DIALOG_TITLE} +
+ * {@link #detailSidecar(BarSlot)} shape is instead mirrored client-side by the {@code juneau-views.js} runtime,
+ * which paints the region from the fetched JSON and reuses the SAME {@code id}-less clone-time id minting the
+ * row-detail host uses (there is no per-dialog {@code <template>} to clone from server-rendered markup, but two
+ * stacked dialogs need the same per-instance identity a cloned row does). Sharing the bean and this shape is the
+ * point; the hosts and their placements stay distinct.
*
* <p>
* The bar beans live in {@code juneau-rest-server-widgets}; this emitter is the only place that turns them into markup.
@@ -80,6 +87,16 @@ public class BarSlotTable {
*/
public static final String ANCHOR_SECTION_TITLE = "section-title";
+ /**
+ * Anchor for a dialog's {@code ModalDef.barSlot}: the region is placed as the immediate next sibling of the
+ * dialog's {@code h2.juneau-view-dialog-title}. Unlike {@link #ANCHOR_RIBBON} / {@link #ANCHOR_SECTION_TITLE},
+ * no server-rendered markup ever carries this anchor value — the dialog is a fetched JSON payload, so the
+ * {@code juneau-views.js} runtime paints the region (and stamps this same anchor attribute onto it) client-side,
+ * mirroring {@link #detailRegion(BarSlot, String)}'s shape. The dialog owns that placement itself, exactly as
+ * the row-detail host owns its own ribbon-trailing relocation; the shared strip builder never learns of it.
+ */
+ public static final String ANCHOR_DIALOG_TITLE = "dialog-title";
+
/** Class added to a detail-hosted region, so CSS and the runtime address it by name rather than by position. */
public static final String DETAIL_SLOT_CLASS = "juneau-view-detail-bar-slot";
@@ -114,16 +131,18 @@ public class BarSlotTable {
}
/**
- * Builds the {@code data-juneau-bar-slot} region for a bar slot hosted on the <b>row-detail ribbon</b>.
+ * Builds the {@code data-juneau-bar-slot} region for a bar slot hosted on the <b>row-detail ribbon</b> (or, for
+ * the {@link #ANCHOR_DIALOG_TITLE} shape a dialog's client-side painting mirrors, a dialog title).
*
* <p>
* Same region as {@link #of(BarSlot)}, plus {@link #DETAIL_SLOT_CLASS} and the declared
* {@link #BAR_SLOT_ANCHOR_ATTR}. The marker still carries the <b>author</b> {@link BarSlot#id}; the runtime
- * rewrites it to a row-qualified suffix once the enclosing {@code <template>} has been cloned, since one document
- * can hold one such region per expanded row.
+ * rewrites it to an instance-qualified suffix once the enclosing {@code <template>} has been cloned (row-detail)
+ * or the dialog has been built (dialog), since one document can hold one such region per expanded row / open
+ * dialog instance.
*
* @param bar The built bar slot. Must not be <jk>null</jk>.
- * @param anchor {@link #ANCHOR_RIBBON} or {@link #ANCHOR_SECTION_TITLE}.
+ * @param anchor {@link #ANCHOR_RIBBON}, {@link #ANCHOR_SECTION_TITLE}, or {@link #ANCHOR_DIALOG_TITLE}.
* @return A new {@link Div} carrying the detail-hosted region.
* @throws IllegalArgumentException If {@code bar} is <jk>null</jk> or fails {@link BarSlot#validate()}.
*/
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
index c3204923f3..97e6b656c3 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-views.css
@@ -1479,6 +1479,16 @@ table.dataTable thead .sorting_desc_disabled:after {
font-size: 1.1em;
}
+/*
+ * THIRD bar-slot host: a bar slot anchored to a dialog's title (ModalDef.barSlot), painted client-side from JSON
+ * (juneau-views.js buildDialogBarSlotRegion) rather than relocated like the row-detail host - so only the block
+ * bottom-margin needs tuning here to match the dialog's own vertical rhythm; .jc-bar-slot already supplies the
+ * shared flex/wrap/gap shape. NEUTRAL - shape only.
+ */
+.juneau-view-dialog-bar-slot {
+ margin: 0 0 0.75em;
+}
+
.juneau-view-dialog-fields {
margin: 0 0 var(--jc-space-4);
}
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 0ac43b63ff..37033adcf8 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
@@ -3423,6 +3423,128 @@
.catch(function () { renderRowActionRefusal(tr, action, "request-failed"); });
}
+ // THIRD bar-slot host: a dialog's ModalDef.barSlot (BarSlotTable constants of the same names on the server).
+ // Unlike the row-detail host, this one travels the wire as JSON - the modal itself IS the fetched payload, with
+ // no server-rendered pass to ride into - so the dialog caller paints the region + its id-less dynamic-count
+ // sidecar from that JSON, mirroring BarSlotTable.detailRegion(bar, ANCHOR_DIALOG_TITLE) +
+ // BarSlotTable.detailSidecar(bar)'s exact shape, rather than relocating server-painted markup. Identity minting,
+ // enhance-on-insert, and collapse teardown are NOT reimplemented here: this caller reuses
+ // mintDetailBarSlotIdentity / enhanceChromeInPanel / teardownDetailBarSlot verbatim (see showActionDialog below),
+ // exactly as spec'd - the shared strip builder never learns ANY bar-slot host exists (FINISHED-J0445u/J0445w).
+ const DIALOG_BAR_SLOT_CLASS = "juneau-view-dialog-bar-slot";
+ const DIALOG_BAR_ANCHOR = "dialog-title";
+ const BAR_SLOT_ANCHOR_ATTR = "data-juneau-bar-slot-anchor";
+ const BAR_WIDGET_MARKER = "data-juneau-bar-widget";
+ const JUNEAU_BAR_SLOT_CONTRACT_VERSION = "1";
+ const BAR_BADGE_NS = "bar";
+ const BAR_BADGE_ATTR = "data-juneau-badge";
+ const BAR_BADGE_TONE_ATTR = "data-juneau-badge-tone";
+ const BAR_BADGE_MAX_ATTR = "data-juneau-badge-max";
+
+ /** Clamps a fresh count above `max` to "<max>+", exactly as the server's AppHeaderTable.clampCount does. Pure. */
+ function clampDialogBarCount(count, max) {
+ if (typeof count !== "number") return "";
+ if (typeof max === "number" && count > max) return max + "+";
+ return String(count);
+ }
+
+ /** True for a BarText-shaped widget: duck-typed on `text` presence, since a BarBadge JSON never carries one. */
+ function isDialogBarTextWidget(w) {
+ return !! w && typeof w.text === "string";
+ }
+
+ /** Paints one BarBadge's count/tone overlay from JSON, mirroring BarSlotTable's private emitBadge(). */
+ function buildDialogBarBadgeEl(id, badge) {
+ const span = document.createElement("span");
+ span.className = "jc-badge";
+ span.setAttribute(BAR_BADGE_ATTR, BAR_BADGE_NS + ":" + id);
+ if (badge.tone != null) span.setAttribute(BAR_BADGE_TONE_ATTR, String(badge.tone).toLowerCase());
+ if (badge.dot) {
+ span.className = "jc-badge jc-badge-dot";
+ } else if (typeof badge.count === "number") {
+ const max = typeof badge.max === "number" ? badge.max : null;
+ if (max != null) span.setAttribute(BAR_BADGE_MAX_ATTR, String(max));
+ span.textContent = clampDialogBarCount(badge.count, max);
+ }
+ if (typeof badge.label === "string" && badge.label !== "") span.setAttribute("aria-label", badge.label);
+ return span;
+ }
+
+ /** Paints one BarWidget (BarText or BarBadge) from JSON, mirroring BarSlotTable's private emitWidget(). */
+ function buildDialogBarWidgetEl(w) {
+ if (! w || typeof w.id !== "string" || w.id === "") return null;
+ const span = document.createElement("span");
+ span.setAttribute(BAR_WIDGET_MARKER, w.id);
+ if (isDialogBarTextWidget(w)) {
+ span.className = "jc-bar-text";
+ span.textContent = w.text;
+ return span;
+ }
+ span.className = "jc-bar-badge";
+ if (typeof w.label === "string" && w.label !== "") {
+ const label = document.createElement("span");
+ label.className = "jc-bar-label";
+ label.textContent = w.label;
+ span.appendChild(label);
+ }
+ if (w.badge) span.appendChild(buildDialogBarBadgeEl(w.id, w.badge));
+ return span;
+ }
+
+ /**
+ * Builds a dialog's bar-slot region + id-less sidecar from the JSON `bar` (a {@code ModalDef.barSlot}), mirroring
+ * {@code BarSlotTable.detailRegion(bar, BarSlotTable.ANCHOR_DIALOG_TITLE)} +
+ * {@code BarSlotTable.detailSidecar(bar)}'s exact shape - {@code createElement}/{@code textContent} only, NEVER
+ * {@code innerHTML} (BLK-1/MED-9: this is fetched, attacker-influenceable data, the same reasoning
+ * {@code buildDialogOverlay} states for the confirmation fields below). Returns <jk>null</jk> for a
+ * missing/malformed/empty bar - a peripheral, additive feature failing closed must never block the confirm/cancel
+ * dialog itself from opening.
+ */
+ function buildDialogBarSlotRegion(bar) {
+ if (! bar || typeof bar.id !== "string" || bar.id === "") return null;
+ if (bar.contractVersion !== JUNEAU_BAR_SLOT_CONTRACT_VERSION) return null;
+ if (! Array.isArray(bar.widgets) || ! bar.widgets.length) return null;
+ const region = document.createElement("div");
+ region.className = "jc-bar-slot " + DIALOG_BAR_SLOT_CLASS;
+ region.setAttribute(DETAIL_BAR_MARKER, bar.id);
+ region.setAttribute(BAR_SLOT_ANCHOR_ATTR, DIALOG_BAR_ANCHOR);
+ const badges = {};
+ let any = false;
+ bar.widgets.forEach(function (w) {
+ const el = buildDialogBarWidgetEl(w);
+ if (! el) return;
+ region.appendChild(el);
+ any = true;
+ if (! isDialogBarTextWidget(w) && w.badge && typeof w.badge.count === "number")
+ badges[BAR_BADGE_NS + ":" + w.id] = w.badge.count;
+ });
+ if (! any) return null;
+ const sidecar = document.createElement("script");
+ sidecar.setAttribute("type", "application/json");
+ sidecar.setAttribute(DETAIL_BAR_META, bar.id);
+ sidecar.textContent = JSON.stringify({ contractVersion: bar.contractVersion, badges: badges });
+ return { region: region, sidecar: sidecar };
+ }
+
+ /**
+ * Inserts a dialog's bar slot as the immediate next sibling of `titleEl` (the {@code ANCHOR_DIALOG_TITLE}
+ * placement - the dialog OWNS this call site, exactly as the row-detail caller owns its own ribbon-trailing
+ * relocation; the shared strip builder never learns a dialog bar slot exists), appends its sidecar, and mints its
+ * clone-time identity via the SAME {@code mintDetailBarSlotIdentity} the row-detail host uses - reused verbatim
+ * rather than reimplemented. Two stacked dialogs need the same per-instance identity a cloned detail row does,
+ * and `dialogSeq` already namespaces other per-dialog state (the form field ids), so it namespaces this too.
+ *
+ * @return true when a region was painted, inserted, and minted.
+ */
+ function insertDialogBarSlot(dialog, titleEl, bar, seq) {
+ const built = buildDialogBarSlotRegion(bar);
+ if (! built) return false;
+ dialog.insertBefore(built.region, titleEl.nextSibling);
+ dialog.appendChild(built.sidecar);
+ mintDetailBarSlotIdentity(dialog, "dialog", seq);
+ return true;
+ }
+
/**
* Builds a `present=dialog` overlay DOM node (a backdrop + a `role=dialog` box) from a ModalDef, painting the
* title and each typed confirmation field with `textContent` ONLY - never `innerHTML`, never raw markup, never a
@@ -3444,6 +3566,8 @@
title.textContent = (modal?.title) || (action?.confirm || action?.label || action?.id) || "Confirm";
dialog.appendChild(title);
+ insertDialogBarSlot(dialog, title, modal?.barSlot, seq);
+
if (modal?.fields?.length) {
const dl = document.createElement("dl");
dl.className = "juneau-view-dialog-fields";
@@ -3915,6 +4039,7 @@
pushLayer(ui.backdrop, {
kind: "dialog", portal: true, trapFocus: true, lightDismiss: false, detachOnPop: true,
onDismiss: function () {
+ teardownDetailBarSlot(ui.dialog); // reused verbatim from the row-detail host - see insertDialogBarSlot
if (ctx?._dialogStack) {
const i = ctx._dialogStack.indexOf(ui.backdrop);
if (i >= 0) ctx._dialogStack.splice(i, 1);
@@ -3922,6 +4047,10 @@
}
}
});
+ // Enhance-on-insert for a dialog-hosted bar slot: MUST run after pushLayer has portalled the dialog into the
+ // document (initAll() scans document-wide), reusing the SAME chrome entry + shared wired marker the
+ // row-detail host's enhanceChromeInPanel already uses - mirroring FINISHED-J0445u.
+ enhanceChromeInPanel(ui.dialog);
return ui;
}
@@ -5021,6 +5150,9 @@
renderActionOutcome: renderActionOutcome,
openActionDialog: openActionDialog,
buildDialogOverlay: buildDialogOverlay,
+ // Dialog-hosted bar slot (the THIRD named host) - exposed for manual verification and the node harness.
+ buildDialogBarSlotRegion: buildDialogBarSlotRegion,
+ insertDialogBarSlot: insertDialogBarSlot,
appendDialogForm: appendDialogForm,
collectDialogFormFields: collectDialogFormFields,
isTypedFormInputType: isTypedFormInputType,
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BarSlotTable_DialogAnchor_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BarSlotTable_DialogAnchor_Test.java
new file mode 100644
index 0000000000..e99c5bfde7
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/BarSlotTable_DialogAnchor_Test.java
@@ -0,0 +1,82 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.widgets.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Pins {@link BarSlotTable#ANCHOR_DIALOG_TITLE} — the anchor constant for the <b>third</b> named
+ * {@link BarSlot} host, a dialog's {@code ModalDef.barSlot}.
+ *
+ * <p>
+ * Unlike the row-detail host, no server-side code path ever calls {@link BarSlotTable#detailRegion} with this anchor
+ * in production: a dialog is a fetched JSON payload with no server-rendered pass to ride into, so the
+ * {@code juneau-views.js} runtime paints the region client-side instead (pinned separately by the node harness in
+ * {@code ViewsJs_Dialog_BarSlot_Test}). This class instead proves the SHAPE the runtime must mirror: the existing,
+ * anchor-agnostic {@link BarSlotTable#detailRegion(BarSlot, String)} emitter already produces the correct markup for
+ * this third anchor value with no code change, exactly as it does for {@link BarSlotTable#ANCHOR_RIBBON} and
+ * {@link BarSlotTable#ANCHOR_SECTION_TITLE} — the "wire the dialog host through {@code of}/{@code
+ * detailRegion}" shape the spec calls for.
+ */
+class BarSlotTable_DialogAnchor_Test extends TestBase {
+
+ private static BarSlot bar() {
+ return BarSlot.create("dialog-ctx").widgets(BarBadge.of("open").label("Open").badge(Badge.count(3)));
+ }
+
+ @Test void a01_anchorConstant_isDialogTitle() {
+ assertEquals("dialog-title", BarSlotTable.ANCHOR_DIALOG_TITLE);
+ }
+
+ @Test void a02_anchorConstant_isDistinctFromTheOtherTwo() {
+ assertNotEquals(BarSlotTable.ANCHOR_DIALOG_TITLE, BarSlotTable.ANCHOR_RIBBON);
+ assertNotEquals(BarSlotTable.ANCHOR_DIALOG_TITLE, BarSlotTable.ANCHOR_SECTION_TITLE);
+ }
+
+ @Test void a03_detailRegion_emitsTheDialogAnchorAttribute() {
+ var h = Html.of(BarSlotTable.detailRegion(bar(), BarSlotTable.ANCHOR_DIALOG_TITLE));
+ assertTrue(h.contains(BarSlotTable.BAR_SLOT_ANCHOR_ATTR + "=\"" + BarSlotTable.ANCHOR_DIALOG_TITLE + "\""), h);
+ }
+
+ @Test void a04_detailRegion_carriesTheAuthorMarkerAndDetailClass() {
+ var h = Html.of(BarSlotTable.detailRegion(bar(), BarSlotTable.ANCHOR_DIALOG_TITLE));
+ assertTrue(h.contains(BarSlotTable.BAR_SLOT_MARKER + "=\"dialog-ctx\""), h);
+ assertTrue(h.contains(BarSlotTable.DETAIL_SLOT_CLASS), h);
+ }
+
+ @Test void a05_detailRegion_paintsTheWidget() {
+ var h = Html.of(BarSlotTable.detailRegion(bar(), BarSlotTable.ANCHOR_DIALOG_TITLE));
+ assertTrue(h.contains("jc-bar-slot"), h);
+ assertTrue(h.contains("jc-bar-badge"), h);
+ assertTrue(h.contains(">Open<"), h);
+ }
+
+ @Test void a06_detailSidecar_isIdLess_andFoundByAttribute_sameAsTheOtherTwoAnchors() {
+ // The dialog runtime mints its own clone-time id from this id-less shape (mintDetailBarSlotIdentity), just
+ // like the row-detail host - so the sidecar's SERVER shape must be identical regardless of anchor.
+ var h = Html.of(BarSlotTable.detailSidecar(bar()));
+ assertTrue(h.contains(BarSlotTable.BAR_META_ATTR + "=\"dialog-ctx\""), h);
+ assertFalse(h.contains("id=\"" + BarSlotTable.SIDECAR_ID_PREFIX), h);
+ assertTrue(h.contains("\"contractVersion\":\"" + BarSlot.CONTRACT_VERSION + "\""), h);
+ assertTrue(h.contains(BarSlotTable.BADGE_NS + ":open"), h);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Dialog_BarSlot_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Dialog_BarSlot_Test.java
new file mode 100644
index 0000000000..4e4e41b24f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Dialog_BarSlot_Test.java
@@ -0,0 +1,303 @@
+/*
+ * 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.*;
+
+/**
+ * Always-on coverage for the dialog-hosted {@link org.apache.juneau.rest.server.widgets.BarSlot} runtime — the
+ * <b>third</b> named host, {@code ModalDef.barSlot}: painting the region + widgets + sidecar from the fetched JSON
+ * (there is no server-rendered pass to relocate, unlike the row-detail host), the {@code dialog-title} anchor, and
+ * the REUSE (not reimplementation) of the row-detail host's clone-time id minting, enhance-on-insert, and collapse
+ * teardown.
+ *
+ * <p>
+ * Source-shape always runs; the behavioral {@code dialog-bar-slot.cjs} harness runs when {@code node} is on
+ * {@code PATH} (skipped otherwise — no {@code -Pjs-tests} required). That harness loads
+ * {@code juneau-views.js} <b>and</b> {@code juneau-chrome.js} into one sandbox, exactly as
+ * {@code ViewsJs_RowDetail_BarSlot_Test} does for the second host.
+ */
+class ViewsJs_Dialog_BarSlot_Test extends TestBase {
+
+ private static String resource(String path) throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(path)) {
+ assertNotNull(in, () -> "missing classpath resource: " + path);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String viewsJs() throws IOException {
+ return resource(ViewsMixin.VIEWS_JS_RESOURCE);
+ }
+
+ private static String chromeJs() throws IOException {
+ return resource(ViewsMixin.CHROME_JS_RESOURCE);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Source shape
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_helpersExportedOnNsInit() throws Exception {
+ var body = viewsJs();
+ for (var name : new String[]{
+ "buildDialogBarSlotRegion: buildDialogBarSlotRegion",
+ "insertDialogBarSlot: insertDialogBarSlot"
+ })
+ assertTrue(body.contains(name), () -> "missing export '" + name + "'");
+ }
+
+ @Test void a02_dialogOwnsItsOwnPlacement_notTheSharedStripBuilder() throws Exception {
+ var body = viewsJs();
+ // buildDialogOverlay calls the dialog's own insert helper; the generic strip builders must never learn a
+ // dialog bar slot exists (FINISHED-J0445u/J0445w locks this the same way for the row-detail host).
+ assertTrue(body.contains("insertDialogBarSlot(dialog, title, modal?.barSlot, seq)"), body);
+ var buildRibbonAt = body.indexOf("function buildRibbonStrip(");
+ var buildDetailAt = body.indexOf("function buildDetailStrip(");
+ assertTrue(buildRibbonAt >= 0 && buildDetailAt >= 0, body);
+ var ribbonBody = body.substring(buildRibbonAt, body.indexOf("\n\t}", buildRibbonAt));
+ var detailStripBody = body.substring(buildDetailAt, body.indexOf("\n\t}", buildDetailAt));
+ assertFalse(ribbonBody.contains("Dialog"), "buildRibbonStrip must not learn about dialogs");
+ assertFalse(detailStripBody.contains("insertDialogBarSlot"), "buildDetailStrip must not learn about dialogs");
+ }
+
+ @Test void a03_identityMintingIsReusedVerbatim_notReimplemented() throws Exception {
+ var body = viewsJs();
+ // The dialog caller calls the SAME mintDetailBarSlotIdentity/teardownDetailBarSlot/enhanceChromeInPanel the
+ // row-detail host uses - no dialog-specific mint/teardown/enhance function exists.
+ assertTrue(body.contains("mintDetailBarSlotIdentity(dialog, \"dialog\", seq)"), body);
+ assertTrue(body.contains("teardownDetailBarSlot(ui.dialog)"), body);
+ assertTrue(body.contains("enhanceChromeInPanel(ui.dialog)"), body);
+ assertFalse(body.contains("function mintDialogBarSlotIdentity("), body);
+ assertFalse(body.contains("function teardownDialogBarSlot("), body);
+ }
+
+ @Test void a04_regionPaintedWithCreateElementAndTextContentOnly_neverInnerHtml() throws Exception {
+ var body = viewsJs();
+ var at = body.indexOf("function buildDialogBarSlotRegion(");
+ var widgetAt = body.indexOf("function buildDialogBarWidgetEl(");
+ var badgeAt = body.indexOf("function buildDialogBarBadgeEl(");
+ assertTrue(at >= 0 && widgetAt >= 0 && badgeAt >= 0, body);
+ var end = body.indexOf("function buildDialogOverlay(");
+ assertTrue(end > at, body);
+ var slice = body.substring(Math.min(at, Math.min(widgetAt, badgeAt)), end);
+ assertFalse(slice.contains(".innerHTML"), "dialog bar-slot painting must never use innerHTML:\n" + slice);
+ }
+
+ @Test void a05_enhanceOnInsertGoesThroughTheExportedChromeEntry() throws Exception {
+ var body = viewsJs();
+ assertTrue(body.contains("window.JuneauChrome"), body);
+ assertTrue(body.contains("initAll()"), body);
+ }
+
+ @Test void a06_chromeSharesOneWiredMarkerForSafeActions() throws Exception {
+ var body = chromeJs();
+ assertTrue(body.contains("SAFE_WIRED_ATTR = \"data-juneau-safe-wired\""), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Behavioral harness
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var viewsFile = Files.createTempFile("juneau-views-", ".js");
+ var chromeFile = Files.createTempFile("juneau-chrome-", ".js");
+ try {
+ Files.writeString(viewsFile, viewsJs(), UTF_8);
+ Files.writeString(chromeFile, chromeJs(), UTF_8);
+ report = Json.to(runNode(harness, viewsFile, chromeFile), Map.class);
+ } finally {
+ Files.deleteIfExists(viewsFile);
+ Files.deleteIfExists(chromeFile);
+ }
+ }
+
+ 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-bar-slot.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/dialog-bar-slot.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/dialog-bar-slot.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path viewsJs, Path chromeJs) throws Exception {
+ var stdout = Files.createTempFile("dialog-bar-slot-stdout-", ".json");
+ var stderr = Files.createTempFile("dialog-bar-slot-stderr-", ".txt");
+ try {
+ var cmd = List.of("node", harness.toString(), viewsJs.toString(), chromeJs.toString());
+ var p = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile()).start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("dialog-bar-slot.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("dialog-bar-slot.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-bar-slot.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ @Test void b01_bothRuntimesLoadedAndHelpersPresent() {
+ var r = report();
+ assertEquals(true, r.get("hasViews"), r::toString);
+ assertEquals(true, r.get("hasChrome"), r::toString);
+ assertEquals(true, r.get("hasBuildRegion"), r::toString);
+ assertEquals(true, r.get("hasInsert"), r::toString);
+ assertEquals(true, r.get("hasMint"), r::toString);
+ assertEquals(true, r.get("hasTeardown"), r::toString);
+ assertEquals(true, r.get("hasEnhance"), r::toString);
+ }
+
+ @Test void b02_regionPaintedFromJson_immediatelyFollowingTheTitle() {
+ var r = report();
+ assertEquals(true, r.get("paint_inserted"), r::toString);
+ assertEquals(1.0, ((Number)r.get("paint_regionCount")).doubleValue(), r::toString);
+ assertEquals(true, r.get("paint_regionFollowsTitle"), r::toString);
+ assertEquals(true, r.get("paint_hasBarSlotClass"), r::toString);
+ assertEquals(true, r.get("paint_hasDialogSlotClass"), r::toString);
+ }
+
+ @Test void b03_regionCarriesTheDialogTitleAnchor() {
+ var r = report();
+ assertEquals(true, r.get("paint_anchorIsDialogTitle"), r::toString);
+ }
+
+ @Test void b04_widgetsPaintedWithTextContentOnly() {
+ var r = report();
+ assertEquals(true, r.get("paint_textWidgetPainted"), r::toString);
+ assertEquals("state", r.get("paint_textWidgetMarker"), r::toString);
+ assertEquals(true, r.get("paint_badgeLabelPainted"), r::toString);
+ assertEquals(true, r.get("paint_badgeCountPainted"), r::toString);
+ assertEquals(true, r.get("paint_badgeNamespaced"), r::toString);
+ }
+
+ @Test void b05_sidecarIsIdLess_andCarriesTheBarContractAndInitialCounts() {
+ var r = report();
+ assertEquals(true, r.get("paint_sidecarPresent"), r::toString);
+ assertEquals(true, r.get("paint_sidecarJsonOk"), r::toString);
+ }
+
+ @Test void b06_cloneTimeIdMinting_reusedForTwoStackedDialogs() {
+ var r = report();
+ assertEquals("dialog:1", r.get("mint_markerA"), r::toString);
+ assertEquals("dialog:2", r.get("mint_markerB"), r::toString);
+ assertEquals(true, r.get("mint_markerA_isDialogSeq1"), r::toString);
+ assertEquals(true, r.get("mint_markerB_isDialogSeq2"), r::toString);
+ assertEquals(true, r.get("mint_distinct"), () -> "two stacked dialogs must never share an id: " + r);
+ assertEquals("juneau-bar:dialog:1", r.get("mint_sidecarIdA"), r::toString);
+ assertEquals("juneau-bar:dialog:2", r.get("mint_sidecarIdB"), r::toString);
+ assertEquals(true, r.get("mint_sidecarIdA_isPrefixPlusMarker"), r::toString);
+ assertEquals(true, r.get("mint_authorIdUnresolvable"),
+ () -> "getElementById('juneau-bar:' + authorId) must not resolve after minting: " + r);
+ }
+
+ @Test void b07_eachMintedIdRoundTripsThroughReadSidecar() {
+ var r = report();
+ assertEquals(true, r.get("mint_roundTripA"), r::toString);
+ assertEquals(true, r.get("mint_roundTripB"), r::toString);
+ }
+
+ @Test void b08_enhanceOnInsert_appliesCountsAndIsIdempotent_noDoubleBoundSafeClick() {
+ var r = report();
+ assertEquals(true, r.get("enh_seamCalled"), r::toString);
+ assertEquals("3", r.get("enh_countBeforeA"), r::toString);
+ assertEquals("3", r.get("enh_countAfterA"), r::toString);
+ assertEquals("1", r.get("enh_wiredMarker"), () -> "the shared wired marker must be stamped: " + r);
+ assertEquals(1.0, ((Number)r.get("enh_safeListenersAfterFirst")).doubleValue(), r::toString);
+ assertEquals(1.0, ((Number)r.get("enh_safeListenersAfterSecond")).doubleValue(),
+ () -> "a second enhance-on-insert must not double-bind the SAFE action: " + r);
+ assertEquals("3", r.get("enh_countStillA"), r::toString);
+ assertEquals(1.0, ((Number)r.get("enh_safeFires")).doubleValue(), r::toString);
+ }
+
+ @Test void b09_dialogWithNoBarSlot_enhanceIsACleanNoOp() {
+ var r = report();
+ assertEquals(false, r.get("enh_seamSkippedWithoutSlot"), r::toString);
+ }
+
+ @Test void b10_collapseTeardown_reusedVerbatim_leavesTheOtherStackedDialogAlone() {
+ var r = report();
+ assertEquals(true, r.get("tear_sidecarIdRemoved"), r::toString);
+ assertEquals(true, r.get("tear_aUnresolvable"), r::toString);
+ assertEquals(true, r.get("tear_bStillResolvable"), r::toString);
+ assertEquals(true, r.get("tear_bStillRoundTrips"), r::toString);
+ assertEquals(true, r.get("tear_noThrowOnNull"), r::toString);
+ }
+
+ @Test void b11_failClosedNoOp_missingMalformedOrEmptyBarSlotNeverBlocksTheDialog() {
+ var r = report();
+ assertEquals(false, r.get("noop_undeclared"), r::toString);
+ assertEquals(0.0, ((Number)r.get("noop_undeclaredRegionCount")).doubleValue(), r::toString);
+ assertEquals(false, r.get("noop_emptyWidgets"), r::toString);
+ assertEquals(false, r.get("noop_badContractVersion"),
+ () -> "a bar-slot contract mismatch must be refused, not painted: " + r);
+ assertEquals(false, r.get("noop_missingId"), r::toString);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/dialog-bar-slot.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-bar-slot.cjs
new file mode 100644
index 0000000000..5db064d820
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/dialog-bar-slot.cjs
@@ -0,0 +1,511 @@
+/*
+ * 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-bar-slot.cjs - always-on Node harness for the THIRD named bar-slot host: a BarSlot anchored to a dialog's
+ * title (ModalDef.barSlot). Loads juneau-views.js AND juneau-chrome.js into ONE sandbox sharing one fake document,
+ * the same shape detail-bar-slot.cjs uses for the row-detail host, because this slice reuses that host's identity
+ * minting / enhance-on-insert / teardown machinery verbatim rather than reimplementing it.
+ *
+ * Usage: node dialog-bar-slot.cjs <juneau-views.js> <juneau-chrome.js>
+ *
+ * Covers: painting the region + widgets + sidecar from JSON (client-side, since a dialog has no server-rendered pass
+ * to ride into); the dialog-title anchor attribute; clone-time id minting reused from the row-detail host for two
+ * stacked dialogs; enhance-on-insert via JuneauChrome.init.initAll() sharing the wired marker with wireSafeActions;
+ * collapse teardown; and the fail-closed no-op for a missing/malformed/empty bar slot.
+ *
+ * Prints ONE JSON object to stdout; every assertion lives in the Java test.
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const viewsJsPath = process.argv[2];
+const chromeJsPath = process.argv[3];
+if (!viewsJsPath || !chromeJsPath) {
+ console.error('usage: node dialog-bar-slot.cjs <juneau-views.js> <juneau-chrome.js>');
+ process.exit(2);
+}
+
+// ------------------------------------------------------------------------------------------------------------------
+// Fake DOM. Verbatim copy of detail-bar-slot.cjs's self-contained shim (deliberately not shared via a shim module -
+// see that file's own header note) - rich enough for createElement/insertBefore/appendChild/querySelectorAll/
+// getElementById/dataset/addEventListener-dispatchEvent, which is everything the paths under test touch.
+// ------------------------------------------------------------------------------------------------------------------
+
+function matchesClassToken(node, tok) {
+ const raw = ' ' + (node.className || node.getAttribute('class') || '') + ' ';
+ return raw.indexOf(' ' + tok + ' ') >= 0;
+}
+
+function matchesAttrToken(node, name, value) {
+ const v = node.getAttribute(name);
+ if (v == null) return false;
+ return value === undefined || v === value;
+}
+
+function elMatches(node, sel) {
+ if (!node || node.nodeType !== 1) return false;
+ if (sel.indexOf(',') >= 0)
+ return sel.split(',').some(function (part) { return elMatches(node, part.trim()); });
+ let rest = sel.trim();
+ const tm = /^[a-zA-Z][\w-]*/.exec(rest);
+ if (tm) {
+ if (node.tagName !== tm[0].toUpperCase()) return false;
+ rest = rest.slice(tm[0].length);
+ if (!rest) return true;
+ }
+ while (rest.length) {
+ const cm = /^\.([\w-]+)/.exec(rest);
+ if (cm) {
+ if (!matchesClassToken(node, cm[1])) return false;
+ rest = rest.slice(cm[0].length);
+ continue;
+ }
+ const am = /^\[([\w:-]+)(?:=["']?([^\]"']*)["']?)?\]/.exec(rest);
+ if (am) {
+ if (!matchesAttrToken(node, am[1], am[2])) return false;
+ rest = rest.slice(am[0].length);
+ continue;
+ }
+ return false;
+ }
+ return true;
+}
+
+function elWalk(node, sel, acc) {
+ for (const c of node.childNodes) {
+ if (c.nodeType === 1) {
+ if (elMatches(c, sel)) acc.push(c);
+ elWalk(c, sel, acc);
+ }
+ }
+ return acc;
+}
+
+function closestFrom(node, sel) {
+ let n = node;
+ while (n && n.nodeType === 1) {
+ if (elMatches(n, sel)) return n;
+ n = n.parentNode;
+ }
+ return null;
+}
+
+function dispatchFrom(node, ev) {
+ let n = node;
+ while (n) {
+ (n._listeners && n._listeners[ev.type] || []).slice().forEach(function (fn) { fn(ev); });
+ n = ev.bubbles ? n.parentNode : null;
+ }
+}
+
+function datasetKeyToAttr(key) {
+ return 'data-' + key.replace(/[A-Z]/g, function (m) { return '-' + m.toLowerCase(); });
+}
+
+function attrToDatasetKey(attr) {
+ return attr.slice(5).replace(/-([a-z])/g, function (_, c) { return c.toUpperCase(); });
+}
+
+function makeDataset(node) {
+ return new Proxy({}, {
+ get(_, key) {
+ if (typeof key !== 'string') return undefined;
+ const v = node.getAttribute(datasetKeyToAttr(key));
+ return v == null ? undefined : v;
+ },
+ set(_, key, val) {
+ node.setAttribute(datasetKeyToAttr(key), val);
+ return true;
+ },
+ deleteProperty(_, key) {
+ node.removeAttribute(datasetKeyToAttr(key));
+ return true;
+ },
+ has(_, key) {
+ return typeof key === 'string' && node.getAttribute(datasetKeyToAttr(key)) != null;
+ },
+ ownKeys() {
+ return Object.keys(node.attrs)
+ .filter(function (k) { return k.indexOf('data-') === 0; })
+ .map(attrToDatasetKey);
+ },
+ getOwnPropertyDescriptor(_, key) {
+ const v = node.getAttribute(datasetKeyToAttr(key));
+ return v == null ? undefined : { value: v, enumerable: true, configurable: true };
+ }
+ });
+}
+
+const byId = {};
+
+function el(tag) {
+ const node = {
+ nodeType: 1,
+ tagName: String(tag).toUpperCase(),
+ childNodes: [],
+ attrs: {},
+ parentNode: null,
+ className: '',
+ hidden: false,
+ _listeners: {},
+ _text: '',
+ get firstChild() { return this.childNodes[0] || null; },
+ get nextSibling() {
+ if (!this.parentNode) return null;
+ const kids = this.parentNode.childNodes;
+ const i = kids.indexOf(this);
+ return i >= 0 && i + 1 < kids.length ? kids[i + 1] : null;
+ },
+ get dataset() { return makeDataset(this); },
+ getAttribute: function (k) {
+ if (k === 'class' && this.className) return this.className;
+ return Object.hasOwn(this.attrs, k) ? this.attrs[k] : null;
+ },
+ setAttribute: function (k, v) {
+ this.attrs[k] = v == null ? '' : String(v);
+ if (k === 'class') this.className = this.attrs[k];
+ if (k === 'id') byId[this.attrs[k]] = this;
+ },
+ removeAttribute: function (k) {
+ if (k === 'id' && this.attrs.id != null) delete byId[this.attrs.id];
+ delete this.attrs[k];
+ if (k === 'class') this.className = '';
+ },
+ remove: function () {
+ if (this.parentNode) {
+ const kids = this.parentNode.childNodes;
+ const i = kids.indexOf(this);
+ if (i >= 0) kids.splice(i, 1);
+ this.parentNode = null;
+ }
+ },
+ _detach: function () {
+ this.remove();
+ },
+ appendChild: function (c) {
+ if (c._detach) c._detach();
+ this.childNodes.push(c);
+ c.parentNode = this;
+ return c;
+ },
+ insertBefore: function (c, ref) {
+ if (c._detach) c._detach();
+ const i = ref ? this.childNodes.indexOf(ref) : -1;
+ if (i < 0) this.childNodes.push(c); else this.childNodes.splice(i, 0, c);
+ c.parentNode = this;
+ return c;
+ },
+ removeChild: function (c) {
+ c.remove();
+ return c;
+ },
+ querySelectorAll: function (sel) { return elWalk(this, sel, []); },
+ querySelector: function (sel) { const r = elWalk(this, sel, []); return r.length ? r[0] : null; },
+ closest: function (sel) { return closestFrom(this, sel); },
+ contains: function (other) {
+ if (other === this) return true;
+ for (const c of this.childNodes) {
+ if (c === other) return true;
+ if (c.nodeType === 1 && c.contains?.(other)) return true;
+ }
+ return false;
+ },
+ addEventListener: function (type, fn) {
+ this._listeners[type] = this._listeners[type] || [];
+ this._listeners[type].push(fn);
+ },
+ listenerCount: function (type) { return (this._listeners[type] || []).length; },
+ dispatchEvent: function (ev) {
+ dispatchFrom(this, ev);
+ return true;
+ },
+ focus: function () { document.activeElement = this; },
+ getBoundingClientRect: function () { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }; },
+ set textContent(v) { this.childNodes.length = 0; this._text = v == null ? '' : String(v); },
+ get textContent() {
+ if (this.childNodes.length === 0) return this._text || '';
+ return this.childNodes.map(function (c) { return c.textContent; }).join('');
+ }
+ };
+ return node;
+}
+
+const body = el('body');
+
+const document = {
+ readyState: 'loading',
+ activeElement: null,
+ body: body,
+ addEventListener: function () {},
+ createElement: function (tag) { return el(tag); },
+ getElementById: function (id) { return Object.hasOwn(byId, id) ? byId[id] : null; },
+ querySelectorAll: function (sel) { return body.querySelectorAll(sel); },
+ querySelector: function (sel) { return body.querySelector(sel); }
+};
+
+function CustomEvent(type, init) {
+ this.type = type;
+ this.detail = init && init.detail;
+ this.bubbles = !!(init && init.bubbles);
+ this.defaultPrevented = false;
+ this.preventDefault = function () { this.defaultPrevented = true; };
+}
+
+const window = {
+ document: document,
+ console: console,
+ jQuery: undefined,
+ innerWidth: 1024,
+ innerHeight: 768,
+ CustomEvent: CustomEvent,
+ addEventListener: function () {},
+ matchMedia: function () { return { matches: false, addEventListener: function () {} }; },
+ getComputedStyle: function () { return { getPropertyValue: function () { return ''; } }; }
+};
+
+const sandbox = {
+ window: window,
+ document: document,
+ console: console,
+ CustomEvent: CustomEvent,
+ setTimeout: function (fn) { if (typeof fn === 'function') { fn(); } return 0; },
+ clearTimeout: function () {},
+ setInterval: function () { return 0; },
+ clearInterval: function () {},
+ Promise: Promise
+};
+
+// NOSONAR javascript:S1523 -- this harness's entire purpose is to load the production runtime under test (a
+// repo-local file path from argv, not attacker-controlled input) into an isolated VM sandbox; that IS the test.
+vm.runInNewContext(fs.readFileSync(path.resolve(viewsJsPath), 'utf8'), sandbox, { filename: 'juneau-views.js' });
+// NOSONAR javascript:S1523 -- same rationale: loading the production juneau-chrome.js under test into the sandbox.
+vm.runInNewContext(fs.readFileSync(path.resolve(chromeJsPath), 'utf8'), sandbox, { filename: 'juneau-chrome.js' });
+
+const VNS = window.JuneauViews;
+const V = VNS?.init;
+const CNS = window.JuneauChrome;
+const C = CNS?.init;
+
+const out = {
+ hasViews: !!(typeof V?.buildDialogOverlay === 'function'),
+ hasChrome: !!(typeof C?.initAll === 'function'),
+ hasBuildRegion: !!(typeof V?.buildDialogBarSlotRegion === 'function'),
+ hasInsert: !!(typeof V?.insertDialogBarSlot === 'function'),
+ hasMint: !!(typeof V?.mintDetailBarSlotIdentity === 'function'),
+ hasTeardown: !!(typeof V?.teardownDetailBarSlot === 'function'),
+ hasEnhance: !!(typeof V?.enhanceChromeInPanel === 'function')
+};
+if (!(out.hasViews && out.hasChrome && out.hasBuildRegion && out.hasInsert && out.hasMint && out.hasTeardown
+ && out.hasEnhance)) {
+ process.stdout.write(JSON.stringify(out));
+ process.exit(0);
+}
+
+const BAR_MARKER = 'data-juneau-bar-slot';
+const BAR_ANCHOR = 'data-juneau-bar-slot-anchor';
+const BAR_META = 'data-juneau-bar-meta';
+const BAR_WIDGET_MARKER = 'data-juneau-bar-widget';
+const SIDECAR_PREFIX = 'juneau-bar:';
+const AUTHOR_ID = 'dialog-ctx';
+
+// ------------------------------------------------------------------------------------------------------------------
+// Fixture builders: a hand-painted equivalent of what buildDialogOverlay assembles (backdrop > dialog > title).
+// ------------------------------------------------------------------------------------------------------------------
+
+function barSlotJson(id, count) {
+ return {
+ contractVersion: '1',
+ id: id || AUTHOR_ID,
+ widgets: [
+ { id: 'state', text: 'Region us-east' },
+ { id: 'open', label: 'Open', badge: { count: count == null ? 3 : count } }
+ ]
+ };
+}
+
+function dialogFixture() {
+ const backdrop = el('div');
+ backdrop.setAttribute('class', 'juneau-view-dialog-backdrop');
+ const dialog = el('div');
+ dialog.setAttribute('class', 'juneau-view-dialog');
+ dialog.setAttribute('role', 'dialog');
+ const title = el('h2');
+ title.setAttribute('class', 'juneau-view-dialog-title');
+ title.textContent = 'Really delete?';
+ dialog.appendChild(title);
+ backdrop.appendChild(dialog);
+ return { backdrop: backdrop, dialog: dialog, title: title };
+}
+
+function regionsIn(root) {
+ return root.querySelectorAll('[' + BAR_MARKER + ']');
+}
+
+// ------------------------------------------------------------------------------------------------------------------
+// 1. Paint from JSON: widgets, anchor attribute, placement as the title's immediate next sibling, id-less sidecar.
+// ------------------------------------------------------------------------------------------------------------------
+
+(function () {
+ const f = dialogFixture();
+ const inserted = V.insertDialogBarSlot(f.dialog, f.title, barSlotJson(), 1);
+ out.paint_inserted = inserted;
+ out.paint_regionCount = regionsIn(f.dialog).length;
+
+ const region = regionsIn(f.dialog)[0];
+ out.paint_regionFollowsTitle = f.title.nextSibling === region;
+ out.paint_anchorIsDialogTitle = region.getAttribute(BAR_ANCHOR) === 'dialog-title';
+ out.paint_hasBarSlotClass = matchesClassToken(region, 'jc-bar-slot');
+ out.paint_hasDialogSlotClass = matchesClassToken(region, 'juneau-view-dialog-bar-slot');
+
+ const textWidget = region.querySelector('.jc-bar-text');
+ out.paint_textWidgetPainted = !!textWidget && textWidget.textContent === 'Region us-east';
+ out.paint_textWidgetMarker = textWidget?.getAttribute(BAR_WIDGET_MARKER);
+
+ const badgeWidget = region.querySelector('.jc-bar-badge');
+ out.paint_badgeLabelPainted = badgeWidget?.querySelector('.jc-bar-label')?.textContent === 'Open';
+ const badge = badgeWidget?.querySelector('[data-juneau-badge]');
+ out.paint_badgeCountPainted = badge?.textContent === '3';
+ out.paint_badgeNamespaced = badge?.getAttribute('data-juneau-badge') === 'bar:open';
+
+ const sidecar = f.dialog.querySelector('[' + BAR_META + ']');
+ out.paint_sidecarIsIdLess_beforeMint = false; // insertDialogBarSlot mints immediately; checked via mint_ below
+ out.paint_sidecarPresent = !!sidecar;
+ out.paint_sidecarJsonOk = (function () {
+ try {
+ const j = JSON.parse(sidecar.textContent);
+ return j.contractVersion === '1' && j.badges['bar:open'] === 3;
+ } catch (e) { return false; }
+ })();
+})();
+
+// ------------------------------------------------------------------------------------------------------------------
+// 2. Clone-time id minting is REUSED verbatim from the row-detail host, namespaced by dialogSeq for two stacked
+// dialogs - each must resolve its OWN sidecar, never the other's.
+// ------------------------------------------------------------------------------------------------------------------
+
+const fA = dialogFixture();
+const fB = dialogFixture();
+
+(function () {
+ body.appendChild(fA.backdrop);
+ body.appendChild(fB.backdrop);
+ V.insertDialogBarSlot(fA.dialog, fA.title, barSlotJson('dialog-ctx', 3), 1);
+ V.insertDialogBarSlot(fB.dialog, fB.title, barSlotJson('dialog-ctx', 7), 2);
+
+ const regionA = regionsIn(fA.dialog)[0];
+ const regionB = regionsIn(fB.dialog)[0];
+ out.mint_markerA = regionA.getAttribute(BAR_MARKER);
+ out.mint_markerB = regionB.getAttribute(BAR_MARKER);
+ out.mint_markerA_isDialogSeq1 = out.mint_markerA === 'dialog:1';
+ out.mint_markerB_isDialogSeq2 = out.mint_markerB === 'dialog:2';
+ out.mint_distinct = out.mint_markerA !== out.mint_markerB;
+
+ const sidecarA = fA.dialog.querySelector('[' + BAR_META + ']');
+ const sidecarB = fB.dialog.querySelector('[' + BAR_META + ']');
+ out.mint_sidecarIdA = sidecarA.getAttribute('id');
+ out.mint_sidecarIdB = sidecarB.getAttribute('id');
+ out.mint_sidecarIdA_isPrefixPlusMarker = out.mint_sidecarIdA === SIDECAR_PREFIX + out.mint_markerA;
+ out.mint_authorIdUnresolvable = document.getElementById(SIDECAR_PREFIX + AUTHOR_ID) === null;
+
+ const rtA = C.readSidecar(SIDECAR_PREFIX, out.mint_markerA);
+ const rtB = C.readSidecar(SIDECAR_PREFIX, out.mint_markerB);
+ out.mint_roundTripA = !!(rtA?.badges?.['bar:open'] === 3);
+ out.mint_roundTripB = !!(rtB?.badges?.['bar:open'] === 7);
+})();
+
+// ------------------------------------------------------------------------------------------------------------------
+// 3. Enhance-on-insert: initAll() (reused verbatim via enhanceChromeInPanel) applies the sidecar counts and shares
+// the SAFE_WIRED_ATTR marker with wireSafeActions - a re-scan must not double-bind an existing SAFE action.
+// ------------------------------------------------------------------------------------------------------------------
+
+let safeAction = null;
+let safeFires = 0;
+
+(function () {
+ const header = el('div');
+ header.setAttribute('class', 'jc-app-header');
+ header.dataset.juneauAppHeader = 'main';
+ safeAction = el('button');
+ safeAction.dataset.juneauHeaderAction = 'ping';
+ safeAction.dataset.juneauBehavior = 'safe';
+ safeAction.dataset.juneauSafe = 'ping';
+ header.appendChild(safeAction);
+ body.appendChild(header);
+ header.addEventListener(C.SAFE_EVENT, function () { safeFires++; });
+
+ const badgeA = fA.dialog.querySelector('[data-juneau-badge]');
+ out.enh_countBeforeA = badgeA.textContent; // painted at insert-time from the JSON count itself
+ out.enh_seamCalled = V.enhanceChromeInPanel(fA.dialog);
+ out.enh_countAfterA = badgeA.textContent;
+ out.enh_safeListenersAfterFirst = safeAction.listenerCount('click');
+ out.enh_wiredMarker = safeAction.dataset.juneauSafeWired;
+
+ // Idempotent: a second enhance-on-insert call must not re-bind the already-wired SAFE action.
+ V.enhanceChromeInPanel(fA.dialog);
+ out.enh_safeListenersAfterSecond = safeAction.listenerCount('click');
+ out.enh_countStillA = badgeA.textContent;
+
+ safeAction.dispatchEvent(new CustomEvent('click', { bubbles: true }));
+ out.enh_safeFires = safeFires;
+
+ // A dialog with NO bar slot must not drag chrome in at all (a confirm-only dialog stays a clean no-op).
+ const fPlain = dialogFixture();
+ out.enh_seamSkippedWithoutSlot = V.enhanceChromeInPanel(fPlain.dialog);
+})();
+
+// ------------------------------------------------------------------------------------------------------------------
+// 4. Collapse teardown: reused verbatim from the row-detail host - drops the minted sidecar id without touching the
+// OTHER stacked dialog's own sidecar.
+// ------------------------------------------------------------------------------------------------------------------
+
+(function () {
+ V.teardownDetailBarSlot(fA.dialog);
+ out.tear_sidecarIdRemoved = fA.dialog.querySelector('[' + BAR_META + ']').getAttribute('id') === null;
+ out.tear_aUnresolvable = document.getElementById(SIDECAR_PREFIX + 'dialog:1') === null;
+ out.tear_bStillResolvable = document.getElementById(SIDECAR_PREFIX + 'dialog:2') !== null;
+ out.tear_bStillRoundTrips = !!C.readSidecar(SIDECAR_PREFIX, 'dialog:2');
+ out.tear_noThrowOnNull = (function () { V.teardownDetailBarSlot(null); return true; })();
+})();
+
+// ------------------------------------------------------------------------------------------------------------------
+// 5. Fail-closed no-op: a missing/malformed/empty bar slot never blocks the confirm/cancel dialog from opening, and
+// a contract-version mismatch is refused rather than painted.
+// ------------------------------------------------------------------------------------------------------------------
+
+(function () {
+ const fNone = dialogFixture();
+ out.noop_undeclared = V.insertDialogBarSlot(fNone.dialog, fNone.title, undefined, 3);
+ out.noop_undeclaredRegionCount = regionsIn(fNone.dialog).length;
+
+ const fEmpty = dialogFixture();
+ out.noop_emptyWidgets = V.insertDialogBarSlot(fEmpty.dialog, fEmpty.title,
+ { contractVersion: '1', id: 'x', widgets: [] }, 4);
+
+ const fBadContract = dialogFixture();
+ out.noop_badContractVersion = V.insertDialogBarSlot(fBadContract.dialog, fBadContract.title,
+ { contractVersion: '99', id: 'x', widgets: [{ id: 'a', text: 'A' }] }, 5);
+
+ const fNoId = dialogFixture();
+ out.noop_missingId = V.insertDialogBarSlot(fNoId.dialog, fNoId.title,
+ { contractVersion: '1', widgets: [{ id: 'a', text: 'A' }] }, 6);
+})();
+
+process.stdout.write(JSON.stringify(out, null, 2) + '\n');
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 b770db03a3..0897042abb 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
@@ -72,9 +72,23 @@ import org.apache.juneau.commons.bean.*;
* <li class='jc'>{@link Widget}
* </ul>
*
+ * <h5 class='section'>The third named bar-slot host</h5>
+ * <p>
+ * {@link #barSlot} is the <i>third</i> named {@link BarSlot} attachment — alongside the rich-view module's page
+ * and row-detail hosts (that module never depends on this one, so they are not linked here). Unlike those two
+ * — Java-only fields a server-side emitter paints directly, omitted from their wire — this modal itself
+ * <b>is</b> the wire payload the modal-open confirmation fetch returns, with no separate server-rendered pass the
+ * dialog could ride into. So this field <b>is</b> carried on the wire (additive-only: {@code null} when unset, and
+ * its presence does not bump {@link #CONTRACT_VERSION}), and the {@code juneau-views.js} runtime paints the region
+ * and its dynamic-count sidecar client-side from the JSON, the same way it already paints typed confirmation
+ * {@link #fields} and {@link #form} controls — {@code textContent} only, never {@code innerHTML}. The dialog
+ * anchors its own region immediately after the title (the rich-view module's {@code
+ * BarSlotTable.ANCHOR_DIALOG_TITLE}), owning that placement itself rather than leaning on the shared strip builder,
+ * exactly as the row-detail host owns its own ribbon-trailing relocation.
+ *
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,title,fields,form,idempotencyKey")
+@BeanType(properties="contractVersion,title,fields,form,idempotencyKey,barSlot")
@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class ModalDef implements Widget {
@@ -147,6 +161,13 @@ public class ModalDef implements Widget {
*/
public String idempotencyKey;
+ /**
+ * Optional additive bar slot anchored to this dialog's title — the <i>third</i> named {@link BarSlot}
+ * attachment (see the class Javadoc for how this host differs from the other two). Omitted from the wire when
+ * unset; painted by the runtime immediately after {@link #title}, ahead of {@link #fields} and {@link #form}.
+ */
+ public BarSlot barSlot;
+
/**
* Starts a new {@link ModalDef} with the specified title / confirmation prompt.
*
@@ -198,6 +219,21 @@ public class ModalDef implements Widget {
return this;
}
+ /**
+ * Declares the additive bar slot anchored to this dialog's title.
+ *
+ * <p>
+ * See {@link #barSlot} — a third named host for the same {@link BarSlot} bean, not a re-use of the
+ * rich-view module's page or row-detail hosts.
+ *
+ * @param value The bar slot. Can be <jk>null</jk> (no dialog bar slot).
+ * @return This object.
+ */
+ public ModalDef barSlot(BarSlot value) {
+ barSlot = value;
+ return this;
+ }
+
/**
* Fail-closed bean validation.
*
@@ -222,6 +258,8 @@ public class ModalDef implements Widget {
}
if (form != null)
form.validate();
+ if (barSlot != null)
+ barSlot.validate();
}
/**
diff --git a/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_BarSlot_Test.java b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_BarSlot_Test.java
new file mode 100644
index 0000000000..fbebea8dc8
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ModalDef_BarSlot_Test.java
@@ -0,0 +1,132 @@
+/*
+ * 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 <b>third</b> named {@link BarSlot} attachment, {@link ModalDef#barSlot}.
+ *
+ * <p>
+ * The type is shared with the rich-view module's page and row-detail hosts while the placement is not: this host is
+ * a dialog title. Unlike those two Java-only fields, this one travels the wire (the modal itself is the fetched
+ * payload with no separate server-rendered pass to ride into), so this class pins the wire shape (additive, omitted
+ * when unset, no {@link ModalDef#CONTRACT_VERSION} bump) in addition to the field/fluent-setter/validate-cascade
+ * shape {@code RowDetailDef_BarSlot_Test} pins for the second host.
+ */
+class ModalDef_BarSlot_Test extends TestBase {
+
+ private static BarSlot bar(String id) {
+ return BarSlot.create(id).widgets(BarBadge.of("open").label("Open").badge(Badge.count(3)));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Field + fluent setter + validate cascade
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_fluentSetter_storesSlot() {
+ var m = ModalDef.create("Really delete?").barSlot(bar("dialog-bar"));
+ assertNotNull(m.barSlot);
+ assertEquals("dialog-bar", m.barSlot.id);
+ }
+
+ @Test void a02_nullSlot_isLegal() {
+ assertDoesNotThrow(() -> ModalDef.create("Really delete?").validate());
+ }
+
+ @Test void a03_validSlot_passes() {
+ assertDoesNotThrow(() -> ModalDef.create("Really delete?").barSlot(bar("dialog-bar")).validate());
+ }
+
+ @Test void a04_cascadesIntoBarSlotValidate_blankId() {
+ var m = ModalDef.create("Really delete?").barSlot(BarSlot.create(" ").widgets(BarText.of("x", "X")));
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("BarSlot"), e::getMessage);
+ }
+
+ @Test void a05_cascadesIntoBarSlotValidate_noWidgets() {
+ var m = ModalDef.create("Really delete?").barSlot(BarSlot.create("dialog-bar"));
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("BarSlot"), e::getMessage);
+ }
+
+ @Test void a06_cascadesIntoBarSlotValidate_badRefreshUrl() {
+ var m = ModalDef.create("Really delete?").barSlot(bar("dialog-bar").refreshUrl("http://evil.example/counts"));
+ var e = assertThrows(IllegalArgumentException.class, m::validate);
+ assertTrue(e.getMessage().contains("BarSlot"), e::getMessage);
+ }
+
+ @Test void a07_checkedCascadesToBarSlotValidateToo() {
+ // The serving-path hook validates the whole modal, including a malformed dialog bar slot.
+ var m = ModalDef.create("Really delete?").barSlot(BarSlot.create("dialog-bar"));
+ assertThrows(IllegalArgumentException.class, m::checked);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Wire shape: additive, omitted when unset, no CONTRACT_VERSION bump
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_barSlotIsAdditiveOnTheWire() {
+ var json = Json.of(ModalDef.create("Really delete?").barSlot(bar("dialog-bar")));
+ var expected = Json.to("""
+ {"title":"Really delete?",
+ "barSlot":{"contractVersion":"1","id":"dialog-bar",
+ "widgets":[{"id":"open","label":"Open","badge":{"count":3}}]}}
+ """, Map.class);
+ assertEquals(expected, Json.to(json, Map.class), json);
+ }
+
+ @Test void b02_confirmOnlyModal_withNoBarSlot_omitsIt() {
+ var json = Json.of(ModalDef.create("Really delete?"));
+ assertFalse(json.contains("barSlot"), json);
+ }
+
+ @Test void b03_barSlotRidesAlongsideAFormBearingModal() {
+ var modal = ModalDef.create("Acknowledge this incident?")
+ .field("Incident", "INC-42")
+ .form(FormDef.ofTemplate("servlet:/incidents/ack-form.ftl"))
+ .barSlot(bar("dialog-bar"))
+ .checked();
+ var json = Json.of(modal);
+ assertTrue(json.contains("\"barSlot\""), json);
+ assertTrue(json.contains("\"contractVersion\":\"" + ModalDef.CONTRACT_VERSION + "\""), json);
+ }
+
+ @Test void b04_topLevelKeyOrder_barSlotIsLast() {
+ var modal = ModalDef.create("t").field("A", "1").form(FormDef.ofTemplate("u")).idempotencyKey("k")
+ .barSlot(bar("dialog-bar")).checked();
+ Map<?,?> actual = Json.to(Json.of(modal), Map.class);
+ assertEquals(List.of("contractVersion", "title", "fields", "form", "idempotencyKey", "barSlot"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void c01_modalContractVersionUnchanged() {
+ // Adding a third wire-carried, additive-only field does not bump the modal's own contract version - the
+ // version guards the fail-loud form handshake, not this orthogonal, additive attachment.
+ assertEquals("2", ModalDef.CONTRACT_VERSION);
+ }
+
+ @Test void c02_barSlotContractVersionUnchanged() {
+ assertEquals("1", BarSlot.CONTRACT_VERSION);
+ }
+}