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/20 15:35:51 UTC
(juneau) branch master updated (4ba9524e6c -> 742f803d69)
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
from 4ba9524e6c Ignore JMH-generated benchmark sources under juneau-integration-tests
new 99fc927e9b TODO-445a: Add juneau-rest-server-widgets module with ActionBar, ActionRef, and SafeAction
new 742f803d69 TODO-444/445a: Add column configurator, saved-view persistence, and RowDetailDef
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
juneau-bom/pom.xml | 1 +
juneau-examples/juneau-examples-views/pom.xml | 8 +
.../org/apache/juneau/examples/views/Alert.java | 63 +
.../juneau/examples/views/ExampleViewsRest.java | 188 ++-
.../examples/views/ExampleViewsEndToEnd_Test.java | 58 +
juneau-rest/juneau-rest-server-views/pom.xml | 5 +
.../apache/juneau/rest/server/views/Column.java | 70 +-
.../juneau/rest/server/views/ColumnConfig.java | 52 +
.../juneau/rest/server/views/DetailField.java | 62 +
.../juneau/rest/server/views/DetailSection.java | 95 ++
.../rest/server/views/InMemorySavedViewStore.java | 219 +++
.../apache/juneau/rest/server/views/PageDef.java | 10 +-
.../apache/juneau/rest/server/views/PageTable.java | 43 +-
.../juneau/rest/server/views/RowDetailDef.java | 162 ++
.../rest/server/views/SavedViewQuotaException.java | 79 +
.../juneau/rest/server/views/SavedViewStore.java | 202 +++
.../juneau/rest/server/views/SavedViewsMixin.java | 429 +++++
.../apache/juneau/rest/server/views/ViewDef.java | 118 +-
.../apache/juneau/rest/server/views/ViewTable.java | 153 +-
.../juneau/rest/server/views/ViewsMixin.java | 60 +-
.../org/apache/juneau/views/juneau-config.css | 138 ++
.../org/apache/juneau/views/juneau-config.js | 1765 ++++++++++++++++++++
.../org/apache/juneau/views/juneau-pages.js | 10 +-
.../org/apache/juneau/views/juneau-ribbon.js | 63 +-
.../org/apache/juneau/views/juneau-views.css | 36 +-
.../org/apache/juneau/views/juneau-views.js | 892 +++++++---
.../server/views/ColumnConfig_BrowserTest.java | 124 ++
.../views/ConfigPersistence_BrowserTest.java | 297 ++++
.../server/views/InMemorySavedViewStore_Test.java | 255 +++
.../rest/server/views/JuneauPagesJs_Test.java | 3 +-
.../rest/server/views/PageDef_Contract_Test.java | 2 +-
.../rest/server/views/PageServerWiring_Test.java | 5 +-
.../rest/server/views/PageTable_Emit_Test.java | 13 +
.../rest/server/views/PagingPill_Wiring_Test.java | 12 +-
.../rest/server/views/RawContentSinkScanner.java | 48 +
.../views/RawContentSink_SecurityScan_Test.java | 130 ++
.../server/views/RibbonIcons_Resolution_Test.java | 23 +
.../server/views/RowActionCsrf_BrowserTest.java | 6 +-
.../rest/server/views/RowDetailDef_Test.java | 164 ++
.../rest/server/views/RowDetail_BrowserTest.java | 117 ++
.../views/RowDetailsExpander_Wiring_Test.java | 109 +-
.../server/views/SavedViewsMixin_Serving_Test.java | 317 ++++
.../server/views/TablePolling_Wiring_Test.java | 18 +-
.../rest/server/views/ViewMeta_Contract_Test.java | 113 +-
.../rest/server/views/ViewTable_Emit_Test.java | 20 +
.../views/ViewTable_RowDetail_Emit_Test.java | 116 ++
.../server/views/ViewTable_SelectionBulk_Test.java | 5 +-
.../views/ViewsJs_ConfigApplication_Test.java | 330 ++++
.../server/views/ViewsJs_ConfigChooser_Test.java | 276 +++
.../views/ViewsJs_ConfigPersistence_Test.java | 356 ++++
.../rest/server/views/ViewsJs_Reinit_Test.java | 342 ++++
.../rest/server/views/ViewsJs_RowActions_Test.java | 6 +-
.../rest/server/views/ViewsJs_RowDetail_Test.java | 198 +++
.../rest/server/views/ViewsJs_Selection_Test.java | 40 +-
.../rest/server/views/ViewsMixin_Serving_Test.java | 68 +-
.../src/test/js/column-config.cjs | 104 ++
.../src/test/js/config-application.cjs | 183 ++
.../src/test/js/config-chooser.cjs | 157 ++
.../src/test/js/config-persistence.cjs | 191 +++
.../src/test/js/config-reinit.cjs | 116 ++
.../src/test/js/row-detail-browser.cjs | 86 +
.../src/test/js/row-detail.cjs | 145 ++
.../pom.xml | 12 +-
.../juneau/rest/server/widgets/ActionBar.java | 74 +
.../juneau/rest/server/widgets/ActionBarItem.java} | 13 +-
.../juneau/rest/server/widgets/ActionRef.java | 49 +
.../juneau/rest/server/widgets/SafeAction.java} | 45 +-
.../apache/juneau/rest/server/widgets/Widget.java} | 22 +-
.../juneau/rest/server/widgets}/package-info.java | 18 +-
.../juneau/rest/server/widgets/ActionBar_Test.java | 78 +
juneau-rest/pom.xml | 1 +
71 files changed, 9290 insertions(+), 498 deletions(-)
create mode 100644 juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Alert.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ColumnConfig.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewQuotaException.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewStore.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewsMixin.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.css
create mode 100644 juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.js
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ColumnConfig_BrowserTest.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfigPersistence_BrowserTest.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailDef_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_BrowserTest.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SavedViewsMixin_Serving_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigApplication_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigChooser_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigPersistence_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Reinit_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/column-config.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/config-application.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/config-chooser.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/config-persistence.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/config-reinit.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/row-detail-browser.cjs
create mode 100644 juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs
copy juneau-rest/{juneau-rest-server-console-ui => juneau-rest-server-widgets}/pom.xml (88%)
create mode 100644 juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBar.java
copy juneau-rest/{juneau-rest-server/src/main/java/org/apache/juneau/rest/server/health/HealthProbe.java => juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBarItem.java} (76%)
create mode 100644 juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionRef.java
copy juneau-rest/{juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpRole.java => juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/SafeAction.java} (50%)
copy juneau-rest/{juneau-rest-auth-oauth-flow/src/main/java/org/apache/juneau/rest/auth/oauth/oidc/OidcDiscoveryException.java => juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/Widget.java} (60%)
copy juneau-rest/{juneau-rest-server-auth-oauth/src/main/java/org/apache/juneau/rest/server/auth/oauth/flow => juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets}/package-info.java (53%)
create mode 100644 juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ActionBar_Test.java
(juneau) 02/02: TODO-444/445a: Add column configurator, saved-view persistence, and RowDetailDef
Posted by ja...@apache.org.
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
commit 742f803d69362a99df909b479b6dbf576cfe5f84
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Aug 20 11:35:25 2026 -0400
TODO-444/445a: Add column configurator, saved-view persistence, and RowDetailDef
Ship the view-only column configurator (visibility, pin, format) with SavedViewStore persistence, plus RowDetailDef expanders that compose the new widgets ActionBar.
---
juneau-examples/juneau-examples-views/pom.xml | 8 +
.../org/apache/juneau/examples/views/Alert.java | 63 +
.../juneau/examples/views/ExampleViewsRest.java | 188 ++-
.../examples/views/ExampleViewsEndToEnd_Test.java | 58 +
juneau-rest/juneau-rest-server-views/pom.xml | 5 +
.../apache/juneau/rest/server/views/Column.java | 70 +-
.../juneau/rest/server/views/ColumnConfig.java | 52 +
.../juneau/rest/server/views/DetailField.java | 62 +
.../juneau/rest/server/views/DetailSection.java | 95 ++
.../rest/server/views/InMemorySavedViewStore.java | 219 +++
.../apache/juneau/rest/server/views/PageDef.java | 10 +-
.../apache/juneau/rest/server/views/PageTable.java | 43 +-
.../juneau/rest/server/views/RowDetailDef.java | 162 ++
.../rest/server/views/SavedViewQuotaException.java | 79 +
.../juneau/rest/server/views/SavedViewStore.java | 202 +++
.../juneau/rest/server/views/SavedViewsMixin.java | 429 +++++
.../apache/juneau/rest/server/views/ViewDef.java | 118 +-
.../apache/juneau/rest/server/views/ViewTable.java | 153 +-
.../juneau/rest/server/views/ViewsMixin.java | 60 +-
.../org/apache/juneau/views/juneau-config.css | 138 ++
.../org/apache/juneau/views/juneau-config.js | 1765 ++++++++++++++++++++
.../org/apache/juneau/views/juneau-pages.js | 10 +-
.../org/apache/juneau/views/juneau-ribbon.js | 63 +-
.../org/apache/juneau/views/juneau-views.css | 36 +-
.../org/apache/juneau/views/juneau-views.js | 892 +++++++---
.../server/views/ColumnConfig_BrowserTest.java | 124 ++
.../views/ConfigPersistence_BrowserTest.java | 297 ++++
.../server/views/InMemorySavedViewStore_Test.java | 255 +++
.../rest/server/views/JuneauPagesJs_Test.java | 3 +-
.../rest/server/views/PageDef_Contract_Test.java | 2 +-
.../rest/server/views/PageServerWiring_Test.java | 5 +-
.../rest/server/views/PageTable_Emit_Test.java | 13 +
.../rest/server/views/PagingPill_Wiring_Test.java | 12 +-
.../rest/server/views/RawContentSinkScanner.java | 48 +
.../views/RawContentSink_SecurityScan_Test.java | 130 ++
.../server/views/RibbonIcons_Resolution_Test.java | 23 +
.../server/views/RowActionCsrf_BrowserTest.java | 6 +-
.../rest/server/views/RowDetailDef_Test.java | 164 ++
.../rest/server/views/RowDetail_BrowserTest.java | 117 ++
.../views/RowDetailsExpander_Wiring_Test.java | 109 +-
.../server/views/SavedViewsMixin_Serving_Test.java | 317 ++++
.../server/views/TablePolling_Wiring_Test.java | 18 +-
.../rest/server/views/ViewMeta_Contract_Test.java | 113 +-
.../rest/server/views/ViewTable_Emit_Test.java | 20 +
.../views/ViewTable_RowDetail_Emit_Test.java | 116 ++
.../server/views/ViewTable_SelectionBulk_Test.java | 5 +-
.../views/ViewsJs_ConfigApplication_Test.java | 330 ++++
.../server/views/ViewsJs_ConfigChooser_Test.java | 276 +++
.../views/ViewsJs_ConfigPersistence_Test.java | 356 ++++
.../rest/server/views/ViewsJs_Reinit_Test.java | 342 ++++
.../rest/server/views/ViewsJs_RowActions_Test.java | 6 +-
.../rest/server/views/ViewsJs_RowDetail_Test.java | 198 +++
.../rest/server/views/ViewsJs_Selection_Test.java | 40 +-
.../rest/server/views/ViewsMixin_Serving_Test.java | 68 +-
.../src/test/js/column-config.cjs | 104 ++
.../src/test/js/config-application.cjs | 183 ++
.../src/test/js/config-chooser.cjs | 157 ++
.../src/test/js/config-persistence.cjs | 191 +++
.../src/test/js/config-reinit.cjs | 116 ++
.../src/test/js/row-detail-browser.cjs | 86 +
.../src/test/js/row-detail.cjs | 145 ++
61 files changed, 9033 insertions(+), 442 deletions(-)
diff --git a/juneau-examples/juneau-examples-views/pom.xml b/juneau-examples/juneau-examples-views/pom.xml
index 31a9bc35ad..fa4b32485c 100644
--- a/juneau-examples/juneau-examples-views/pom.xml
+++ b/juneau-examples/juneau-examples-views/pom.xml
@@ -44,6 +44,14 @@
<version>${project.version}</version>
</dependency>
+ <!-- ActionBar / ActionRef / SafeAction — declared directly because this module references the API,
+ not just juneau-rest-server-views's (same posture as the datatables CDN constants below). -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-widgets</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
<!-- DataTablesMixin's jQuery/DataTables CDN URL constants, used directly by the demo page's <head>.
Already a transitive compile dep of juneau-rest-server-views; declared directly too since this
module references its API, not just juneau-rest-server-views's. -->
diff --git a/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Alert.java b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Alert.java
new file mode 100644
index 0000000000..a2207d5cf5
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Alert.java
@@ -0,0 +1,63 @@
+/*
+ * 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.examples.views;
+
+/**
+ * A row bean for the fake "Alerts" tab — the {@code RowDetailDef} / {@code ActionBar} dogfood
+ * (two sections, two mutating actions, expand GET).
+ *
+ * @since 10.0.0
+ */
+public class Alert {
+
+ /** Stable row id (also the expand-GET {@code {id}}). */
+ public String id;
+
+ /** One of {@code "critical"}, {@code "warning"}, or {@code "info"}. */
+ public String severity;
+
+ /** Short title shown in the table and the overview section. */
+ public String title;
+
+ /** One of {@code "open"}, {@code "acknowledged"}, or {@code "escalated"}. */
+ public String status;
+
+ /** Longer summary shown only in the expander. */
+ public String summary;
+
+ /** On-call assignee shown only in the expander. */
+ public String assignee;
+
+ /**
+ * Creates a fully-populated row.
+ *
+ * @param id The stable row id.
+ * @param severity The severity token.
+ * @param title The short title.
+ * @param status The workflow status.
+ * @param summary The expander-only summary.
+ * @param assignee The expander-only assignee.
+ */
+ public Alert(String id, String severity, String title, String status, String summary, String assignee) {
+ this.id = id;
+ this.severity = severity;
+ this.title = title;
+ this.status = status;
+ this.summary = summary;
+ this.assignee = assignee;
+ }
+}
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 1eecc71550..d4227defef 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
@@ -25,19 +25,20 @@ import org.apache.juneau.http.*;
import org.apache.juneau.http.entity.*;
import org.apache.juneau.http.header.*;
import org.apache.juneau.http.resource.*;
+import org.apache.juneau.http.response.*;
import org.apache.juneau.marshall.marshaller.Html;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.datatables.DataTablesMixin;
import org.apache.juneau.rest.server.servlet.BasicRestServlet;
import org.apache.juneau.rest.server.views.RowClassRule.Op;
import org.apache.juneau.rest.server.views.ViewDef.DataMode;
-import org.apache.juneau.rest.server.views.ViewDef.DetailDef;
import org.apache.juneau.rest.server.views.ViewDef.Dir;
import org.apache.juneau.rest.server.views.*;
+import org.apache.juneau.rest.server.widgets.*;
/**
- * Demonstrates a {@link PageDef} with a sub-tabbed tab ("Catalog" → Active/Archived) alongside a sibling plain
- * tab ("Audit Log"), served on an embedded Jetty server via {@link ExampleViewsServer}.
+ * Demonstrates a {@link PageDef} with a sub-tabbed tab ("Catalog" → Active/Archived) alongside sibling
+ * plain tabs ("Audit Log", "Alerts"), served on an embedded Jetty server via {@link ExampleViewsServer}.
*
* <p>
* This is a real caller of {@link PageDef}'s {@link Tab#subtabs(Subtab...) subtabs} outside the views module's own
@@ -46,19 +47,23 @@ import org.apache.juneau.rest.server.views.*;
* <h5 class='section'>What this dogfoods (beyond the bare sub-tab requirement):</h5>
* <ul>
* <li>The "Active" sub-tab additionally declares {@link ViewDef#poll(long) poll} and
- * {@link ViewDef#details(DetailDef...) details}, plus a ribbon and a {@code rowClassRule}, so this one view
- * exercises most of the toolkit's declarative surface in one place.
+ * {@link ViewDef#details(RowDetailDef) details}, plus a ribbon and a {@code rowClassRule}, so this one view
+ * exercises most of the toolkit's declarative surface in one place. Expand GET
+ * {@code /data/widgets/active/{id}} projects owner/updatedAt/notes (the expander is the only place notes
+ * appear).
* <li>The "Archived" sub-tab and the "Audit Log" tab are deliberately PLAIN (no ribbon/poll/details), both to
* satisfy the "at least one sibling plain tab" requirement and to keep a contrasting baseline the sub-tabbed
* panel's blank-panel regression would show up against.
- * <li>Two distinct row types ({@link Widget}, {@link AuditEntry}) are composed into one page, rather than one
- * type reused everywhere.
+ * <li>The "Alerts" tab dogfoods {@link RowDetailDef} with two named sections, two mutating {@link ActionRef}s,
+ * {@link SafeAction#COLLAPSE}, and expand GET {@code /data/alerts/{id}}.
+ * <li>Three distinct row types ({@link Widget}, {@link AuditEntry}, {@link Alert}) are composed into one page,
+ * rather than one type reused everywhere.
* <li>Every view uses {@link DataMode#CLIENT} for simplicity (a static in-memory row list, no
* {@code ProtocolQueryable}/{@code QueryableSettings} wiring) — {@code SERVER} mode is already covered
* end-to-end by {@code ViewServerWiring_Test} in the views module itself, so this example does not repeat it.
* <li>Each sub-tab/tab carries enough rows (see {@link #buildActiveWidgets()}/{@link #buildArchivedWidgets()}/
- * {@link #buildAuditLog()}) that a column-sizing regression from the eager-init defect would be visibly wrong,
- * not just theoretically present.
+ * {@link #buildAuditLog()}/{@link #buildAlerts()}) that a column-sizing regression from the eager-init defect
+ * would be visibly wrong, not just theoretically present.
* </ul>
*
* <p>
@@ -79,13 +84,17 @@ public class ExampleViewsRest extends BasicRestServlet {
private static final String STATUS_ERROR = "error";
private static final String VALUE_ARCHIVED = "archived";
private static final String TITLE_OWNER = "Owner";
+ private static final String STATUS_OPEN = "open";
+ private static final String STATUS_ACKNOWLEDGED = "acknowledged";
+ private static final String STATUS_ESCALATED = "escalated";
private static final List<Widget> ACTIVE_WIDGETS = buildActiveWidgets();
private static final List<Widget> ARCHIVED_WIDGETS = buildArchivedWidgets();
private static final List<AuditEntry> AUDIT_LOG = buildAuditLog();
+ private static final List<Alert> ALERTS = buildAlerts();
//------------------------------------------------------------------------------------------------------------------
- // The composed page: Catalog (sub-tabbed: Active/Archived) + a sibling plain Audit Log tab.
+ // The composed page: Catalog (sub-tabbed: Active/Archived) + sibling Audit Log + Alerts tabs.
//------------------------------------------------------------------------------------------------------------------
static PageDef page() {
@@ -95,7 +104,8 @@ public class ExampleViewsRest extends BasicRestServlet {
Tab.create("catalog", "Catalog").subtabs(
Subtab.create("active", "Active").view(activeView()),
Subtab.create(VALUE_ARCHIVED, "Archived").view(archivedView())),
- Tab.create("audit", "Audit Log").view(auditView()))
+ Tab.create("audit", "Audit Log").view(auditView()),
+ Tab.create("alerts", "Alerts").view(alertsView()))
.build();
}
@@ -118,11 +128,15 @@ public class ExampleViewsRest extends BasicRestServlet {
// Use 10s for the per-table poll (well above the 5s floor) so the
// staleness chip's "Xs ago" advance is easy to observe without hammering this demo endpoint.
.poll(10_000L)
- // "notes" is intentionally not a table column; the expander is the only place it appears.
- .details(
- DetailDef.of(COL_OWNER).title(TITLE_OWNER),
- DetailDef.of(COL_UPDATED_AT).title("Last updated"),
- DetailDef.of("notes").title("Notes"))
+ // "notes" is intentionally not a table column; the expander GET is the only place it appears.
+ .details(RowDetailDef.create()
+ .endpoint("/data/widgets/active/{id}")
+ .sections(DetailSection.create("info", "Info")
+ .fields(
+ DetailField.of(COL_OWNER).title(TITLE_OWNER),
+ DetailField.of(COL_UPDATED_AT).title("Last updated"),
+ DetailField.of("notes").title("Notes"))
+ .actions(ActionBar.create().items(SafeAction.COLLAPSE))))
.build();
}
@@ -155,21 +169,55 @@ public class ExampleViewsRest extends BasicRestServlet {
.build();
}
+ /** Fake alerts table — two detail sections, two mutating ActionRefs, expand GET. */
+ static ViewDef alertsView() {
+ return ViewDef.create("alerts")
+ .rowType(Alert.class)
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data/alerts")
+ .defaultOrder("id", Dir.ASC)
+ .columns(
+ Column.of("id").title("Id"),
+ Column.of("severity").title("Severity").render("tag:status"),
+ Column.of("title").title("Title"),
+ Column.of(COL_STATUS).title("Status"))
+ .rowActions(
+ RowAction.create("ack").label("Acknowledge").endpoint("/data/alerts/{id}/ack")
+ .method(RowAction.Method.POST).onSuccess(RowAction.OnSuccess.REDRAW),
+ RowAction.create("esc").label("Escalate").endpoint("/data/alerts/{id}/esc")
+ .method(RowAction.Method.POST).onSuccess(RowAction.OnSuccess.REDRAW))
+ .details(RowDetailDef.create()
+ .endpoint("/data/alerts/{id}")
+ .sections(
+ DetailSection.create("overview", "Overview")
+ .columns(2)
+ .fields(
+ DetailField.of("severity").title("Severity"),
+ DetailField.of("title").title("Title"))
+ .actions(ActionBar.create().items(ActionRef.of("ack"), SafeAction.COLLAPSE)),
+ DetailSection.create("context", "Context")
+ .fields(
+ DetailField.of("summary").title("Summary"),
+ DetailField.of("assignee").title("Assignee"))
+ .actions(ActionBar.create().items(ActionRef.of("esc")))))
+ .build();
+ }
+
//------------------------------------------------------------------------------------------------------------------
// HTML page (hand-built, no template engine - this module takes no dependency on FreeMarker/console-ui).
//------------------------------------------------------------------------------------------------------------------
/**
- * [GET /] — the composed page: tab bar + sub-tab bar + all three panels, plus the DataTables/jQuery
+ * [GET /] — the composed page: tab bar + sub-tab bar + all panels, plus the DataTables/jQuery
* (caller-provided, per ASF category-A discipline) and first-party toolkit asset links a real page needs.
*
* @param req The current request, resolved against for {@link ViewsMixin#viewAssetUrl(RestRequest,String)}
* so the head links stay correct however this example is mounted.
* @return The full HTML page.
*/
- @RestGet(path="/", summary="The Catalog (Active/Archived sub-tabs) + Audit Log demo page")
+ @RestGet(path="/", summary="The Catalog (Active/Archived sub-tabs) + Audit Log + Alerts demo page")
public HttpResource index(RestRequest req) {
- var pageMarkup = Html.of(PageTable.of(page()));
+ var pageMarkup = Html.of(PageTable.of(req, page()));
var deepLink = "#" + PAGE_ID + "/catalog/archived";
var html = """
<!DOCTYPE html>
@@ -187,7 +235,8 @@ public class ExampleViewsRest extends BasicRestServlet {
<body>
<h1>Apache Juneau — Rich Views Example</h1>
<p>Demonstrates a sub-tabbed <code>PageDef</code>: the <b>Catalog</b> tab holds two sub-tabs
- (<b>Active</b>/<b>Archived</b>), and <b>Audit Log</b> is a sibling plain leaf tab. The Active sub-tab
+ (<b>Active</b>/<b>Archived</b>), <b>Audit Log</b> is a sibling plain leaf tab, and <b>Alerts</b>
+ dogfoods row-detail sections with an expand GET and mutating action-bar buttons. The Active sub-tab
also declares a poll interval (watch the staleness chip) and a row-details expander (click any row).</p>
<p><a href="%s">Deep link straight to the Archived sub-tab</a> (exercises
<code>juneau-pages.js</code>'s hash-routing on load, not just via the tab bar's own links).</p>
@@ -234,6 +283,23 @@ public class ExampleViewsRest extends BasicRestServlet {
return ACTIVE_WIDGETS;
}
+ /**
+ * [GET /data/widgets/active/{id}] — expand envelope for one active widget (row id = {@link Widget#name}).
+ *
+ * @param id The widget name.
+ * @return {@code {contractVersion, fields}} for the expander.
+ */
+ @RestGet(path="/data/widgets/active/{id}", swagger=@OpSwagger(ignore=true))
+ public Map<String,Object> activeWidgetDetail(@Path("id") String id) {
+ for (var w : ACTIVE_WIDGETS)
+ if (id.equals(w.name))
+ return detailEnvelope(Map.of(
+ COL_OWNER, w.owner,
+ COL_UPDATED_AT, w.updatedAt,
+ "notes", w.notes));
+ throw new NotFound("Widget not found: %s", id);
+ }
+
/**
* [GET /data/widgets/archived] — the Archived sub-tab's rows.
*
@@ -254,10 +320,76 @@ public class ExampleViewsRest extends BasicRestServlet {
return AUDIT_LOG;
}
+ /**
+ * [GET /data/alerts] — the Alerts tab's rows.
+ *
+ * @return The alerts.
+ */
+ @RestGet(path="/data/alerts", swagger=@OpSwagger(ignore=true))
+ public List<Alert> alertsData() {
+ return ALERTS;
+ }
+
+ /**
+ * [GET /data/alerts/{id}] — expand envelope for one alert.
+ *
+ * @param id The alert id.
+ * @return {@code {contractVersion, fields}} for the expander.
+ */
+ @RestGet(path="/data/alerts/{id}", swagger=@OpSwagger(ignore=true))
+ public Map<String,Object> alertDetail(@Path("id") String id) {
+ var a = findAlert(id);
+ return detailEnvelope(Map.of(
+ "severity", a.severity,
+ "title", a.title,
+ "summary", a.summary,
+ "assignee", a.assignee));
+ }
+
+ /**
+ * [POST /data/alerts/{id}/ack] — acknowledge an open alert.
+ *
+ * @param id The alert id.
+ * @return The typed action result carrying the updated row.
+ */
+ @RestPost(path="/data/alerts/{id}/ack", swagger=@OpSwagger(ignore=true))
+ public ActionResult ackAlert(@Path("id") String id) {
+ var a = findAlert(id);
+ a.status = STATUS_ACKNOWLEDGED;
+ return ActionResult.success(a);
+ }
+
+ /**
+ * [POST /data/alerts/{id}/esc] — escalate an alert.
+ *
+ * @param id The alert id.
+ * @return The typed action result carrying the updated row.
+ */
+ @RestPost(path="/data/alerts/{id}/esc", swagger=@OpSwagger(ignore=true))
+ public ActionResult escalateAlert(@Path("id") String id) {
+ var a = findAlert(id);
+ a.status = STATUS_ESCALATED;
+ return ActionResult.success(a);
+ }
+
//------------------------------------------------------------------------------------------------------------------
// Row generation - enough rows per panel that a column-sizing regression would be visibly wrong.
//------------------------------------------------------------------------------------------------------------------
+ private static Map<String,Object> detailEnvelope(Map<String,?> fields) {
+ var out = new LinkedHashMap<String,Object>();
+ out.put("contractVersion", RowDetailDef.CONTRACT_VERSION);
+ out.put("fields", fields);
+ return out;
+ }
+
+ private static Alert findAlert(String id) {
+ for (var a : ALERTS)
+ if (id.equals(a.id))
+ return a;
+ throw new NotFound("Alert not found: %s", id);
+ }
+
private static List<Widget> buildActiveWidgets() {
var out = new ArrayList<Widget>();
var owners = List.of("Platform", "Storefront", "Billing", "Growth");
@@ -303,4 +435,20 @@ public class ExampleViewsRest extends BasicRestServlet {
}
return List.copyOf(out);
}
+
+ private static List<Alert> buildAlerts() {
+ var out = new ArrayList<Alert>();
+ var severities = List.of("critical", "warning", "info");
+ var assignees = List.of("alice", "bob", "carol");
+ for (var i = 1; i <= 12; i++) {
+ out.add(new Alert(
+ "ALRT-" + i,
+ severities.get(i % severities.size()),
+ "Synthetic alert " + i,
+ STATUS_OPEN,
+ "Fired by the views-example generator; row " + i + " of the fake pager.",
+ assignees.get(i % assignees.size())));
+ }
+ return out;
+ }
}
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 62b8aadb79..98d495e91c 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
@@ -71,8 +71,15 @@ class ExampleViewsEndToEnd_Test extends TestBase {
assertTrue(body.contains("data-juneau-page=\"widgets-demo\""), "page marker");
assertTrue(body.contains("data-tab-id=\"catalog\""), "Catalog tab");
assertTrue(body.contains("data-tab-id=\"audit\""), "Audit Log tab");
+ assertTrue(body.contains("data-tab-id=\"alerts\""), "Alerts tab");
assertTrue(body.contains("data-subtab-id=\"active\""), "Active sub-tab");
assertTrue(body.contains("data-subtab-id=\"archived\""), "Archived sub-tab");
+ assertTrue(body.contains("data-juneau-row-detail"), "row-detail template");
+ assertTrue(body.contains("data-juneau-detail-url=\"/data/alerts/{id}\""), "alerts expand URL");
+ assertTrue(body.contains("data-juneau-detail-url=\"/data/widgets/active/{id}\""), "widget expand URL");
+ assertTrue(body.contains("data-juneau-action=\"ack\""), "ack ActionRef");
+ assertTrue(body.contains("data-juneau-action=\"esc\""), "esc ActionRef");
+ assertTrue(body.contains("data-juneau-safe=\"collapse\""), "COLLAPSE");
}
@Test
@@ -133,4 +140,55 @@ class ExampleViewsEndToEnd_Test extends TestBase {
var res = get("/juneau-views.css");
assertEquals(200, res.statusCode());
}
+
+ @Test
+ void d01_alertsData_returnsNonEmptyRows() throws Exception {
+ var res = getJson("/data/alerts");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ assertTrue(body.contains("\"id\":\"ALRT-1\""));
+ assertTrue(body.contains("\"severity\""));
+ assertTrue(body.contains("\"assignee\""));
+ }
+
+ @Test
+ void d02_widgetExpandGet_projectsNotesNotOnTheTable() throws Exception {
+ var res = getJson("/data/widgets/active/widget-1");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ assertTrue(body.contains("\"contractVersion\":\"1\""));
+ assertTrue(body.contains("\"notes\""));
+ assertTrue(body.contains("\"owner\""));
+ }
+
+ @Test
+ void d03_alertExpandGet_returnsSectionFields() throws Exception {
+ var res = getJson("/data/alerts/ALRT-1");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ assertTrue(body.contains("\"contractVersion\":\"1\""));
+ assertTrue(body.contains("\"severity\""));
+ assertTrue(body.contains("\"summary\""));
+ assertTrue(body.contains("\"assignee\""));
+ }
+
+ @Test
+ void d04_unknownAlert_returns404() throws Exception {
+ var res = getJson("/data/alerts/NO-SUCH");
+ assertEquals(404, res.statusCode());
+ }
+
+ @Test
+ void d05_ackAlert_mutatesStatus() throws Exception {
+ var uri = server.getRootUrl().resolve("/data/alerts/ALRT-2/ack");
+ var req = HttpRequest.newBuilder(uri)
+ .header("Accept", "application/json")
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString("{}"))
+ .build();
+ var res = http.send(req, BodyHandlers.ofString());
+ assertEquals(200, res.statusCode(), res.body());
+ assertTrue(res.body().contains("\"outcome\":\"success\""), res.body());
+ assertTrue(res.body().contains("\"acknowledged\""), res.body());
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/pom.xml b/juneau-rest/juneau-rest-server-views/pom.xml
index 6bba1ff813..2b82e3bbb0 100644
--- a/juneau-rest/juneau-rest-server-views/pom.xml
+++ b/juneau-rest/juneau-rest-server-views/pom.xml
@@ -35,6 +35,11 @@
</properties>
<dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-widgets</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-datatables</artifactId>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
index e3e30a2576..2b94a7d3bb 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Column.java
@@ -16,6 +16,10 @@
*/
package org.apache.juneau.rest.server.views;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
import org.apache.juneau.commons.bean.*;
/**
@@ -25,12 +29,15 @@ import org.apache.juneau.commons.bean.*;
* Mirrors the DataTables column shape (the DataTables-native terms {@code orderable}/{@code searchable}/
* {@code className} are kept) and adds a named-renderer reference ({@link #render}) plus a declarative
* {@code {property}} URL template ({@link #href}). Optional fields ({@code name}, {@code href}, {@code className})
- * are omitted from the wire when unset.
+ * are omitted from the wire when unset, as are the column-configurator fields ({@link #pinned},
+ * {@link #defaultVisible}, {@link #formats}) — each is a nullable wrapper type left <jk>null</jk> by default
+ * so the serializer drops it, rather than a primitive that would emit on every column.
*
* <p>
- * The reserved catalog/View-Settings fields ({@code format}, {@code description}, {@code pinned},
- * {@code defaultVisible}) are <b>not</b> exposed by this MVP builder and are therefore omitted from the serialized
- * contract (design doc §6.4 reserved stubs).
+ * The reserved catalog/View-Settings fields ({@code format}, {@code description}) remain <b>not</b> exposed by
+ * this MVP builder and are therefore omitted from the serialized contract (design doc §6.4 reserved stubs); the
+ * <b>selected</b> format for a column (which id from {@link #formats} is active) is persistence-only and never a
+ * wire field here.
*
* <h5 class='section'>See Also:</h5>
* <ul>
@@ -40,7 +47,7 @@ import org.apache.juneau.commons.bean.*;
*
* @since 10.0.0
*/
-@BeanType(properties="data,name,title,orderable,searchable,render,href,className")
+@BeanType(properties="data,name,title,orderable,searchable,render,href,className,pinned,defaultVisible,formats")
@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class Column {
@@ -68,6 +75,26 @@ public class Column {
/** Optional static CSS class on the cell (DataTables-native {@code className}). */
public String className;
+ /**
+ * Whether this column is always visible and un-hideable in the column chooser (its checkbox renders disabled).
+ * A pinned column may still be freely reordered. Omitted from the wire when unset (not pinned).
+ */
+ public Boolean pinned;
+
+ /**
+ * Whether this column is in the initial visible set when no saved view overrides it. Absent/<jk>null</jk> is
+ * treated as <jk>true</jk> by the client. Omitted from the wire when unset.
+ */
+ public Boolean defaultVisible;
+
+ /**
+ * The selectable P3 renderer ids (e.g. {@code "date"}, {@code "datetime"}, {@code "ts-zulu"}) offered by the
+ * column chooser's per-column format dropdown. Absent/<jk>null</jk> means no dropdown; the column always
+ * renders with its fixed {@link #render}. The chooser-selected format itself is persistence-only, never a
+ * wire field on this bean. Omitted from the wire when unset.
+ */
+ public List<String> formats;
+
/**
* Creates a column bound to the specified data key, with {@code orderable}/{@code searchable} defaulting to
* <jk>true</jk>.
@@ -168,4 +195,37 @@ public class Column {
className = value;
return this;
}
+
+ /**
+ * Marks this column as always-visible and un-hideable in the column chooser (still freely reorderable).
+ *
+ * @param value The new value.
+ * @return This object.
+ */
+ public Column pinned(boolean value) {
+ pinned = value;
+ return this;
+ }
+
+ /**
+ * Sets whether this column is in the initial visible set when no saved view overrides it.
+ *
+ * @param value The new value. Absent/<jk>null</jk> is treated as <jk>true</jk> by the client.
+ * @return This object.
+ */
+ public Column defaultVisible(boolean value) {
+ defaultVisible = value;
+ return this;
+ }
+
+ /**
+ * Sets the selectable P3 renderer ids offered by the column chooser's per-column format dropdown.
+ *
+ * @param value The renderer ids. Can be <jk>null</jk>/omitted to unset (no dropdown).
+ * @return This object.
+ */
+ public Column formats(String...value) {
+ formats = l(value);
+ return this;
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ColumnConfig.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ColumnConfig.java
new file mode 100644
index 0000000000..43bc0e8c62
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ColumnConfig.java
@@ -0,0 +1,52 @@
+/*
+ * 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 org.apache.juneau.commons.bean.*;
+
+/**
+ * The opt-in per-view column-configurator settings in the {@code VIEW_META} wire contract (design doc §6.8).
+ *
+ * <p>
+ * Set {@link ViewDef#columnConfig(ColumnConfig)} to enable the View-only column chooser (visible set + order +
+ * per-column label override + per-column format) for that view; the mere <b>presence</b> of this bean is the
+ * opt-in signal — an empty {@link #create()} is sufficient, there is no separate {@code enabled} flag.
+ *
+ * <p>
+ * This type currently declares no wire fields of its own; it reserves room for the deferred Search/Sort/Options
+ * tab flags without a further {@link ViewDef} signature change.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewDef}
+ * <li class='jc'>{@link Column}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@BeanType
+public class ColumnConfig {
+
+ /**
+ * Creates a new, empty column-configurator opt-in.
+ *
+ * @return A new {@link ColumnConfig}.
+ */
+ public static ColumnConfig create() {
+ return new ColumnConfig();
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
new file mode 100644
index 0000000000..34b9996317
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailField.java
@@ -0,0 +1,62 @@
+/*
+ * 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.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * One field slot in a {@link DetailSection}.
+ *
+ * <p>
+ * Values are filled from the expand GET JSON {@code fields} map via {@code textContent} only. There is no
+ * {@code render} field in this slice.
+ *
+ * @since 10.0.0
+ */
+public class DetailField {
+
+ /** The key into the expand JSON {@code fields} map. Unique across the whole {@link RowDetailDef}. */
+ public String data;
+
+ /** The label shown above the value slot. */
+ public String title;
+
+ /**
+ * Creates a field bound to the specified expand-JSON key.
+ *
+ * @param data The {@code fields} map key. Must not be <jk>null</jk> or blank.
+ * @return A new {@link DetailField}.
+ */
+ public static DetailField of(String data) {
+ if (data == null || data.isBlank())
+ throw iaex("DetailField data must not be null or blank.");
+ var f = new DetailField();
+ f.data = data;
+ return f;
+ }
+
+ /**
+ * Sets the label shown above the value slot.
+ *
+ * @param value The label. May be <jk>null</jk> (the {@link #data} key is used as a fallback at emit time).
+ * @return This object.
+ */
+ public DetailField title(String value) {
+ title = value;
+ return this;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
new file mode 100644
index 0000000000..5566c8e29b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/DetailSection.java
@@ -0,0 +1,95 @@
+/*
+ * 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.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.server.widgets.*;
+
+/**
+ * A named subsection of a {@link RowDetailDef} expander panel.
+ *
+ * @since 10.0.0
+ */
+public class DetailSection {
+
+ /** Stable section id, unique within the enclosing {@link RowDetailDef}. */
+ public String id;
+
+ /** Heading painted with {@code textContent}. */
+ public String title;
+
+ /** CSS-grid column hint; default {@code 2}. Must be {@code >= 1}. */
+ public int columns = 2;
+
+ /** The field slots, in display order. */
+ public List<DetailField> fields;
+
+ /** Optional per-section action bar. */
+ public ActionBar actions;
+
+ /**
+ * Creates a section with the specified id and title.
+ *
+ * @param id The section id. Must not be <jk>null</jk> or blank.
+ * @param title The heading. May be <jk>null</jk> (the id is used as a fallback at emit time).
+ * @return A new {@link DetailSection}.
+ */
+ public static DetailSection create(String id, String title) {
+ if (id == null || id.isBlank())
+ throw iaex("DetailSection id must not be null or blank.");
+ var s = new DetailSection();
+ s.id = id;
+ s.title = title;
+ return s;
+ }
+
+ /**
+ * Sets the CSS-grid column count.
+ *
+ * @param value The column count. Must be {@code >= 1} (enforced by {@link RowDetailDef#validate(List)}).
+ * @return This object.
+ */
+ public DetailSection columns(int value) {
+ columns = value;
+ return this;
+ }
+
+ /**
+ * Sets the field slots.
+ *
+ * @param value The fields, in display order. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public DetailSection fields(DetailField...value) {
+ fields = l(value);
+ return this;
+ }
+
+ /**
+ * Sets the optional action bar.
+ *
+ * @param value The action bar. May be <jk>null</jk> (no bar).
+ * @return This object.
+ */
+ public DetailSection actions(ActionBar value) {
+ actions = value;
+ return this;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore.java
new file mode 100644
index 0000000000..5bab2f04b1
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore.java
@@ -0,0 +1,219 @@
+/*
+ * 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.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.f;
+import static org.apache.juneau.commons.utils.Shorts.iaex;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * The framework's non-durable, in-memory default {@link SavedViewStore} (TODO-444 §3.3, owner decision item 8).
+ *
+ * <p>
+ * <b>Not persistent:</b> everything lives in heap and is lost on JVM restart. It exists so a host composing
+ * {@link SavedViewsMixin} works out-of-the-box for dev/demo and the framework's own tests/canary with zero setup; a
+ * consumer overrides {@link SavedViewsMixin#savedViewStore()} to supply a durable implementation against its own
+ * datastore.
+ *
+ * <h5 class='section'>Thread-safety + race-safe quotas</h5>
+ * <p>
+ * Each user's data is guarded by its own monitor: every read and mutation for a given {@code principalName}
+ * serializes on that user's {@code UserData} instance. This is what makes the per-user aggregate quota race-safe
+ * — two parallel creates in DIFFERENT {@code (page,view)} scopes for the same user still run one-at-a-time
+ * through the aggregate check, so they cannot both slip past {@link SavedViewStore#MAX_VIEWS_PER_USER} (§3.2).
+ * Different users proceed concurrently. The blob-and-active flip of {@link #saveAndActivate} happens inside that
+ * same monitor, so it is atomic.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link SavedViewStore}
+ * <li class='jc'>{@link SavedViewsMixin}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class InMemorySavedViewStore implements SavedViewStore {
+
+ private static volatile InMemorySavedViewStore shared;
+
+ /**
+ * The process-wide shared default instance returned by {@link SavedViewsMixin#savedViewStore()}.
+ *
+ * <p>
+ * Lazily created. Shared across every host that composes {@link SavedViewsMixin} without overriding the seam;
+ * because every entry is keyed principal-first, users stay isolated even in this shared instance. Consumers that
+ * want isolation between resources (or durability) override {@code savedViewStore()} with their own instance.
+ *
+ * @return The shared in-memory store. Never <jk>null</jk>.
+ */
+ static synchronized InMemorySavedViewStore shared() {
+ if (shared == null)
+ shared = new InMemorySavedViewStore();
+ return shared;
+ }
+
+ private final int maxViewsPerScope;
+ private final int maxViewsPerUser;
+
+ private final ConcurrentMap<String,UserData> users = new ConcurrentHashMap<>();
+
+ /**
+ * Creates a store with the default {@link SavedViewStore#MAX_VIEWS_PER_SCOPE} / {@link SavedViewStore#MAX_VIEWS_PER_USER}
+ * count caps.
+ */
+ public InMemorySavedViewStore() {
+ this(MAX_VIEWS_PER_SCOPE, MAX_VIEWS_PER_USER);
+ }
+
+ /**
+ * Creates a store with explicit count caps (chiefly for tests that need to trip a quota without minting hundreds
+ * of views).
+ *
+ * @param maxViewsPerScope The per-{@code (user,page,view)} count cap. Must be positive.
+ * @param maxViewsPerUser The per-user aggregate count cap. Must be positive.
+ */
+ public InMemorySavedViewStore(int maxViewsPerScope, int maxViewsPerUser) {
+ if (maxViewsPerScope <= 0)
+ throw iaex("Argument 'maxViewsPerScope' must be positive: %s.", maxViewsPerScope);
+ if (maxViewsPerUser <= 0)
+ throw iaex("Argument 'maxViewsPerUser' must be positive: %s.", maxViewsPerUser);
+ this.maxViewsPerScope = maxViewsPerScope;
+ this.maxViewsPerUser = maxViewsPerUser;
+ }
+
+ @Override /* SavedViewStore */
+ public Listing list(String principalName, String pageId, String viewId) {
+ requireKey(principalName, viewId);
+ var ud = users.get(principalName);
+ if (ud == null)
+ return new Listing(null, List.of());
+ synchronized (ud) {
+ var s = ud.scopes.get(new ScopeKey(pageId, viewId));
+ if (s == null)
+ return new Listing(null, List.of());
+ var names = List.copyOf(s.views.keySet());
+ // Dangling-active resolution (§3.2): an active pointer at a name no longer present resolves to Default.
+ var active = (s.active != null && ! s.views.containsKey(s.active)) ? null : s.active;
+ return new Listing(active, names);
+ }
+ }
+
+ @Override /* SavedViewStore */
+ public String load(String principalName, String pageId, String viewId, String name) {
+ requireKey(principalName, viewId);
+ assertArgNotNull("name", name);
+ var ud = users.get(principalName);
+ if (ud == null)
+ return null;
+ synchronized (ud) {
+ var s = ud.scopes.get(new ScopeKey(pageId, viewId));
+ return s == null ? null : s.views.get(name);
+ }
+ }
+
+ @Override /* SavedViewStore */
+ public void save(String principalName, String pageId, String viewId, String name, String blob) {
+ write(principalName, pageId, viewId, name, blob, false);
+ }
+
+ @Override /* SavedViewStore */
+ public void saveAndActivate(String principalName, String pageId, String viewId, String name, String blob) {
+ write(principalName, pageId, viewId, name, blob, true);
+ }
+
+ @Override /* SavedViewStore */
+ public void setActive(String principalName, String pageId, String viewId, String name) {
+ requireKey(principalName, viewId);
+ var ud = users.computeIfAbsent(principalName, k -> new UserData());
+ synchronized (ud) {
+ // A set-active never counts against a quota (it writes no blob), so the scope is created on demand and the
+ // pointer stored as-is; a dangling name is resolved back to Default on the next list().
+ ud.scopes.computeIfAbsent(new ScopeKey(pageId, viewId), k -> new ScopeData()).active = name;
+ }
+ }
+
+ @Override /* SavedViewStore */
+ public void delete(String principalName, String pageId, String viewId, String name) {
+ requireKey(principalName, viewId);
+ assertArgNotNull("name", name);
+ var ud = users.get(principalName);
+ if (ud == null)
+ return;
+ synchronized (ud) {
+ var s = ud.scopes.get(new ScopeKey(pageId, viewId));
+ if (s == null)
+ return;
+ s.views.remove(name);
+ if (name.equals(s.active))
+ s.active = null;
+ }
+ }
+
+ private void write(String principalName, String pageId, String viewId, String name, String blob, boolean activate) {
+ requireKey(principalName, viewId);
+ assertArgNotNull("name", name);
+ assertArgNotNull("blob", blob);
+ var ud = users.computeIfAbsent(principalName, k -> new UserData());
+ synchronized (ud) {
+ var s = ud.scopes.computeIfAbsent(new ScopeKey(pageId, viewId), k -> new ScopeData());
+ var isReplace = s.views.containsKey(name);
+ if (! isReplace) {
+ // The count applies to a CREATE only; a replace never trips it. Both checks run under the user's
+ // monitor, so the aggregate check is serialized across all of this user's scopes (race-safe).
+ if (s.views.size() >= maxViewsPerScope)
+ throw new SavedViewQuotaException(SavedViewQuotaException.Kind.SCOPE,
+ f("scope already has %s saved views (MAX_VIEWS_PER_SCOPE)", maxViewsPerScope));
+ if (totalViews(ud) >= maxViewsPerUser)
+ throw new SavedViewQuotaException(SavedViewQuotaException.Kind.AGGREGATE,
+ f("aggregate saved-view count reached MAX_VIEWS_PER_USER (%s)", maxViewsPerUser));
+ }
+ s.views.put(name, blob);
+ if (activate)
+ s.active = name;
+ }
+ }
+
+ /** Total saved views for a user across every scope. Caller must hold the user's monitor. */
+ private static int totalViews(UserData ud) {
+ var total = 0;
+ for (var s : ud.scopes.values())
+ total += s.views.size();
+ return total;
+ }
+
+ private static void requireKey(String principalName, String viewId) {
+ assertArgNotNull("principalName", principalName);
+ assertArgNotNull("viewId", viewId);
+ }
+
+ /** Per-user data; every access is guarded by {@code synchronized(this-instance)} (see the class javadoc). */
+ private static final class UserData {
+ final Map<ScopeKey,ScopeData> scopes = new HashMap<>();
+ }
+
+ /** One {@code (page,view)} scope's saved-view blobs (insertion-ordered) plus its active pointer. */
+ private static final class ScopeData {
+ final Map<String,String> views = new LinkedHashMap<>();
+ String active;
+ }
+
+ /** The structured scope key — separate fields, never a delimiter-joined string ({@code pageId} may be null). */
+ private record ScopeKey(String pageId, String viewId) {}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
index 937544d04b..6b232c868a 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageDef.java
@@ -136,7 +136,7 @@ public class PageDef {
return this;
}
- private void validate() {
+ void validate() {
if (tabs == null || tabs.isEmpty())
throw iaex("PageDef '%s' must declare at least one tab.", id);
var tabIds = new HashSet<String>();
@@ -145,12 +145,16 @@ public class PageDef {
t.validate();
if (!tabIds.add(t.id))
throw iaex("PageDef '%s': duplicate tab id '%s'.", id, t.id);
- if (t.view != null)
+ if (t.view != null) {
addViewId(viewIds, t.view.id);
+ t.view.validate();
+ }
if (t.subtabs != null)
for (var s : t.subtabs)
- if (s.view != null)
+ if (s.view != null) {
addViewId(viewIds, s.view.id);
+ s.view.validate();
+ }
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
index 6d902cf500..bc3d2c514e 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java
@@ -26,6 +26,7 @@ import org.apache.juneau.commons.bean.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.*;
/**
* Builds the HTML delivery shell for a {@link PageDef} — the self-contained, class-based tab/sub-tab shell
@@ -180,6 +181,15 @@ public class PageTable {
*/
public static final String PARENT_TAB_ATTR = "data-parent-tab";
+ /**
+ * Attribute the resolved saved-views REST base is stamped onto on this page shell so every nested
+ * {@code table[data-juneau-view]} can find it via {@code closest('[data-juneau-saved-views]')}.
+ *
+ * <p>
+ * Same spelling as {@link ViewTable#SAVED_VIEWS_ATTR} (the shared contract with {@code juneau-config.js}).
+ */
+ public static final String SAVED_VIEWS_ATTR = ViewTable.SAVED_VIEWS_ATTR;
+
private PageTable() {}
/**
@@ -190,7 +200,19 @@ public class PageTable {
* referenced view, and the PAGE_META sidecar.
*/
public static Div of(PageDef pageDef) {
- return of(MarshallingContext.DEFAULT, pageDef);
+ return of(MarshallingContext.DEFAULT, pageDef, null);
+ }
+
+ /**
+ * Builds the page shell, stamping the request's resolved saved-views REST base onto the page shell so
+ * nested view tables can discover it via {@code closest(...)}.
+ *
+ * @param req The current request, supplying the URI resolver. Must not be <jk>null</jk>.
+ * @param pageDef The built page definition. Must not be <jk>null</jk>.
+ * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell and {@link #SAVED_VIEWS_ATTR}.
+ */
+ public static Div of(RestRequest req, PageDef pageDef) {
+ return of(MarshallingContext.DEFAULT, pageDef, SavedViewsMixin.resolvedBaseUrl(req));
}
/**
@@ -203,6 +225,20 @@ public class PageTable {
* referenced view, and the PAGE_META sidecar.
*/
public static Div of(MarshallingContext ctx, PageDef pageDef) {
+ return of(ctx, pageDef, null);
+ }
+
+ /**
+ * Builds the page shell, optionally stamping a pre-resolved saved-views REST base onto the page shell.
+ *
+ * @param ctx The marshalling context passed through to each child {@code ViewTable.of(...)} call. Must not be
+ * <jk>null</jk>.
+ * @param pageDef The built page definition. Must not be <jk>null</jk>.
+ * @param savedViewsBase The already-resolved saved-views REST base, or <jk>null</jk>/blank to stamp none.
+ * @return A new {@link Div} carrying the {@code [data-juneau-page]} shell and optional {@link #SAVED_VIEWS_ATTR}.
+ */
+ public static Div of(MarshallingContext ctx, PageDef pageDef, String savedViewsBase) {
+ pageDef.validate();
var id = pageDef.id;
var tabs = pageDef.tabs == null ? List.<Tab>of() : pageDef.tabs;
@@ -222,7 +258,10 @@ public class PageTable {
var json = escapeForScript(Json.of(buildMeta(pageDef)));
var sidecar = script().type("application/json").id(SIDECAR_ID_PREFIX + id).text(rawText(json));
- return div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
+ var shell = div(tabBar, panels, sidecar).id(id).attr(MARKER_ATTR, id).class_(PAGE_CLASS);
+ if (savedViewsBase != null && ! savedViewsBase.isBlank())
+ shell.attr(SAVED_VIEWS_ATTR, savedViewsBase);
+ return shell;
}
/**
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
new file mode 100644
index 0000000000..870b889fd9
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RowDetailDef.java
@@ -0,0 +1,162 @@
+/*
+ * 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.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.server.widgets.*;
+
+/**
+ * The row-details expander definition: named sections, an expand GET endpoint, and optional per-section
+ * {@link ActionBar}s.
+ *
+ * <p>
+ * Structure is emitted as a {@code <template data-juneau-row-detail>} sibling of the view table; field values
+ * arrive via a same-origin GET and are painted with {@code textContent} only. This type is Java-only — it
+ * is not part of the {@code VIEW_META} JSON sidecar.
+ *
+ * @since 10.0.0
+ */
+public class RowDetailDef {
+
+ /** The frozen contract version for the expand GET envelope and the stamped {@code data-juneau-detail-contract}. */
+ public static final String CONTRACT_VERSION = "1";
+
+ /** Same-origin path template; {@code {id}} is substituted with {@code encodeURIComponent(rowId)}. */
+ public String endpoint;
+
+ /** The named sections, in display order. At least one is required. */
+ public List<DetailSection> sections;
+
+ /**
+ * Creates an empty row-detail definition.
+ *
+ * @return A new {@link RowDetailDef}.
+ */
+ public static RowDetailDef create() {
+ return new RowDetailDef();
+ }
+
+ /**
+ * Sets the expand GET path template.
+ *
+ * @param value A same-origin path template containing {@code {id}}. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public RowDetailDef endpoint(String value) {
+ endpoint = value;
+ return this;
+ }
+
+ /**
+ * Sets the named sections.
+ *
+ * @param value The sections, in display order. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public RowDetailDef sections(DetailSection...value) {
+ sections = l(value);
+ return this;
+ }
+
+ /**
+ * Fail-closed bean validation, including {@link ActionRef} existence against the enclosing view's action
+ * catalog.
+ *
+ * @param rowActions The enclosing {@link ViewDef#rowActions}, or <jk>null</jk> (any {@link ActionRef} then
+ * fails).
+ * @throws IllegalArgumentException If this definition is not well-formed.
+ */
+ public void validate(List<RowAction> rowActions) {
+ if (endpoint == null || endpoint.isBlank())
+ throw iaex("RowDetailDef endpoint must not be null or blank.");
+ if (!endpoint.contains("{id}"))
+ throw iaex("RowDetailDef endpoint must contain '{id}'.");
+ if (!isSafeDetailEndpoint(endpoint))
+ throw iaex("RowDetailDef endpoint must be a same-origin path template (no absolute URL, '..', or scheme): %s",
+ endpoint);
+ if (sections == null || sections.isEmpty())
+ throw iaex("RowDetailDef must declare at least one section.");
+
+ var actionIds = new HashSet<String>();
+ if (rowActions != null)
+ for (var a : rowActions)
+ if (a != null && a.id != null)
+ actionIds.add(a.id);
+
+ var sectionIds = new HashSet<String>();
+ var fieldKeys = new HashSet<String>();
+ for (var s : sections) {
+ if (s == null)
+ throw iaex("RowDetailDef section must not be null.");
+ if (s.id == null || s.id.isBlank())
+ throw iaex("DetailSection id must not be null or blank.");
+ if (!sectionIds.add(s.id))
+ throw iaex("RowDetailDef duplicate section id '%s'.", s.id);
+ if (s.columns < 1)
+ throw iaex("DetailSection '%s' columns must be >= 1.", s.id);
+ if (s.fields != null) {
+ for (var f : s.fields) {
+ if (f == null)
+ throw iaex("DetailSection '%s' field must not be null.", s.id);
+ if (f.data == null || f.data.isBlank())
+ throw iaex("DetailSection '%s' field data must not be null or blank.", s.id);
+ if (!fieldKeys.add(f.data))
+ throw iaex("RowDetailDef duplicate field data key '%s'.", f.data);
+ }
+ }
+ if (s.actions != null) {
+ s.actions.validate();
+ if (s.actions.items != null) {
+ for (var item : s.actions.items) {
+ if (item instanceof ActionRef ar) {
+ if (!actionIds.contains(ar.id))
+ throw iaex("ActionRef '%s' is not declared on the enclosing view's rowActions.", ar.id);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Whether {@code endpoint} is a same-origin path template: no {@code ://} , no {@code //} prefix, no scheme
+ * colon-before-slash, and no {@code ..} path segments.
+ *
+ * @param endpoint The candidate template. May be <jk>null</jk>.
+ * @return <jk>true</jk> if the string is a same-origin path template.
+ */
+ public static boolean isSafeDetailEndpoint(String endpoint) {
+ if (endpoint == null || endpoint.isBlank())
+ return false;
+ if (endpoint.contains("://"))
+ return false;
+ if (endpoint.startsWith("//"))
+ return false;
+ var colon = endpoint.indexOf(':');
+ var slash = endpoint.indexOf('/');
+ if (colon >= 0 && (slash < 0 || colon < slash))
+ return false;
+ for (var seg : endpoint.split("/", -1)) {
+ if ("..".equals(seg))
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewQuotaException.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewQuotaException.java
new file mode 100644
index 0000000000..2d6e3b5d20
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewQuotaException.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+/**
+ * Thrown by a {@link SavedViewStore} when a create would exceed one of the store's count quotas — the
+ * per-scope cap ({@link SavedViewStore#MAX_VIEWS_PER_SCOPE}) or the per-user aggregate cap
+ * ({@link SavedViewStore#MAX_VIEWS_PER_USER}) — so {@link SavedViewsMixin} can map it to the quota HTTP
+ * status the client's server-persisted provider classifies as a {@code quota}-typed error (TODO-444 §3.2).
+ *
+ * <p>
+ * The count quotas are the store's responsibility (not the mixin's) precisely because they are stateful and
+ * race-sensitive: two parallel creates in different {@code (page,view)} scopes for the same user each pass a naive
+ * per-scope check, so the aggregate ceiling can only be enforced correctly under the store's own per-user
+ * serialization (see {@link InMemorySavedViewStore}). A consumer's durable store implementation throws this same
+ * exception so it gets the identical HTTP mapping without re-implementing the mixin.
+ *
+ * <p>
+ * The per-blob size cap is a stateless check enforced on the mixin (a different HTTP status), not here — see
+ * {@link SavedViewsMixin} for the full status mapping.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link SavedViewStore}
+ * <li class='jc'>{@link SavedViewsMixin}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class SavedViewQuotaException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Which count quota was exceeded.
+ */
+ public enum Kind {
+
+ /** The per-{@code (user,page,view)} saved-view count cap ({@link SavedViewStore#MAX_VIEWS_PER_SCOPE}). */
+ SCOPE,
+
+ /** The per-user aggregate saved-view count cap across all scopes ({@link SavedViewStore#MAX_VIEWS_PER_USER}). */
+ AGGREGATE
+ }
+
+ private final Kind kind;
+
+ /**
+ * Constructor.
+ *
+ * @param kind The quota that was exceeded. Must not be <jk>null</jk>.
+ * @param message A human-readable description of the exceeded quota.
+ */
+ public SavedViewQuotaException(Kind kind, String message) {
+ super(message);
+ this.kind = kind;
+ }
+
+ /**
+ * The quota that was exceeded.
+ *
+ * @return The quota kind. Never <jk>null</jk>.
+ */
+ public Kind kind() { return kind; }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewStore.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewStore.java
new file mode 100644
index 0000000000..a2198844a4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewStore.java
@@ -0,0 +1,202 @@
+/*
+ * 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 java.util.*;
+
+/**
+ * The consumer-injectable persistence seam behind {@link SavedViewsMixin} — the server half of the saved-views
+ * persistence SPI (TODO-444 §3.3).
+ *
+ * <p>
+ * Every operation is keyed on a <b>structured tuple</b> — {@code (principalName, pageId, viewId, name)} for the
+ * per-view operations, {@code (principalName, pageId, viewId)} for the scope-level ones — passed as separate
+ * arguments, <b>never</b> a {@code .}-joined or otherwise delimiter-concatenated composite key. A structured tuple
+ * cannot be spoofed by a value that happens to contain the delimiter, and it lets a durable backend map each
+ * dimension to its own column/field. The {@code enc()}/{@code dec()} segment codec that {@code juneau-config.js}'s
+ * {@code localStorage} provider uses to flatten those dimensions into one string key is a
+ * <b>localStorage-only</b> concern; it is <b>not</b> part of this seam (§3.3).
+ *
+ * <h5 class='section'>Identity comes from the request principal, always</h5>
+ * <p>
+ * {@code principalName} is {@link SavedViewsMixin}'s canonicalized {@link java.security.Principal#getName()} —
+ * derived server-side and never client-supplied. Because it is the <b>first</b> key dimension, a user saving
+ * arbitrary {@code page}/{@code view}/{@code name} strings only ever writes inside <i>their own</i> namespace, so
+ * there is no cross-user IDOR to defend against here beyond the principal-first keying plus the quotas below.
+ * <b>Residual risk:</b> the framework keys on {@code getName()} alone, so two IdPs (or tenants) that mint the same
+ * name would share a namespace — a multi-tenant consumer whose principal names are not globally unique MUST
+ * override this store to prefix a tenant discriminator onto {@code principalName}.
+ *
+ * <h5 class='section'>Atomicity + race-safe quotas</h5>
+ * <p>
+ * {@link #saveAndActivate(String, String, String, String, String)} MUST write the blob AND flip the active pointer
+ * as ONE serialized mutation for a given {@code (principalName, pageId, viewId)}, so a concurrent arrival can never
+ * observe a half-applied blob-without-active or active-without-blob (§3.2). The two count quotas
+ * ({@link #MAX_VIEWS_PER_SCOPE}, {@link #MAX_VIEWS_PER_USER}) MUST be checked INSIDE the write op, never in a
+ * separate pre-flight that could race, and the aggregate check MUST be serialized at {@code principalName} scope so
+ * two parallel creates in DIFFERENT {@code (page,view)} scopes cannot both slip past the ceiling. A create that
+ * would exceed either count throws {@link SavedViewQuotaException}. The count applies to a CREATE (a new name), not
+ * to a replace (overwriting an existing name).
+ *
+ * <h5 class='section'>Non-durable default</h5>
+ * <p>
+ * The framework ships {@link InMemorySavedViewStore} as the out-of-the-box default (returned by
+ * {@link SavedViewsMixin#savedViewStore()}); it is <b>in-memory and non-persistent</b> — lost on restart —
+ * and exists so a host composing {@link SavedViewsMixin} works for dev/demo/tests with zero setup. A consumer
+ * overrides {@code savedViewStore()} to supply a durable implementation against its own datastore. The framework
+ * owns the wire (endpoint shape + blob schema) and this seam; the storage backend stays out of the framework, since
+ * an Apache category-A module cannot take a persistence dependency.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link SavedViewsMixin}
+ * <li class='jc'>{@link InMemorySavedViewStore}
+ * <li class='jc'>{@link SavedViewQuotaException}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface SavedViewStore {
+
+ /**
+ * Default per-{@code (user,page,view)} saved-view count cap.
+ *
+ * <p>
+ * A textually SEPARATE copy of {@code juneau-config.js}'s {@code LOCALSTORAGE_MAX_VIEWS_PER_SCOPE} (the two are a
+ * stated, testable parity invariant, never a shared source-of-truth across the JS/Java boundary): change one and
+ * change the other in the same commit.
+ */
+ int MAX_VIEWS_PER_SCOPE = 50;
+
+ /**
+ * Default per-user aggregate saved-view count cap across ALL {@code (page,view)} scopes.
+ *
+ * <p>
+ * A DISTINCT number from {@link #MAX_VIEWS_PER_SCOPE}: it bounds the abuse of minting unlimited {@code (page,view)}
+ * scopes to evade the per-scope cap. Mirrors {@code juneau-config.js}'s {@code LOCALSTORAGE_MAX_VIEWS_PER_USER}
+ * (same parity invariant as above).
+ */
+ int MAX_VIEWS_PER_USER = 500;
+
+ /**
+ * Lists a scope's saved views and its resolved active pointer.
+ *
+ * <p>
+ * The returned {@link Listing#active()} MUST already have dangling-active resolved: an {@code active} pointing at a
+ * name that is not among {@link Listing#names()} resolves to {@code null} (Default), never an error (§3.2).
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @return The scope listing. Never <jk>null</jk> (an unknown scope lists as {@code (null, [])}).
+ */
+ Listing list(String principalName, String pageId, String viewId);
+
+ /**
+ * Loads a single saved-view blob.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @param name The saved-view name (decoded). Must not be <jk>null</jk> or blank.
+ * @return The stored blob JSON, or <jk>null</jk> if no such view exists.
+ */
+ String load(String principalName, String pageId, String viewId, String name);
+
+ /**
+ * Saves (creates or replaces) a saved-view blob without touching the active pointer.
+ *
+ * <p>
+ * Enforces the count quotas atomically (see the interface javadoc); throws {@link SavedViewQuotaException} when a
+ * CREATE would exceed one of them.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @param name The saved-view name (decoded). Must not be <jk>null</jk> or blank.
+ * @param blob The blob JSON to store. Must not be <jk>null</jk>.
+ * @throws SavedViewQuotaException If a create would exceed a count quota.
+ */
+ void save(String principalName, String pageId, String viewId, String name, String blob);
+
+ /**
+ * Atomically saves a blob AND flips the active pointer to it as ONE serialized mutation (§3.2).
+ *
+ * <p>
+ * Enforces the count quotas inside the same atomic op; throws {@link SavedViewQuotaException} when a CREATE would
+ * exceed one of them.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @param name The saved-view name (decoded). Must not be <jk>null</jk> or blank.
+ * @param blob The blob JSON to store. Must not be <jk>null</jk>.
+ * @throws SavedViewQuotaException If a create would exceed a count quota.
+ */
+ void saveAndActivate(String principalName, String pageId, String viewId, String name, String blob);
+
+ /**
+ * Sets or clears the scope's active pointer, without writing any blob (the bare set-active op).
+ *
+ * <p>
+ * A {@code name} that does not (yet) name a persisted view is honored as-is; the next {@link #list} resolves it
+ * back to Default via the dangling-active rule (§3.2). A <jk>null</jk> {@code name} clears the pointer.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @param name The saved-view name (decoded) to activate, or <jk>null</jk> to clear (select Default).
+ */
+ void setActive(String principalName, String pageId, String viewId, String name);
+
+ /**
+ * Deletes a saved view. A no-op if it does not exist. Clears the active pointer if it named this view.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @param name The saved-view name (decoded). Must not be <jk>null</jk> or blank.
+ */
+ void delete(String principalName, String pageId, String viewId, String name);
+
+ /**
+ * The resolved active-view name for a scope, or <jk>null</jk> for Default.
+ *
+ * <p>
+ * A client-side convenience derived uniformly from {@link #list} (so the dangling-active resolution is applied
+ * exactly once, in {@code list}), mirroring {@code juneau-config.js}'s {@code getActive}. There is deliberately
+ * no separate {@code GET .../active} HTTP endpoint — the JS server provider reads the active pointer off the
+ * list response — so this method exists for direct Java callers of the seam, not for the wire.
+ *
+ * @param principalName The canonicalized request-principal name. Must not be <jk>null</jk> or blank.
+ * @param pageId The enclosing page id, or <jk>null</jk> for a standalone view.
+ * @param viewId The view id. Must not be <jk>null</jk> or blank.
+ * @return The active-view name, or <jk>null</jk> for Default.
+ */
+ default String getActive(String principalName, String pageId, String viewId) {
+ return list(principalName, pageId, viewId).active();
+ }
+
+ /**
+ * The result of {@link SavedViewStore#list(String, String, String)}: the resolved active-view name (or
+ * <jk>null</jk> for Default) and the names of every saved view in the scope.
+ *
+ * @param active The resolved active-view name, or <jk>null</jk> for Default (dangling already resolved).
+ * @param names The saved-view names in the scope. Never <jk>null</jk> (empty when the scope has none).
+ */
+ record Listing(String active, List<String> names) {}
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewsMixin.java
new file mode 100644
index 0000000000..240fc846ff
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/SavedViewsMixin.java
@@ -0,0 +1,429 @@
+/*
+ * 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 java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.rest.server.*;
+
+/**
+ * Mixin interface that exposes the server half of the saved-views persistence SPI on any Juneau REST resource
+ * (TODO-444 §3.3) — the fixed-mount REST endpoints the {@code juneau-config.js} server-persisted provider
+ * already calls, backed by a consumer-injectable {@link SavedViewStore}.
+ *
+ * <p>
+ * A composing resource gets the endpoints for free and, unless it overrides {@link #savedViewStore()}, a
+ * non-durable {@link InMemorySavedViewStore in-memory default} so the wire works out-of-the-box for dev/demo/tests.
+ * A consumer wanting persistence overrides {@code savedViewStore()} to return a durable implementation.
+ *
+ * <h5 class='section'>Fixed mount (not overridable)</h5>
+ * <p>
+ * Every path is baked into the op-level {@code @RestGet/@RestPut/@RestDelete} annotations off
+ * {@link #SAVED_VIEWS_PREFIX}, exactly like {@link AsyncJobsMixin#JOBS_PREFIX}. A class-level {@code @Rest(path=...)}
+ * on the composing host is <b>silently ignored</b> under the mixin pattern (see {@link ViewsMixin}), and an
+ * interface constant cannot be overridden by a subclass, so the mount is a constraint, not a preference. A consumer
+ * that genuinely needs a different path does not compose this mixin at all — it re-implements the documented
+ * wire at its own path and points the JS provider's {@code data-juneau-saved-views} base attribute there. The
+ * framework guarantees the <i>wire</i>, not the <i>mount</i>.
+ *
+ * <h5 class='section'>Endpoints</h5>
+ * <table class='styled'>
+ * <tr><th>Method + path</th><th>Query</th><th>Body</th><th>Store op</th></tr>
+ * <tr><td>{@code GET /juneau-saved-views}</td><td>{@code view},{@code page?}</td><td>—</td>
+ * <td>{@link SavedViewStore#list list} → <code>{active,views:[{name}]}</code></td></tr>
+ * <tr><td>{@code GET /juneau-saved-views/item}</td><td>{@code view},{@code page?},{@code name}</td><td>—</td>
+ * <td>{@link SavedViewStore#load load} → the blob, or {@code 404}</td></tr>
+ * <tr><td>{@code PUT /juneau-saved-views/item}</td><td>{@code view},{@code page?},{@code name}</td><td>blob</td>
+ * <td>{@link SavedViewStore#save save}</td></tr>
+ * <tr><td>{@code PUT /juneau-saved-views/item?activate=1}</td><td>{@code view},{@code page?},{@code name}</td>
+ * <td>blob</td><td>{@link SavedViewStore#saveAndActivate saveAndActivate}</td></tr>
+ * <tr><td>{@code PUT /juneau-saved-views/active}</td><td>{@code view},{@code page?}</td>
+ * <td><code>{name}</code> / <code>{}</code></td><td>{@link SavedViewStore#setActive setActive} (or clear)</td></tr>
+ * <tr><td>{@code DELETE /juneau-saved-views/item}</td><td>{@code view},{@code page?},{@code name}</td>
+ * <td><code>{}</code></td><td>{@link SavedViewStore#delete delete}</td></tr>
+ * </table>
+ * <p>
+ * There is deliberately <b>no</b> {@code GET .../active} endpoint: the JS provider's {@code getActive} reads the
+ * active pointer off the {@code list} response (verified against slice-2 {@code juneau-config.js}), so a second
+ * endpoint would be dead wire. {@code activate} is a QUERY FLAG on {@code PUT .../item}, never a blob field —
+ * an activate boolean buried in the blob would wrongly persist into every saved view (§3.3). {@code name} is a
+ * QUERY param, never a path segment, because {@code %2F} in a name is not reliably decoded inside a path segment
+ * across servlet containers (§3.3). The container URL-decodes {@code view}/{@code page}/{@code name}; this mixin
+ * works with the DECODED strings and passes them straight into the store's structured tuple — it never
+ * {@code enc()}s them, because the store keys on separate fields, not a delimiter-joined string, so no
+ * delimiter-safety codec is needed (§3.3).
+ *
+ * <h5 class='section'>Identity: the request principal, always ({@code 401} on absent/blank)</h5>
+ * <p>
+ * EVERY op — including the safe {@code GET list} — resolves the caller from
+ * {@link RestRequest#getUserPrincipal()}{@code .getName()}, canonicalized (trimmed), and answers {@code 401} when it
+ * is absent or blank. A missing principal is NEVER folded into an {@code "anonymous"} bucket — that would let
+ * every unauthenticated caller share one saved-views namespace. Any client-supplied user id in a query param or
+ * body is IGNORED; identity comes only from the principal, which is the FIRST key dimension, so a user can only ever
+ * read/write inside their own namespace (no cross-user IDOR). <b>Residual risk:</b> identity is
+ * {@code Principal.getName()} alone, so two IdPs/tenants minting the same name would collide on one namespace; a
+ * multi-tenant consumer whose principal names are not globally unique MUST override {@link #savedViewStore()} to
+ * prefix a tenant discriminator (see {@link SavedViewStore}).
+ *
+ * <h5 class='section'>CSRF / Origin / Host: the boundary filter's job, not this mixin's</h5>
+ * <p>
+ * The CSRF token, {@code Origin} and {@code Host} checks on writes are enforced by
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter} (a {@code /*} servlet filter), which runs
+ * BEFORE any request reaches this mixin and rejects a forged write with {@code 403}/{@code 421}. This mixin does
+ * NOT re-implement CSRF: it has no handle to the process {@link org.apache.juneau.rest.server.filter.SynchronizerToken
+ * token} (only the filter does), and a second, independently-maintained CSRF control is exactly the kind of parallel
+ * mechanism that drifts out of agreement with the real one. As defense-in-depth AND so the check is exercisable
+ * without a filter chain (a {@code MockRestClient} does not run filters), this mixin DOES independently require
+ * {@code Content-Type: application/json} on every write and answers {@code 415} otherwise — the same status
+ * {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} returns, so the two layers agree.
+ *
+ * <h5 class='section'>Validation, quotas, and the HTTP-status → typed-error mapping</h5>
+ * <p>
+ * The mixin validates the decoded params and blob BEFORE touching the store, and the store enforces the count quotas
+ * atomically (see {@link SavedViewStore}). The statuses below are pinned to what the slice-2 JS provider's
+ * {@code classifyStatus} already assumes (a discrepancy the plan left open for slice 3): {@code 413}/{@code 429}/
+ * {@code 507}→{@code quota}, {@code 401}/{@code 403}→{@code unavailable}, {@code >=500}→{@code network},
+ * everything else→{@code malformed}. So {@code 409}/{@code 422} could NOT be used for a quota rejection (the JS
+ * would misfile them as {@code malformed}); the count quota is {@code 507} and the per-blob size cap is {@code 413}.
+ * <table class='styled'>
+ * <tr><th>Condition</th><th>HTTP</th><th>JS typed code</th></tr>
+ * <tr><td>Absent/blank principal</td><td>{@code 401}</td><td>{@code unavailable}</td></tr>
+ * <tr><td>Forged/absent CSRF or {@code Origin} (filter)</td><td>{@code 403}</td><td>{@code unavailable}</td></tr>
+ * <tr><td>Blank/reserved/over-length name, missing {@code view}, non-JSON body</td><td>{@code 400}</td>
+ * <td>{@code malformed}</td></tr>
+ * <tr><td>Non-{@code application/json} write</td><td>{@code 415}</td><td>{@code malformed}</td></tr>
+ * <tr><td>{@code load} of a missing name</td><td>{@code 404}</td>
+ * <td>{@code malformed} (the JS {@code load} maps {@code 404}→{@code null} before classifying)</td></tr>
+ * <tr><td>Blob exceeds {@link #MAX_BLOB_BYTES}</td><td>{@code 413}</td><td>{@code quota}</td></tr>
+ * <tr><td>Per-scope / per-user count quota</td><td>{@code 507}</td><td>{@code quota}</td></tr>
+ * </table>
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <ja>@Rest</ja>
+ * <jk>public class</jk> MyResource <jk>extends</jk> BasicRestServlet <jk>implements</jk> SavedViewsMixin {
+ * <jc>// Uses the non-durable in-memory default; override savedViewStore() for a durable backend.</jc>
+ * }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link SavedViewStore}
+ * <li class='jc'>{@link InMemorySavedViewStore}
+ * <li class='jc'>{@link SavedViewQuotaException}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface SavedViewsMixin {
+
+ /** The fixed URL path prefix for the saved-views endpoints (relative to the host mount). Not overridable. */
+ String SAVED_VIEWS_PREFIX = "/juneau-saved-views";
+
+ /** The {@code /item} sub-path (load/save/saveAndActivate/delete). */
+ String ITEM_PATH = SAVED_VIEWS_PREFIX + "/item";
+
+ /** The {@code /active} sub-path (set/clear the active pointer). */
+ String ACTIVE_PATH = SAVED_VIEWS_PREFIX + "/active";
+
+ /**
+ * Decoded saved-view-name cap.
+ *
+ * <p>
+ * A textually SEPARATE copy of {@code juneau-config.js}'s {@code MAX_NAME_LEN} (a stated, testable parity
+ * invariant — change one, change the other in the same commit) so the client and the mixin reject an
+ * over-length name at the exact same boundary.
+ */
+ int MAX_NAME_LEN = 128;
+
+ /**
+ * Per-blob size cap in bytes (64 KB), a STATELESS check enforced here on the mixin (§3.2).
+ *
+ * <p>
+ * Mirrors {@code juneau-config.js}'s {@code LOCALSTORAGE_MAX_BLOB_BYTES}. A blob whose UTF-8 length exceeds this
+ * is rejected with {@code 413} (the count quotas, which are stateful/race-sensitive, are the store's job and map
+ * to {@code 507} — see the interface javadoc).
+ */
+ long MAX_BLOB_BYTES = 64L * 1024;
+
+ /** The reserved saved-view name (case-insensitive) — it IS the catalog defaults and can never be saved. */
+ String RESERVED_DEFAULT_NAME = "Default";
+
+ /**
+ * Resolves the context-path-aware saved-views REST base for stamping {@code data-juneau-saved-views}
+ * on a table or page shell.
+ *
+ * <p>
+ * The JS server-provider reads that attribute via {@code table.closest('[data-juneau-saved-views]')} and
+ * fails closed when it is absent — never a hardcoded {@code /}-rooted fallback. The mount itself is
+ * fixed ({@link #SAVED_VIEWS_PREFIX}); only the resolved URL varies with the servlet context path.
+ *
+ * @param req The current request. Must not be <jk>null</jk>.
+ * @return The resolved URL of {@link #SAVED_VIEWS_PREFIX}.
+ */
+ static String resolvedBaseUrl(RestRequest req) {
+ return req.getUriResolver().resolve("servlet:" + SAVED_VIEWS_PREFIX);
+ }
+
+ /**
+ * Returns the {@link SavedViewStore} backing this resource's saved views.
+ *
+ * <p>
+ * Defaults to the process-wide, non-durable {@link InMemorySavedViewStore} (owner-locked in-memory default, item
+ * 8) so a host that overrides nothing still gets a working — if non-persistent — endpoint. This
+ * DIFFERS deliberately from {@link AsyncJobsMixin#asyncJobRegistry()}, which is abstract with no default. A
+ * consumer overrides this to supply a durable store.
+ *
+ * @return The store. Never <jk>null</jk>.
+ */
+ default SavedViewStore savedViewStore() {
+ return InMemorySavedViewStore.shared();
+ }
+
+ /**
+ * [GET /juneau-saved-views] — list a scope's saved views and its resolved active pointer.
+ *
+ * @param req The REST request.
+ * @return <code>{active:<name|null>, views:[{name}]}</code> with dangling-active already resolved.
+ */
+ @RestGet(path=SAVED_VIEWS_PREFIX, summary="List saved views", swagger=@OpSwagger(ignore=true))
+ default JsonMap listSavedViews(RestRequest req) {
+ var user = requirePrincipal(req);
+ var viewId = requireViewId(req);
+ var pageId = optionalPageId(req);
+ var listing = savedViewStore().list(user, pageId, viewId);
+ var views = new ArrayList<JsonMap>(listing.names().size());
+ listing.names().forEach(n -> views.add(JsonMap.of("name", n)));
+ return JsonMap.create().append("active", listing.active()).append("views", views);
+ }
+
+ /**
+ * [GET /juneau-saved-views/item] — load a single saved-view blob.
+ *
+ * @param req The REST request.
+ * @return The stored blob JSON.
+ */
+ @RestGet(path=ITEM_PATH, summary="Load a saved view", swagger=@OpSwagger(ignore=true))
+ default JsonMap loadSavedView(RestRequest req) {
+ var user = requirePrincipal(req);
+ var viewId = requireViewId(req);
+ var pageId = optionalPageId(req);
+ var name = requireValidName(nameParam(req));
+ var blob = savedViewStore().load(user, pageId, viewId, name);
+ if (blob == null)
+ throw new NotFound("No saved view named '%s'.", name);
+ return parseObject(blob, () -> new InternalServerError("Stored saved-view blob is not valid JSON."));
+ }
+
+ /**
+ * [PUT /juneau-saved-views/item] — save (or save+activate, with {@code ?activate=1}) a saved-view blob.
+ *
+ * @param req The REST request.
+ * @throws IOException If the request body could not be read.
+ */
+ @RestPut(path=ITEM_PATH, summary="Save a saved view", swagger=@OpSwagger(ignore=true))
+ default void saveSavedView(RestRequest req) throws IOException {
+ var user = requirePrincipal(req);
+ requireJsonContentType(req);
+ var viewId = requireViewId(req);
+ var pageId = optionalPageId(req);
+ var name = requireValidName(nameParam(req));
+ var blob = requireValidBlob(req.getContent().asString());
+ try {
+ if (isActivate(req))
+ savedViewStore().saveAndActivate(user, pageId, viewId, name, blob);
+ else
+ savedViewStore().save(user, pageId, viewId, name, blob);
+ } catch (SavedViewQuotaException e) {
+ throw new InsufficientStorage(e.getMessage());
+ }
+ }
+
+ /**
+ * [PUT /juneau-saved-views/active] — set the active pointer (body <code>{name}</code>) or clear it
+ * (body <code>{}</code> / <code>{name:null}</code>).
+ *
+ * @param req The REST request.
+ * @throws IOException If the request body could not be read.
+ */
+ @RestPut(path=ACTIVE_PATH, summary="Set or clear the active saved view", swagger=@OpSwagger(ignore=true))
+ default void setActiveSavedView(RestRequest req) throws IOException {
+ var user = requirePrincipal(req);
+ requireJsonContentType(req);
+ var viewId = requireViewId(req);
+ var pageId = optionalPageId(req);
+ var body = parseObject(bodyOrEmpty(req.getContent().asString()),
+ () -> new BadRequest("Request body is not a valid JSON object."));
+ var name = body.getString("name");
+ if (name != null)
+ name = requireValidName(name);
+ savedViewStore().setActive(user, pageId, viewId, name);
+ }
+
+ /**
+ * [DELETE /juneau-saved-views/item] — delete a saved view (a no-op if it does not exist).
+ *
+ * @param req The REST request.
+ */
+ @RestDelete(path=ITEM_PATH, summary="Delete a saved view", swagger=@OpSwagger(ignore=true))
+ default void deleteSavedView(RestRequest req) {
+ var user = requirePrincipal(req);
+ requireJsonContentType(req);
+ var viewId = requireViewId(req);
+ var pageId = optionalPageId(req);
+ var name = requireValidName(nameParam(req));
+ savedViewStore().delete(user, pageId, viewId, name);
+ }
+
+ // ------------------------------------------------------------------------------------------------------------------
+ // Helpers (static so they stay off the endpoint surface and are unit-testable)
+ // ------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Resolves the canonical (trimmed) principal name, or answers {@code 401} when it is absent or blank.
+ *
+ * @param req The REST request.
+ * @return The canonical principal name. Never <jk>null</jk> or blank.
+ */
+ static String requirePrincipal(RestRequest req) {
+ var p = req.getUserPrincipal();
+ var name = p == null ? null : p.getName();
+ if (name == null || name.isBlank())
+ throw new Unauthorized("Authentication is required to access saved views.");
+ return name.trim();
+ }
+
+ /**
+ * Resolves the required {@code view} query param, or answers {@code 400} when it is absent or blank.
+ *
+ * @param req The REST request.
+ * @return The view id.
+ */
+ static String requireViewId(RestRequest req) {
+ var v = req.getQueryParam("view").asString().orElse(null);
+ if (v == null || v.isBlank())
+ throw new BadRequest("Missing required query parameter 'view'.");
+ return v;
+ }
+
+ /**
+ * Resolves the optional {@code page} query param, treating absent/blank as {@code null} (a standalone view).
+ *
+ * @param req The REST request.
+ * @return The page id, or <jk>null</jk>.
+ */
+ static String optionalPageId(RestRequest req) {
+ var v = req.getQueryParam("page").asString().orElse(null);
+ return (v == null || v.isBlank()) ? null : v;
+ }
+
+ /**
+ * The raw (decoded) {@code name} query param, or <jk>null</jk> when absent.
+ *
+ * @param req The REST request.
+ * @return The name, or <jk>null</jk>.
+ */
+ static String nameParam(RestRequest req) {
+ return req.getQueryParam("name").asString().orElse(null);
+ }
+
+ /**
+ * Validates a saved-view name exactly as {@code juneau-config.js}'s {@code validateNameBasic} does — blank,
+ * over-{@link #MAX_NAME_LEN}, or reserved {@code Default} (case-insensitive) each answer {@code 400}.
+ *
+ * <p>
+ * Deliberately applies NO allowed-charset or encoded-segment-length check: those are localStorage-KEY concerns
+ * (the server keys on a structured tuple, so a {@code /} or {@code %} in a decoded name is harmless), and the
+ * slice-2 server provider itself validates with {@code validateNameBasic}, not the localStorage variant.
+ *
+ * @param name The decoded name. Can be <jk>null</jk>.
+ * @return The name unchanged (not trimmed — the raw name is the stored key).
+ */
+ static String requireValidName(String name) {
+ if (name == null || name.isBlank())
+ throw new BadRequest("Saved-view name must not be blank.");
+ if (name.length() > MAX_NAME_LEN)
+ throw new BadRequest("Saved-view name exceeds MAX_NAME_LEN (%s).", MAX_NAME_LEN);
+ if (RESERVED_DEFAULT_NAME.equalsIgnoreCase(name.trim()))
+ throw new BadRequest("'Default' is reserved and cannot be used as a saved-view name.");
+ return name;
+ }
+
+ /**
+ * Enforces {@code Content-Type: application/json} on a write, answering {@code 415} otherwise (defense-in-depth
+ * with the loopback boundary filter; see the interface javadoc). Base type only —
+ * {@code application/json;charset=utf-8} passes.
+ *
+ * @param req The REST request.
+ */
+ static void requireJsonContentType(RestRequest req) {
+ var ct = req.getContentType();
+ var semi = ct == null ? -1 : ct.indexOf(';');
+ var base = ct == null ? null : (semi < 0 ? ct : ct.substring(0, semi)).trim();
+ if (base == null || ! base.equalsIgnoreCase("application/json"))
+ throw new UnsupportedMediaType("A state-changing saved-views request must use content type 'application/json'.");
+ }
+
+ /**
+ * Validates the incoming blob: present, {@code <=} {@link #MAX_BLOB_BYTES} (else {@code 413}), and a valid JSON
+ * object (else {@code 400}). The size check runs BEFORE the parse so an over-large body is rejected without
+ * being parsed.
+ *
+ * @param blob The raw request body. Can be <jk>null</jk>.
+ * @return The blob unchanged.
+ */
+ static String requireValidBlob(String blob) {
+ if (blob == null || blob.isBlank())
+ throw new BadRequest("A saved-view blob body is required.");
+ if (blob.getBytes(StandardCharsets.UTF_8).length > MAX_BLOB_BYTES)
+ throw new PayloadTooLarge("Saved view exceeds the per-blob size cap (%s bytes).", MAX_BLOB_BYTES);
+ parseObject(blob, () -> new BadRequest("Saved-view blob body is not a valid JSON object."));
+ return blob;
+ }
+
+ /**
+ * Whether the {@code ?activate=1} flag routes a save to {@link SavedViewStore#saveAndActivate}. Any present,
+ * non-blank value activates; the fork is a QUERY flag, never a blob field (§3.3).
+ *
+ * @param req The REST request.
+ * @return <jk>true</jk> to save-and-activate.
+ */
+ static boolean isActivate(RestRequest req) {
+ var v = req.getQueryParam("activate").asString().orElse(null);
+ return v != null && ! v.isBlank();
+ }
+
+ /** A blank/absent body is treated as {@code "{}"} (the JS always sends a real body, but a parser must not 400 on empty). */
+ private static String bodyOrEmpty(String body) {
+ return (body == null || body.isBlank()) ? "{}" : body;
+ }
+
+ /** Parses {@code json} as a JSON object, throwing the supplied HTTP exception (not a raw {@link ParseException}) on failure. */
+ private static JsonMap parseObject(String json, java.util.function.Supplier<? extends RuntimeException> onError) {
+ try {
+ return JsonMap.ofString(json);
+ } catch (ParseException e) {
+ throw onError.get();
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
index c944676831..9b82d35b7f 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewDef.java
@@ -35,10 +35,11 @@ import org.apache.juneau.rest.server.datatables.*;
* sidecar the server writes and the {@code juneau-views.js} runtime consumes stay wire-stable.
*
* <p>
- * {@code details} (the row-details expander's field list) and {@code rowActions} (the per-row action menu; see
- * {@link #rowActions(RowAction...)}) are both implemented. The remaining reserved catalog fields ({@code catalog},
- * {@code format}, ...) are <b>not</b> part of this MVP builder and are therefore omitted from the serialized
- * contract (design doc §6.10 reserved stubs) — omitted, not emitted as {@code null}.
+ * {@code rowActions} (the per-row action menu; see {@link #rowActions(RowAction...)}) is a {@code VIEW_META} wire
+ * field. Row-details structure ({@link #details(RowDetailDef)}) is Java-only (a {@code <template>} sibling, never
+ * a sidecar key). The remaining reserved catalog fields ({@code catalog}, {@code format}, ...) are <b>not</b>
+ * part of this MVP builder and are therefore omitted from the serialized contract (design doc §6.10 reserved
+ * stubs) — omitted, not emitted as {@code null}.
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
@@ -57,16 +58,20 @@ import org.apache.juneau.rest.server.datatables.*;
* <li class='jc'>{@link RibbonAction}
* <li class='jc'>{@link RowClassRule}
* <li class='jc'>{@link RowAction}
+ * <li class='jc'>{@link RowDetailDef}
+ * <li class='jc'>{@link ColumnConfig}
* </ul>
*
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,rowActions,details,pollIntervalMs")
-@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,rowActions,pollIntervalMs,columnConfig")
+@SuppressWarnings({
+ "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
+})
public class ViewDef {
/** The frozen contract version. Bumped only on a breaking wire change. */
- public static final String CONTRACT_VERSION = "3";
+ public static final String CONTRACT_VERSION = "4";
/**
* The minimum honored polling interval, in milliseconds.
@@ -169,49 +174,6 @@ public class ViewDef {
}
}
- /**
- * A single field projected into the row-details expander's body.
- *
- * <p>
- * Client-rendered from the row's own already-fetched data by default — declaring a {@link #data} key
- * reads a value the row already carries, so expanding a row never issues a request of its own. An optional
- * server-render path for consumers that need it is not exposed by this type.
- *
- * @since 10.0.0
- */
- @BeanType(properties="data,title")
- public static class DetailDef {
-
- /** The row bean-property / JSON key this field reads from the row's own data. */
- public String data;
-
- /** The label shown next to the value in the expanded detail panel. */
- public String title;
-
- /**
- * Creates a detail field bound to the specified row data key.
- *
- * @param data The bean-property / JSON key. Must not be <jk>null</jk>.
- * @return A new {@link DetailDef}.
- */
- public static DetailDef of(String data) {
- var d = new DetailDef();
- d.data = data;
- return d;
- }
-
- /**
- * Sets the label shown next to the value in the expanded detail panel.
- *
- * @param value The new value.
- * @return This object.
- */
- public DetailDef title(String value) {
- title = value;
- return this;
- }
- }
-
/** The frozen contract version discriminator (always {@value #CONTRACT_VERSION} for this contract). */
public String contractVersion = CONTRACT_VERSION;
@@ -250,14 +212,11 @@ public class ViewDef {
public List<RowAction> rowActions;
/**
- * The row-details expander's field list; omitted from the wire when unset (no expander).
- *
- * <p>
- * Rendered client-side from the row's own already-fetched data by default — declaring this does not
- * add a request per expansion. Expanding a row does not survive a redraw: a sort, page change, search, or
- * {@link #poll(long)} tick collapses any expanded row.
+ * The row-details expander definition; <b>not</b> a {@code VIEW_META} wire field (omitted from
+ * {@code @BeanType}). When set, {@link ViewTable} emits a {@code <template data-juneau-row-detail>} sibling
+ * and the client expands via GET.
*/
- public List<DetailDef> details;
+ public RowDetailDef details;
/**
* The declared table-refresh polling interval, in milliseconds; omitted from the wire when unset (no polling).
@@ -265,6 +224,16 @@ public class ViewDef {
*/
public Long pollIntervalMs;
+ /**
+ * The opt-in column-configurator settings; omitted from the wire when unset (no chooser).
+ *
+ * <p>
+ * Presence alone — even an empty {@link ColumnConfig}) — enables the View-only column chooser for
+ * this view; there is no separate {@code enabled} flag. Appended last in the {@code @BeanType} order so an
+ * existing golden fixture only ever gains a trailing key when this is set.
+ */
+ public ColumnConfig columnConfig;
+
/**
* The row bean type, retained (non-serialized) so {@link #build()} can auto-seed {@link #columns} from
* {@link DataTablesColumns#of(Class)} when no explicit columns were declared. Not a wire field.
@@ -407,20 +376,31 @@ public class ViewDef {
}
/**
- * Declares the row-details expander's field list.
+ * Declares the row-details expander.
*
* <p>
- * Rendered client-side ({@code juneau-views.js}) from the row's own data by default; see {@link #details} for
- * the no-extra-request and collapse-on-redraw notes.
+ * Structure is a server-emitted {@code <template>}; field values arrive from {@link RowDetailDef#endpoint}.
+ * See {@link #details} — this is not a {@code VIEW_META} JSON field.
*
- * @param value The detail fields, in display order.
+ * @param value The row-detail definition. May be <jk>null</jk> (no expander).
* @return This object.
*/
- public ViewDef details(DetailDef...value) {
- details = l(value);
+ public ViewDef details(RowDetailDef value) {
+ details = value;
return this;
}
+ /**
+ * Fail-closed bean validation. When {@link #details} is set, delegates to
+ * {@link RowDetailDef#validate(List)} against this view's {@link #rowActions}.
+ *
+ * @throws IllegalArgumentException If this view (or its nested details) is not well-formed.
+ */
+ public void validate() {
+ if (details != null)
+ details.validate(rowActions);
+ }
+
/**
* Declares the per-row action menu.
*
@@ -437,6 +417,18 @@ public class ViewDef {
return this;
}
+ /**
+ * Enables the View-only column chooser for this view by setting its column-configurator settings.
+ *
+ * @param value The column-configurator settings. Presence alone enables the chooser; an empty
+ * {@link ColumnConfig#create()} suffices.
+ * @return This object.
+ */
+ public ViewDef columnConfig(ColumnConfig value) {
+ columnConfig = value;
+ return this;
+ }
+
/**
* Finalizes the builder and returns the wire-ready {@link ViewDef}.
*
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
index 272c6fb85b..3a9d61ed60 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java
@@ -26,6 +26,7 @@ import org.apache.juneau.bean.html5.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.filter.*;
import jakarta.servlet.http.*;
@@ -118,6 +119,39 @@ public class ViewTable {
/** Prefix of the bulk-actions sidecar {@code <script>} element id: {@code juneau-view-bulk:<viewId>}. */
public static final String BULK_SIDECAR_ID_PREFIX = "juneau-view-bulk:";
+ /**
+ * Attribute the resolved, context-path-aware saved-views REST base is stamped onto on the wrapper
+ * {@code <div>} (standalone tables) so {@code juneau-config.js} can locate it via
+ * {@code table.closest('[data-juneau-saved-views]')}.
+ *
+ * <p>
+ * The mount is fixed at {@link SavedViewsMixin#SAVED_VIEWS_PREFIX}; only the resolved URL varies with the
+ * servlet context path. Page-embedded tables find the same attribute on the enclosing {@link PageTable}
+ * shell instead. Absent/blank means the JS server-provider is unavailable for this table (fail closed).
+ */
+ public static final String SAVED_VIEWS_ATTR = "data-juneau-saved-views";
+
+ /** Marker attribute on the row-detail {@code <template>} sibling of the view table. */
+ public static final String DETAIL_TEMPLATE_ATTR = "data-juneau-row-detail";
+
+ /** Attribute carrying {@link RowDetailDef#CONTRACT_VERSION} on the row-detail template. */
+ public static final String DETAIL_CONTRACT_ATTR = "data-juneau-detail-contract";
+
+ /** Attribute carrying the server-stamped expand GET path template on the row-detail template. */
+ public static final String DETAIL_URL_ATTR = "data-juneau-detail-url";
+
+ /** Attribute carrying a {@link DetailSection#id} on each {@code <section>}. */
+ public static final String DETAIL_SECTION_ATTR = "data-juneau-detail-section";
+
+ /** Attribute carrying a {@link DetailField#data} key on each empty field slot. */
+ public static final String DETAIL_FIELD_ATTR = "data-juneau-field";
+
+ /** Attribute carrying an {@link org.apache.juneau.rest.server.widgets.ActionRef} id on a write button. */
+ public static final String DETAIL_ACTION_ATTR = "data-juneau-action";
+
+ /** Attribute carrying a {@link org.apache.juneau.rest.server.widgets.SafeAction#wire()} token. */
+ public static final String DETAIL_SAFE_ATTR = "data-juneau-safe";
+
private ViewTable() {}
/**
@@ -161,7 +195,7 @@ public class ViewTable {
* @return A new {@link Div} carrying the {@code <table data-juneau-view>} and the JSON sidecar.
*/
public static Div of(HttpServletRequest req, ViewDef viewDef) {
- return of(MarshallingContext.DEFAULT, viewDef, null, csrfToken(req));
+ return of(MarshallingContext.DEFAULT, viewDef, null, csrfToken(req), null, null, savedViewsBase(req));
}
/**
@@ -179,7 +213,7 @@ public class ViewTable {
* JSON sidecar.
*/
public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows) {
- return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req));
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), null, null, savedViewsBase(req));
}
/**
@@ -205,7 +239,7 @@ public class ViewTable {
public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, SelectionDef selection) {
if (selection == null)
throw iaex("selection must not be null; use one of the other of(...) overloads for a table with no selection.");
- return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), selection, null);
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), selection, null, savedViewsBase(req));
}
/**
@@ -230,7 +264,8 @@ public class ViewTable {
public static Div of(HttpServletRequest req, ViewDef viewDef, Collection<?> rows, BulkMutateDef bulkMutate) {
if (bulkMutate == null)
throw iaex("bulkMutate must not be null; use the SelectionDef overload for selection without bulk mutation.");
- return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), bulkMutate.selection(), bulkMutate);
+ return of(MarshallingContext.DEFAULT, viewDef, rows, csrfToken(req), bulkMutate.selection(), bulkMutate,
+ savedViewsBase(req));
}
/**
@@ -325,6 +360,33 @@ public class ViewTable {
*/
public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken,
SelectionDef selection, BulkMutateDef bulkMutate) {
+ return of(ctx, viewDef, rows, csrfToken, selection, bulkMutate, null);
+ }
+
+ /**
+ * Builds the view-table shell, optionally stamping a CSRF token, selection, bulk-mutate, AND a resolved
+ * saved-views REST base onto the wrapper {@code <div>}.
+ *
+ * <p>
+ * A non-blank {@code savedViewsBase} is stamped into {@link #SAVED_VIEWS_ATTR} on the wrapper (not the
+ * {@code <table>}) so page-embedded tables still discover a page-shell stamp via {@code closest(...)}
+ * without this emitter having to thread a per-child request into {@link PageTable}.
+ *
+ * @param ctx The marshalling context used to read bean-property cell values. Must not be <jk>null</jk>.
+ * @param viewDef The built view definition. Must not be <jk>null</jk>.
+ * @param rows The rows to render (beans or maps). Can be <jk>null</jk> (server-side mode) or empty.
+ * @param csrfToken The CSRF token to embed, or <jk>null</jk>/blank to embed none.
+ * @param selection The selection opt-in, or <jk>null</jk> for none. When {@code bulkMutate} is non-<jk>null</jk>,
+ * must be either <jk>null</jk> or exactly {@code bulkMutate.selection()}.
+ * @param bulkMutate The bulk-mutate opt-in, or <jk>null</jk> for none.
+ * @param savedViewsBase The already-resolved saved-views REST base, or <jk>null</jk>/blank to stamp none.
+ * @return A new {@link Div} carrying the table, sidecars, and optional {@link #SAVED_VIEWS_ATTR} stamp.
+ * @throws IllegalArgumentException If {@code selection} and {@code bulkMutate} are both non-<jk>null</jk> but
+ * {@code selection} is not {@code bulkMutate.selection()}.
+ */
+ public static Div of(MarshallingContext ctx, ViewDef viewDef, Collection<?> rows, String csrfToken,
+ SelectionDef selection, BulkMutateDef bulkMutate, String savedViewsBase) {
+ viewDef.validate();
if (bulkMutate != null) {
if (selection != null && selection != bulkMutate.selection())
throw iaex("selection must be exactly bulkMutate.selection() when both are supplied; "
@@ -384,6 +446,8 @@ public class ViewTable {
var children = new ArrayList<>();
children.add(table);
+ if (viewDef.details != null)
+ children.add(emitDetailTemplate(viewDef));
// Bulk-mutate opt-in: its OWN independently-versioned sidecar (BulkMutateDef.CONTRACT_VERSION), never
// merged into VIEW_META - a version bump here can never force a ViewDef.CONTRACT_VERSION bump (R2).
@@ -395,7 +459,76 @@ public class ViewTable {
}
children.add(sidecar);
- return div(children.toArray());
+ var wrapper = div(children.toArray());
+ if (savedViewsBase != null && ! savedViewsBase.isBlank())
+ wrapper.attr(SAVED_VIEWS_ATTR, savedViewsBase);
+ return wrapper;
+ }
+
+ /**
+ * Emits the one {@code <template data-juneau-row-detail>} sibling: empty field slots, {@link ActionRef}
+ * buttons initially disabled, {@link org.apache.juneau.rest.server.widgets.SafeAction#COLLAPSE} enabled.
+ * Labels are HtmlBuilder text children (never poured in as markup).
+ */
+ private static Template emitDetailTemplate(ViewDef viewDef) {
+ var d = viewDef.details;
+ var sections = new ArrayList<>();
+ for (var s : d.sections) {
+ var kids = new ArrayList<>();
+ kids.add(h2(s.title == null || s.title.isBlank() ? s.id : s.title)
+ .class_("juneau-view-detail-section-title"));
+ if (s.actions != null && s.actions.items != null && !s.actions.items.isEmpty())
+ kids.add(emitActionBar(s.actions, viewDef.rowActions));
+ var fieldSlots = new ArrayList<>();
+ if (s.fields != null) {
+ for (var f : s.fields) {
+ fieldSlots.add(div(
+ div(f.title == null || f.title.isBlank() ? f.data : f.title)
+ .class_("juneau-view-detail-field-title"),
+ div().attr(DETAIL_FIELD_ATTR, f.data).class_("juneau-view-detail-field-value")
+ ).class_("juneau-view-detail-field"));
+ }
+ }
+ kids.add(div(fieldSlots.toArray())
+ .class_("juneau-view-detail-fields")
+ .attr("style", "grid-template-columns:repeat(" + s.columns + ",minmax(0,1fr))"));
+ sections.add(section(kids.toArray())
+ .attr(DETAIL_SECTION_ATTR, s.id)
+ .class_("juneau-view-detail-section"));
+ }
+ return template()
+ .attr(DETAIL_TEMPLATE_ATTR, "1")
+ .attr(DETAIL_CONTRACT_ATTR, RowDetailDef.CONTRACT_VERSION)
+ .attr(DETAIL_URL_ATTR, d.endpoint)
+ .children(sections.toArray());
+ }
+
+ private static Div emitActionBar(org.apache.juneau.rest.server.widgets.ActionBar bar, List<RowAction> rowActions) {
+ var buttons = new ArrayList<>();
+ for (var item : bar.items) {
+ if (item instanceof org.apache.juneau.rest.server.widgets.ActionRef ar) {
+ var label = actionLabel(ar.id, rowActions);
+ buttons.add(button("button", label)
+ .attr(DETAIL_ACTION_ATTR, ar.id)
+ .attr("class", "juneau-view-detail-action")
+ .disabled(true));
+ } else if (item instanceof org.apache.juneau.rest.server.widgets.SafeAction sa) {
+ buttons.add(button("button", sa.label())
+ .attr(DETAIL_SAFE_ATTR, sa.wire())
+ .attr("class", "juneau-view-detail-action juneau-view-detail-safe"));
+ }
+ }
+ return div(buttons.toArray()).class_("juneau-view-detail-actions");
+ }
+
+ private static String actionLabel(String id, List<RowAction> rowActions) {
+ if (rowActions != null) {
+ for (var a : rowActions) {
+ if (a != null && id.equals(a.id) && a.label != null && !a.label.isBlank())
+ return a.label;
+ }
+ }
+ return id;
}
/** Reads the boundary-stamped CSRF token off the request, or {@code null} when absent. */
@@ -406,6 +539,16 @@ public class ViewTable {
return v == null ? null : v.toString();
}
+ /**
+ * Resolves the saved-views REST base when {@code req} is a {@link RestRequest}; otherwise {@code null}
+ * (a plain {@link HttpServletRequest} has no URI resolver).
+ */
+ private static String savedViewsBase(HttpServletRequest req) {
+ if (!(req instanceof RestRequest rr))
+ return null;
+ return SavedViewsMixin.resolvedBaseUrl(rr);
+ }
+
/** Reads a column value from a row: a direct key lookup for a {@code Map}, a bean-property read otherwise. */
private static Object value(MarshallingContext ctx, Object row, String key) {
if (row instanceof java.util.Map<?,?> m)
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index 524527f45a..71f611a693 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -22,8 +22,9 @@ import org.apache.juneau.rest.server.util.*;
/**
* Mixin that serves the first-party rich-view runtime assets — {@code juneau-views.js},
- * {@code juneau-ribbon.js}, {@code juneau-renders.js}, {@code juneau-views.css}, and the opt-in
- * {@code juneau-pages.js} tabs/sub-tabs page runtime — each at its stable path (design doc §6.1).
+ * {@code juneau-ribbon.js}, {@code juneau-renders.js}, {@code juneau-views.css}, the opt-in
+ * {@code juneau-pages.js} tabs/sub-tabs page runtime, and the opt-in {@code juneau-config.js}/
+ * {@code juneau-config.css} column-chooser runtime — each at its stable path (design doc §6.1).
*
* <p>
* Compose into a host resource via {@link Rest#mixins() @Rest(mixins=ViewsMixin.class)}; the asset URLs then become
@@ -99,6 +100,17 @@ public class ViewsMixin {
*/
public static final String PAGES_JS_PATH = "/juneau-pages.js";
+ /**
+ * The URL path at which the opt-in column-chooser runtime is served (relative to the host mount). A
+ * consumer adds this {@code <script>} after {@code juneau-views.js}; a non-configurable table never loads it.
+ */
+ public static final String CONFIG_JS_PATH = "/juneau-config.js";
+
+ /**
+ * The URL path at which the opt-in column-chooser stylesheet is served (relative to the host mount).
+ */
+ public static final String CONFIG_CSS_PATH = "/juneau-config.css";
+
/**
* The frozen {@code VIEW_META} contract-version handshake constant, kept in one source of truth with the value the
* model emits ({@link ViewDef#CONTRACT_VERSION}).
@@ -123,6 +135,12 @@ public class ViewsMixin {
/** Classpath location of the shipped page runtime. */
static final String PAGES_JS_RESOURCE = "/org/apache/juneau/views/juneau-pages.js";
+ /** Classpath location of the shipped column-chooser runtime. */
+ static final String CONFIG_JS_RESOURCE = "/org/apache/juneau/views/juneau-config.js";
+
+ /** Classpath location of the shipped column-chooser stylesheet. */
+ static final String CONFIG_CSS_RESOURCE = "/org/apache/juneau/views/juneau-config.css";
+
/** Content type emitted for the JavaScript assets. */
static final String JS_CONTENT_TYPE = "text/javascript;charset=utf-8";
@@ -229,13 +247,44 @@ public class ViewsMixin {
return serve(PAGES_JS_RESOURCE, JS_CONTENT_TYPE);
}
+ /**
+ * [GET /juneau-config.js] — serve the opt-in column-chooser / saved-views runtime.
+ *
+ * @return The column-chooser runtime as a JavaScript {@link HttpResource}.
+ */
+ @RestGet(
+ path=CONFIG_JS_PATH,
+ summary="Juneau rich-view column-chooser runtime",
+ description="First-party, opt-in JavaScript that renders the View-tab column chooser and saved-views persistence for a columnConfig view.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getConfigScript() {
+ return serve(CONFIG_JS_RESOURCE, JS_CONTENT_TYPE);
+ }
+
+ /**
+ * [GET /juneau-config.css] — serve the opt-in column-chooser stylesheet.
+ *
+ * @return The column-chooser stylesheet as a CSS {@link HttpResource}.
+ */
+ @RestGet(
+ path=CONFIG_CSS_PATH,
+ summary="Juneau rich-view column-chooser stylesheet",
+ description="First-party, opt-in CSS for the View-tab column chooser dialog.",
+ swagger=@OpSwagger(ignore=true)
+ )
+ public HttpResource getConfigStylesheet() {
+ return serve(CONFIG_CSS_RESOURCE, CSS_CONTENT_TYPE);
+ }
+
/**
* Returns the servlet-relative URL for a served asset, carrying a {@code ?v=<buildVersion>-<hash8>} content-
* sensitive cache-buster suitable for a page's {@code head=} block (see the class Javadoc's cache-busting
* section for why the buster is content- rather than purely version-keyed).
*
* @param path One of the asset path constants ({@link #VIEWS_JS_PATH}, {@link #RIBBON_JS_PATH},
- * {@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH}).
+ * {@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH},
+ * {@link #CONFIG_JS_PATH}, {@link #CONFIG_CSS_PATH}).
* @return The servlet-relative asset URL with the version+content-hash cache-buster appended.
*/
public static String viewAssetUrl(String path) {
@@ -257,7 +306,8 @@ public class ViewsMixin {
*
* @param req The current request, supplying the context path/mount to resolve against.
* @param path One of the asset path constants ({@link #VIEWS_JS_PATH}, {@link #RIBBON_JS_PATH},
- * {@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH}).
+ * {@link #RENDERS_JS_PATH}, {@link #VIEWS_CSS_PATH}, {@link #ICONS_JS_PATH}, {@link #PAGES_JS_PATH},
+ * {@link #CONFIG_JS_PATH}, {@link #CONFIG_CSS_PATH}).
* @return The absolute asset URL with the version+content-hash cache-buster appended.
*/
public static String viewAssetUrl(RestRequest req, String path) {
@@ -277,6 +327,8 @@ public class ViewsMixin {
if (VIEWS_CSS_PATH.equals(path)) return VIEWS_CSS_RESOURCE;
if (ICONS_JS_PATH.equals(path)) return ICONS_JS_RESOURCE;
if (PAGES_JS_PATH.equals(path)) return PAGES_JS_RESOURCE;
+ if (CONFIG_JS_PATH.equals(path)) return CONFIG_JS_RESOURCE;
+ if (CONFIG_CSS_PATH.equals(path)) return CONFIG_CSS_RESOURCE;
throw new IllegalArgumentException("Unknown asset path: " + path);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.css b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.css
new file mode 100644
index 0000000000..9ab345dc9b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.css
@@ -0,0 +1,138 @@
+/*
+ * 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.
+ */
+
+/*
+ * juneau-config.css - View-tab column chooser (opt-in; loaded after juneau-views.css).
+ * Neutral shape/layout only, no palette color; a host's chrome.css MAY theme these same classes.
+ */
+
+.juneau-config-dialog-backdrop {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.35);
+ z-index: 1100;
+}
+
+.juneau-config-dialog {
+ min-width: 32em;
+ max-width: 90vw;
+ max-height: 90vh;
+ overflow: auto;
+ padding: 1em 1.25em;
+ border: 1px solid;
+ border-radius: 6px;
+ background: #fff;
+}
+
+.juneau-config-title {
+ margin: 0 0 0.5em;
+ font-size: 1.1em;
+}
+
+.juneau-config-tabs {
+ display: flex;
+ gap: 0.25em;
+ margin-bottom: 0.75em;
+ border-bottom: 1px solid;
+}
+
+.juneau-config-tab {
+ border: 1px solid;
+ border-bottom: none;
+ border-radius: 4px 4px 0 0;
+ padding: 0.25em 0.75em;
+ background: transparent;
+ cursor: default;
+}
+
+.juneau-config-tab-active {
+ font-weight: 600;
+}
+
+.juneau-config-saved-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4em;
+ margin-bottom: 0.5em;
+}
+
+.juneau-config-view-select {
+ min-width: 10em;
+}
+
+.juneau-config-dirty {
+ font-size: 0.85em;
+}
+
+.juneau-config-status {
+ margin: 0 0 0.5em;
+ font-size: 0.9em;
+}
+
+.juneau-config-status-error {
+ font-weight: 600;
+}
+
+.juneau-config-col-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35em;
+ margin: 0.5em 0 0.75em;
+ max-height: 50vh;
+ overflow: auto;
+}
+
+.juneau-config-col-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4em;
+}
+
+.juneau-config-col-name {
+ min-width: 7em;
+ flex: 1 1 7em;
+}
+
+.juneau-config-col-pinned {
+ font-size: 0.8em;
+ opacity: 0.75;
+}
+
+.juneau-config-col-move {
+ padding: 0.15em 0.45em;
+}
+
+.juneau-config-col-label {
+ min-width: 8em;
+ flex: 1 1 8em;
+}
+
+.juneau-config-col-format {
+ min-width: 7em;
+}
+
+.juneau-config-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5em;
+ margin-top: 0.5em;
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.js
new file mode 100644
index 0000000000..67db60b5ca
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-config.js
@@ -0,0 +1,1765 @@
+/*
+ * 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.
+ */
+
+/*
+ * juneau-config.js - opt-in column-configurator persistence + pure config-application layer (TODO-444).
+ *
+ * This file is loaded AFTER juneau-views.js (a template <script> include the consumer adds, exactly like
+ * juneau-pages.js - NOT a dynamic fetch; see design §4.1) and extends the SAME window.JuneauViews namespace. A
+ * non-configurable table never loads it and pays nothing.
+ *
+ * LANDED SLICES:
+ * - Slice 2: async persistence SPI, strict localStorage key codec (enc/dec), localStorage + server providers.
+ * - Slice 3: (Java) SavedViewsMixin + SavedViewStore; this file's server-provider HTTP mapping stays as-is.
+ * - Slice 4: pure DOM-free config-application layer - computeEffectiveColumns / validateView / saved-view
+ * (de)serialization / dtIndex (the INDEX MODEL only).
+ * - Slice 5: resolveActiveView (awaited before first draw) + applyView (programmatic reinit entry point).
+ * - Slice 6: View-tab chooser UI, XSS textContent painting, mountChooser seam.
+ * - Slice 7: ribbon toggle persistence routes through this file's synchronous getItem/setItem
+ * (same exact keys as juneau-ribbon.js; never a Promise).
+ *
+ * Every provider implements the SAME seven-method async contract (Promise-based; TODO-444 §3.2/§3.3, refined by
+ * the round-3 saveAndActivate addendum): list/load/save/saveAndActivate/setActive/delete/getActive. Each method
+ * takes the LIVE table element as its first argument (never a raw pageId/viewId string) - both providers derive
+ * their own scope from it, mirroring how the pageId is discovered (table.closest('[data-juneau-page]')). This is
+ * a deliberate refinement of the design doc's illustrative §3.2 pseudocode (a generic string-keyed load/save/
+ * remove/list(prefix) KV interface): the server provider cannot honor a generic opaque string key - it keys on
+ * the STRUCTURED tuple (principal, pageId, viewId, name) the REST endpoint expects (§3.3) - so both providers are
+ * built against the domain-shaped SavedViewStore method set instead (list/load/save/saveAndActivate/setActive/
+ * delete), with getActive added as a client-side convenience derived uniformly from list() (see the "PUBLIC API"
+ * section below).
+ */
+(function () {
+ "use strict";
+
+ const NS = window.JuneauViews = window.JuneauViews || {};
+
+ // ==================================================================================================================
+ // PURE LOGIC LAYER (no DOM, no jQuery, no DataTables, no localStorage/fetch - plain data in, plain data out)
+ // ==================================================================================================================
+
+ /**
+ * Blob schema version every saved-view blob carries (§3.2) - lets both backends refuse an unknown/newer shape
+ * deterministically rather than guess at it.
+ */
+ const CURRENT_SCHEMA_VERSION = 1;
+
+ /** Decoded-name cap (§3.1) - named so JS and the slice-3 Java mixin reject at the exact same boundary. */
+ const MAX_NAME_LEN = 128;
+
+ /**
+ * Encoded key-SEGMENT cap (§3.1) - a DISTINCT number from {@link #MAX_NAME_LEN}: a 128-multibyte-char name can
+ * enc() to far more than 128 bytes, so the encoded cap must be named and checked independently or JS/Java could
+ * drift on where a pathological name is actually rejected. localStorage-only (the server keys on a structured
+ * tuple and never enc()s a name - see createServerProvider below).
+ */
+ const MAX_ENCODED_SEGMENT_LEN = 512;
+
+ /**
+ * localStorage's OWN copy of the default per-(user,page,view)/per-blob/per-user bounds (§3.2) - a deliberate,
+ * textually SEPARATE copy of the same three numbers the slice-3 Java SavedViewStore default will enforce. Per
+ * the plan's should-fix: the client and server sides must never share one source-of-truth across the JS/Java
+ * boundary (there isn't one), so keeping two independently-named copies turns "these must stay equal" into a
+ * stated, testable invariant instead of an accidental one. If you change one of these three numbers, change
+ * its Java mirror in the SAME commit.
+ */
+ const LOCALSTORAGE_MAX_VIEWS_PER_SCOPE = 50; // mirrors the server default MAX_VIEWS_PER_SCOPE
+ const LOCALSTORAGE_MAX_BLOB_BYTES = 64 * 1024; // mirrors the server default per-blob cap (64 KB)
+ const LOCALSTORAGE_MAX_VIEWS_PER_USER = 500; // mirrors the server default MAX_VIEWS_PER_USER (aggregate)
+
+ /** The shell attribute juneau-pages.js/PageTable stamp the page id onto (§3.1) - read via closest(...). */
+ const PAGE_ID_ATTR = "data-juneau-page";
+
+ /**
+ * The shell attribute the slice-3 emitter (ViewTable/PageTable) stamps the resolved, context-path-aware
+ * saved-views REST base onto (§3.3 DECISION option (b)) - read via closest(...), mirroring PAGE_ID_ATTR
+ * exactly. Absent/blank means the server-persisted provider is UNAVAILABLE for this table - never a
+ * hardcoded "/"-rooted fallback path.
+ */
+ const SAVED_VIEWS_BASE_ATTR = "data-juneau-saved-views";
+
+ /** The attribute juneau-views.js reads a table's own stable view id off (reused here - no new attribute). */
+ const VIEW_ID_ATTR = "data-juneau-view";
+
+ const KEY_ROOT = "juneau.view.";
+
+ /** Blank/absent/whitespace-only - mirrors juneau-views.js's own isBlankToken so the two files agree by construction. */
+ function isBlank(v) {
+ return v == null || String(v).trim() === "";
+ }
+
+ /** Builds a typed persistence-SPI error (§3.2: {code:'quota'|'unavailable'|'network'|'malformed', message}). */
+ function typedError(code, message) {
+ const e = new Error(message);
+ e.code = code;
+ return e;
+ }
+
+ function malformedError(message) { return typedError("malformed", message); }
+ function quotaError(message) { return typedError("quota", message); }
+ function unavailableError(message) { return typedError("unavailable", message); }
+ function networkError(message) { return typedError("network", message); }
+
+ /**
+ * Normalizes ANY thrown value into the typed {code,message} shape (§3.2 "typed failure, never silent
+ * success") - a value already carrying one of the four frozen codes passes through; a native localStorage
+ * quota/private-mode DOMException maps to 'quota'; anything else is 'unavailable' rather than swallowed.
+ */
+ function toTypedError(e) {
+ if (e && (e.code === "quota" || e.code === "unavailable" || e.code === "network" || e.code === "malformed"))
+ return { code: e.code, message: e.message };
+ if (e && (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014))
+ return { code: "quota", message: "localStorage quota exceeded or blocked (private mode)" };
+ return { code: "unavailable", message: (e && e.message) ? e.message : String(e) };
+ }
+
+ /**
+ * Manual, dependency-free UTF-8 byte encoder (deliberately NOT TextEncoder - keeps this pure-logic function
+ * testable/portable with zero Web-API surface, matching the module's Option-B "no DOM/library" convention).
+ * Surrogate pairs are combined via codePointAt/advancing the loop index, so a >0xFFFF code point emits its
+ * correct 4-byte sequence rather than two separate (invalid) 3-byte ones.
+ */
+ function utf8Encode(str) {
+ const bytes = [];
+ for (let i = 0; i < str.length; i++) {
+ const code = str.codePointAt(i);
+ if (code > 0xFFFF) i++; // this code point consumed a trailing low surrogate too - skip it
+ if (code < 0x80) {
+ bytes.push(code);
+ } else if (code < 0x800) {
+ bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
+ } else if (code < 0x10000) {
+ bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
+ } else {
+ bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
+ }
+ }
+ return bytes;
+ }
+
+ /** The inverse of {@link #utf8Encode} - rejects (throws a 'malformed' error) any invalid UTF-8 byte sequence. */
+ function utf8Decode(bytes) {
+ let out = "";
+ let i = 0;
+ while (i < bytes.length) {
+ const b0 = bytes[i];
+ let cp, len;
+ if (b0 < 0x80) { cp = b0; len = 1; }
+ else if ((b0 & 0xE0) === 0xC0) { cp = b0 & 0x1F; len = 2; }
+ else if ((b0 & 0xF0) === 0xE0) { cp = b0 & 0x0F; len = 3; }
+ else if ((b0 & 0xF8) === 0xF0) { cp = b0 & 0x07; len = 4; }
+ else throw malformedError("invalid UTF-8 lead byte in encoded segment");
+ if (i + len > bytes.length) throw malformedError("truncated UTF-8 sequence in encoded segment");
+ for (let j = 1; j < len; j++) {
+ const b = bytes[i + j];
+ if ((b & 0xC0) !== 0x80) throw malformedError("invalid UTF-8 continuation byte in encoded segment");
+ cp = (cp << 6) | (b & 0x3F);
+ }
+ out += String.fromCodePoint(cp);
+ i += len;
+ }
+ return out;
+ }
+
+ /** The exact allowed raw (never-escaped) segment alphabet (§3.1) - a single ASCII char test. */
+ function isSafeSegmentChar(ch) {
+ return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-";
+ }
+
+ function hex2Upper(b) {
+ const h = b.toString(16).toUpperCase();
+ return h.length < 2 ? "0" + h : h;
+ }
+
+ /**
+ * Strict segment encoder (§3.1 finding - Blocker: key codec). Percent-encodes every UTF-8 byte OUTSIDE the
+ * alphabet [A-Za-z0-9_-] as "%HH" with UPPERCASE hex - deliberately NOT encodeURIComponent (which leaves
+ * ".", "!", "~", "*", "'", "(", ")" and space unescaped; an unescaped "." would climb this grammar's "."
+ * path separator). This is the STORAGE-KEY encoder only - never the wire/query-string encoder (see
+ * createServerProvider, which uses ordinary encodeURIComponent instead and never calls this function).
+ */
+ function encSegment(s) {
+ const str = s == null ? "" : String(s);
+ const bytes = utf8Encode(str);
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ const ch = b < 128 ? String.fromCharCode(b) : null;
+ out += (ch != null && isSafeSegmentChar(ch)) ? ch : ("%" + hex2Upper(b));
+ }
+ return out;
+ }
+
+ /**
+ * The exact inverse of {@link #encSegment} - throws a typed 'malformed' error (never a lenient best-effort
+ * decode) on: a "%" not followed by exactly two hex digits, lowercase hex digits (the canon is uppercase), or
+ * any RAW character outside the safe alphabet that was not percent-encoded (a spec-compliant encoder would
+ * never have left it raw, so accepting it here would silently accept a non-canonical key).
+ */
+ function decSegment(s) {
+ const str = s == null ? "" : String(s);
+ const bytes = [];
+ let i = 0;
+ while (i < str.length) {
+ const ch = str.charAt(i);
+ if (ch === "%") {
+ const hex = str.substr(i + 1, 2);
+ if (!/^[0-9A-F]{2}$/.test(hex))
+ throw malformedError("non-canonical percent-encoding at offset " + i + " in '" + str + "'");
+ bytes.push(parseInt(hex, 16));
+ i += 3;
+ } else {
+ if (!isSafeSegmentChar(ch))
+ throw malformedError("illegal raw character '" + ch + "' at offset " + i + " in '" + str + "'");
+ bytes.push(ch.charCodeAt(0));
+ i += 1;
+ }
+ }
+ return utf8Decode(bytes);
+ }
+
+ /**
+ * Builds the scope segment (§3.1): {@code enc(pageId)~enc(viewId)} for a page-embedded view, or bare
+ * {@code enc(viewId)} for a standalone one. The "~" join is outside the allowed segment alphabet, so it can
+ * never appear INSIDE an encoded segment - two encoded segments joined by it can always be split back apart
+ * unambiguously. Page-qualification is load-bearing (not "view-only for simplicity"): two different pages
+ * embedding a view with the same ViewDef.id would otherwise collide on one shared saved-views namespace.
+ */
+ function scopeKey(pageId, viewId) {
+ return isBlank(pageId) ? encSegment(viewId) : (encSegment(pageId) + "~" + encSegment(viewId));
+ }
+
+ function viewsPrefixKey(scope) { return KEY_ROOT + scope + ".columns.views."; }
+ function viewKeyFor(scope, encodedName) { return viewsPrefixKey(scope) + encodedName; }
+ function activeKeyFor(scope) { return KEY_ROOT + scope + ".columns.active"; }
+
+ /** "Default" (case-insensitive) is reserved - it IS the catalog defaults and can never be a saved-view name. */
+ function isReservedName(name) {
+ return String(name).trim().toLowerCase() === "default";
+ }
+
+ /**
+ * The wire-side name check (§3.1/§3.2): blank/reserved/too-long - shared by BOTH providers, since the server
+ * mixin (slice 3) will enforce the exact same MAX_NAME_LEN + reserved-word rule on the raw DECODED name it
+ * receives as a query param. Deliberately does NOT touch encSegment/MAX_ENCODED_SEGMENT_LEN - that check is
+ * localStorage-key-specific (see validateNameForLocalStorage below).
+ */
+ function validateNameBasic(name) {
+ if (name == null) return { ok: false, code: "malformed", message: "saved-view name must not be null" };
+ const s = String(name);
+ if (s.trim().length === 0) return { ok: false, code: "malformed", message: "saved-view name must not be blank" };
+ if (s.length > MAX_NAME_LEN)
+ return { ok: false, code: "malformed", message: "saved-view name exceeds MAX_NAME_LEN (" + MAX_NAME_LEN + ")" };
+ if (isReservedName(s))
+ return { ok: false, code: "malformed", message: "'Default' is reserved and cannot be used as a saved-view name" };
+ return { ok: true };
+ }
+
+ /**
+ * The localStorage-only name check: {@link #validateNameBasic} PLUS the encoded-segment length cap (§3.1) -
+ * the localStorage provider is the one place a name is actually enc()'d into a storage key, so it is the one
+ * place MAX_ENCODED_SEGMENT_LEN can be exceeded independently of MAX_NAME_LEN.
+ */
+ function validateNameForLocalStorage(name) {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) return basic;
+ const encoded = encSegment(name);
+ if (encoded.length > MAX_ENCODED_SEGMENT_LEN)
+ return { ok: false, code: "malformed",
+ message: "encoded saved-view name exceeds MAX_ENCODED_SEGMENT_LEN (" + MAX_ENCODED_SEGMENT_LEN + ")" };
+ return { ok: true, encoded: encoded };
+ }
+
+ /**
+ * The dangling-active resolution rule (§3.2 should-fix), applied uniformly to EVERY provider's raw list()
+ * result by the public facade below (never duplicated per-provider): if `active` does not name any view
+ * actually present in `views`, the runtime treats it as Default (never an error, never a crash) and flags
+ * `dangling:true` so a caller can surface the required one-time notice. Covers the concurrent hole "tab A
+ * writes blob X, tab B deletes X, tab A flips active to X": the flip is honored, but this resolution turns
+ * the now-missing X back into Default rather than an empty/broken table.
+ */
+ function resolveActiveAgainstViews(active, views) {
+ if (active == null) return { name: null, dangling: false };
+ const found = (views || []).some(function (v) { return v && v.name === active; });
+ return found ? { name: active, dangling: false } : { name: null, dangling: true };
+ }
+
+ /** Rejects an unknown/newer blob schemaVersion as 'malformed' (§3.2 stale-load handling) - never crashes. */
+ function assertSupportedSchema(blob) {
+ if (!blob || blob.schemaVersion !== CURRENT_SCHEMA_VERSION)
+ throw malformedError("saved-view blob has an unsupported schemaVersion (expected " + CURRENT_SCHEMA_VERSION + ")");
+ return blob;
+ }
+
+ /**
+ * Resolves the enclosing page id (§3.1): {@code table.closest('[data-juneau-page]')}'s OWN attribute value -
+ * the SAME shell attribute juneau-pages.js/PageTable stamp - falling back to null (standalone) when there is
+ * no page shell. This is the CONTRACT, not a convenience read: a future non-juneau-pages.js host that wants
+ * page-qualification must stamp this exact attribute rather than inventing a parallel one.
+ */
+ function resolvePageId(table) {
+ const host = (table && table.closest) ? table.closest("[" + PAGE_ID_ATTR + "]") : null;
+ const v = host ? host.getAttribute(PAGE_ID_ATTR) : null;
+ return isBlank(v) ? null : v;
+ }
+
+ /** Resolves the table's own stable view id - reuses the SAME attribute juneau-views.js's initTable reads. */
+ function resolveViewId(table) {
+ const v = table ? table.getAttribute(VIEW_ID_ATTR) : null;
+ return isBlank(v) ? null : v;
+ }
+
+ /**
+ * Resolves the server-persisted provider's context-path-aware REST base (§3.3 DECISION option (b)):
+ * {@code table.closest('[data-juneau-saved-views]')}'s own attribute value, mirroring resolvePageId exactly.
+ * Returns null (never a hardcoded "/"-rooted guess) when absent/blank - the caller MUST fail closed.
+ */
+ function resolveSavedViewsBase(table) {
+ const host = (table && table.closest) ? table.closest("[" + SAVED_VIEWS_BASE_ATTR + "]") : null;
+ const v = host ? host.getAttribute(SAVED_VIEWS_BASE_ATTR) : null;
+ return isBlank(v) ? null : v;
+ }
+
+ // ==================================================================================================================
+ // PURE CONFIG-APPLICATION LAYER (§4.3 — Option-B testable; no DOM / jQuery / DataTables)
+ // ==================================================================================================================
+
+ /** True when `arr` contains the same value more than once (strict equality). */
+ function hasDuplicateEntries(arr) {
+ if (!arr || arr.length < 2) return false;
+ const seen = Object.create(null);
+ for (let i = 0; i < arr.length; i++) {
+ const k = String(arr[i]);
+ if (seen[k]) return true;
+ seen[k] = true;
+ }
+ return false;
+ }
+
+ /** Catalog → Map<dataKey, column> (first wins if the author somehow duplicated a data key). */
+ function catalogByData(catalog) {
+ const map = Object.create(null);
+ (catalog || []).forEach(function (c) {
+ if (c && c.data != null && map[c.data] == null) map[c.data] = c;
+ });
+ return map;
+ }
+
+ /**
+ * Shallow-copies a catalog column into an effective-column model. Nested `render` / `formats` are copied so a
+ * later format swap cannot mutate the live catalog object the VIEW_META sidecar handed us.
+ */
+ function copyCatalogColumn(col) {
+ const out = {
+ data: col.data,
+ orderable: col.orderable,
+ searchable: col.searchable,
+ pinned: col.pinned,
+ defaultVisible: col.defaultVisible
+ };
+ if (col.name != null) out.name = col.name;
+ if (col.title != null) out.title = col.title;
+ if (col.href != null) out.href = col.href;
+ if (col.className != null) out.className = col.className;
+ if (col.formats) out.formats = col.formats.slice();
+ if (col.render != null) {
+ if (typeof col.render === "string") {
+ out.render = col.render;
+ } else {
+ out.render = { id: col.render.id };
+ if (col.render.meta) {
+ out.render.meta = {};
+ for (const k in col.render.meta)
+ if (Object.hasOwn(col.render.meta, k)) out.render.meta[k] = col.render.meta[k];
+ }
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Format-swap helper (§4.3): replace ONLY the renderer id, keeping any existing `meta` (and leaving column
+ * `href` on the column itself — {@link #mergeMeta} in juneau-views.js still folds href into meta at display
+ * time). Accepts both the wire object form `{id,meta}` and the compact string sugar `"id:field"`.
+ */
+ function swapRenderId(render, newId) {
+ if (render == null || render === "") return { id: newId };
+ if (typeof render === "string") {
+ const i = render.indexOf(":");
+ if (i < 0) return { id: newId };
+ return { id: newId, meta: { field: render.substring(i + 1) } };
+ }
+ const out = { id: newId };
+ if (render.meta) {
+ out.meta = {};
+ for (const k in render.meta) if (Object.hasOwn(render.meta, k)) out.meta[k] = render.meta[k];
+ }
+ return out;
+ }
+
+ /**
+ * Default visible set when a saved view omits `visible` (§4.3): each catalog column contributes iff
+ * `defaultVisible !== false`, and pinned columns are always included regardless of defaultVisible.
+ */
+ function defaultVisibleKeys(catalog) {
+ const out = [];
+ (catalog || []).forEach(function (c) {
+ if (!c || c.data == null) return;
+ if (c.pinned || c.defaultVisible !== false) out.push(c.data);
+ });
+ return out;
+ }
+
+ /**
+ * Validates / normalizes a saved-view blob against a column catalog (§4.3 hardening):
+ * <ul>
+ * <li>pinned columns are always visible</li>
+ * <li>≥1 visible column (repairs an all-hidden blob by forcing the first catalog column visible)</li>
+ * <li>unknown column ids in visible/order/labels/formats are dropped</li>
+ * <li>duplicate entries in `visible` OR `order` are rejected ({@code ok:false})</li>
+ * <li>format overrides not in that column's declared `formats` list are dropped</li>
+ * <li>absent `visible` ⇒ each column's {@code defaultVisible ?? true}; absent `order` ⇒ catalog order</li>
+ * </ul>
+ * Returns {@code {ok:true, view}} with a normalized blob, or {@code {ok:false, code, message}} on hard reject.
+ * {@code view == null} (the Default) is valid and returns {@code {ok:true, view:null}}.
+ */
+ function validateView(view, catalog) {
+ if (view == null) return { ok: true, view: null };
+ if (typeof view !== "object")
+ return { ok: false, code: "malformed", message: "saved view must be a plain object" };
+
+ const cols = catalog || [];
+ const byData = catalogByData(cols);
+ const catalogOrder = cols.map(function (c) { return c.data; }).filter(function (d) { return d != null; });
+
+ let order;
+ if (view.order == null) {
+ order = catalogOrder.slice();
+ } else {
+ if (!Array.isArray(view.order))
+ return { ok: false, code: "malformed", message: "saved-view order must be an array" };
+ if (hasDuplicateEntries(view.order))
+ return { ok: false, code: "malformed", message: "saved-view order contains duplicate column ids" };
+ order = [];
+ view.order.forEach(function (id) {
+ if (byData[id] != null) order.push(id);
+ });
+ catalogOrder.forEach(function (id) {
+ if (order.indexOf(id) < 0) order.push(id);
+ });
+ }
+
+ let visible;
+ if (view.visible == null) {
+ visible = defaultVisibleKeys(cols);
+ } else {
+ if (!Array.isArray(view.visible))
+ return { ok: false, code: "malformed", message: "saved-view visible must be an array" };
+ if (hasDuplicateEntries(view.visible))
+ return { ok: false, code: "malformed", message: "saved-view visible contains duplicate column ids" };
+ visible = [];
+ view.visible.forEach(function (id) {
+ if (byData[id] != null) visible.push(id);
+ });
+ }
+
+ // Pinned columns are always visible (un-hideable but reorderable).
+ cols.forEach(function (c) {
+ if (c && c.pinned && c.data != null && visible.indexOf(c.data) < 0) visible.push(c.data);
+ });
+
+ // ≥1 visible — repair an all-hidden blob rather than crash the table.
+ if (visible.length === 0 && catalogOrder.length > 0)
+ visible = [catalogOrder[0]];
+
+ const labels = {};
+ if (view.labels && typeof view.labels === "object") {
+ for (const k in view.labels) {
+ if (Object.hasOwn(view.labels, k) && byData[k] != null)
+ labels[k] = view.labels[k];
+ }
+ }
+
+ const formats = {};
+ if (view.formats && typeof view.formats === "object") {
+ for (const k in view.formats) {
+ if (!Object.hasOwn(view.formats, k) || byData[k] == null) continue;
+ const fmt = view.formats[k];
+ const allowed = byData[k].formats;
+ // Constrain to the column's declared formats list — drop (never apply) an undeclared override.
+ if (Array.isArray(allowed) && allowed.indexOf(fmt) >= 0) formats[k] = fmt;
+ }
+ }
+
+ return {
+ ok: true,
+ view: {
+ schemaVersion: view.schemaVersion != null ? view.schemaVersion : CURRENT_SCHEMA_VERSION,
+ visible: visible,
+ order: order,
+ labels: labels,
+ formats: formats
+ }
+ };
+ }
+
+ /**
+ * Layers a (possibly null = Default) saved view over the catalog to produce the ordered, visibility-tagged,
+ * relabeled, reformatted effective column model (§4.1 / §4.3). Runs {@link #validateView} first so unknown
+ * ids are dropped and format overrides are constrained; a hard-reject (duplicate ids) throws a typed
+ * {@code malformed} error for the load path to treat as Default.
+ *
+ * <p>Blank label overrides revert to the catalog {@code title}. A format swap replaces only the renderer
+ * id — {@code render.meta} and column {@code href} are preserved.
+ */
+ function computeEffectiveColumns(catalog, savedView) {
+ const cols = catalog || [];
+ const validated = validateView(savedView, cols);
+ if (!validated.ok) throw malformedError(validated.message);
+ const normalized = validated.view; // null ⇒ Default
+
+ const byData = catalogByData(cols);
+ const order = normalized ? normalized.order : cols.map(function (c) { return c.data; });
+ const visibleSet = Object.create(null);
+ (normalized ? normalized.visible : defaultVisibleKeys(cols)).forEach(function (id) {
+ visibleSet[id] = true;
+ });
+ cols.forEach(function (c) {
+ if (c && c.pinned && c.data != null) visibleSet[c.data] = true;
+ });
+ const visibleKeys = Object.keys(visibleSet);
+ if (visibleKeys.length === 0 && order.length > 0) visibleSet[order[0]] = true;
+
+ const out = [];
+ order.forEach(function (dataKey) {
+ const col = byData[dataKey];
+ if (!col) return;
+ const effective = copyCatalogColumn(col);
+
+ // Label override — blank/whitespace reverts to the catalog title (persistence-only; no wire field).
+ if (normalized && normalized.labels && Object.hasOwn(normalized.labels, dataKey)) {
+ const override = normalized.labels[dataKey];
+ if (override != null && String(override).trim() !== "")
+ effective.title = String(override);
+ // else leave catalog title (blank-label revert)
+ }
+
+ // Format override — id swap only; meta + href preserved via copyCatalogColumn + swapRenderId.
+ if (normalized && normalized.formats && Object.hasOwn(normalized.formats, dataKey)) {
+ effective.render = swapRenderId(col.render, normalized.formats[dataKey]);
+ }
+
+ effective.visible = !!visibleSet[dataKey];
+ out.push(effective);
+ });
+ return out;
+ }
+
+ /**
+ * Deserializes a saved-view blob (object or JSON string) into a plain
+ * {@code {schemaVersion, visible, order, labels, formats}} (§3.1). Unknown/newer {@code schemaVersion}
+ * rejects as typed {@code malformed} via {@link #assertSupportedSchema}.
+ */
+ function deserializeSavedView(raw) {
+ let blob = raw;
+ if (typeof raw === "string") {
+ try { blob = JSON.parse(raw); }
+ catch (e) { throw malformedError("saved-view blob is not valid JSON"); }
+ }
+ assertSupportedSchema(blob);
+ return {
+ schemaVersion: blob.schemaVersion,
+ visible: blob.visible == null ? null : Array.prototype.slice.call(blob.visible),
+ order: blob.order == null ? null : Array.prototype.slice.call(blob.order),
+ labels: (blob.labels && typeof blob.labels === "object") ? Object.assign({}, blob.labels) : {},
+ formats: (blob.formats && typeof blob.formats === "object") ? Object.assign({}, blob.formats) : {}
+ };
+ }
+
+ /**
+ * Serializes a draft / normalized view into the persisted blob shape (§3.1):
+ * {@code {schemaVersion, visible, order, labels, formats}}. Blank label overrides are omitted (they mean
+ * "use catalog title"); empty {@code labels}/{@code formats} objects are still emitted so the schema stays
+ * stable for round-trips.
+ */
+ function serializeSavedView(view) {
+ const blob = {
+ schemaVersion: CURRENT_SCHEMA_VERSION,
+ visible: (view && Array.isArray(view.visible)) ? view.visible.slice() : [],
+ order: (view && Array.isArray(view.order)) ? view.order.slice() : [],
+ labels: {},
+ formats: {}
+ };
+ if (view && view.labels && typeof view.labels === "object") {
+ for (const k in view.labels) {
+ if (!Object.hasOwn(view.labels, k)) continue;
+ const v = view.labels[k];
+ if (v != null && String(v).trim() !== "") blob.labels[k] = String(v);
+ }
+ }
+ if (view && view.formats && typeof view.formats === "object") {
+ for (const k in view.formats) {
+ if (Object.hasOwn(view.formats, k) && view.formats[k] != null)
+ blob.formats[k] = view.formats[k];
+ }
+ }
+ return blob;
+ }
+
+ /**
+ * Builds the ACTUAL DataTables {@code opts.columns} index space (§4.2):
+ * {@code [selection?] + effectiveColumns(including hidden, in order) + [actions?]}.
+ * Synthetic selection/actions cells use {@code data:null} and a {@code _juneau} marker so they are never
+ * mistaken for a catalog column by {@link #dtIndex}. Pure / DOM-free — slice 5 rewires consumers onto this.
+ */
+ function buildOptsColumnSpace(effectiveColumns, options) {
+ const cols = [];
+ if (options && options.hasSelection)
+ cols.push({ data: null, _juneau: "selection" });
+ (effectiveColumns || []).forEach(function (c) {
+ cols.push({
+ data: c.data,
+ visible: c.visible !== false,
+ title: c.title
+ });
+ });
+ if (options && options.hasActions)
+ cols.push({ data: null, _juneau: "actions" });
+ return cols;
+ }
+
+ /**
+ * The single DataTables index function (§4.2): index of {@code dataKey} in the ACTUAL {@code opts.columns}
+ * array ({@link #buildOptsColumnSpace}). Hidden columns stay in the array ({@code visible:false}), so this
+ * is NOT "Nth visible + selection offset". Returns {@code -1} when the key is absent.
+ *
+ * <p>Load-bearing fixture: {@code [sel, A, B(hidden), C, actions]} → {@code dtIndex('C') === 3} (not 2).
+ */
+ function dtIndex(dataKey, optsColumns) {
+ if (!optsColumns) return -1;
+ for (let i = 0; i < optsColumns.length; i++) {
+ if (optsColumns[i] && optsColumns[i].data === dataKey) return i;
+ }
+ return -1;
+ }
+
+ // ==================================================================================================================
+ // LOCALSTORAGE PROVIDER (the zero-config default)
+ // ==================================================================================================================
+
+ /**
+ * Builds the zero-config {@code localStorage} persistence provider (§3.3). Wraps every operation in a
+ * try/catch that maps a native quota/private-mode failure to the typed 'quota' error, resolves in a
+ * microtask (so a configurable table's compute-before-first-draw handshake, §4.1, never has to show a
+ * loading placeholder for this provider), and listens for the {@code storage} event so an external tab's
+ * write can be reconciled (last-write-wins) - see {@code watchExternalChanges} below.
+ */
+ function createLocalStorageProvider() {
+
+ function requireScope(table) {
+ const viewId = resolveViewId(table);
+ if (viewId == null)
+ throw unavailableError("table has no " + VIEW_ID_ATTR + " id; cannot resolve a persistence scope");
+ const pageId = resolvePageId(table);
+ return { pageId: pageId, viewId: viewId, scope: scopeKey(pageId, viewId) };
+ }
+
+ /** Reads the raw active pointer. A malformed (undecodable) stored value is passed through un-decoded -
+ * it will simply never match a real (always-valid) view name, so it naturally resolves to the dangling/
+ * Default path (resolveActiveAgainstViews) rather than needing a second failure mode here. */
+ function readActiveRaw(scope) {
+ const raw = window.localStorage.getItem(activeKeyFor(scope));
+ if (raw == null) return null;
+ try { return decSegment(raw); } catch (e) { return raw; }
+ }
+
+ function writeActiveRaw(scope, name) {
+ const key = activeKeyFor(scope);
+ if (name == null) window.localStorage.removeItem(key);
+ else window.localStorage.setItem(key, encSegment(name));
+ }
+
+ /** Every saved view currently under `scope`, decoded - an undecodable key is silently skipped (never crashes). */
+ function listViews(scope) {
+ const prefix = viewsPrefixKey(scope);
+ const out = [];
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const k = window.localStorage.key(i);
+ if (k != null && k.indexOf(prefix) === 0) {
+ try { out.push({ name: decSegment(k.slice(prefix.length)) }); } catch (e) { /* skip unreadable key */ }
+ }
+ }
+ return out;
+ }
+
+ /** The per-user AGGREGATE count across every (page,view) scope (§3.2 quota-bypass fix) - a substring scan,
+ * never a regex, so an attacker-controlled scope/name segment cannot be mistaken for pattern syntax. */
+ function countAllViewsForThisUser() {
+ let count = 0;
+ for (let i = 0; i < window.localStorage.length; i++) {
+ const k = window.localStorage.key(i);
+ if (k != null && k.indexOf(KEY_ROOT) === 0 && k.indexOf(".columns.views.") > 0) count++;
+ }
+ return count;
+ }
+
+ function byteLength(str) { return utf8Encode(str).length; }
+
+ /** Enforces the per-blob/per-scope/per-user bounds (§3.2) INSIDE the write op, never as a racy pre-flight. */
+ function enforceBounds(scope, name, blob) {
+ const json = JSON.stringify(blob);
+ if (byteLength(json) > LOCALSTORAGE_MAX_BLOB_BYTES)
+ throw quotaError("saved view exceeds the per-blob size cap (" + LOCALSTORAGE_MAX_BLOB_BYTES + " bytes)");
+ const existing = listViews(scope);
+ const isReplace = existing.some(function (v) { return v.name === name; });
+ if (!isReplace && existing.length >= LOCALSTORAGE_MAX_VIEWS_PER_SCOPE)
+ throw quotaError("scope already has " + LOCALSTORAGE_MAX_VIEWS_PER_SCOPE + " saved views (MAX_VIEWS_PER_SCOPE)");
+ if (!isReplace && countAllViewsForThisUser() >= LOCALSTORAGE_MAX_VIEWS_PER_USER)
+ throw quotaError("aggregate saved-view count reached MAX_VIEWS_PER_USER (" + LOCALSTORAGE_MAX_VIEWS_PER_USER + ")");
+ }
+
+ function persistBlob(scope, name, blob) {
+ const v = validateNameForLocalStorage(name);
+ if (!v.ok) throw malformedError(v.message);
+ assertSupportedSchema(blob);
+ enforceBounds(scope, name, blob);
+ window.localStorage.setItem(viewKeyFor(scope, v.encoded), JSON.stringify(blob));
+ }
+
+ /** Runs `fn` synchronously but always returns a settled-in-a-microtask Promise, typed-error on throw. */
+ function asAsync(fn) {
+ return new Promise(function (resolve, reject) {
+ try { resolve(fn()); } catch (e) { reject(toTypedError(e)); }
+ });
+ }
+
+ return {
+
+ list: function (table) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ return { active: readActiveRaw(ctx.scope), views: listViews(ctx.scope) };
+ });
+ },
+
+ load: function (table, name) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ const v = validateNameForLocalStorage(name);
+ if (!v.ok) throw malformedError(v.message);
+ const raw = window.localStorage.getItem(viewKeyFor(ctx.scope, v.encoded));
+ if (raw == null) return null;
+ let blob;
+ try { blob = JSON.parse(raw); } catch (e) { throw malformedError("stored saved-view blob is not valid JSON"); }
+ return assertSupportedSchema(blob);
+ });
+ },
+
+ save: function (table, name, blob) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ persistBlob(ctx.scope, name, blob);
+ });
+ },
+
+ /**
+ * Writes the blob AND flips the active pointer as the single seam method (§3.2/round-3 R3-B3) - JS is
+ * single-threaded, so within THIS tab no other script can interleave between the two localStorage
+ * writes below; across tabs, each write is simply the last one to land (last-write-wins), which is
+ * the documented localStorage consistency model.
+ */
+ saveAndActivate: function (table, name, blob) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ persistBlob(ctx.scope, name, blob);
+ writeActiveRaw(ctx.scope, name);
+ });
+ },
+
+ setActive: function (table, name) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ if (name == null) { writeActiveRaw(ctx.scope, null); return; }
+ const v = validateNameForLocalStorage(name);
+ if (!v.ok) throw malformedError(v.message);
+ writeActiveRaw(ctx.scope, name);
+ });
+ },
+
+ "delete": function (table, name) {
+ return asAsync(function () {
+ const ctx = requireScope(table);
+ const v = validateNameForLocalStorage(name);
+ if (!v.ok) throw malformedError(v.message);
+ window.localStorage.removeItem(viewKeyFor(ctx.scope, v.encoded));
+ });
+ },
+
+ /**
+ * Multi-tab reconcile (§3.2, localStorage-only): listens for the native `storage` event and invokes
+ * `onChange` whenever a key under THIS table's scope changed in another tab/window. Returns an
+ * unsubscribe function. A later slice's chooser wires this to a "changed in another tab" notice and
+ * a last-write-wins re-read; slice 2 lands only the wiring primitive itself.
+ */
+ watchExternalChanges: function (table, onChange) {
+ const ctx = requireScope(table);
+ const scopePrefix = KEY_ROOT + ctx.scope + ".";
+ function handler(e) {
+ if (e.key != null && e.key.indexOf(scopePrefix) === 0) onChange({ key: e.key, oldValue: e.oldValue, newValue: e.newValue });
+ }
+ window.addEventListener("storage", handler);
+ return function unwatch() { window.removeEventListener("storage", handler); };
+ }
+ };
+ }
+
+ // ==================================================================================================================
+ // SERVER-PERSISTED PROVIDER (client side of the SavedViewsMixin REST endpoint - slice 3 ships the server half)
+ // ==================================================================================================================
+
+ /**
+ * Builds the server-persisted persistence provider (§3.3). EVERY method resolves the REST base fresh, per
+ * call, via {@code table.closest('[data-juneau-saved-views]')} and FAILS CLOSED (typed 'unavailable', no
+ * request issued) when it is absent/blank - never a hardcoded "/"-rooted path. `page`/`view`/`name` are
+ * ordinary WIRE-encoded query-param values (plain {@code encodeURIComponent}) - this provider never calls
+ * encSegment/decSegment, because the server keys on the STRUCTURED tuple (principal,pageId,viewId,name), not
+ * a delimited string, so it needs no delimiter-safety codec (§3.3 "enc()/dec() is the localStorage KEY codec,
+ * NOT a server delimiter-safety mechanism"). Writes assemble the transport ENVELOPE directly (JSON content
+ * type + the CSRF header via the SAME resolveCsrfToken/resolveCsrfHeaderName/isBlankToken helpers
+ * juneau-views.js's row-action path uses, fail-closed on a blank token) - this deliberately does NOT call
+ * buildActionRequest, which refuses safe methods and hard-codes the body to {action:id} (neither fits a
+ * saved-views write). GET (list/load) is a plain, CSRF-free fetch - never an EventSource/SSE.
+ */
+ function createServerProvider() {
+
+ function baseFor(table) {
+ const base = resolveSavedViewsBase(table);
+ if (base == null)
+ throw unavailableError("no [" + SAVED_VIEWS_BASE_ATTR + "] shell found for this table; " +
+ "the server-persisted provider is unavailable");
+ return base;
+ }
+
+ function requireViewId(table) {
+ const v = resolveViewId(table);
+ if (v == null) throw unavailableError("table has no " + VIEW_ID_ATTR + " id");
+ return v;
+ }
+
+ /** Ordinary wire-side query-string assembly - plain encodeURIComponent, deliberately never encSegment. */
+ function buildQuery(params) {
+ const parts = [];
+ for (const k in params) {
+ if (Object.hasOwn(params, k) && params[k] != null)
+ parts.push(encodeURIComponent(k) + "=" + encodeURIComponent(params[k]));
+ }
+ return parts.length ? ("?" + parts.join("&")) : "";
+ }
+
+ function queryFor(table, extra) {
+ const params = { view: requireViewId(table) };
+ const pageId = resolvePageId(table);
+ if (pageId != null) params.page = pageId;
+ if (extra) for (const k in extra) if (Object.hasOwn(extra, k) && extra[k] != null) params[k] = extra[k];
+ return buildQuery(params);
+ }
+
+ /** HTTP-status -> typed-error-code classification (flagged for slice 3 to lock down together - see the
+ * implementer report: the plan does not pin exact statuses for a quota rejection vs. a plain bad request). */
+ function classifyStatus(status) {
+ if (status === 413 || status === 429 || status === 507) return "quota";
+ if (status >= 500) return "network";
+ if (status === 401 || status === 403) return "unavailable";
+ return "malformed";
+ }
+
+ function httpError(status, bodyText) {
+ let env = null;
+ try { env = bodyText ? JSON.parse(bodyText) : null; } catch (e) { env = null; }
+ const err = typedError(classifyStatus(status), (env && env.message) ? env.message : ("saved-views request failed (HTTP " + status + ")"));
+ err.httpStatus = status;
+ return err;
+ }
+
+ function doFetch(url, init) {
+ let req;
+ try { req = fetch(url, init); } catch (e) { return Promise.reject(networkError("the saved-views request could not be sent")); }
+ return req.then(function (resp) {
+ if (resp.ok) return resp;
+ return resp.text().then(function (text) { throw httpError(resp.status, text); },
+ function () { throw httpError(resp.status, null); });
+ }, function () {
+ throw networkError("the saved-views request failed (network error)");
+ });
+ }
+
+ function readJsonBody(resp) {
+ return resp.text().then(function (text) {
+ if (text == null || text === "") return null;
+ try { return JSON.parse(text); } catch (e) { throw malformedError("saved-views response was not valid JSON"); }
+ });
+ }
+
+ function get(table, path, extraParams) {
+ const url = baseFor(table) + path + queryFor(table, extraParams);
+ return doFetch(url, { method: "GET", credentials: "same-origin" }).then(readJsonBody);
+ }
+
+ /**
+ * Assembles the fail-closed transport envelope for a non-safe write - JSON content type, the CSRF header
+ * (via juneau-views.js's own resolveCsrfToken/resolveCsrfHeaderName/isBlankToken, so the two files agree
+ * by construction), and `credentials:'same-origin'`. The body is ALWAYS real JSON, never empty (a `{}`
+ * for a body-less DELETE/clear-active, per §3.2/§3.3).
+ */
+ function writeRequest(table, method, url, body) {
+ const init = NS.init;
+ const token = init ? init.resolveCsrfToken(table) : null;
+ if (!init || init.isBlankToken(token))
+ return { refuse: true, reason: "missing-token" };
+ const headerName = init.resolveCsrfHeaderName(table);
+ const headers = { "Content-Type": "application/json" };
+ headers[headerName] = token;
+ return { url: url, method: method, headers: headers, body: JSON.stringify(body == null ? {} : body) };
+ }
+
+ function write(table, method, path, extraParams, body) {
+ const url = baseFor(table) + path + queryFor(table, extraParams);
+ const req = writeRequest(table, method, url, body);
+ if (req.refuse)
+ return Promise.reject(unavailableError("no CSRF token available for this table; the write was not sent"));
+ return doFetch(req.url, { method: req.method, headers: req.headers, body: req.body, credentials: "same-origin" });
+ }
+
+ /** Runs `fn` (which may throw synchronously, e.g. baseFor's fail-closed check) as a typed-rejecting Promise. */
+ function asAsync(fn) {
+ try {
+ return Promise.resolve(fn()).then(null, function (e) { throw toTypedError(e); });
+ } catch (e) {
+ return Promise.reject(toTypedError(e));
+ }
+ }
+
+ return {
+
+ list: function (table) {
+ return asAsync(function () { return get(table, ""); });
+ },
+
+ load: function (table, name) {
+ return asAsync(function () {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) throw malformedError(basic.message);
+ return get(table, "/item", { name: name }).then(
+ function (blob) { return blob == null ? null : assertSupportedSchema(blob); },
+ function (e) { if (e && e.httpStatus === 404) return null; throw e; });
+ });
+ },
+
+ save: function (table, name, blob) {
+ return asAsync(function () {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) throw malformedError(basic.message);
+ assertSupportedSchema(blob);
+ return write(table, "PUT", "/item", { name: name }, blob).then(function () {});
+ });
+ },
+
+ saveAndActivate: function (table, name, blob) {
+ return asAsync(function () {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) throw malformedError(basic.message);
+ assertSupportedSchema(blob);
+ // The save-vs-save+activate fork is this explicit ?activate=1 QUERY FLAG (§3.3 finding -
+ // Blocker) - never a field inside the persisted blob, which would then wrongly persist into
+ // every saved view.
+ return write(table, "PUT", "/item", { name: name, activate: 1 }, blob).then(function () {});
+ });
+ },
+
+ setActive: function (table, name) {
+ return asAsync(function () {
+ if (name != null) {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) throw malformedError(basic.message);
+ }
+ // PUT .../active always sends a real JSON body - {name} to set, {} to clear - never empty.
+ return write(table, "PUT", "/active", null, name == null ? {} : { name: name }).then(function () {});
+ });
+ },
+
+ "delete": function (table, name) {
+ return asAsync(function () {
+ const basic = validateNameBasic(name);
+ if (!basic.ok) throw malformedError(basic.message);
+ return write(table, "DELETE", "/item", { name: name }, {}).then(function () {});
+ });
+ }
+
+ // Deliberately NO watchExternalChanges: the `storage` event never fires for an HTTP write (§3.2).
+ // The server provider's multi-tab story is "reload to see another tab's change" - nothing more; do
+ // not imply a live reconcile exists here.
+ };
+ }
+
+ // ==================================================================================================================
+ // PUBLIC API (the provider-selection seam + the uniform facade both providers are called through)
+ // ==================================================================================================================
+
+ let currentProvider = null;
+ let lazyDefaultProvider = null;
+
+ function activeProvider() {
+ if (currentProvider) return currentProvider;
+ if (!lazyDefaultProvider) lazyDefaultProvider = createLocalStorageProvider();
+ return lazyDefaultProvider;
+ }
+
+ /** Swaps the active persistence provider (§3.2/§5) - e.g. `JuneauViews.setPersistenceProvider(JuneauViews.persistenceProviders.server())`. */
+ NS.setPersistenceProvider = function (provider) {
+ currentProvider = provider;
+ };
+
+ /** The provider-selection seam (§5): factories for the two first-party providers this file ships. */
+ NS.persistenceProviders = {
+ localStorage: createLocalStorageProvider,
+ server: createServerProvider
+ };
+
+ /**
+ * The async persistence facade every consumer (the later chooser/config-application slices) calls through -
+ * delegates to whichever provider is currently active. `list`/`getActive` uniformly apply the
+ * dangling-active resolution (resolveActiveAgainstViews) here, ONCE, rather than duplicating it inside each
+ * provider - both providers' own list() stays a bare {active,views} read.
+ */
+ NS.persistence = {
+
+ list: function (table) {
+ return activeProvider().list(table).then(function (r) {
+ const resolved = resolveActiveAgainstViews(r.active, r.views);
+ return { active: resolved.name, views: r.views, dangling: resolved.dangling };
+ });
+ },
+
+ load: function (table, name) { return activeProvider().load(table, name); },
+
+ save: function (table, name, blob) { return activeProvider().save(table, name, blob); },
+
+ saveAndActivate: function (table, name, blob) { return activeProvider().saveAndActivate(table, name, blob); },
+
+ setActive: function (table, name) { return activeProvider().setActive(table, name); },
+
+ "delete": function (table, name) { return activeProvider()["delete"](table, name); },
+
+ /** Convenience: the resolved active-view NAME (or null for Default), derived uniformly from list(). */
+ getActive: function (table) {
+ return NS.persistence.list(table).then(function (r) { return { name: r.active, dangling: r.dangling }; });
+ }
+ };
+
+ /**
+ * Synchronous localStorage get/set for ribbon toggle persistence. Uses the SAME exact keys
+ * {@code juneau-ribbon.js} already documents ({@code juneau.view.<viewId>.ribbon.<optionId>}) — this is not
+ * the saved-view key codec, and these methods MUST stay synchronous (no Promise). A missing/blocked
+ * store returns {@code null} / no-ops rather than throwing, so a ribbon click cannot become async-broken.
+ */
+ NS.persistence.getItem = function (key) {
+ try {
+ return window.localStorage.getItem(key);
+ } catch (e) { return null; }
+ };
+ NS.persistence.setItem = function (key, value) {
+ try {
+ window.localStorage.setItem(key, String(value));
+ } catch (e) { /* quota / private mode — ribbon click path stays synchronous and must not throw */ }
+ };
+
+ // Exposed for the pure-logic/source-shape tests and for later slices (chooser UI, config-application layer).
+ NS.config = NS.config || {};
+ NS.config.CURRENT_SCHEMA_VERSION = CURRENT_SCHEMA_VERSION;
+ NS.config.MAX_NAME_LEN = MAX_NAME_LEN;
+ NS.config.MAX_ENCODED_SEGMENT_LEN = MAX_ENCODED_SEGMENT_LEN;
+ NS.config.LOCALSTORAGE_MAX_VIEWS_PER_SCOPE = LOCALSTORAGE_MAX_VIEWS_PER_SCOPE;
+ NS.config.LOCALSTORAGE_MAX_BLOB_BYTES = LOCALSTORAGE_MAX_BLOB_BYTES;
+ NS.config.LOCALSTORAGE_MAX_VIEWS_PER_USER = LOCALSTORAGE_MAX_VIEWS_PER_USER;
+ NS.config.PAGE_ID_ATTR = PAGE_ID_ATTR;
+ NS.config.SAVED_VIEWS_BASE_ATTR = SAVED_VIEWS_BASE_ATTR;
+ NS.config.VIEW_ID_ATTR = VIEW_ID_ATTR;
+ NS.config.encSegment = encSegment;
+ NS.config.decSegment = decSegment;
+ NS.config.scopeKey = scopeKey;
+ NS.config.viewsPrefixKey = viewsPrefixKey;
+ NS.config.viewKeyFor = viewKeyFor;
+ NS.config.activeKeyFor = activeKeyFor;
+ NS.config.isReservedName = isReservedName;
+ NS.config.validateNameBasic = validateNameBasic;
+ NS.config.validateNameForLocalStorage = validateNameForLocalStorage;
+ NS.config.resolveActiveAgainstViews = resolveActiveAgainstViews;
+ NS.config.assertSupportedSchema = assertSupportedSchema;
+ NS.config.resolvePageId = resolvePageId;
+ NS.config.resolveViewId = resolveViewId;
+ NS.config.resolveSavedViewsBase = resolveSavedViewsBase;
+ NS.config.createLocalStorageProvider = createLocalStorageProvider;
+ NS.config.createServerProvider = createServerProvider;
+ // Slice 4 — pure config-application layer (§4.3) + the dtIndex index model (§4.2 INDEX MODEL only).
+ NS.config.validateView = validateView;
+ NS.config.computeEffectiveColumns = computeEffectiveColumns;
+ NS.config.deserializeSavedView = deserializeSavedView;
+ NS.config.serializeSavedView = serializeSavedView;
+ NS.config.buildOptsColumnSpace = buildOptsColumnSpace;
+ NS.config.dtIndex = dtIndex;
+ NS.config.swapRenderId = swapRenderId;
+
+ /**
+ * Loads the active saved-view blob for a configurable table (or {@code null} for Default). Stale / unknown-
+ * schema blobs resolve as Default rather than rejecting. Persistence failures reject so {@code initTable}
+ * can refuse the first draw (isDataTable stays false).
+ */
+ function resolveActiveView(table, viewDef) {
+ if (!NS.persistence) return Promise.resolve(null);
+ return NS.persistence.getActive(table).then(function (r) {
+ if (!r || r.name == null) return null;
+ return NS.persistence.load(table, r.name).then(function (blob) {
+ if (blob == null) return null;
+ try { return deserializeSavedView(blob); }
+ catch (e) { return null; }
+ });
+ });
+ }
+
+ /**
+ * Programmatic Apply entry point (no chooser UI). Computes effective columns from {@code savedView} and
+ * runs the destroy/reinit transaction via {@code NS.init.buildTable}.
+ */
+ function applyView(table, savedView) {
+ const ctx = table && table.__juneauCtx;
+ if (!ctx || !ctx.viewDef) return { ok: false, reason: "not-initialized" };
+ if (!NS.init || typeof NS.init.buildTable !== "function") return { ok: false, reason: "no-buildTable" };
+ let effective;
+ try {
+ effective = computeEffectiveColumns(ctx.viewDef.columns || [], savedView);
+ } catch (e) {
+ return { ok: false, reason: "malformed", message: e && e.message };
+ }
+ return NS.init.buildTable(table, ctx.viewDef, effective, ctx);
+ }
+
+ NS.config.resolveActiveView = resolveActiveView;
+ NS.config.applyView = applyView;
+
+ // ==================================================================================================================
+ // CHOOSER UI (View tab only — Search/Sort/Options tabs are out of scope)
+ // ==================================================================================================================
+ //
+ // XSS HARD RULE: saved-view names and per-column label overrides are user-controlled and are painted into
+ // the chooser AND into DataTables header titles. This origin holds the CSRF token, so a stored-XSS →
+ // token-theft → arbitrary-write chain is in scope. Paint every user-controlled string with textContent /
+ // input.value ONLY — never innerHTML / jQuery html(), including the DataTables column title path.
+
+ const CHOOSER_BACKDROP_CLASS = "juneau-config-dialog-backdrop";
+ const DEFAULT_VIEW_LABEL = "Default";
+
+ /** Sets el.textContent; the ONLY sanctioned paint path for user-controlled strings. */
+ function paintUserText(el, value) {
+ if (!el) return;
+ el.textContent = value == null ? "" : String(value);
+ }
+
+ /** Sets input.value; the ONLY sanctioned paint path for user-controlled strings in form controls. */
+ function paintUserInput(el, value) {
+ if (!el) return;
+ el.value = value == null ? "" : String(value);
+ }
+
+ /**
+ * DataTables treats {@code columns.title} as HTML. Blank every data-column title so a user label
+ * override is never parsed as markup; {@link #paintHeaderTitles} then writes the real label with
+ * {@code textContent}.
+ */
+ function sanitizeColumnTitlesForDataTables(cols) {
+ (cols || []).forEach(function (c) {
+ if (c && c._juneau !== "selection" && c._juneau !== "actions")
+ c.title = "";
+ });
+ }
+
+ /**
+ * Paints DataTables header cells from the effective column model using {@code textContent} only.
+ * Selection / actions headers are skipped (they are unlabeled by design).
+ */
+ function paintHeaderTitles(table, effectiveColumns, ctx) {
+ if (!table) return;
+ const headRow = table.querySelector("thead tr");
+ if (!headRow) return;
+ const ths = headRow.children;
+ const offset = (ctx && ctx.selectionState) ? 1 : 0;
+ (effectiveColumns || []).forEach(function (col, i) {
+ const th = ths[offset + i];
+ if (!th) return;
+ const label = (col.title != null && String(col.title).trim() !== "") ? String(col.title) : (col.data || "");
+ paintUserText(th, label);
+ });
+ }
+
+ function catalogByDataLocal(catalog) {
+ const m = Object.create(null);
+ (catalog || []).forEach(function (c) { if (c && c.data != null) m[c.data] = c; });
+ return m;
+ }
+
+ function defaultDraftFromCatalog(catalog) {
+ const cols = catalog || [];
+ const order = [];
+ const visible = [];
+ cols.forEach(function (c) {
+ if (!c || c.data == null) return;
+ order.push(c.data);
+ if (c.pinned || c.defaultVisible !== false) visible.push(c.data);
+ });
+ if (visible.length === 0 && order.length > 0) visible.push(order[0]);
+ return { visible: visible, order: order, labels: {}, formats: {} };
+ }
+
+ function draftFromSavedView(catalog, savedView) {
+ if (savedView == null) return defaultDraftFromCatalog(catalog);
+ const validated = validateView(savedView, catalog);
+ if (!validated.ok || !validated.view) return defaultDraftFromCatalog(catalog);
+ return {
+ visible: validated.view.visible.slice(),
+ order: validated.view.order.slice(),
+ labels: Object.assign({}, validated.view.labels || {}),
+ formats: Object.assign({}, validated.view.formats || {})
+ };
+ }
+
+ function snapshotDraft(draft) {
+ return JSON.stringify({
+ visible: draft.visible,
+ order: draft.order,
+ labels: draft.labels,
+ formats: draft.formats
+ });
+ }
+
+ function visibleCount(draft) {
+ return (draft && draft.visible) ? draft.visible.length : 0;
+ }
+
+ /**
+ * Whether {@code dataKey} may be unchecked. Pinned columns never; the last remaining visible column never.
+ */
+ function canHideColumn(draft, catalog, dataKey) {
+ const byData = catalogByDataLocal(catalog);
+ const col = byData[dataKey];
+ if (col && col.pinned) return false;
+ if (!draft || !draft.visible) return false;
+ if (draft.visible.indexOf(dataKey) < 0) return true;
+ return visibleCount(draft) > 1;
+ }
+
+ function moveColumn(draft, dataKey, delta) {
+ if (!draft || !Array.isArray(draft.order)) return false;
+ const i = draft.order.indexOf(dataKey);
+ if (i < 0) return false;
+ const j = i + delta;
+ if (j < 0 || j >= draft.order.length) return false;
+ const tmp = draft.order[i];
+ draft.order[i] = draft.order[j];
+ draft.order[j] = tmp;
+ return true;
+ }
+
+ function markDirty(ctx) {
+ ctx._configDirty = snapshotDraft(ctx._configDraft) !== ctx._configCleanSnapshot;
+ refreshChooserDirty(ctx);
+ }
+
+ function refreshChooserDirty(ctx) {
+ const el = ctx._configDirtyEl;
+ if (!el) return;
+ el.hidden = !ctx._configDirty;
+ paintUserText(el, ctx._configDirty ? "Unsaved changes" : "");
+ }
+
+ function showChooserStatus(ctx, message, isError) {
+ const el = ctx._configStatusEl;
+ if (!el) return;
+ paintUserText(el, message == null ? "" : message);
+ el.hidden = !message;
+ if (el.classList) el.classList.toggle("juneau-config-status-error", !!isError);
+ }
+
+ function closeChooserDialog(ctx) {
+ const backdrop = ctx && ctx._configBackdrop;
+ if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
+ if (ctx) {
+ ctx._configBackdrop = null;
+ ctx._configDirtyEl = null;
+ ctx._configStatusEl = null;
+ ctx._configListEl = null;
+ ctx._configSelectEl = null;
+ }
+ }
+
+ function currentCatalog(ctx) {
+ return (ctx && ctx.viewDef && ctx.viewDef.columns) ? ctx.viewDef.columns : [];
+ }
+
+ function renderChooserColumnList(ctx) {
+ const list = ctx._configListEl;
+ if (!list) return;
+ while (list.firstChild) list.removeChild(list.firstChild);
+ const catalog = currentCatalog(ctx);
+ const byData = catalogByDataLocal(catalog);
+ const draft = ctx._configDraft;
+ (draft.order || []).forEach(function (dataKey) {
+ const col = byData[dataKey];
+ if (!col) return;
+ list.appendChild(buildChooserRow(ctx, col, draft));
+ });
+ }
+
+ function buildChooserRow(ctx, col, draft) {
+ const row = document.createElement("div");
+ row.className = "juneau-config-col-row";
+ row.setAttribute("data-col", col.data);
+
+ const vis = document.createElement("input");
+ vis.type = "checkbox";
+ vis.className = "juneau-config-col-vis";
+ vis.checked = draft.visible.indexOf(col.data) >= 0;
+ vis.disabled = !!col.pinned || (!canHideColumn(draft, currentCatalog(ctx), col.data) && vis.checked);
+ vis.setAttribute("aria-label", "Show column");
+ vis.addEventListener("change", function () {
+ if (vis.checked) {
+ if (draft.visible.indexOf(col.data) < 0) draft.visible.push(col.data);
+ } else {
+ if (!canHideColumn(draft, currentCatalog(ctx), col.data)) {
+ vis.checked = true;
+ return;
+ }
+ draft.visible = draft.visible.filter(function (id) { return id !== col.data; });
+ }
+ markDirty(ctx);
+ renderChooserColumnList(ctx);
+ });
+ row.appendChild(vis);
+
+ const name = document.createElement("span");
+ name.className = "juneau-config-col-name";
+ paintUserText(name, col.title || col.data);
+ if (col.pinned) {
+ const pin = document.createElement("span");
+ pin.className = "juneau-config-col-pinned";
+ paintUserText(pin, " pinned");
+ name.appendChild(pin);
+ }
+ row.appendChild(name);
+
+ const up = document.createElement("button");
+ up.type = "button";
+ up.className = "juneau-config-col-move";
+ paintUserText(up, "Up");
+ up.setAttribute("aria-label", "Move column up");
+ up.disabled = draft.order.indexOf(col.data) === 0;
+ up.addEventListener("click", function () {
+ if (moveColumn(draft, col.data, -1)) {
+ markDirty(ctx);
+ renderChooserColumnList(ctx);
+ }
+ });
+ row.appendChild(up);
+
+ const down = document.createElement("button");
+ down.type = "button";
+ down.className = "juneau-config-col-move";
+ paintUserText(down, "Down");
+ down.setAttribute("aria-label", "Move column down");
+ down.disabled = draft.order.indexOf(col.data) === draft.order.length - 1;
+ down.addEventListener("click", function () {
+ if (moveColumn(draft, col.data, 1)) {
+ markDirty(ctx);
+ renderChooserColumnList(ctx);
+ }
+ });
+ row.appendChild(down);
+
+ const label = document.createElement("input");
+ label.type = "text";
+ label.className = "juneau-config-col-label";
+ label.setAttribute("aria-label", "Column label");
+ paintUserInput(label, draft.labels[col.data] || "");
+ label.placeholder = col.title || col.data;
+ label.addEventListener("input", function () {
+ const v = label.value;
+ if (v == null || String(v).trim() === "") delete draft.labels[col.data];
+ else draft.labels[col.data] = String(v);
+ markDirty(ctx);
+ });
+ row.appendChild(label);
+
+ if (Array.isArray(col.formats) && col.formats.length) {
+ const sel = document.createElement("select");
+ sel.className = "juneau-config-col-format";
+ sel.setAttribute("aria-label", "Column format");
+ const empty = document.createElement("option");
+ empty.value = "";
+ paintUserText(empty, "(default)");
+ sel.appendChild(empty);
+ col.formats.forEach(function (fmt) {
+ const opt = document.createElement("option");
+ opt.value = fmt;
+ paintUserText(opt, fmt);
+ sel.appendChild(opt);
+ });
+ sel.value = draft.formats[col.data] || "";
+ sel.addEventListener("change", function () {
+ if (!sel.value) delete draft.formats[col.data];
+ else draft.formats[col.data] = sel.value;
+ markDirty(ctx);
+ });
+ row.appendChild(sel);
+ }
+
+ return row;
+ }
+
+ function fillViewSelect(ctx, listing) {
+ const sel = ctx._configSelectEl;
+ if (!sel) return;
+ while (sel.firstChild) sel.removeChild(sel.firstChild);
+ const defOpt = document.createElement("option");
+ defOpt.value = "";
+ paintUserText(defOpt, DEFAULT_VIEW_LABEL);
+ sel.appendChild(defOpt);
+ const views = (listing && listing.views) ? listing.views : [];
+ views.forEach(function (v) {
+ const n = (v && v.name != null) ? String(v.name) : String(v);
+ const opt = document.createElement("option");
+ opt.value = n;
+ paintUserText(opt, n);
+ sel.appendChild(opt);
+ });
+ const active = ctx._configActiveName;
+ sel.value = active == null ? "" : active;
+ }
+
+ function askSaveAsName() {
+ if (typeof NS.config.askSaveAsName === "function")
+ return NS.config.askSaveAsName();
+ if (typeof window.prompt === "function")
+ return window.prompt("Save view as:");
+ return null;
+ }
+
+ function confirmDiscard(ctx) {
+ if (!ctx._configDirty) return true;
+ if (typeof NS.config.confirmDiscard === "function")
+ return !!NS.config.confirmDiscard();
+ if (typeof window.confirm === "function")
+ return window.confirm("Discard unsaved changes?");
+ return true;
+ }
+
+ function applyDraft(table, ctx) {
+ const saved = {
+ schemaVersion: CURRENT_SCHEMA_VERSION,
+ visible: ctx._configDraft.visible.slice(),
+ order: ctx._configDraft.order.slice(),
+ labels: Object.assign({}, ctx._configDraft.labels),
+ formats: Object.assign({}, ctx._configDraft.formats)
+ };
+ const result = applyView(table, saved);
+ if (result && result.ok) {
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ refreshChooserDirty(ctx);
+ showChooserStatus(ctx, "", false);
+ } else if (result && result.reason === "in-flight") {
+ showChooserStatus(ctx, "Finish the in-progress action first.", true);
+ } else if (result && !result.ok) {
+ showChooserStatus(ctx, result.message || "Could not apply view.", true);
+ }
+ return result;
+ }
+
+ function persistDraft(table, ctx, name, activate) {
+ const blob = serializeSavedView(ctx._configDraft);
+ const op = activate ? NS.persistence.saveAndActivate : NS.persistence.save;
+ return op(table, name, blob).then(function () {
+ ctx._configActiveName = name;
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ refreshChooserDirty(ctx);
+ showChooserStatus(ctx, activate ? "Saved and applied." : "Saved.", false);
+ return NS.persistence.list(table).then(function (listing) {
+ fillViewSelect(ctx, listing);
+ });
+ }, function (e) {
+ const typed = toTypedError(e);
+ const msg = typed.code === "quota" ? "Storage quota exceeded." : (typed.message || "Save failed.");
+ showChooserStatus(ctx, msg, true);
+ throw e;
+ });
+ }
+
+ function loadNamedView(table, ctx, name) {
+ if (name == null || name === "") {
+ ctx._configDraft = defaultDraftFromCatalog(currentCatalog(ctx));
+ ctx._configActiveName = null;
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ renderChooserColumnList(ctx);
+ refreshChooserDirty(ctx);
+ return Promise.resolve();
+ }
+ return NS.persistence.load(table, name).then(function (blob) {
+ ctx._configDraft = draftFromSavedView(currentCatalog(ctx), blob);
+ ctx._configActiveName = name;
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ renderChooserColumnList(ctx);
+ refreshChooserDirty(ctx);
+ }, function (e) {
+ showChooserStatus(ctx, (toTypedError(e).message) || "Load failed.", true);
+ });
+ }
+
+ function openChooser(table, ctx) {
+ if (ctx._configBackdrop) {
+ closeChooserDialog(ctx);
+ return;
+ }
+ if (!ctx._configDraft)
+ ctx._configDraft = draftFromSavedView(currentCatalog(ctx), null);
+ if (ctx._configCleanSnapshot == null)
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+
+ const backdrop = document.createElement("div");
+ backdrop.className = CHOOSER_BACKDROP_CLASS;
+ backdrop.setAttribute("role", "presentation");
+
+ const dialog = document.createElement("div");
+ dialog.className = "juneau-config-dialog";
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-labelledby", "juneau-config-title");
+
+ const title = document.createElement("h2");
+ title.id = "juneau-config-title";
+ title.className = "juneau-config-title";
+ paintUserText(title, "View Settings");
+ dialog.appendChild(title);
+
+ const tabs = document.createElement("div");
+ tabs.className = "juneau-config-tabs";
+ const viewTab = document.createElement("button");
+ viewTab.type = "button";
+ viewTab.className = "juneau-config-tab juneau-config-tab-active";
+ paintUserText(viewTab, "View");
+ tabs.appendChild(viewTab);
+ dialog.appendChild(tabs);
+
+ const toolbar = document.createElement("div");
+ toolbar.className = "juneau-config-saved-bar";
+
+ const sel = document.createElement("select");
+ sel.className = "juneau-config-view-select";
+ sel.setAttribute("aria-label", "Saved view");
+ ctx._configSelectEl = sel;
+ sel.addEventListener("change", function () {
+ if (!confirmDiscard(ctx)) {
+ sel.value = ctx._configActiveName == null ? "" : ctx._configActiveName;
+ return;
+ }
+ const name = sel.value === "" ? null : sel.value;
+ loadNamedView(table, ctx, name).then(function () {
+ NS.persistence.setActive(table, name).catch(function () {});
+ });
+ });
+ toolbar.appendChild(sel);
+
+ const saveBtn = document.createElement("button");
+ saveBtn.type = "button";
+ paintUserText(saveBtn, "Save");
+ saveBtn.addEventListener("click", function () {
+ if (ctx._configActiveName == null) {
+ showChooserStatus(ctx, "Use Save as… to name a new view.", true);
+ return;
+ }
+ persistDraft(table, ctx, ctx._configActiveName, true);
+ });
+ toolbar.appendChild(saveBtn);
+
+ const saveAsBtn = document.createElement("button");
+ saveAsBtn.type = "button";
+ paintUserText(saveAsBtn, "Save as…");
+ saveAsBtn.addEventListener("click", function () {
+ const name = askSaveAsName();
+ if (name == null || String(name).trim() === "") return;
+ const basic = validateNameBasic(name);
+ if (!basic.ok) {
+ showChooserStatus(ctx, basic.message, true);
+ return;
+ }
+ persistDraft(table, ctx, String(name).trim(), true);
+ });
+ toolbar.appendChild(saveAsBtn);
+
+ const delBtn = document.createElement("button");
+ delBtn.type = "button";
+ paintUserText(delBtn, "Delete");
+ delBtn.addEventListener("click", function () {
+ if (ctx._configActiveName == null) {
+ showChooserStatus(ctx, "The Default view cannot be deleted.", true);
+ return;
+ }
+ const name = ctx._configActiveName;
+ NS.persistence["delete"](table, name).then(function () {
+ return NS.persistence.setActive(table, null);
+ }).then(function () {
+ ctx._configActiveName = null;
+ ctx._configDraft = defaultDraftFromCatalog(currentCatalog(ctx));
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ renderChooserColumnList(ctx);
+ refreshChooserDirty(ctx);
+ return NS.persistence.list(table);
+ }).then(function (listing) {
+ fillViewSelect(ctx, listing);
+ showChooserStatus(ctx, "Deleted.", false);
+ }, function (e) {
+ showChooserStatus(ctx, (toTypedError(e).message) || "Delete failed.", true);
+ });
+ });
+ toolbar.appendChild(delBtn);
+
+ const dirty = document.createElement("span");
+ dirty.className = "juneau-config-dirty";
+ dirty.hidden = true;
+ ctx._configDirtyEl = dirty;
+ toolbar.appendChild(dirty);
+
+ dialog.appendChild(toolbar);
+
+ const status = document.createElement("div");
+ status.className = "juneau-config-status";
+ status.hidden = true;
+ ctx._configStatusEl = status;
+ dialog.appendChild(status);
+
+ const list = document.createElement("div");
+ list.className = "juneau-config-col-list";
+ ctx._configListEl = list;
+ dialog.appendChild(list);
+
+ const actions = document.createElement("div");
+ actions.className = "juneau-config-actions";
+
+ const applyBtn = document.createElement("button");
+ applyBtn.type = "button";
+ applyBtn.className = "juneau-config-apply";
+ paintUserText(applyBtn, "Apply");
+ applyBtn.addEventListener("click", function () { applyDraft(table, ctx); });
+ actions.appendChild(applyBtn);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.type = "button";
+ paintUserText(closeBtn, "Close");
+ closeBtn.addEventListener("click", function () {
+ if (!confirmDiscard(ctx)) return;
+ closeChooserDialog(ctx);
+ });
+ actions.appendChild(closeBtn);
+
+ dialog.appendChild(actions);
+ backdrop.appendChild(dialog);
+ backdrop.addEventListener("click", function (e) {
+ if (e.target === backdrop) {
+ if (!confirmDiscard(ctx)) return;
+ closeChooserDialog(ctx);
+ }
+ });
+ document.body.appendChild(backdrop);
+ ctx._configBackdrop = backdrop;
+
+ renderChooserColumnList(ctx);
+ refreshChooserDirty(ctx);
+ NS.persistence.list(table).then(function (listing) {
+ if (ctx._configActiveName === undefined)
+ ctx._configActiveName = listing.active;
+ fillViewSelect(ctx, listing);
+ }, function () {
+ fillViewSelect(ctx, { views: [] });
+ });
+ }
+
+ /**
+ * Wires the Columns affordance onto the table toolbar when {@code columnConfig} is present. Called from
+ * {@code constructTable} on first init AND every Apply rebuild.
+ */
+ function mountChooser(table, ctx, toolbarRow) {
+ if (!ctx || !ctx.viewDef || !ctx.viewDef.columnConfig) return;
+ let host = null;
+ if (toolbarRow)
+ host = toolbarRow.querySelector(".juneau-view-toolbar-right") || toolbarRow;
+ if (!host) {
+ const wrapper = table && table.parentNode;
+ if (!wrapper) return;
+ host = wrapper.querySelector(".juneau-view-toolbar-right") || wrapper;
+ }
+ if (host.querySelector && host.querySelector(".juneau-config-chooser-btn")) return;
+
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "juneau-view-ribbon-btn juneau-config-chooser-btn";
+ btn.title = "Columns";
+ btn.setAttribute("aria-label", "Columns");
+ paintUserText(btn, "Columns");
+ const markup = NS.icons && typeof NS.icons.resolveIcon === "function" ? NS.icons.resolveIcon("tune") : null;
+ if (markup != null && typeof DOMParser === "function") {
+ try {
+ const doc = new DOMParser().parseFromString(markup, "image/svg+xml");
+ const svg = doc.documentElement;
+ if (svg && svg.tagName && svg.tagName.toLowerCase() === "svg") {
+ btn.textContent = "";
+ btn.appendChild(document.importNode ? document.importNode(svg, true) : svg);
+ }
+ } catch (e) { /* text fallback already applied */ }
+ }
+ btn.addEventListener("click", function () { openChooser(table, ctx); });
+ host.appendChild(btn);
+
+ if (ctx._configDraft == null) {
+ ctx._configDraft = defaultDraftFromCatalog(currentCatalog(ctx));
+ ctx._configCleanSnapshot = snapshotDraft(ctx._configDraft);
+ ctx._configDirty = false;
+ }
+ }
+
+ NS.config.sanitizeColumnTitlesForDataTables = sanitizeColumnTitlesForDataTables;
+ NS.config.paintHeaderTitles = paintHeaderTitles;
+ NS.config.paintUserText = paintUserText;
+ NS.config.paintUserInput = paintUserInput;
+ NS.config.canHideColumn = canHideColumn;
+ NS.config.moveColumn = moveColumn;
+ NS.config.defaultDraftFromCatalog = defaultDraftFromCatalog;
+ NS.config.draftFromSavedView = draftFromSavedView;
+ NS.config.mountChooser = mountChooser;
+ NS.config.openChooser = openChooser;
+ NS.config.closeChooserDialog = closeChooserDialog;
+ NS.config.applyDraft = applyDraft;
+ NS.config.CHOOSER_BACKDROP_CLASS = CHOOSER_BACKDROP_CLASS;
+})();
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
index 6c84011944..d1662b3a10 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-pages.js
@@ -54,7 +54,7 @@
// Contract-version handshake for PAGE_META: MUST equal PageDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION
// (single source of truth on the server, itself reusing ViewDef.CONTRACT_VERSION). Distinct from - and does
// not replace - the per-view VIEW_META handshake juneau-views.js's initTable(...) already performs.
- const JUNEAU_PAGE_CONTRACT_VERSION = "3";
+ const JUNEAU_PAGE_CONTRACT_VERSION = "4";
const NS = window.JuneauViews = window.JuneauViews || {};
@@ -174,10 +174,10 @@
Array.prototype.forEach.call(tables, function (t) {
if (t.closest(PANEL_SELECTOR) !== panel) return;
const $ = window.jQuery;
- if ($?.fn?.dataTable?.isDataTable(t)) {
- $(t).DataTable().columns.adjust();
- } else if (NS.init?.initTable) {
- NS.init.initTable(t);
+ if (NS.init?.initTable) {
+ Promise.resolve(NS.init.initTable(t)).then(function () {
+ if ($?.fn?.dataTable?.isDataTable(t)) $(t).DataTable().columns.adjust();
+ });
} else {
warn("Juneau page: juneau-views.js not loaded (or too old to expose initTable); cannot lazy-init view '" +
t.getAttribute("data-juneau-view") + "'.");
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
index 03de76562c..6d8df616fd 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-ribbon.js
@@ -53,12 +53,14 @@
/**
* Maps ONE option/opt to the {name, value} request param it contributes, or null. Column-scoped options resolve
* to `columns[<index>][search][value]`; custom-param options contribute `param=value` verbatim; a valueless (or
- * column+param-less) option contributes nothing. Byte-for-byte identical to the Java addOptionParam(...).
+ * column+param-less) option contributes nothing. Byte-for-byte identical to the Java addOptionParam(...) for
+ * the no-selection/no-reorder catalog index; when `optsColumns` is supplied, the index is the live
+ * {@code dtIndex} (selection offset + client reorder).
*/
- function optionParam(viewDef, opt) {
+ function optionParam(viewDef, opt, optsColumns) {
if (opt == null || opt.value == null) return null;
if (opt.column != null) {
- const idx = columnIndex(viewDef, opt.column);
+ const idx = indexForRibbonColumn(viewDef, opt.column, optsColumns);
if (idx < 0) return null;
return { name: "columns[" + idx + "][search][value]", value: opt.value };
}
@@ -66,6 +68,18 @@
return null;
}
+ /** Live {@code dtIndex} when {@code optsColumns} is the actual DataTables array; else catalog index. */
+ function indexForRibbonColumn(viewDef, columnKey, optsColumns) {
+ if (optsColumns) {
+ if (NS.config && typeof NS.config.dtIndex === "function")
+ return NS.config.dtIndex(columnKey, optsColumns);
+ for (let i = 0; i < optsColumns.length; i++)
+ if (optsColumns[i] && optsColumns[i].data === columnKey) return i;
+ return -1;
+ }
+ return columnIndex(viewDef, columnKey);
+ }
+
/**
* The pure counterpart of RibbonAction.toQueryParams(ViewDef): the request params the ribbon contributes given the
* client's ACTIVE toggle state. `activeState` is a map keyed by option/group id:
@@ -73,20 +87,20 @@
* - an `optionGroup` contributes its member whose id === activeState[group.id] (the selected radio value).
* refresh/columnSearchToggle/divider/export are not query-contributing and are skipped.
*/
- function ribbonToQueryParams(viewDef, activeState) {
+ function ribbonToQueryParams(viewDef, activeState, optsColumns) {
const out = {};
const state = activeState || {};
(viewDef.ribbon || []).forEach(function (a) {
if (a.type === "option") {
if (state[a.id]) {
- const p = optionParam(viewDef, a);
+ const p = optionParam(viewDef, a, optsColumns);
if (p) out[p.name] = p.value;
}
} else if (a.type === "optionGroup" && a.options) {
const selected = state[a.id];
a.options.forEach(function (o) {
if (o.id === selected) {
- const p = optionParam(viewDef, o);
+ const p = optionParam(viewDef, o, optsColumns);
if (p) out[p.name] = p.value;
}
});
@@ -163,16 +177,36 @@
try { return window.localStorage; } catch (e) { return null; }
}
+ /**
+ * Reads a ribbon-toggle value. Prefers the slice-2 persistence SPI's synchronous {@code getItem}
+ * (same exact keys) when {@code juneau-config.js} is loaded; falls back to localStorage so a
+ * non-configurable table (no config.js) keeps working and the ribbon never becomes Promise-based.
+ */
+ function storageGet(key) {
+ if (NS.persistence && typeof NS.persistence.getItem === "function")
+ return NS.persistence.getItem(key);
+ const store = safeStorage();
+ return store ? store.getItem(key) : null;
+ }
+
+ /** Writes a ribbon-toggle value; same SPI-or-localStorage split as {@link #storageGet}. */
+ function storageSet(key, value) {
+ if (NS.persistence && typeof NS.persistence.setItem === "function") {
+ NS.persistence.setItem(key, value);
+ return;
+ }
+ const store = safeStorage();
+ if (store) store.setItem(key, String(value));
+ }
+
function loadPersistedState(viewDef) {
const state = {};
- const store = safeStorage();
- if (!store) return state;
(viewDef.ribbon || []).forEach(function (a) {
if (a.type === "option" && a.persist) {
- const raw = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+ const raw = storageGet(ribbonStorageKey(viewDef.id, a.id));
if (raw != null) state[a.id] = (raw === "true");
} else if (a.type === "optionGroup" && a.persist) {
- const sel = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+ const sel = storageGet(ribbonStorageKey(viewDef.id, a.id));
if (sel != null) state[a.id] = sel;
}
});
@@ -180,8 +214,7 @@
}
function persist(viewDef, id, value) {
- const store = safeStorage();
- if (store) store.setItem(ribbonStorageKey(viewDef.id, id), String(value));
+ storageSet(ribbonStorageKey(viewDef.id, id), String(value));
}
/**
@@ -241,7 +274,10 @@
// Still registers/feature-gates each button with DataTables Buttons - but renders our own
// first-party icon buttons instead of delegating to Buttons' own DOM (design doc §4.A); a
// click programmatically triggers the SAME, already-reviewed Buttons action (design doc §7).
- new $.fn.dataTable.Buttons(ctx.dataTable, { buttons: ids });
+ new $.fn.dataTable.Buttons(ctx.dataTable, {
+ buttons: ids,
+ exportOptions: { columns: ":visible" }
+ });
const exportGroupId = a.group != null ? a.group : ("__export" + idx);
ids.forEach(function (id) {
place(button(id, resolveButtonIcon(null, id), function () {
@@ -346,6 +382,7 @@
// pure
columnIndex: columnIndex,
optionParam: optionParam,
+ indexForRibbonColumn: indexForRibbonColumn,
ribbonToQueryParams: ribbonToQueryParams,
detectExportFeatures: detectExportFeatures,
resolveExportButtons: resolveExportButtons,
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 9f1a8b414a..d9d62cf856 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
@@ -399,10 +399,8 @@ table.dataTable > tbody > tr > td {
/*
* Row-details expander - initDetailsExpander(...)/createdRow (juneau-views.js) mark every row
- * `.juneau-view-detail-row` when the view declares a `details` field list; a `::before` triangle glyph on the
- * first cell (a bare CSS content string, like the paging pill's checkmark above - no icon-registry dependency)
- * flips direction via the `.juneau-view-detail-open` class the same click handler toggles. NEUTRAL - no palette
- * color; console-ui's chrome.css MAY theme these same classes when present.
+ * `.juneau-view-detail-row` when a sibling `<template data-juneau-row-detail>` is present; a `::before` triangle
+ * glyph on the first cell flips direction via the `.juneau-view-detail-open` class. NEUTRAL - no palette color.
*/
.juneau-view-detail-row {
cursor: pointer;
@@ -425,15 +423,33 @@ table.dataTable > tbody > tr > td {
padding: 0.5em 1em;
}
-.juneau-view-detail-panel dt {
- float: left;
- clear: left;
- width: 40%;
+.juneau-view-detail-section {
+ margin: 0 0 1em;
+}
+
+.juneau-view-detail-section-title {
+ margin: 0 0 0.5em;
+ font-size: 1em;
+}
+
+.juneau-view-detail-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ margin: 0 0 0.75em;
+}
+
+.juneau-view-detail-fields {
+ display: grid;
+ gap: 0.5em 1em;
+}
+
+.juneau-view-detail-field-title {
font-weight: 600;
}
-.juneau-view-detail-panel dd {
- margin: 0 0 0.35em 42%;
+.juneau-view-detail-status {
+ margin: 0.5em 0 0;
}
/*
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 b5f085b4ca..7600fa4770 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
@@ -38,7 +38,7 @@
// Contract-version handshake: MUST equal ViewDef.CONTRACT_VERSION / ViewsMixin.CONTRACT_VERSION (single source
// of truth on the server). The initializer fails loud when a sidecar's contractVersion differs.
- const JUNEAU_VIEW_CONTRACT_VERSION = "3";
+ const JUNEAU_VIEW_CONTRACT_VERSION = "4";
// The typed action-result contract version (ActionResult.CONTRACT_VERSION on the server). This is a SEPARATE,
// independently-versioned wire contract from VIEW_META - it is deliberately NOT aliased to
@@ -57,6 +57,9 @@
*/
const JUNEAU_BULK_CONTRACT_VERSION = "1";
+ /** Independently-versioned row-detail expand envelope ({@code RowDetailDef.CONTRACT_VERSION}). */
+ const JUNEAU_ROW_DETAIL_CONTRACT_VERSION = "1";
+
/**
* TODO-428 selection/bulk DOM attribute names - MUST equal ViewTable's constants of the same names
* (SELECT_ATTR, ROW_ID_FIELD_ATTR, SELECT_ALL_ATTR, BULK_ATTR, BULK_SIDECAR_ID_PREFIX) on the server. Selection
@@ -77,6 +80,7 @@
NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
NS.ACTION_RESULT_CONTRACT_VERSION = JUNEAU_ACTION_RESULT_CONTRACT_VERSION;
NS.BULK_CONTRACT_VERSION = JUNEAU_BULK_CONTRACT_VERSION;
+ NS.ROW_DETAIL_CONTRACT_VERSION = JUNEAU_ROW_DETAIL_CONTRACT_VERSION;
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
@@ -101,16 +105,43 @@
return -1;
}
+ /**
+ * The live DataTables index of `dataKey` in the ACTUAL `opts.columns` array. Delegates to slice 4's
+ * {@code JuneauViews.config.dtIndex} when juneau-config.js is present; otherwise walks the same array
+ * (the no-config-js seam still has to fix the selection-offset off-by-one). Returns -1 when absent.
+ */
+ function liveDtIndex(dataKey, optsColumns) {
+ if (NS.config && typeof NS.config.dtIndex === "function")
+ return NS.config.dtIndex(dataKey, optsColumns);
+ if (!optsColumns) return -1;
+ for (let i = 0; i < optsColumns.length; i++)
+ if (optsColumns[i] && optsColumns[i].data === dataKey) return i;
+ return -1;
+ }
+
/**
* Resolves `defaultOrder` [{data,dir}] to DataTables' positional `order` [[colIndex, dir]] by field name (m2) -
* indices are not pinned server-side, so client-side column reorder stays correct. Unknown fields are skipped.
+ * When `optsColumns` (the ACTUAL DataTables column array) is supplied, indices go through {@link #liveDtIndex}
+ * so a leading selection column cannot off-by-one the ordered field; a now-hidden ordered column falls back
+ * to the first visible orderable catalog column.
*/
- function resolveOrder(viewDef) {
+ function resolveOrder(viewDef, optsColumns) {
const out = [];
(viewDef.defaultOrder || []).forEach(function (e) {
- const idx = columnIndexOf(viewDef, e.data);
- if (idx >= 0) out.push([idx, e.dir]);
+ const idx = optsColumns ? liveDtIndex(e.data, optsColumns) : columnIndexOf(viewDef, e.data);
+ if (idx < 0) return;
+ if (optsColumns && optsColumns[idx] && optsColumns[idx].visible === false) return;
+ out.push([idx, e.dir]);
});
+ if (out.length === 0 && optsColumns) {
+ for (let i = 0; i < optsColumns.length; i++) {
+ const c = optsColumns[i];
+ if (!c || c.data == null || c.visible === false || c.orderable === false) continue;
+ out.push([i, "asc"]);
+ break;
+ }
+ }
return out;
}
@@ -142,18 +173,53 @@
}
/**
- * Projects a VIEW_META `details` field list ([{data,title}]) against one row's data into label/value pairs
- * for the row-details expander (client-rendered from row data by default - this never issues a request of
- * its own) - pure, DOM-free. A field whose row value is null/undefined renders as "" rather than
- * "null"/"undefined", matching evaluateRowClassRules' null-safety above.
+ * Whether `endpoint` is a same-origin path template: no `://`, no `//` prefix, no scheme colon-before-slash,
+ * and no `..` path segments. Mirrors {@code RowDetailDef.isSafeDetailEndpoint}.
*/
- function buildDetailFields(details, rowData) {
- const out = [];
- (details || []).forEach(function (d) {
- const v = rowData ? rowData[d.data] : undefined;
- out.push({ title: d.title || d.data, value: (v == null ? "" : String(v)) });
- });
- return out;
+ function isSafeDetailUrl(url) {
+ if (url == null || String(url).trim() === "") return false;
+ const s = String(url);
+ if (s.indexOf("://") >= 0) return false;
+ if (s.indexOf("//") === 0) return false;
+ const colon = s.indexOf(":");
+ const slash = s.indexOf("/");
+ if (colon >= 0 && (slash < 0 || colon < slash)) return false;
+ if (/(^|\/)\.\.(\/|$)/.test(s)) return false;
+ return true;
+ }
+
+ /**
+ * Substitutes `{id}` via encodeURIComponent and re-checks the result is still a same-origin path.
+ * Returns null when the template or the substituted URL is unsafe.
+ */
+ function substituteDetailUrl(template, id) {
+ if (!isSafeDetailUrl(template) || id == null) return null;
+ const encoded = encodeURIComponent(String(id));
+ const url = String(template).split("{id}").join(encoded);
+ return isSafeDetailUrl(url) ? url : null;
+ }
+
+ /** Coalesce key for an in-flight expand GET: row id + expander generation. */
+ function detailCoalesceKey(rowId, generation) {
+ return String(rowId) + ":" + generation;
+ }
+
+ /** Scalar expand-JSON values become strings; objects/arrays/undefined become "". */
+ function scalarFieldValue(v) {
+ if (v == null) return "";
+ const t = typeof v;
+ if (t === "string" || t === "number" || t === "boolean") return String(v);
+ return "";
+ }
+
+ /** Loud-handshake check for the expand GET envelope. */
+ function detailContractOk(body, expected) {
+ return !!(body && typeof body === "object" && body.contractVersion === (expected || JUNEAU_ROW_DETAIL_CONTRACT_VERSION));
+ }
+
+ /** True when a settled GET must be discarded (child gone or generation mismatch). */
+ function shouldDropDetailPayload(childShown, expectedGen, actualGen) {
+ return !childShown || expectedGen !== actualGen;
}
/**
@@ -466,6 +532,7 @@
if (col.title != null) def.title = col.title;
if (col.name != null) def.name = col.name;
if (col.className != null) def.className = col.className;
+ if (col.visible === false) def.visible = false;
if (col.render) {
const spec = deps.parseRenderId(col.render);
@@ -501,8 +568,12 @@
*/
function buildOptions(viewDef, deps) {
const opts = {};
- opts.columns = (viewDef.columns || []).map(function (c) { return buildColumnDef(c, deps); });
- opts.order = resolveOrder(viewDef);
+ // Catalog-column defs only. Selection / actions / order are assembled AFTERWARDS from the full
+ // `opts.columns` array (see assembleFullColumnArray) so a leading selection column cannot off-by-one
+ // defaultOrder or the ribbon/search indices. `deps.effectiveColumns` is the post-chooser model when
+ // present; otherwise the sidecar catalog.
+ const catalog = (deps && deps.effectiveColumns) || viewDef.columns || [];
+ opts.columns = catalog.map(function (c) { return buildColumnDef(c, deps); });
// Text polish (design doc §4.B): the native search input's label is blanked (searchPlaceholder replaces it
// as the input's placeholder attribute); the native length-select's language.lengthMenu is deliberately NOT
// set here - Task 9's paging pill hides and replaces that control entirely, so there is nothing left for it
@@ -535,26 +606,11 @@
});
// Marks every row as expandable when the view declares a details field list - initDetailsExpander
// (below) delegates its click listener off this class rather than binding one handler per row.
- if (viewDef.details && viewDef.details.length) {
+ if (deps.hasRowDetail) {
rowEl.className += (rowEl.className ? " " : "") + "juneau-view-detail-row";
}
};
- // A declared rowActions list appends ONE synthetic, non-orderable/non-searchable trailing column whose
- // cell is the per-row action trigger. initTable(...) appends the matching <th> to <thead> before booting
- // DataTables so column and header counts stay in step. The action BEHAVIOR (modal/form/typed result) is a
- // later wave (TODO-416); this column only surfaces the menu trigger and routes its click to submitRowAction.
- if (viewDef.rowActions && viewDef.rowActions.length) {
- opts.columns.push({
- data: null,
- orderable: false,
- searchable: false,
- className: "juneau-view-actions-cell",
- defaultContent: "",
- title: "",
- render: function () { return actionTriggerMarkup(); }
- });
- }
return opts;
}
@@ -771,16 +827,19 @@
* (`ctx.onColumnSearchToggle`, wired by the caller, toggles visibility). Returns null when the table has no
* `<thead>` (defensive; every juneau view table has one).
*/
- function buildColumnSearchRow(table, viewDef, dt) {
+ function buildColumnSearchRow(table, optsColumns, dt) {
const thead = table.querySelector("thead");
if (!thead) return null;
const row = document.createElement("tr");
row.className = "juneau-view-columnsearch-row";
row.setAttribute("data-testid", "col-search-row");
row.style.display = "none";
- (viewDef.columns || []).forEach(function (col, idx) {
+ (optsColumns || []).forEach(function (col, idx) {
const th = document.createElement("th");
- if (col.searchable !== false) {
+ const isSynthetic = !col || col.data == null;
+ const isHidden = col && col.visible === false;
+ if (isHidden) th.style.display = "none";
+ if (!isSynthetic && !isHidden && col.searchable !== false) {
const input = document.createElement("input");
input.type = "text";
input.className = "juneau-view-columnsearch-input";
@@ -857,6 +916,35 @@
return !!table.querySelector("tbody tr[data-juneau-inflight]");
}
+ /** Whether `table` currently has a row streaming an async job (`data-juneau-job` marker). */
+ function hasJobRow(table) {
+ return !!table.querySelector("tbody tr[data-juneau-job]");
+ }
+
+ /**
+ * Visible Apply-refused notice (teardown step 1). Never queued; the user re-applies once in-flight / job
+ * markers clear. Painted with {@code textContent} only.
+ */
+ function renderReinitNotice(table, message) {
+ const host = table.parentNode || table;
+ let el = host.querySelector ? host.querySelector(".juneau-view-reinit-notice") : null;
+ if (!el) {
+ el = document.createElement("div");
+ el.className = "juneau-view-reinit-notice";
+ el.setAttribute("role", "alert");
+ el.setAttribute("data-testid", "reinit-notice");
+ if (table.parentNode) table.parentNode.insertBefore(el, table);
+ else table.insertBefore(el, table.firstChild);
+ }
+ el.textContent = message;
+ }
+
+ function clearReinitNotice(table) {
+ const host = table.parentNode || table;
+ const el = host.querySelector ? host.querySelector(".juneau-view-reinit-notice") : null;
+ if (el && el.parentNode) el.parentNode.removeChild(el);
+ }
+
/**
* Builds the per-table staleness-indicator chip (per-table, never a single page-level chip). Starts in the
* neutral "fresh" state; {@link #initPolling} drives every subsequent update.
@@ -870,40 +958,98 @@
}
/**
- * Builds the row-details expander's detail-body element (client-rendered from row data by default). A
- * plain `<dl>` of label/value pairs built with `textContent` only (never `innerHTML`) - the values are row
- * data, not markup, so this stays safe by construction without needing an escaper (unlike a raw-markup
- * panel content feature, which is a different, still-gated, question). There is no server-render path wired
- * here; see the class comment atop `ViewDef.java`'s row-details-expander section for why that path is
- * deferred rather than designed twice.
- */
- function buildDetailPanel(fields) {
- const dl = document.createElement("dl");
- dl.className = "juneau-view-detail-panel";
- dl.setAttribute("data-testid", "detail-panel");
- fields.forEach(function (f) {
- const dtEl = document.createElement("dt");
- dtEl.textContent = f.title;
- const ddEl = document.createElement("dd");
- ddEl.textContent = f.value;
- dl.appendChild(dtEl);
- dl.appendChild(ddEl);
- });
- return dl;
+ * Locates the server-emitted {@code <template data-juneau-row-detail>} sibling of `table`.
+ */
+ function findRowDetailTemplate(table) {
+ const parent = table && table.parentNode;
+ return parent && parent.querySelector ? parent.querySelector("template[data-juneau-row-detail]") : null;
+ }
+
+ /**
+ * Fills `[data-juneau-field]` slots from an expand JSON `fields` map via textContent only. Unknown keys are
+ * dropped; missing keys and non-scalars become empty.
+ */
+ function fillDetailSlots(root, fields) {
+ if (!root || !root.querySelectorAll) return;
+ const map = fields && typeof fields === "object" ? fields : {};
+ const slots = root.querySelectorAll("[data-juneau-field]");
+ for (let i = 0; i < slots.length; i++) {
+ const key = slots[i].getAttribute("data-juneau-field");
+ slots[i].textContent = Object.hasOwn(map, key) ? scalarFieldValue(map[key]) : "";
+ }
+ }
+
+ /** Enables or disables ActionRef buttons; SafeAction.COLLAPSE is never touched. */
+ function setActionRefEnabled(root, enabled) {
+ if (!root || !root.querySelectorAll) return;
+ const buttons = root.querySelectorAll("[data-juneau-action]");
+ for (let i = 0; i < buttons.length; i++)
+ buttons[i].disabled = !enabled;
+ }
+
+ /** Hides ActionRef buttons (404/500 / contract-fail closed) while leaving COLLAPSE in place. */
+ function hideActionRefs(root) {
+ if (!root || !root.querySelectorAll) return;
+ const buttons = root.querySelectorAll("[data-juneau-action]");
+ for (let i = 0; i < buttons.length; i++) {
+ buttons[i].disabled = true;
+ buttons[i].hidden = true;
+ }
+ }
+
+ function findRowActionById(viewDef, id) {
+ const actions = viewDef && viewDef.rowActions ? viewDef.rowActions : [];
+ for (let i = 0; i < actions.length; i++)
+ if (actions[i] && actions[i].id === id) return actions[i];
+ return null;
}
/**
* Wires the row-details expander via DataTables' native child-row API. ONE delegated click listener on
- * `table` (rather than one per row) toggles a client-rendered detail panel, built from that row's OWN data
- * (no extra network request), for whichever `.juneau-view-detail-row` `<tr>` was clicked; `createdRow` (see
- * `buildOptions` above) is what applies that marker class.
+ * `table` toggles expand, collapse, and ActionRef submit. Expand GETs the stamped URL; ActionRef buttons
+ * stay disabled until 2xx + loud contract OK. SafeAction.COLLAPSE works during loading and after failure.
*
- * <p>"Collapse on redraw" needs no extra code here: DataTables' child-row API does not survive a `draw.dt` -
- * a sort, page change, search, or poll tick rebuilds `<tbody>` (and any open child `<tr>` along with it),
- * which IS the accepted behavior, not an oversight.
+ * <p>"Collapse on redraw" needs no extra code here: DataTables' child-row API does not survive a `draw.dt`.
*/
- function initDetailsExpander(table, dt, viewDef) {
+ function initDetailsExpander(table, ctx, viewDef) {
+ if (!ctx._detailInflight) ctx._detailInflight = new Map();
+ if (!ctx._detailGeneration) ctx._detailGeneration = new WeakMap();
+
table.addEventListener("click", function (e) {
+ const dt = ctx.dataTable;
+ if (!dt) return;
+ const tpl = findRowDetailTemplate(table);
+ if (!tpl) return;
+
+ const safeBtn = e.target && e.target.closest ? e.target.closest("[data-juneau-safe=\"collapse\"]") : null;
+ if (safeBtn) {
+ e.preventDefault();
+ e.stopPropagation();
+ const panel = safeBtn.closest(".juneau-view-detail-panel");
+ const parentTr = panel && panel._juneauParentTr;
+ if (!parentTr) return;
+ const row = dt.row(parentTr);
+ if (row && row.child && row.child.isShown()) {
+ row.child.hide();
+ parentTr.classList.remove("juneau-view-detail-open");
+ }
+ return;
+ }
+
+ const actionBtn = e.target && e.target.closest ? e.target.closest("[data-juneau-action]") : null;
+ if (actionBtn) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (actionBtn.disabled || actionBtn.hidden) return;
+ const panel = actionBtn.closest(".juneau-view-detail-panel");
+ const parentTr = panel && panel._juneauParentTr;
+ if (!parentTr) return;
+ const action = findRowActionById(viewDef, actionBtn.getAttribute("data-juneau-action"));
+ if (!action) return;
+ submitRowAction(action, table, parentTr, ctx);
+ return;
+ }
+
const tr = e.target && e.target.closest ? e.target.closest("tr.juneau-view-detail-row") : null;
if (!tr) return;
const row = dt.row(tr);
@@ -911,10 +1057,107 @@
if (row.child.isShown()) {
row.child.hide();
tr.classList.remove("juneau-view-detail-open");
- } else {
- row.child(buildDetailPanel(buildDetailFields(viewDef.details, row.data()))).show();
- tr.classList.add("juneau-view-detail-open");
+ return;
}
+ expandDetailRow(table, ctx, viewDef, tpl, dt, tr, row);
+ });
+ }
+
+ function expandDetailRow(table, ctx, viewDef, tpl, dt, tr, row) {
+ const gen = (ctx._detailGeneration.get(tr) || 0) + 1;
+ ctx._detailGeneration.set(tr, gen);
+ const rowId = tr.getAttribute(ROW_ID_ATTR);
+ const key = detailCoalesceKey(rowId, gen);
+
+ const panel = document.createElement("div");
+ panel.className = "juneau-view-detail-panel";
+ panel.setAttribute("data-testid", "detail-panel");
+ panel.setAttribute("data-juneau-detail-state", "loading");
+ panel._juneauParentTr = tr;
+ panel.appendChild(tpl.content.cloneNode(true));
+ const loading = document.createElement("p");
+ loading.className = "juneau-view-detail-status";
+ loading.textContent = "Loading…";
+ panel.appendChild(loading);
+ row.child(panel).show();
+ tr.classList.add("juneau-view-detail-open");
+
+ function stillCurrent() {
+ return ctx._detailGeneration.get(tr) === gen && row.child.isShown();
+ }
+
+ function settleMap() {
+ if (ctx._detailInflight) ctx._detailInflight.delete(key);
+ }
+
+ function failClosed(kind, message) {
+ if (!stillCurrent()) { settleMap(); return; }
+ hideActionRefs(panel);
+ panel.setAttribute("data-juneau-detail-state", kind);
+ loading.textContent = message;
+ settleMap();
+ }
+
+ if (rowId == null || String(rowId) === "") {
+ failClosed("error", "this row has no stable id");
+ return;
+ }
+
+ const url = substituteDetailUrl(tpl.getAttribute("data-juneau-detail-url"), rowId);
+ if (!url) {
+ failClosed("error", "detail URL is not a same-origin path template");
+ return;
+ }
+
+ function handleEnvelope(env) {
+ if (!stillCurrent()) { settleMap(); return; }
+ if (!env) {
+ failClosed("error", "the detail request could not be completed");
+ return;
+ }
+ if (env.status === 404) {
+ failClosed("empty", "this row is gone");
+ return;
+ }
+ if (!env.ok) {
+ failClosed("error", "the detail request was refused (" + env.status + ")");
+ return;
+ }
+ const body = parseJsonSafe(env.text);
+ const expected = tpl.getAttribute("data-juneau-detail-contract") || JUNEAU_ROW_DETAIL_CONTRACT_VERSION;
+ if (!detailContractOk(body, expected)) {
+ const m = "Juneau view '" + (viewDef && viewDef.id ? viewDef.id : "") +
+ "': row-detail contract version mismatch (page='" +
+ (body && body.contractVersion) + "', runtime='" + expected + "').";
+ error(m);
+ renderBanner(table, m);
+ failClosed("error", m);
+ return;
+ }
+ if (shouldDropDetailPayload(row.child.isShown(), gen, ctx._detailGeneration.get(tr))) {
+ settleMap();
+ return;
+ }
+ fillDetailSlots(panel, body.fields);
+ setActionRefEnabled(panel, true);
+ panel.setAttribute("data-juneau-detail-state", "ok");
+ if (loading.parentNode) loading.parentNode.removeChild(loading);
+ settleMap();
+ }
+
+ const inflight = ctx._detailInflight;
+ let req = inflight.get(key);
+ if (!req) {
+ req = fetch(url, { method: "GET", credentials: "same-origin", headers: { "Accept": "application/json" } })
+ .then(function (resp) {
+ return readBodyText(resp).then(function (text) {
+ return { status: resp.status, ok: resp.ok, text: text };
+ });
+ });
+ inflight.set(key, req);
+ }
+ req.then(handleEnvelope).catch(function () {
+ failClosed("error", "the detail request could not be completed");
});
}
@@ -930,7 +1173,7 @@
* "error" state without touching the last-success timestamp, so a frozen clock and a broken poll never look
* identical to a healthy one (the whole point of this function).
*/
- function initPolling(table, dt, viewDef, indicator) {
+ function initPolling(table, dt, viewDef, indicator, ctx) {
const intervalMs = clampPollInterval(viewDef.pollIntervalMs);
const state = { lastSuccessAt: Date.now(), failed: false };
@@ -949,10 +1192,14 @@
dt.ajax.reload(null, false);
}
- setInterval(poll, intervalMs);
+ if (ctx && ctx._pollTimers) {
+ ctx._pollTimers.forEach(function (id) { clearInterval(id); });
+ }
+ const pollId = setInterval(poll, intervalMs);
// The visible age ("5s ago" -> "6s ago" ...) must keep advancing between polls, independent of the data-
// fetch cadence - a short, fixed, network-free tick keeps the label honest without any extra ajax cost.
- setInterval(render, 1000);
+ const renderId = setInterval(render, 1000);
+ if (ctx) ctx._pollTimers = [pollId, renderId];
render();
}
@@ -1017,8 +1264,12 @@
* `submitActionDialog`) treats it as already-authoritative once stamped.
*/
function stampRowId(rowEl, rowData, rowIdField) {
- if (!rowIdField) return;
- const id = rowIdOf(rowData, rowIdField);
+ var id;
+ if (rowIdField) id = rowIdOf(rowData, rowIdField);
+ if (id == null && rowData) {
+ if (rowData.id != null) id = rowData.id;
+ else if (rowData.name != null) id = rowData.name;
+ }
if (id != null) rowEl.setAttribute(ROW_ID_ATTR, String(id));
}
@@ -1057,7 +1308,7 @@
* page, or a TODO-426 poll tick). Select-all is scoped to the CURRENT draw's rows only (the ones actually on
* screen) - consistent with the drop rule, it can never reach into an off-screen page.
*/
- function initSelection(table, dt, selectionState, ctx) {
+ function initSelection(table, ctx) {
function currentRowIds() {
const ids = [];
Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
@@ -1067,10 +1318,23 @@
}
function refresh() {
- if (ctx && ctx.bulkToolbar) ctx.bulkToolbar.refresh(selectionState.selected.size);
+ if (ctx && ctx.bulkToolbar) ctx.bulkToolbar.refresh(ctx.selectionState.selected.size);
}
table.addEventListener("change", function (e) {
+ const selectionState = ctx.selectionState;
+ if (!selectionState) return;
+ const allCb = e.target && e.target.closest ? e.target.closest(".juneau-view-select-all-checkbox") : null;
+ if (allCb) {
+ Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+ const id = tr.getAttribute(ROW_ID_ATTR);
+ const cb = tr.querySelector(".juneau-view-select-checkbox");
+ if (cb) cb.checked = allCb.checked;
+ if (allCb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
+ });
+ refresh();
+ return;
+ }
const cb = e.target && e.target.closest ? e.target.closest(".juneau-view-select-checkbox") : null;
if (!cb) return;
const tr = cb.closest("tr");
@@ -1080,36 +1344,42 @@
refresh();
});
- if (table.getAttribute(SELECT_ALL_ATTR) === "1") {
- const th = table.querySelector(".juneau-view-select-th");
- if (th) {
- const allCb = document.createElement("input");
- allCb.type = "checkbox";
- allCb.className = "juneau-view-select-all-checkbox";
- allCb.setAttribute("aria-label", "Select all rows on this page");
- allCb.addEventListener("change", function () {
- Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
- const id = tr.getAttribute(ROW_ID_ATTR);
- const cb = tr.querySelector(".juneau-view-select-checkbox");
- if (cb) cb.checked = allCb.checked;
- if (allCb.checked) selectionState.selected.add(id); else selectionState.selected.delete(id);
- });
- refresh();
- });
- th.appendChild(allCb);
- }
- }
+ // Select-all checkbox element is (re)created per build in ensureSelectAllCheckbox - the listener above is
+ // delegated off `table` so it is bound exactly once (listener-lifetime class (a)).
+ }
- // The off-screen-id-drop rule (Q2/MED-11): every redraw prunes the live selection down to ids still
- // actually on screen, so a poll/sort/page tick can never leave a bulk mutate targeting a row the user can
- // no longer see.
+ /**
+ * Per-instance off-screen-id-drop prune (listener-lifetime class (b)). Re-registered against the freshly
+ * constructed DataTables instance on every buildTable; dies with {@code dt.destroy()}.
+ */
+ function bindSelectionPrune(table, ctx) {
+ const dt = ctx.dataTable;
+ if (!dt || !ctx.selectionState) return;
dt.on("draw.dt", function () {
- const pruned = pruneSelection(Array.from(selectionState.selected), currentRowIds());
+ const selectionState = ctx.selectionState;
+ const ids = [];
+ Array.prototype.forEach.call(table.querySelectorAll("tbody tr[" + ROW_ID_ATTR + "]"), function (tr) {
+ ids.push(tr.getAttribute(ROW_ID_ATTR));
+ });
+ const pruned = pruneSelection(Array.from(selectionState.selected), ids);
selectionState.selected = new Set(pruned);
- refresh();
+ if (ctx.bulkToolbar) ctx.bulkToolbar.refresh(selectionState.selected.size);
});
}
+ /** Ensures the select-all checkbox exists in the (possibly restored) selection header cell. No listener. */
+ function ensureSelectAllCheckbox(table) {
+ if (table.getAttribute(SELECT_ALL_ATTR) !== "1") return;
+ const th = table.querySelector(".juneau-view-select-th");
+ if (!th) return;
+ if (th.querySelector(".juneau-view-select-all-checkbox")) return;
+ const allCb = document.createElement("input");
+ allCb.type = "checkbox";
+ allCb.className = "juneau-view-select-all-checkbox";
+ allCb.setAttribute("aria-label", "Select all rows on this page");
+ th.appendChild(allCb);
+ }
+
/**
* Reads and JSON.parses the bulk-actions sidecar (`BULK_SIDECAR_ID_PREFIX + id`) - the independently-versioned
* {@code BulkMutateDef} contract, deliberately never merged into VIEW_META (R2 guard). Returns `null` on a
@@ -1445,6 +1715,7 @@
if (st.settled) return;
st.settled = true;
es.close();
+ if (ctx && ctx._jobSources) ctx._jobSources.delete(es);
setRowJobRunning(tr, false);
clearJobProgress(tr);
if (result) finishJobFromResult(action, table, tr, ctx, result);
@@ -1459,7 +1730,10 @@
es.addEventListener("error", function () {
finish(null, { outcome: "unknown", message: "the progress stream was interrupted" });
});
- if (ctx) ctx._jobSource = es;
+ if (ctx) {
+ if (!ctx._jobSources) ctx._jobSources = new Set();
+ ctx._jobSources.add(es);
+ }
return es;
}
@@ -1592,12 +1866,16 @@
/** Shows a dialog overlay for an action and wires its confirm (submit) / cancel (dismiss) buttons. */
function showActionDialog(modal, action, table, tr, ctx) {
const ui = buildDialogOverlay(modal, action);
- function close() { if (ui.backdrop.parentNode) ui.backdrop.parentNode.removeChild(ui.backdrop); }
+ function close() {
+ if (ui.backdrop.parentNode) ui.backdrop.parentNode.removeChild(ui.backdrop);
+ if (ctx && ctx._actionDialog === ui.backdrop) ctx._actionDialog = null;
+ }
ui.cancelBtn.addEventListener("click", close);
ui.confirmBtn.addEventListener("click", function () {
close();
submitActionDialog(modal, action, table, tr, ctx);
});
+ if (ctx) ctx._actionDialog = ui.backdrop;
document.body.appendChild(ui.backdrop);
return ui;
}
@@ -1675,7 +1953,7 @@
* a row's `.juneau-view-action-trigger` toggles a menu of that view's rowActions for that row. Mirrors the
* details-expander's delegated-listener pattern above.
*/
- function initRowActions(table, dt, viewDef, ctx) {
+ function initRowActions(table, viewDef, ctx) {
table.addEventListener("click", function (e) {
const trigger = e.target && e.target.closest ? e.target.closest(".juneau-view-action-trigger") : null;
if (!trigger) return;
@@ -1689,11 +1967,243 @@
});
}
- // NOSONAR javascript:S3776 -- sequential wiring of one view table's DataTables instance, ribbon, paging pill,
- // column search, details expander, and polling; several of these steps and their exact call order are pinned
- // verbatim by the wiring canary tests below `functionBody(body, "function initTable(")`, so splitting them
- // into further helpers would reduce test/code locality without reducing real complexity.
- function initTable(table) {
+ function removeEl(el) {
+ if (el && el.parentNode) el.parentNode.removeChild(el);
+ }
+
+ function closeActionDialog(ctx) {
+ const el = ctx && ctx._actionDialog;
+ if (el && el.parentNode) el.parentNode.removeChild(el);
+ if (ctx) ctx._actionDialog = null;
+ Array.prototype.forEach.call(document.querySelectorAll(".juneau-view-dialog-backdrop"), removeEl);
+ }
+
+ function stripGeneratedDom(table) {
+ const wrapper = table.parentNode;
+ if (wrapper) {
+ Array.prototype.forEach.call(wrapper.querySelectorAll(".juneau-view-toolbar-row"), removeEl);
+ }
+ Array.prototype.forEach.call(table.querySelectorAll("thead tr.juneau-view-columnsearch-row"), removeEl);
+ Array.prototype.forEach.call(table.querySelectorAll("thead th.juneau-view-actions-th"), removeEl);
+ Array.prototype.forEach.call(table.querySelectorAll("thead th.juneau-view-select-th"), removeEl);
+ }
+
+ /**
+ * Restores the server-emitted header shell after destroy: re-create the leading selection {@code <th>}
+ * (ViewTable emitted it on first paint; the server will not re-run) then append the trailing actions
+ * {@code <th>} as JS does today.
+ */
+ function restoreHeaderShell(table, ctx) {
+ if (ctx.selectionState) {
+ const headRow = table.querySelector("thead tr");
+ if (headRow && !headRow.querySelector(".juneau-view-select-th")) {
+ const selTh = document.createElement("th");
+ selTh.className = "juneau-view-select-th";
+ headRow.insertBefore(selTh, headRow.firstChild);
+ }
+ }
+ if (ctx.viewDef.rowActions && ctx.viewDef.rowActions.length) {
+ appendActionsHeaderCell(table);
+ }
+ }
+
+ function teardownTable(table, ctx) {
+ if (ctx._pollTimers) {
+ ctx._pollTimers.forEach(function (id) { clearInterval(id); });
+ ctx._pollTimers = [];
+ }
+ if (ctx._jobSources) {
+ ctx._jobSources.forEach(function (es) {
+ try { if (es && es.close) es.close(); } catch (e) { /* already closed */ }
+ });
+ ctx._jobSources.clear();
+ }
+ closeActionDialog(ctx);
+ closeRowActionMenus(table);
+ if (ctx.dataTable) {
+ try { ctx.dataTable.destroy(); } catch (e) { /* already destroyed */ }
+ ctx.dataTable = null;
+ }
+ stripGeneratedDom(table);
+ }
+
+ /**
+ * Catalog → effective columns. When juneau-config.js is present, delegates to
+ * {@code computeEffectiveColumns}; otherwise the no-op seam (default catalog columns, all visible).
+ */
+ function resolveEffectiveColumns(viewDef, savedView) {
+ if (NS.config && typeof NS.config.computeEffectiveColumns === "function")
+ return NS.config.computeEffectiveColumns(viewDef.columns || [], savedView);
+ return (viewDef.columns || []).map(function (c) {
+ const copy = {};
+ for (const k in c) if (Object.hasOwn(c, k)) copy[k] = c[k];
+ copy.visible = true;
+ return copy;
+ });
+ }
+
+ /**
+ * Builds the FULL {@code opts.columns} array FIRST =
+ * {@code [selection?] + effectiveColumns(including hidden, in order) + [actions?]}, THEN derives
+ * {@code opts.order} from {@link #liveDtIndex}. Do not unshift selection after {@link #resolveOrder}.
+ */
+ function assembleFullColumnArray(opts, viewDef, ctx) {
+ const catalogCols = opts.columns || [];
+ const cols = [];
+ if (ctx.selectionState) {
+ const sel = buildSelectionColumnDef(ctx.selectionState);
+ sel._juneau = "selection";
+ cols.push(sel);
+ }
+ catalogCols.forEach(function (c) { cols.push(c); });
+ if (viewDef.rowActions && viewDef.rowActions.length) {
+ cols.push({
+ data: null,
+ _juneau: "actions",
+ orderable: false,
+ searchable: false,
+ className: "juneau-view-actions-cell",
+ defaultContent: "",
+ title: "",
+ render: function () { return actionTriggerMarkup(); }
+ });
+ }
+ opts.columns = cols;
+ const priorCreatedRow = opts.createdRow;
+ opts.createdRow = function (rowEl, rowData, index) {
+ if (priorCreatedRow) priorCreatedRow(rowEl, rowData, index);
+ const field = ctx.selectionState ? ctx.selectionState.rowIdField : null;
+ stampRowId(rowEl, rowData, field);
+ };
+ opts.order = resolveOrder(viewDef, opts.columns);
+ }
+
+ function constructTable(table, viewDef, effectiveColumns, ctx) {
+ const $ = window.jQuery;
+ restoreHeaderShell(table, ctx);
+ clearReinitNotice(table);
+
+ const deps = {
+ parseRenderId: NS.parseRenderId,
+ resolveRenderer: NS.resolveRenderer,
+ warn: warn,
+ effectiveColumns: effectiveColumns,
+ hasRowDetail: !!findRowDetailTemplate(table),
+ ribbonParams: function () {
+ return NS.ribbon?.ribbonToQueryParams
+ ? NS.ribbon.ribbonToQueryParams(viewDef, ctx.activeState, ctx.optsColumns)
+ : {};
+ }
+ };
+
+ const opts = buildOptions(viewDef, deps);
+ assembleFullColumnArray(opts, viewDef, ctx);
+ ctx.optsColumns = opts.columns;
+ ctx.effectiveColumns = effectiveColumns;
+
+ // DataTables treats column.title as HTML. When juneau-config.js is present, strip titles from the
+ // opts array and paint header text with textContent after boot so a user label override cannot XSS.
+ if (NS.config && typeof NS.config.sanitizeColumnTitlesForDataTables === "function")
+ NS.config.sanitizeColumnTitlesForDataTables(opts.columns);
+
+ ctx.dataTable = $(table).DataTable(opts);
+ if (NS.config && typeof NS.config.paintHeaderTitles === "function")
+ NS.config.paintHeaderTitles(table, effectiveColumns, ctx);
+ if (ctx._detailInflight) {
+ ctx.dataTable.on("draw.dt", function () {
+ if (ctx._detailInflight) ctx._detailInflight.clear();
+ });
+ }
+ ctx.redraw = function () {
+ const d = ctx.dataTable;
+ if (!d) return;
+ if (d.ajax) d.ajax.reload(); else d.draw();
+ };
+
+ if (ctx.selectionState) {
+ ensureSelectAllCheckbox(table);
+ bindSelectionPrune(table, ctx);
+ if (ctx._bulkDef)
+ ctx.bulkToolbar = buildBulkToolbar(ctx._bulkDef, table, ctx, ctx.selectionState);
+ else
+ ctx.bulkToolbar = null;
+ }
+
+ const pill = buildPagingPill(viewDef, ctx);
+ const bar = NS.ribbon?.build ? NS.ribbon.build(viewDef, ctx) : null;
+ const columnSearchRow = buildColumnSearchRow(table, ctx.optsColumns, ctx.dataTable);
+ ctx.onColumnSearchToggle = function (on) {
+ if (!columnSearchRow) return;
+ columnSearchRow.style.display = on ? "" : "none";
+ if (!on) {
+ Array.prototype.forEach.call(columnSearchRow.querySelectorAll("input"), function (inp) { inp.value = ""; });
+ if (ctx.dataTable) ctx.dataTable.columns().search("").draw();
+ }
+ };
+
+ const wrapper = table.parentNode;
+ const toolbarRow = wrapper ? buildToolbarRow(wrapper, pill, bar) : null;
+
+ if (toolbarRow) {
+ const leftCluster = toolbarRow.querySelector(".juneau-view-toolbar-left");
+ if (leftCluster) {
+ if (ctx.bulkToolbar) leftCluster.appendChild(ctx.bulkToolbar.el);
+ else if (ctx._bulkError) renderInlineError(leftCluster, ctx._bulkError);
+ }
+ }
+
+ if (viewDef.pollIntervalMs && toolbarRow) {
+ const staleness = buildStalenessIndicator();
+ const rightCluster = toolbarRow.querySelector(".juneau-view-toolbar-right");
+ if (rightCluster) rightCluster.insertBefore(staleness, rightCluster.firstChild);
+ initPolling(table, ctx.dataTable, viewDef, staleness, ctx);
+ }
+
+ // Chooser affordance: no-op when juneau-config.js is absent (v4 JS without the opt-in file).
+ if (viewDef.columnConfig && NS.config && typeof NS.config.mountChooser === "function")
+ NS.config.mountChooser(table, ctx, toolbarRow);
+ }
+
+ /**
+ * Re-runnable DataTable constructor. First construct skips teardown; Apply / programmatic rebuild runs the
+ * 9-step teardown first. Single-slot latest-wins mutex: a second Apply while running replaces
+ * {@code ctx._reinitPending} and never starts a concurrent teardown.
+ */
+ function buildTable(table, viewDef, effectiveColumns, ctx) {
+ const $ = window.jQuery;
+ if (!$?.fn?.DataTable) return { ok: false, reason: "no-datatables" };
+
+ if (ctx._reinitRunning) {
+ ctx._reinitPending = { viewDef: viewDef, effectiveColumns: effectiveColumns };
+ return { ok: true, coalesced: true };
+ }
+
+ const already = !!( $?.fn?.dataTable?.isDataTable && $.fn.dataTable.isDataTable(table) );
+ if (already) {
+ if (hasInFlightRow(table) || hasJobRow(table)) {
+ renderReinitNotice(table, "Finish the in-progress action first.");
+ return { ok: false, reason: "in-flight" };
+ }
+ ctx._reinitRunning = true;
+ teardownTable(table, ctx);
+ }
+
+ try {
+ constructTable(table, viewDef, effectiveColumns, ctx);
+ return { ok: true };
+ } finally {
+ if (already) {
+ ctx._reinitRunning = false;
+ if (ctx._reinitPending) {
+ const pending = ctx._reinitPending;
+ ctx._reinitPending = null;
+ buildTable(table, pending.viewDef, pending.effectiveColumns, ctx);
+ }
+ }
+ }
+ }
+
+ function beginInitTable(table) {
const $ = window.jQuery;
const id = table.getAttribute("data-juneau-view");
const sidecar = document.getElementById("juneau-view:" + id);
@@ -1708,89 +2218,52 @@
return;
}
- // FAIL-LOUD contract-version handshake (§6.2): a mismatch means the served JS is stale vs the JSON - refuse.
if (viewDef.contractVersion !== JUNEAU_VIEW_CONTRACT_VERSION) {
const m = "Juneau view '" + id + "': contract version mismatch (page='" + viewDef.contractVersion +
"', runtime='" + JUNEAU_VIEW_CONTRACT_VERSION + "'). Refusing to init - reload to clear a stale cached script.";
error(m);
renderBanner(table, m);
- return; // refuse to init rather than silently mis-render
+ return;
}
if (!$?.fn?.DataTable) {
warn("Juneau view '" + id + "': jQuery/DataTables not present; cannot bind.");
return;
}
- if ($.fn.dataTable.isDataTable(table)) return; // idempotent
const activeState = NS.ribbon?.loadPersistedState ? NS.ribbon.loadPersistedState(viewDef) : {};
-
- const deps = {
- parseRenderId: NS.parseRenderId,
- resolveRenderer: NS.resolveRenderer,
- warn: warn,
- ribbonParams: function () {
- return NS.ribbon?.ribbonToQueryParams ? NS.ribbon.ribbonToQueryParams(viewDef, activeState) : {};
- }
- };
-
- const opts = buildOptions(viewDef, deps);
-
- // TODO-428: row selection is detected PURELY from the DOM attribute ViewTable stamped (SELECT_ATTR) -
- // never from viewDef/VIEW_META (R2 guard). When present, a synthetic LEADING selection column is
- // unshifted (mirroring how a declared rowActions list appends a synthetic TRAILING one below) and every
- // created row is additionally stamped with its stable id.
const selectionState = hasSelection(table)
? { selected: new Set(), rowIdField: table.getAttribute(ROW_ID_FIELD_ATTR) }
: null;
- if (selectionState) {
- opts.columns.unshift(buildSelectionColumnDef(selectionState));
- const priorCreatedRow = opts.createdRow;
- opts.createdRow = function (rowEl, rowData, index) {
- if (priorCreatedRow) priorCreatedRow(rowEl, rowData, index);
- stampRowId(rowEl, rowData, selectionState.rowIdField);
- };
- }
-
- // A declared rowActions list added a synthetic trailing column in buildOptions; append its matching <th>
- // BEFORE booting DataTables so header and column counts agree (DataTables errors on a mismatch).
- if (viewDef.rowActions && viewDef.rowActions.length) {
- appendActionsHeaderCell(table);
- }
-
- const dt = $(table).DataTable(opts);
- // A declared `details` field list makes every row expandable.
- if (viewDef.details && viewDef.details.length) {
- initDetailsExpander(table, dt, viewDef);
- }
-
- const pill = buildPagingPill(viewDef, { table: table, dataTable: dt });
-
- // Hoisted above the `NS.ribbon.build` call (rather than scoped inside it) because the columnSearchToggle
- // button's click handler reads `ctx.onColumnSearchToggle` at CLICK time, not at build time - as long as
- // this same object is later given that callback (below), the button already wired to it works correctly
- // regardless of which happens first.
const ctx = {
table: table,
- dataTable: dt,
+ viewDef: viewDef,
+ dataTable: null,
activeState: activeState,
+ selectionState: selectionState,
columnSearchOn: false,
- redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
+ _jobSources: new Set(),
+ _pollTimers: [],
+ _reinitPending: null,
+ _reinitRunning: false,
+ redraw: function () {
+ const d = ctx.dataTable;
+ if (!d) return;
+ if (d.ajax) d.ajax.reload(); else d.draw();
+ }
};
+ table.__juneauCtx = ctx;
- // A declared rowActions list wires the per-row action menu + fail-closed CSRF submit (delegated listener).
+ if (findRowDetailTemplate(table)) {
+ initDetailsExpander(table, ctx, viewDef);
+ }
if (viewDef.rowActions && viewDef.rowActions.length) {
- initRowActions(table, dt, viewDef, ctx);
+ initRowActions(table, viewDef, ctx);
}
- // TODO-428: selection wiring (checkbox toggle, select-all, off-screen-id-drop on every draw.dt) is wired
- // whenever selection was declared - completely independent of whether bulk mutation was ALSO declared.
- // The bulk toolbar itself (a SEPARATE, independent opt-in - hasBulk(...)) is only ever built INSIDE this
- // same `if (selectionState)` branch, so a selection-only table (e.g. selectable-for-export) has no code
- // path that can reach it (HIGH-5's compile/DOM-shape separability guarantee).
if (selectionState) {
- initSelection(table, dt, selectionState, ctx);
+ initSelection(table, ctx);
if (hasBulk(table)) {
const bulkDef = readBulkDef(id);
if (!bulkDef) {
@@ -1801,51 +2274,53 @@
bulkDef.contractVersion + "', runtime='" + JUNEAU_BULK_CONTRACT_VERSION + "'); bulk mutation withheld.";
error(ctx._bulkError);
} else {
- // Selection remains fully usable (e.g. for export) even when bulk mutation is withheld above -
- // only the toolbar this branch builds is gated on a healthy sidecar.
- ctx.bulkToolbar = buildBulkToolbar(bulkDef, table, ctx, selectionState);
+ ctx._bulkDef = bulkDef;
}
}
}
- const bar = NS.ribbon?.build ? NS.ribbon.build(viewDef, ctx) : null;
+ function go(saved) {
+ const effective = resolveEffectiveColumns(viewDef, saved);
+ buildTable(table, viewDef, effective, ctx);
+ }
- const columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
- ctx.onColumnSearchToggle = function (on) {
- if (!columnSearchRow) return;
- columnSearchRow.style.display = on ? "" : "none";
- if (!on) {
- Array.prototype.forEach.call(columnSearchRow.querySelectorAll("input"), function (inp) { inp.value = ""; });
- dt.columns().search("").draw();
- }
- };
+ if (viewDef.columnConfig && NS.config && typeof NS.config.resolveActiveView === "function")
+ return NS.config.resolveActiveView(table, viewDef).then(go);
+ go(null);
+ }
- // One unified top-toolbar row (IRS reference layout) - LEFT the unified paging ribbon, RIGHT [search,
- // ribbon], all sitting ABOVE the table as a single row (buildToolbarRow(...) owns the left/right split).
- // Paging exists in exactly this one place - there is no second, right-side paging control any more.
- const wrapper = table.parentNode;
- const toolbarRow = wrapper ? buildToolbarRow(wrapper, pill, bar) : null;
+ /**
+ * ALWAYS returns a thenable. Overlapping calls while {@code data-juneau-init-pending} is set return the SAME
+ * in-flight promise (not a fresh resolve, not a second init). Flag + stash are cleared on settle
+ * (success OR failure).
+ */
+ function initTable(table) {
+ if (table.__juneauInitPromise) return table.__juneauInitPromise;
+ const $ = window.jQuery;
+ if ($?.fn?.dataTable?.isDataTable(table)) return Promise.resolve();
- // The bulk toolbar (or its withheld-sidecar error) is inserted into the LEFT cluster, after the paging
- // pill, as a follow-up DOM step - mirroring the staleness-indicator insertion below, which is likewise a
- // post-hoc addition rather than a buildToolbarRow(...) parameter (keeping that function's own
- // signature/tests untouched).
- if (toolbarRow) {
- const leftCluster = toolbarRow.querySelector(".juneau-view-toolbar-left");
- if (leftCluster) {
- if (ctx.bulkToolbar) leftCluster.appendChild(ctx.bulkToolbar.el);
- else if (ctx._bulkError) renderInlineError(leftCluster, ctx._bulkError);
- }
+ let proceed, fail;
+ const p = new Promise(function (res, rej) { proceed = res; fail = rej; });
+ table.__juneauInitPromise = p;
+ table.setAttribute("data-juneau-init-pending", "1");
+
+ function settleOk(v) {
+ table.removeAttribute("data-juneau-init-pending");
+ table.__juneauInitPromise = null;
+ proceed(v);
+ }
+ function settleErr(err) {
+ table.removeAttribute("data-juneau-init-pending");
+ table.__juneauInitPromise = null;
+ fail(err);
}
- // A declared pollIntervalMs gets its own per-table staleness chip, inserted at the front of the RIGHT
- // toolbar cluster (ahead of search/ribbon) without touching buildToolbarRow's own signature/tests.
- if (viewDef.pollIntervalMs && toolbarRow) {
- const staleness = buildStalenessIndicator();
- const rightCluster = toolbarRow.querySelector(".juneau-view-toolbar-right");
- if (rightCluster) rightCluster.insertBefore(staleness, rightCluster.firstChild);
- initPolling(table, dt, viewDef, staleness);
+ try {
+ Promise.resolve(beginInitTable(table)).then(settleOk, settleErr);
+ } catch (e) {
+ settleErr(e);
}
+ return p;
}
/**
@@ -1881,9 +2356,16 @@
buildOptions: buildOptions,
initAll: initAll,
// Previously private - exposed so juneau-pages.js can init one specific view's table on demand (lazy,
- // on first tab activation). Already idempotent (isDataTable guard below), so re-entry from the page
+ // on first tab activation). Always returns a thenable; overlapping calls coalesce on the in-flight
+ // promise (data-juneau-init-pending). Already idempotent (isDataTable guard), so re-entry from the page
// runtime after the DOMContentLoaded scan has already run is always safe.
initTable: initTable,
+ buildTable: buildTable,
+ liveDtIndex: liveDtIndex,
+ assembleFullColumnArray: assembleFullColumnArray,
+ restoreHeaderShell: restoreHeaderShell,
+ teardownTable: teardownTable,
+ beginInitTable: beginInitTable,
// visual-parity pass: exposed for manual verification.
buildPagingPill: buildPagingPill,
buildPageSizeMenu: buildPageSizeMenu,
@@ -1894,11 +2376,21 @@
clampPollInterval: clampPollInterval,
formatStalenessAge: formatStalenessAge,
hasInFlightRow: hasInFlightRow,
+ hasJobRow: hasJobRow,
buildStalenessIndicator: buildStalenessIndicator,
initPolling: initPolling,
// Row-details expander - exposed for manual verification.
- buildDetailFields: buildDetailFields,
- buildDetailPanel: buildDetailPanel,
+ JUNEAU_ROW_DETAIL_CONTRACT_VERSION: JUNEAU_ROW_DETAIL_CONTRACT_VERSION,
+ isSafeDetailUrl: isSafeDetailUrl,
+ substituteDetailUrl: substituteDetailUrl,
+ scalarFieldValue: scalarFieldValue,
+ fillDetailSlots: fillDetailSlots,
+ findRowDetailTemplate: findRowDetailTemplate,
+ detailCoalesceKey: detailCoalesceKey,
+ detailContractOk: detailContractOk,
+ shouldDropDetailPayload: shouldDropDetailPayload,
+ setActionRefEnabled: setActionRefEnabled,
+ hideActionRefs: hideActionRefs,
initDetailsExpander: initDetailsExpander,
// Row actions + fail-closed CSRF submit - exposed for manual verification and the fail-closed canary.
DEFAULT_CSRF_HEADER: DEFAULT_CSRF_HEADER,
@@ -1954,6 +2446,8 @@
selectionCellMarkup: selectionCellMarkup,
buildSelectionColumnDef: buildSelectionColumnDef,
initSelection: initSelection,
+ bindSelectionPrune: bindSelectionPrune,
+ ensureSelectAllCheckbox: ensureSelectAllCheckbox,
readBulkDef: readBulkDef,
buildBulkToolbar: buildBulkToolbar,
executeBulkAction: executeBulkAction,
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ColumnConfig_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ColumnConfig_BrowserTest.java
new file mode 100644
index 0000000000..12a1bc3aef
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ColumnConfig_BrowserTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Opt-in Chromium XSS canary for the View-tab chooser (TODO-444, slice 6). Always-on source-shape + Node
+ * coverage lives in {@link ViewsJs_ConfigChooser_Test}; this class proves the same {@code textContent} mandate
+ * against a real DOM (an {@code <img onerror>} payload must not execute).
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set (the module's {@code js-tests} Maven profile).
+ */
+@EnabledIfSystemProperty(named=ColumnConfig_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ColumnConfig_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static final String XSS_NAME = "<img src=x onerror=\"window.__juneauChooserXss=1\">";
+ private static final String XSS_LABEL = "<img src=x onerror=\"window.__juneauChooserLabelXss=1\">";
+
+ private static Map<?,?> report;
+
+ 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);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("column-config.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script>\n<script>\n"
+ + resource(ViewsMixin.CONFIG_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("column-config.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("column-config-stdout.json");
+ var stderr = dir.resolve("column-config-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithNoScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasConfig"), () -> "juneau-config.js did not load: " + report);
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ @Test void a02_hostileViewName_isLiteralText_doesNotExecute() {
+ assertEquals(XSS_NAME, report.get("nameText"), () -> report.toString());
+ assertEquals(0, ((Number) report.get("nameImgCount")).intValue(), () -> report.toString());
+ assertEquals(Boolean.FALSE, report.get("xssNameFired"), () -> report.toString());
+ }
+
+ @Test void a03_hostileColumnLabel_isLiteralText_doesNotExecute() {
+ assertEquals(XSS_LABEL, report.get("labelValue"), () -> report.toString());
+ assertEquals(XSS_LABEL, report.get("headerText"), () -> report.toString());
+ assertEquals(0, ((Number) report.get("headerImgCount")).intValue(), () -> report.toString());
+ assertEquals(Boolean.FALSE, report.get("xssLabelFired"), () -> report.toString());
+ assertEquals(Boolean.TRUE, report.get("sanitizedTitleBlank"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfigPersistence_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfigPersistence_BrowserTest.java
new file mode 100644
index 0000000000..3ed4fdf51d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ConfigPersistence_BrowserTest.java
@@ -0,0 +1,297 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * The persistence half of the module's <b>JavaScript-execution harness</b>: runs the REAL served
+ * {@code juneau-config.js} (TODO-444, slice 2) in a real headless browser against REAL {@code window.localStorage}
+ * and a stubbed {@code fetch}, and asserts the async persistence facade actually behaves as documented at
+ * runtime — not merely that the shipped source CONTAINS the right shapes.
+ *
+ * <h5 class='section'>Why this exists (beyond {@link ViewsJs_ConfigPersistence_Test}):</h5>
+ * <p>
+ * That sibling class proves the served script's <i>source shape</i> - the right constants, methods and string
+ * literals are present. It cannot prove a save actually round-trips through {@code localStorage}, that a
+ * dangling {@code active} pointer actually resolves to Default, that a 51st view in one scope is actually
+ * refused, that a synthetic {@code storage} event actually reaches {@code watchExternalChanges}' callback, or
+ * that the server provider's stubbed {@code fetch} calls actually carry the documented URL/method/headers/body.
+ * Node has no Web Storage API at all, so the localStorage half of this canary is the ONLY place in the module's
+ * test suite that exercises the real browser API rather than a hand-rolled shim that could quietly diverge from
+ * it.
+ *
+ * <h5 class='section'>Off by default:</h5>
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set, which only the module's opt-in {@code js-tests}
+ * Maven profile does. It reuses that profile's provisioned Node + Playwright browser, and derives its own
+ * prober ({@code config-persistence.cjs}) from the profile's {@code juneau.jsTests.harness} directory, so no pom
+ * change is needed to add this third canary.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link ViewsJs_ConfigPersistence_Test} — the always-on source-shape half of the same
+ * contract, which runs with no Node at all.
+ * <li class='jc'>{@link RowActionCsrf_BrowserTest} — the sibling {@code juneau-views.js} canary this one
+ * borrows its CSRF-transport conventions from.
+ * </ul>
+ */
+@EnabledIfSystemProperty(named=ConfigPersistence_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class ConfigPersistence_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static Map<?,?> report;
+
+ 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);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ // The pom's js-tests profile provisions ONE harness property (the panel-visibility prober); this third
+ // canary lives beside it in src/test/js, so it is derived from that property's directory rather than
+ // adding a new pom property - the same trick RowActionCsrf_BrowserTest already uses.
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("config-persistence.cjs");
+
+ // The fixture restates nothing under test: it loads the REAL served juneau-views.js (for NS.init's CSRF
+ // helpers, which the server provider calls) followed by the REAL juneau-config.js, exactly the load
+ // order the module's own doc comment requires.
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script>\n<script>\n"
+ + resource(ViewsJs_ConfigPersistence_Test.CONFIG_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("config-persistence.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ /** Runs the prober, failing with its stderr attached (its exit code alone is not a diagnosis). */
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("config-persistence-stdout.json");
+ var stderr = dir.resolve("config-persistence-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> obj(String key) { return (Map<String,Object>) report.get(key); }
+
+ @SuppressWarnings("unchecked")
+ private static List<Object> list(String key) { return (List<Object>) report.get(key); }
+
+ @SuppressWarnings("unchecked")
+ private static List<Object> views(Map<String,Object> listResult) { return (List<Object>) listResult.get("views"); }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) the runtime loaded cleanly
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_runtimeLoadedWithNoScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasConfig"),
+ () -> "juneau-config.js did not populate JuneauViews.persistence/config: " + report);
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) the localStorage provider round-trips through REAL window.localStorage
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_listOnAnUnusedScopeIsEmptyNeverAnError() {
+ var r = obj("a_listEmpty");
+ assertNull(r.get("active"), () -> report.toString());
+ assertEquals(List.of(), views(r), () -> report.toString());
+ }
+
+ @Test void b02_saveIsVisibleToASubsequentList() {
+ var names = views(obj("a_listAfterSave")).stream().map(x -> ((Map<?,?>) x).get("name")).toList();
+ assertEquals(List.of("My View"), names, () -> report.toString());
+ }
+
+ @Test void b03_loadReturnsTheExactBlobThatWasSaved() {
+ var blob = obj("a_loaded");
+ assertEquals(1.0, ((Number) blob.get("schemaVersion")).doubleValue(), () -> report.toString());
+ assertEquals(List.of("x"), blob.get("columns"), () -> report.toString());
+ }
+
+ @Test void b04_setActiveIsReflectedByGetActive() {
+ var r = obj("a_activeAfterSetActive");
+ assertEquals("My View", r.get("name"), () -> report.toString());
+ assertEquals(Boolean.FALSE, r.get("dangling"), () -> report.toString());
+ }
+
+ @Test void b05_deletingTheActiveViewResolvesToDefaultPlusDanglingNotice() {
+ // The dangling-active resolution (§3.2 should-fix) proven at runtime, not just asserted present in source.
+ var r = obj("a_activeAfterDelete");
+ assertNull(r.get("name"), () -> "a dangling active pointer must resolve to Default (null): " + report);
+ assertEquals(Boolean.TRUE, r.get("dangling"), () -> "the dangling flag must be raised: " + report);
+ }
+
+ @Test void b06_saveAndActivateIsAtomicFromTheCallersPerspective() {
+ var r = obj("a_activeAfterSaveAndActivate");
+ assertEquals("Second View", r.get("name"), () -> report.toString());
+ assertEquals(Boolean.FALSE, r.get("dangling"), () -> report.toString());
+ }
+
+ @Test void b07_twoPagesSharingAViewIdDoNotCollide() {
+ // pageId-qualification (§3.1) proven at runtime: reportsB/orders sees none of reportsA/orders' saved views.
+ var r = obj("a_otherPageScopeIsIndependent");
+ assertNull(r.get("active"), () -> report.toString());
+ assertEquals(List.of(), views(r), () -> report.toString());
+ }
+
+ @Test void b08_savingUnderTheReservedNameRejectsAsMalformed() {
+ var r = obj("a_reservedNameRejection");
+ assertEquals(Boolean.TRUE, r.get("threw"), () -> "'Default' must be rejected, not silently accepted: " + report);
+ assertEquals("malformed", r.get("code"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) the per-scope quota is actually enforced by the 51st real localStorage write
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_the51stViewInOneScopeIsRefusedWithATypedQuotaError() {
+ var r = obj("b_overQuota");
+ assertEquals(Boolean.TRUE, r.get("threw"), () -> "MAX_VIEWS_PER_SCOPE (50) was not enforced: " + report);
+ assertEquals("quota", r.get("code"), () -> report.toString());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) a synthetic cross-tab `storage` event actually reaches watchExternalChanges' callback
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_watchExternalChanges_seesOnlyItsOwnScopesKeyAndOnlyWhileWatching() {
+ var seen = list("c_storageEventsSeen");
+ assertEquals(1, seen.size(),
+ () -> "expected exactly one in-scope, pre-unwatch storage event to be observed: " + report);
+ var key = (String) seen.get(0);
+ assertTrue(key.endsWith(".columns.views.someKey"), () -> "wrong key observed: " + key);
+ assertFalse(key.contains("SOME-OTHER-SCOPE"), () -> "an out-of-scope key leaked through: " + key);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) the server-persisted provider's stubbed-fetch calls carry the exact locked transport envelope
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static String url(Map<String,Object> call) { return (String) call.get("url"); }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String,Object> init(Map<String,Object> call) { return (Map<String,Object>) call.get("init"); }
+
+ @Test void e01_list_isAPlainCsrfFreeGetWithSameOriginCredentials() {
+ var call = obj("d_listCall");
+ var i = init(call);
+ assertEquals("GET", i.get("method"), () -> report.toString());
+ assertEquals("same-origin", i.get("credentials"), () -> report.toString());
+ assertTrue(url(call).contains("view=serverView") && url(call).contains("page=serverPage"),
+ () -> "list() must carry view+page query params: " + url(call));
+ assertNull(i.get("body"), () -> "a GET must never carry a body: " + report);
+ }
+
+ @Test void e02_save_isAJsonPutCarryingTheCsrfHeaderAndNameAsAQueryParam_neverAPathSegment() {
+ var call = obj("d_saveCall");
+ var i = init(call);
+ assertEquals("PUT", i.get("method"), () -> report.toString());
+ assertEquals("same-origin", i.get("credentials"), () -> report.toString());
+ @SuppressWarnings("unchecked")
+ var h = (Map<String,Object>) i.get("headers");
+ assertEquals("application/json", h.get("Content-Type"), () -> report.toString());
+ assertEquals("tok-123", h.get("X-Csrf-Token"), () -> report.toString());
+ assertTrue(url(call).contains("name=My%20View") || url(call).contains("name=My+View"),
+ () -> "name must ride as a QUERY PARAM: " + url(call));
+ assertFalse(url(call).contains("/My%20View") || url(call).contains("/My+View"),
+ () -> "name must NEVER be a path segment: " + url(call));
+ assertFalse(url(call).contains("activate="), () -> "a bare save() must not set the activate flag: " + url(call));
+ }
+
+ @Test void e03_saveAndActivate_usesTheDedicatedActivateQueryFlag_neverAFieldInsideTheBlob() {
+ var call = obj("d_saveAndActivateCall");
+ var i = init(call);
+ assertTrue(url(call).contains("activate=1"), () -> "saveAndActivate must set ?activate=1: " + url(call));
+ assertFalse(String.valueOf(i.get("body")).contains("activate"),
+ () -> "the activate flag must never leak into the persisted blob body: " + i.get("body"));
+ }
+
+ @Test void e04_clearingActiveStillSendsARealJsonBody_neverEmpty() {
+ var call = obj("d_clearActiveCall");
+ var i = init(call);
+ assertEquals("PUT", i.get("method"), () -> report.toString());
+ assertTrue(url(call).endsWith("/active") || url(call).contains("/active?"),
+ () -> "setActive must target the /active sub-path: " + url(call));
+ assertEquals("{}", i.get("body"), () -> "clearing active must send {} - never an empty string: " + report);
+ }
+
+ @Test void e05_delete_sendsNameAsAQueryParamWithARealJsonBody() {
+ var call = obj("d_deleteCall");
+ var i = init(call);
+ assertEquals("DELETE", i.get("method"), () -> report.toString());
+ assertTrue(url(call).contains("name=My%20View") || url(call).contains("name=My+View"),
+ () -> "delete's name must ride as a query param: " + url(call));
+ assertEquals("{}", i.get("body"), () -> report.toString());
+ }
+
+ @Test void e06_missingShellAttribute_failsClosed_zeroFetchCallsIssued() {
+ var r = obj("d_noShell");
+ assertEquals(Boolean.TRUE, r.get("threw"), () -> "an absent [data-juneau-saved-views] shell must reject: " + report);
+ assertEquals("unavailable", r.get("code"), () -> report.toString());
+ assertEquals(0.0, ((Number) r.get("calls")).doubleValue(),
+ () -> "a fail-closed provider must never have issued a request: " + report);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore_Test.java
new file mode 100644
index 0000000000..d9ef9c3157
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/InMemorySavedViewStore_Test.java
@@ -0,0 +1,255 @@
+/*
+ * 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 java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link InMemorySavedViewStore} — the non-durable in-memory default {@link SavedViewStore}
+ * (TODO-444 §3.3): CRUD, the count quotas (per-scope, per-user aggregate, and the race-safe aggregate under parallel
+ * creates in different scopes), replace-does-not-count, dangling-active resolution, per-user isolation, and the
+ * derived {@code getActive}.
+ */
+class InMemorySavedViewStore_Test extends TestBase {
+
+ private static final String A = "alice", B = "bob";
+ private static final String PAGE = "orders", VIEW = "grid";
+ private static final String BLOB = "{\"schemaVersion\":1}";
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Basic CRUD + listing
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_saveThenLoadThenList() {
+ var s = new InMemorySavedViewStore();
+ s.save(A, PAGE, VIEW, "v1", BLOB);
+ assertEquals(BLOB, s.load(A, PAGE, VIEW, "v1"));
+ var listing = s.list(A, PAGE, VIEW);
+ assertEquals(List.of("v1"), listing.names());
+ assertNull(listing.active());
+ }
+
+ @Test void a02_loadMissingIsNull() {
+ var s = new InMemorySavedViewStore();
+ assertNull(s.load(A, PAGE, VIEW, "nope"));
+ assertNull(s.load(A, PAGE, VIEW, "nope")); // unknown scope path too
+ }
+
+ @Test void a03_unknownScopeListsEmpty() {
+ var s = new InMemorySavedViewStore();
+ var listing = s.list(A, PAGE, VIEW);
+ assertNull(listing.active());
+ assertTrue(listing.names().isEmpty());
+ }
+
+ @Test void a04_deleteRemovesAndIsIdempotent() {
+ var s = new InMemorySavedViewStore();
+ s.save(A, PAGE, VIEW, "v1", BLOB);
+ s.delete(A, PAGE, VIEW, "v1");
+ assertNull(s.load(A, PAGE, VIEW, "v1"));
+ assertDoesNotThrow(() -> s.delete(A, PAGE, VIEW, "v1")); // no-op on missing
+ assertDoesNotThrow(() -> s.delete(A, "other", VIEW, "v1")); // no-op on missing scope
+ }
+
+ @Test void a05_replaceOverwritesBlobKeepsSingleName() {
+ var s = new InMemorySavedViewStore();
+ s.save(A, PAGE, VIEW, "v1", BLOB);
+ s.save(A, PAGE, VIEW, "v1", "{\"schemaVersion\":1,\"x\":2}");
+ assertEquals("{\"schemaVersion\":1,\"x\":2}", s.load(A, PAGE, VIEW, "v1"));
+ assertEquals(List.of("v1"), s.list(A, PAGE, VIEW).names());
+ }
+
+ @Test void a06_standaloneNullPageIsADistinctScopeFromPageQualified() {
+ var s = new InMemorySavedViewStore();
+ s.save(A, null, VIEW, "v1", BLOB);
+ s.save(A, PAGE, VIEW, "v2", BLOB);
+ assertEquals(List.of("v1"), s.list(A, null, VIEW).names());
+ assertEquals(List.of("v2"), s.list(A, PAGE, VIEW).names());
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Active pointer: set / clear / saveAndActivate / dangling resolution
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_saveAndActivateStoresBlobAndFlipsActive() {
+ var s = new InMemorySavedViewStore();
+ s.saveAndActivate(A, PAGE, VIEW, "v1", BLOB);
+ var listing = s.list(A, PAGE, VIEW);
+ assertEquals("v1", listing.active());
+ assertEquals(List.of("v1"), listing.names());
+ assertEquals(BLOB, s.load(A, PAGE, VIEW, "v1"));
+ }
+
+ @Test void b02_setActiveThenClear() {
+ var s = new InMemorySavedViewStore();
+ s.save(A, PAGE, VIEW, "v1", BLOB);
+ s.setActive(A, PAGE, VIEW, "v1");
+ assertEquals("v1", s.list(A, PAGE, VIEW).active());
+ s.setActive(A, PAGE, VIEW, null);
+ assertNull(s.list(A, PAGE, VIEW).active());
+ }
+
+ @Test void b03_danglingActiveNeverPersistedResolvesToNull() {
+ var s = new InMemorySavedViewStore();
+ s.setActive(A, PAGE, VIEW, "ghost"); // activate a name that was never saved
+ assertNull(s.list(A, PAGE, VIEW).active()); // resolves to Default (null), not an error
+ }
+
+ @Test void b04_danglingActiveAfterDeleteResolvesToNull() {
+ var s = new InMemorySavedViewStore();
+ s.saveAndActivate(A, PAGE, VIEW, "v1", BLOB);
+ s.delete(A, PAGE, VIEW, "v1");
+ var listing = s.list(A, PAGE, VIEW);
+ assertNull(listing.active());
+ assertTrue(listing.names().isEmpty());
+ }
+
+ @Test void b05_getActiveDerivesFromListWithDanglingResolution() {
+ var s = new InMemorySavedViewStore();
+ assertNull(s.getActive(A, PAGE, VIEW));
+ s.saveAndActivate(A, PAGE, VIEW, "v1", BLOB);
+ assertEquals("v1", s.getActive(A, PAGE, VIEW));
+ s.setActive(A, PAGE, VIEW, "ghost");
+ assertNull(s.getActive(A, PAGE, VIEW));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Quotas: per-scope count, replace-does-not-count, per-user aggregate
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_perScopeCountCapTripsOnCreate() {
+ var s = new InMemorySavedViewStore(3, 100);
+ for (var i = 0; i < 3; i++)
+ s.save(A, PAGE, VIEW, "v" + i, BLOB);
+ var e = assertThrows(SavedViewQuotaException.class, () -> s.save(A, PAGE, VIEW, "v3", BLOB));
+ assertEquals(SavedViewQuotaException.Kind.SCOPE, e.kind());
+ }
+
+ @Test void c02_replaceDoesNotTripPerScopeCap() {
+ var s = new InMemorySavedViewStore(3, 100);
+ for (var i = 0; i < 3; i++)
+ s.save(A, PAGE, VIEW, "v" + i, BLOB);
+ // Overwriting an existing name at the cap must succeed (replace, not create).
+ assertDoesNotThrow(() -> s.save(A, PAGE, VIEW, "v0", "{\"schemaVersion\":1,\"y\":1}"));
+ }
+
+ @Test void c03_perUserAggregateCapAcrossDifferentScopes() {
+ var s = new InMemorySavedViewStore(100, 3);
+ s.save(A, "p1", VIEW, "v", BLOB);
+ s.save(A, "p2", VIEW, "v", BLOB);
+ s.save(A, "p3", VIEW, "v", BLOB);
+ var e = assertThrows(SavedViewQuotaException.class, () -> s.save(A, "p4", VIEW, "v", BLOB));
+ assertEquals(SavedViewQuotaException.Kind.AGGREGATE, e.kind());
+ }
+
+ @Test void c04_aggregateCapCountsAcrossScopesNotPerScope() {
+ // Aggregate=3, per-scope=100: three creates spread over scopes fill the aggregate, a 4th anywhere trips it.
+ var s = new InMemorySavedViewStore(100, 3);
+ s.save(A, "p1", VIEW, "a", BLOB);
+ s.save(A, "p1", VIEW, "b", BLOB);
+ s.save(A, "p2", VIEW, "c", BLOB);
+ assertThrows(SavedViewQuotaException.class, () -> s.save(A, "p2", VIEW, "d", BLOB));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Race-safety: parallel creates in DIFFERENT scopes for the same user cannot both bypass the aggregate cap
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_parallelDifferentScopeCreatesRespectAggregateCap() throws Exception {
+ var cap = 20;
+ var threads = 64;
+ var s = new InMemorySavedViewStore(1000, cap);
+ var pool = Executors.newFixedThreadPool(16);
+ var start = new CountDownLatch(1);
+ var ok = new AtomicInteger();
+ var quota = new AtomicInteger();
+ var futures = new ArrayList<Future<?>>();
+ try {
+ for (var i = 0; i < threads; i++) {
+ var scope = "p" + i; // every thread writes in its OWN (page,view) scope
+ futures.add(pool.submit(() -> {
+ await(start);
+ try {
+ s.save(A, scope, VIEW, "v", BLOB);
+ ok.incrementAndGet();
+ } catch (SavedViewQuotaException e) {
+ quota.incrementAndGet();
+ }
+ }));
+ }
+ start.countDown();
+ for (var f : futures)
+ f.get(30, TimeUnit.SECONDS);
+ } finally {
+ pool.shutdownNow();
+ }
+ assertEquals(cap, ok.get(), "exactly the cap should have been created");
+ assertEquals(threads - cap, quota.get(), "the rest should have been rejected as quota");
+ var total = 0;
+ for (var i = 0; i < threads; i++)
+ total += s.list(A, "p" + i, VIEW).names().size();
+ assertEquals(cap, total, "the store must hold no more than the aggregate cap");
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Per-user isolation: one user's data is invisible to another
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_usersAreIsolated() {
+ var s = new InMemorySavedViewStore();
+ s.saveAndActivate(A, PAGE, VIEW, "mine", BLOB);
+ assertTrue(s.list(B, PAGE, VIEW).names().isEmpty());
+ assertNull(s.load(B, PAGE, VIEW, "mine"));
+ assertNull(s.list(B, PAGE, VIEW).active());
+ // B writing the same (page,view,name) does not disturb A.
+ s.save(B, PAGE, VIEW, "mine", "{\"schemaVersion\":1,\"who\":\"bob\"}");
+ assertEquals(BLOB, s.load(A, PAGE, VIEW, "mine"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Argument guards
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_constructorRejectsNonPositiveCaps() {
+ assertThrows(IllegalArgumentException.class, () -> new InMemorySavedViewStore(0, 1));
+ assertThrows(IllegalArgumentException.class, () -> new InMemorySavedViewStore(1, 0));
+ }
+
+ @Test void f02_nullRequiredKeysRejected() {
+ var s = new InMemorySavedViewStore();
+ assertThrows(IllegalArgumentException.class, () -> s.list(null, PAGE, VIEW));
+ assertThrows(IllegalArgumentException.class, () -> s.list(A, PAGE, null));
+ assertThrows(IllegalArgumentException.class, () -> s.save(A, PAGE, VIEW, null, BLOB));
+ assertThrows(IllegalArgumentException.class, () -> s.save(A, PAGE, VIEW, "v", null));
+ }
+
+ private static void await(CountDownLatch l) {
+ try {
+ l.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
index b4b3c6c9eb..d150539b43 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/JuneauPagesJs_Test.java
@@ -114,10 +114,9 @@ class JuneauPagesJs_Test extends TestBase {
@Test void b01_activatePanelViews_lazyInitsFirstThenAdjustsColumnsOnReShow() throws Exception {
var body = pagesJs();
var fn = functionBody(body, "activatePanelViews");
- // First activation: idempotent isDataTable guard decides between NS.init.initTable(...) (first time)...
assertTrue(fn.contains("isDataTable(t)"), fn);
assertTrue(fn.contains("NS.init.initTable"), fn);
- // ...and columns.adjust() (every subsequent show - fixes sizing deferred while display:none).
+ assertTrue(fn.contains("Promise.resolve(NS.init.initTable(t))"), fn);
assertTrue(fn.contains("columns.adjust()"), fn);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
index 7a8c7e10b0..5ae4253b1e 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Contract_Test.java
@@ -113,7 +113,7 @@ class PageDef_Contract_Test extends TestBase {
.tabs(Tab.create("releases", "Releases").view(view("releases")))
.build();
var json = Json.of(page);
- assertTrue(json.contains("\"contractVersion\":\"3\""), json);
+ assertTrue(json.contains("\"contractVersion\":\"4\""), json);
assertTrue(json.contains("\"id\":\"admin\""), json);
assertTrue(json.contains("\"title\":\"Admin\""), json);
assertTrue(json.contains("\"tabs\":["), json);
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java
index 3dd18c8bf9..6b6ad6038e 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageServerWiring_Test.java
@@ -133,10 +133,11 @@ class PageServerWiring_Test extends TestBase {
// The host also exposes the full asset set a real page would load (ViewsMixin, including the new pages.js).
//------------------------------------------------------------------------------------------------------------------
- @Test void b01_hostServesAllFiveAssetsIncludingPagesJs() throws Exception {
+ @Test void b01_hostServesAllAssetsIncludingPagesAndConfig() throws Exception {
for (var path : List.of(
ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH,
- ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.PAGES_JS_PATH))
+ ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.PAGES_JS_PATH,
+ ViewsMixin.CONFIG_JS_PATH, ViewsMixin.CONFIG_CSS_PATH))
c.get(path).run().assertStatus(200);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
index d98e45580e..ed55e8188d 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_Emit_Test.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
import org.apache.juneau.rest.server.views.ViewDef.DataMode;
import org.junit.jupiter.api.*;
@@ -205,4 +206,16 @@ class PageTable_Emit_Test extends TestBase {
assertTrue(html.contains("href=\"#admin/catalog/packages\""), html);
assertTrue(html.contains("href=\"#admin/catalog/bundles\""), html);
}
+
+ @Test void c01_resolvedBase_stampsPageShell() {
+ var html = Html.of(PageTable.of(MarshallingContext.DEFAULT, leafPage(), "/ctx/juneau-saved-views"));
+ assertTrue(html.contains("data-juneau-saved-views="), html);
+ assertTrue(html.contains("/ctx/juneau-saved-views"), html);
+ assertEquals(ViewTable.SAVED_VIEWS_ATTR, PageTable.SAVED_VIEWS_ATTR);
+ }
+
+ @Test void c02_absentBase_doesNotStamp() {
+ var html = Html.of(PageTable.of(leafPage()));
+ assertFalse(html.contains("data-juneau-saved-views"), html);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
index 07a93ef877..899b58b090 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PagingPill_Wiring_Test.java
@@ -148,7 +148,7 @@ class PagingPill_Wiring_Test extends TestBase {
assertTrue(fnBody.contains("juneau-view-toolbar-right"), fnBody);
assertTrue(fnBody.contains("left.appendChild(pill)"), fnBody);
- var initBody = functionBody(body, "function initTable(");
+ var initBody = functionBody(body, "function constructTable(");
assertTrue(initBody.contains("buildPagingPill("), initBody);
assertTrue(initBody.contains("buildToolbarRow(wrapper, pill, bar)"), initBody);
}
@@ -277,7 +277,7 @@ class PagingPill_Wiring_Test extends TestBase {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
assertFalse(body.contains("function buildCompactPagingRibbon("), body);
assertFalse(body.contains("juneau-view-pagingribbon"), body);
- var initBody = functionBody(body, "function initTable(");
+ var initBody = functionBody(body, "function constructTable(");
assertFalse(initBody.contains("pagingRibbon"), initBody);
}
@@ -294,12 +294,12 @@ class PagingPill_Wiring_Test extends TestBase {
assertTrue(fnBody.contains("row.style.display = \"none\""), fnBody);
assertTrue(fnBody.contains("dt.column(idx).search(input.value).draw()"), fnBody);
assertTrue(fnBody.contains("col.searchable !== false"), fnBody);
+ assertTrue(fnBody.contains("col.visible === false"), fnBody);
+ assertTrue(fnBody.contains("th.style.display = \"none\""), fnBody);
- // initTable(...) must actually wire ctx.onColumnSearchToggle to show/hide + clear-and-redraw this row -
- // the root cause of the previously-broken toggle was that nothing ever assigned this callback.
- var initBody = functionBody(body, "function initTable(");
+ var initBody = functionBody(body, "function constructTable(");
assertTrue(initBody.contains("buildColumnSearchRow("), initBody);
assertTrue(initBody.contains("ctx.onColumnSearchToggle = function"), initBody);
- assertTrue(initBody.contains("dt.columns().search(\"\").draw()"), initBody);
+ assertTrue(initBody.contains("ctx.dataTable.columns().search(\"\").draw()"), initBody);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
index 6cd1163be2..84093f9abd 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSinkScanner.java
@@ -79,6 +79,12 @@ final class RawContentSinkScanner {
/** Matches a {@code .content(} call (the {@code Tab}/{@code Subtab} raw-content fluent setter). */
private static final Pattern CONTENT_CALL = Pattern.compile("\\.content\\s*\\(");
+ /** Matches an {@code innerHTML =} assignment in JavaScript (the XSS sink the chooser must never use). */
+ private static final Pattern JS_INNERHTML_ASSIGN = Pattern.compile("\\.innerHTML\\s*=");
+
+ /** Matches a jQuery {@code .html(} call in JavaScript (the other XSS sink the chooser must never use). */
+ private static final Pattern JS_JQUERY_HTML = Pattern.compile("\\.html\\s*\\(");
+
/** A single string literal or text-block literal (content kept verbatim by {@link #stripComments}). */
private static final Pattern LITERAL_TOKEN =
Pattern.compile("\\s*(?:\"\"\"[\\s\\S]*?\"\"\"|\"(?:[^\"\\\\]|\\\\.)*\"|null)\\s*");
@@ -127,6 +133,48 @@ final class RawContentSinkScanner {
return scan("(source)", source);
}
+ /**
+ * Scans a JavaScript source string for {@code innerHTML =} / {@code .html(} sinks. Comments are stripped
+ * first so a mention of the forbidden APIs in a comment is not a hit.
+ *
+ * @param file A label for the source (used in violation messages).
+ * @param source The JavaScript source text.
+ * @return The sinks found and any violations. Every HTML sink here is a violation (there is no
+ * literal-only carve-out: user-controlled strings must never reach these APIs).
+ */
+ static Result scanJsHtmlSinks(String file, String source) {
+ var sinks = new ArrayList<Sink>();
+ var violations = new ArrayList<String>();
+ var code = stripComments(source);
+
+ var inner = JS_INNERHTML_ASSIGN.matcher(code);
+ while (inner.find()) {
+ var detail = "innerHTML assignment";
+ sinks.add(new Sink(file, false, detail));
+ violations.add(file + ": " + detail + " — user-controlled strings must be painted with textContent / input.value only");
+ }
+ var html = JS_JQUERY_HTML.matcher(code);
+ while (html.find()) {
+ var detail = ".html( call";
+ sinks.add(new Sink(file, false, detail));
+ violations.add(file + ": " + detail + " — user-controlled strings must be painted with textContent / input.value only");
+ }
+ return new Result(sinks, violations);
+ }
+
+ /**
+ * Scans the shipped {@code juneau-config.js} asset under {@code src/main/resources} for HTML sinks.
+ *
+ * @param moduleRoot The {@code juneau-rest-server-views} module root.
+ * @return The scan result for that one file.
+ * @throws IOException If the asset cannot be read.
+ */
+ static Result scanConfigJs(Path moduleRoot) throws IOException {
+ var rel = Path.of("src", "main", "resources", "org", "apache", "juneau", "views", "juneau-config.js");
+ var file = moduleRoot.resolve(rel);
+ return scanJsHtmlSinks(rel.toString(), Files.readString(file));
+ }
+
/**
* Walks the specified module root and scans every {@code src/main/java} and {@code src/test/java} Java
* source under it (both trees, per this class's javadoc: this guardrail has no pre-existing main-source-only
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
index 867f18a405..d70df13f59 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RawContentSink_SecurityScan_Test.java
@@ -240,4 +240,134 @@ class RawContentSink_SecurityScan_Test extends TestBase {
() -> "the scanner's own RED/GREEN fixture file must be excluded from the tree walk (its example "
+ "strings would otherwise register as false-positive sinks): " + r.sinks());
}
+
+ // -----------------------------------------------------------------------------------------------------------
+ // c: juneau-config.js HTML-sink gate (innerHTML = / .html() never in the chooser)
+ // -----------------------------------------------------------------------------------------------------------
+
+ @Test void c01_innerHtmlAssignment_isFlagged() {
+ var r = RawContentSinkScanner.scanJsHtmlSinks("x.js", "el.innerHTML = name;");
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ }
+
+ @Test void c02_jqueryHtmlCall_isFlagged() {
+ var r = RawContentSinkScanner.scanJsHtmlSinks("x.js", "$(el).html(name);");
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ }
+
+ @Test void c03_textContentAssignment_isNotFlagged() {
+ var r = RawContentSinkScanner.scanJsHtmlSinks("x.js", "el.textContent = name; inp.value = name;");
+ assertEquals(List.of(), r.violations());
+ assertEquals(List.of(), r.sinks());
+ }
+
+ @Test void c04_commentMentionOfInnerHtml_isNotASink() {
+ var r = RawContentSinkScanner.scanJsHtmlSinks("x.js",
+ "// never innerHTML / .html()\nel.textContent = name;\n");
+ assertEquals(List.of(), r.violations());
+ assertEquals(List.of(), r.sinks());
+ }
+
+ @Test void c05_shippedConfigJs_hasNoHtmlSinks() throws Exception {
+ var r = RawContentSinkScanner.scanConfigJs(requireModuleRoot());
+ assertEquals(List.of(), r.violations(),
+ () -> "juneau-config.js must not assign innerHTML or call .html():\n "
+ + String.join("\n ", r.violations()));
+ }
+
+ @Test void c06_shippedConfigJs_hasTextContentBaseline_notVacuous() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "resources", "org", "apache", "juneau", "views", "juneau-config.js");
+ var source = Files.readString(root.resolve(rel));
+ assertTrue(source.contains(".textContent"),
+ "juneau-config.js must paint user strings with textContent (anti-vacuous baseline)");
+ assertTrue(source.contains("paintUserText"), source);
+ var r = RawContentSinkScanner.scanConfigJs(root);
+ assertEquals(List.of(), r.sinks(),
+ () -> "the HTML-sink scan of a known-good file must be empty, not merely 'no violations': " + r.sinks());
+ }
+
+ @Test void c07_mutatingTextContentIntoInnerHtml_isFlagged() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "resources", "org", "apache", "juneau", "views", "juneau-config.js");
+ var source = Files.readString(root.resolve(rel));
+ assertTrue(source.contains("el.textContent = value == null ? \"\" : String(value);"),
+ "paintUserText body moved - update this fixture");
+ var clean = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), source);
+ assertEquals(List.of(), clean.violations());
+ var mutated = source.replace("el.textContent = value == null ? \"\" : String(value);",
+ "el.innerHTML = value == null ? \"\" : String(value);");
+ var mutatedResult = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), mutated);
+ assertTrue(mutatedResult.violations().size() >= 1,
+ () -> "mutating paintUserText to innerHTML must be flagged: " + mutatedResult.violations());
+ }
+
+ // -----------------------------------------------------------------------------------------------------------
+ // d: row-detail template emitter + fillDetailSlots (textContent baseline + innerHTML fixture)
+ // -----------------------------------------------------------------------------------------------------------
+
+ @Test void d01_viewTableEmitter_hasNoInnerHtml_andEmitsTemplate() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "java", "org", "apache", "juneau", "rest", "server", "views", "ViewTable.java");
+ var source = Files.readString(root.resolve(rel));
+ assertTrue(source.contains("DETAIL_TEMPLATE_ATTR"), source);
+ assertTrue(source.contains("data-juneau-row-detail") || source.contains("DETAIL_TEMPLATE_ATTR"), source);
+ assertFalse(source.contains("innerHTML"), source);
+ assertTrue(source.contains("emitDetailTemplate"), source);
+ }
+
+ @Test void d02_fillDetailSlots_textContentBaseline_noHtmlSinks() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "resources", "org", "apache", "juneau", "views", "juneau-views.js");
+ var source = Files.readString(root.resolve(rel));
+ var fn = extractFunction(source, "function fillDetailSlots(");
+ assertTrue(fn.contains(".textContent"), fn);
+ assertFalse(fn.contains("innerHTML"), fn);
+ var r = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), fn);
+ assertEquals(List.of(), r.violations(), () -> "fillDetailSlots must not assign innerHTML: " + r.violations());
+ assertEquals(List.of(), r.sinks());
+ }
+
+ @Test void d03_mutatingFillDetailSlotsIntoInnerHtml_isFlagged() throws Exception {
+ var root = requireModuleRoot();
+ var rel = Path.of("src", "main", "resources", "org", "apache", "juneau", "views", "juneau-views.js");
+ var source = Files.readString(root.resolve(rel));
+ var needle = "slots[i].textContent = Object.hasOwn(map, key) ? scalarFieldValue(map[key]) : \"\";";
+ assertTrue(source.contains(needle), "fillDetailSlots assignment moved - update this fixture");
+ var fn = extractFunction(source, "function fillDetailSlots(");
+ var clean = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), fn);
+ assertEquals(List.of(), clean.violations());
+ var mutated = fn.replace("slots[i].textContent", "slots[i].innerHTML");
+ var mutatedResult = RawContentSinkScanner.scanJsHtmlSinks(rel.toString(), mutated);
+ assertTrue(mutatedResult.violations().size() >= 1,
+ () -> "mutating fillDetailSlots to innerHTML must be flagged: " + mutatedResult.violations());
+ }
+
+ private static String extractFunction(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found");
+ var i = body.indexOf('{', start);
+ var depth = 0;
+ var j = i;
+ for (; j < body.length(); j++) {
+ var c = body.charAt(j);
+ if (c == '{')
+ depth++;
+ else if (c == '}') {
+ depth--;
+ if (depth == 0) { j++; break; }
+ } else if (c == '"' || c == '\'' || c == '`') {
+ var quote = c;
+ j++;
+ while (j < body.length() && body.charAt(j) != quote) {
+ if (body.charAt(j) == '\\')
+ j++;
+ j++;
+ }
+ }
+ }
+ return body.substring(start, j);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
index c08df7cf11..6ebc768913 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RibbonIcons_Resolution_Test.java
@@ -72,4 +72,27 @@ class RibbonIcons_Resolution_Test extends TestBase {
var fnBody = functionBody(body, "function resolveButtonIcon(");
assertTrue(fnBody.contains("\"tune\""), fnBody);
}
+
+ @Test void a04_ribbonStorageKey_layoutUnchanged() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function ribbonStorageKey(");
+ assertTrue(fnBody.contains("\"juneau.view.\" + viewId + \".ribbon.\" + optionId"), fnBody);
+ }
+
+ @Test void a05_loadPersistedState_routesThroughSyncPersistenceSpi_sameExactKeys() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(200).getContent().asString();
+ var load = functionBody(body, "function loadPersistedState(");
+ assertTrue(load.contains("storageGet(ribbonStorageKey(viewDef.id, a.id))"), load);
+ assertFalse(load.contains("store.getItem("), load);
+ var persistFn = functionBody(body, "function persist(");
+ assertTrue(persistFn.contains("storageSet(ribbonStorageKey(viewDef.id, id), String(value))"), persistFn);
+ assertFalse(persistFn.contains("store.setItem("), persistFn);
+ var getFn = functionBody(body, "function storageGet(");
+ assertTrue(getFn.contains("NS.persistence.getItem(key)"), getFn);
+ assertFalse(getFn.contains("Promise"), getFn);
+ assertFalse(getFn.contains(".then("), getFn);
+ var setFn = functionBody(body, "function storageSet(");
+ assertTrue(setFn.contains("NS.persistence.setItem(key, value)"), setFn);
+ assertFalse(setFn.contains("Promise"), setFn);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
index fd2174cd91..758e0f7d33 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowActionCsrf_BrowserTest.java
@@ -129,12 +129,12 @@ class RowActionCsrf_BrowserTest extends TestBase {
}
//------------------------------------------------------------------------------------------------------------------
- // a) The runtime loaded, at contract version "3"
+ // a) The runtime loaded, at the current contract version
//------------------------------------------------------------------------------------------------------------------
- @Test void a01_runtimeLoadedAtContractVersionThree() {
+ @Test void a01_runtimeLoadedAtCurrentContractVersion() {
assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not populate JuneauViews.init: " + report);
- assertEquals("3", report.get("contractVersion"), () -> report.toString());
+ assertEquals("4", report.get("contractVersion"), () -> report.toString());
assertEquals(LoopbackBoundary.DEFAULT_CSRF_HEADER, report.get("defaultCsrfHeader"), () -> report.toString());
assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailDef_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailDef_Test.java
new file mode 100644
index 0000000000..52eace947c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailDef_Test.java
@@ -0,0 +1,164 @@
+/*
+ * 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.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.widgets.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@link RowDetailDef#validate(java.util.List)} matrix: empty sections, duplicate keys, unknown ActionRef,
+ * missing {@code {id}}, blank / absolute / {@code ..} / scheme endpoints, {@code columns >= 1}.
+ */
+class RowDetailDef_Test extends TestBase {
+
+ private static DetailSection oneSection() {
+ return DetailSection.create("info", "Info").fields(DetailField.of("owner").title("Owner"));
+ }
+
+ private static RowAction ack() {
+ return RowAction.create("ack").endpoint("/x").method(RowAction.Method.POST);
+ }
+
+ @Test void a01_valid_minimal() {
+ RowDetailDef.create().endpoint("/data/{id}").sections(oneSection()).validate(null);
+ }
+
+ @Test void a02_emptySections_rejected() {
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("/data/{id}").validate(null));
+ assertTrue(e.getMessage().contains("at least one section"), e::getMessage);
+ }
+
+ @Test void a03_duplicateFieldKeys_rejected() {
+ var e = assertThrows(IllegalArgumentException.class, () -> RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(
+ DetailSection.create("a", "A").fields(DetailField.of("owner").title("O")),
+ DetailSection.create("b", "B").fields(DetailField.of("owner").title("O2")))
+ .validate(null));
+ assertTrue(e.getMessage().contains("duplicate field"), e::getMessage);
+ }
+
+ @Test void a04_unknownActionRef_rejected() {
+ var e = assertThrows(IllegalArgumentException.class, () -> RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(oneSection().actions(ActionBar.create().items(ActionRef.of("ack"))))
+ .validate(null));
+ assertTrue(e.getMessage().contains("ack"), e::getMessage);
+ }
+
+ @Test void a05_knownActionRef_accepted() {
+ RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(oneSection().actions(ActionBar.create().items(ActionRef.of("ack"), SafeAction.COLLAPSE)))
+ .validate(java.util.List.of(ack()));
+ }
+
+ @Test void a06_missingIdPlaceholder_rejected() {
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("/data/x").sections(oneSection()).validate(null));
+ assertTrue(e.getMessage().contains("{id}"), e::getMessage);
+ }
+
+ @Test void a07_blankEndpoint_rejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint(" ").sections(oneSection()).validate(null));
+ }
+
+ @Test void a08_absoluteUrl_rejected() {
+ var e = assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("https://evil/{id}").sections(oneSection()).validate(null));
+ assertTrue(e.getMessage().contains("same-origin"), e::getMessage);
+ }
+
+ @Test void a09_dotDotSegment_rejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("/data/../x/{id}").sections(oneSection()).validate(null));
+ }
+
+ @Test void a10_schemeColon_rejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("servlet:/data/{id}").sections(oneSection()).validate(null));
+ }
+
+ @Test void a11_protocolRelative_rejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> RowDetailDef.create().endpoint("//evil/{id}").sections(oneSection()).validate(null));
+ }
+
+ @Test void a12_columnsLessThanOne_rejected() {
+ var e = assertThrows(IllegalArgumentException.class, () -> RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("info", "Info").columns(0).fields(DetailField.of("a")))
+ .validate(null));
+ assertTrue(e.getMessage().contains("columns"), e::getMessage);
+ }
+
+ @Test void a13_duplicateSectionIds_rejected() {
+ assertThrows(IllegalArgumentException.class, () -> RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(
+ DetailSection.create("info", "A").fields(DetailField.of("a")),
+ DetailSection.create("info", "B").fields(DetailField.of("b")))
+ .validate(null));
+ }
+
+ @Test void a14_contractVersion_isOne() {
+ assertEquals("1", RowDetailDef.CONTRACT_VERSION);
+ assertEquals("4", ViewDef.CONTRACT_VERSION);
+ assertEquals("1", ActionBar.CONTRACT_VERSION);
+ assertEquals("1", ActionResult.CONTRACT_VERSION);
+ assertEquals("1", BulkMutateDef.CONTRACT_VERSION);
+ }
+
+ @Test void a15_isSafeDetailEndpoint_matrix() {
+ assertTrue(RowDetailDef.isSafeDetailEndpoint("/data/{id}"));
+ assertTrue(RowDetailDef.isSafeDetailEndpoint("data/{id}"));
+ assertFalse(RowDetailDef.isSafeDetailEndpoint("http://x/{id}"));
+ assertFalse(RowDetailDef.isSafeDetailEndpoint("//x/{id}"));
+ assertFalse(RowDetailDef.isSafeDetailEndpoint("servlet:/x/{id}"));
+ assertFalse(RowDetailDef.isSafeDetailEndpoint("/a/../b/{id}"));
+ }
+
+ @Test void a16_viewDefValidate_isInvokedFromViewTableOf() {
+ var v = ViewDef.create("x").dataMode(ViewDef.DataMode.CLIENT).dataUrl("/u")
+ .columns(Column.of("name"))
+ .details(RowDetailDef.create().endpoint("https://evil/{id}").sections(oneSection()))
+ .build();
+ assertThrows(IllegalArgumentException.class, () -> ViewTable.of(v));
+ }
+
+ @Test void a17_pageDefValidate_recursesIntoNestedView() {
+ var v = ViewDef.create("x").dataMode(ViewDef.DataMode.CLIENT).dataUrl("/u")
+ .columns(Column.of("name"))
+ .details(RowDetailDef.create().endpoint("/no-placeholder").sections(oneSection()))
+ .build();
+ var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("p")
+ .tabs(Tab.create("t", "T").view(v))
+ .build());
+ assertTrue(e.getMessage().contains("{id}"), e::getMessage);
+ }
+
+ @Test void a18_actionRefCount() {
+ var bar = ActionBar.create().items(ActionRef.of("ack"), ActionRef.of("esc"), SafeAction.COLLAPSE);
+ assertSize(3, bar.items);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_BrowserTest.java
new file mode 100644
index 0000000000..150e2730e0
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetail_BrowserTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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 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.*;
+import org.junit.jupiter.api.condition.*;
+
+/**
+ * Opt-in Chromium XSS canary for row-detail slot fill (TODO-445a). Always-on source-shape + Node coverage
+ * lives in {@link ViewsJs_RowDetail_Test}; this class proves the same {@code textContent} mandate against a
+ * real DOM (an {@code <img onerror>} payload must not execute).
+ *
+ * <p>
+ * Disabled unless the {@value #GATE} system property is set (the module's {@code js-tests} Maven profile).
+ */
+@EnabledIfSystemProperty(named=RowDetail_BrowserTest.GATE, matches="true",
+ disabledReason="JS-execution harness is opt-in; run with `mvn -Pjs-tests -f juneau-rest/juneau-rest-server-views/pom.xml test`")
+class RowDetail_BrowserTest extends TestBase {
+
+ /** System property the {@code js-tests} profile sets to enable this class. */
+ static final String GATE = "juneau.jsTests";
+
+ private static final String XSS = "<img src=x onerror=\"window.__juneauDetailXss=1\">";
+
+ private static Map<?,?> report;
+
+ 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);
+ }
+ }
+
+ @BeforeAll
+ static void probe() throws Exception {
+ var dir = Path.of(requiredProperty("juneau.jsTests.dir"));
+ var harness = Path.of(requiredProperty("juneau.jsTests.harness")).getParent().resolve("row-detail-browser.cjs");
+
+ var fixture = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body>\n<script>\n"
+ + resource(ViewsMixin.VIEWS_JS_RESOURCE)
+ + "\n</script></body></html>";
+ var fixtureFile = Files.createDirectories(dir.resolve("fixtures")).resolve("row-detail.html");
+ Files.write(fixtureFile, fixture.getBytes(UTF_8));
+
+ report = Json.to(run(dir, harness, fixtureFile), Map.class);
+ }
+
+ private static String requiredProperty(String name) {
+ var v = System.getProperty(name);
+ assertNotNull(v, () -> "-D" + name + " not set; the js-tests profile is responsible for providing it");
+ return v;
+ }
+
+ private static String run(Path dir, Path harness, Path fixture) throws Exception {
+ var cmd = List.of(System.getProperty("juneau.jsTests.node", "node"), harness.toString(), fixture.toString());
+ var stdout = dir.resolve("row-detail-stdout.json");
+ var stderr = dir.resolve("row-detail-stderr.txt");
+ var pb = new ProcessBuilder(cmd).redirectOutput(stdout.toFile()).redirectError(stderr.toFile());
+ pb.environment().put("NODE_PATH", dir.resolve("node_modules").toString());
+ pb.environment().put("PLAYWRIGHT_BROWSERS_PATH", requiredProperty("juneau.jsTests.browsers"));
+
+ var p = pb.start();
+ if (!p.waitFor(3, TimeUnit.MINUTES)) {
+ p.destroyForcibly();
+ fail("prober did not finish within 3m; stderr:\n" + quietRead(stderr));
+ }
+ assertEquals(0, p.exitValue(), () -> "prober exited non-zero; stderr:\n" + quietRead(stderr));
+ return Files.readString(stdout);
+ }
+
+ private static String quietRead(Path p) {
+ try {
+ return Files.readString(p);
+ } catch (IOException e) {
+ return "<unreadable: " + e + ">";
+ }
+ }
+
+ @Test void a01_runtimeLoadedWithNoScriptErrors() {
+ assertEquals(Boolean.TRUE, report.get("hasInit"), () -> "juneau-views.js did not load: " + report);
+ assertEquals(List.of(), report.get("jsFailures"), () -> "the runtime logged errors: " + report.get("jsFailures"));
+ }
+
+ @Test void a02_hostileField_isLiteralText_doesNotExecute() {
+ assertEquals(XSS, report.get("titleText"), () -> report.toString());
+ assertEquals(0, ((Number) report.get("titleImgCount")).intValue(), () -> report.toString());
+ assertEquals(Boolean.FALSE, report.get("xssFired"), () -> report.toString());
+ }
+
+ @Test void a03_hostileId_isUriEncoded() {
+ assertEquals(Boolean.TRUE, report.get("subEncoded"), () -> report.toString());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailsExpander_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailsExpander_Wiring_Test.java
index 99bf730e38..6675d769c7 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailsExpander_Wiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailsExpander_Wiring_Test.java
@@ -51,86 +51,103 @@ class RowDetailsExpander_Wiring_Test extends TestBase {
}
//------------------------------------------------------------------------------------------------------------------
- // Pure layer: buildDetailFields
+ // Pure layer: URL safety / scalar fill
//------------------------------------------------------------------------------------------------------------------
- @Test void a01_buildDetailFields_projectsDataAndTitle_fromRowData() throws Exception {
- var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function buildDetailFields(");
- assertTrue(fnBody.contains("rowData[d.data]"), fnBody);
- assertTrue(fnBody.contains("title: d.title || d.data"), fnBody);
+ @Test void a01_isSafeDetailUrl_rejectsAbsoluteDotDotAndScheme() throws Exception {
+ var fnBody = functionBody(cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString(),
+ "function isSafeDetailUrl(");
+ assertTrue(fnBody.contains("://"), fnBody);
+ assertTrue(fnBody.contains("\\.\\.") || fnBody.contains(".."), fnBody);
}
- @Test void a02_buildDetailFields_nullOrUndefinedValue_rendersAsEmptyString_notNullOrUndefined() throws Exception {
- var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function buildDetailFields(");
- assertTrue(fnBody.contains("v == null ? \"\" : String(v)"), fnBody);
+ @Test void a02_substituteDetailUrl_usesEncodeURIComponent() throws Exception {
+ var fnBody = functionBody(cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString(),
+ "function substituteDetailUrl(");
+ assertTrue(fnBody.contains("encodeURIComponent"), fnBody);
+ assertTrue(fnBody.contains("split(\"{id}\")"), fnBody);
}
- @Test void a03_buildDetailFields_isPure_noDomOrDateAccess() throws Exception {
- var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function buildDetailFields(");
- assertFalse(fnBody.contains("document."), fnBody);
- assertFalse(fnBody.contains("Date.now()"), fnBody);
+ @Test void a03_scalarFieldValue_nonScalarsBecomeEmpty() throws Exception {
+ var fnBody = functionBody(cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString(),
+ "function scalarFieldValue(");
+ assertTrue(fnBody.contains("typeof v"), fnBody);
+ assertFalse(fnBody.contains("JSON.stringify"), fnBody);
}
- //------------------------------------------------------------------------------------------------------------------
- // Binding layer: buildDetailPanel / initDetailsExpander
- //------------------------------------------------------------------------------------------------------------------
-
- /** Client-rendered by default: the panel is built with textContent only, never innerHTML. */
- @Test void b01_buildDetailPanel_usesTextContentOnly_neverInnerHTML() throws Exception {
- var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function buildDetailPanel(");
- assertTrue(fnBody.contains("dtEl.textContent = f.title"), fnBody);
- assertTrue(fnBody.contains("ddEl.textContent = f.value"), fnBody);
+ @Test void a04_fillDetailSlots_usesTextContentOnly_neverInnerHTML() throws Exception {
+ var fnBody = functionBody(cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString(),
+ "function fillDetailSlots(");
+ assertTrue(fnBody.contains(".textContent"), fnBody);
assertFalse(fnBody.contains("innerHTML"), fnBody);
}
- @Test void b02_buildDetailPanel_marksItselfForTestSelection() throws Exception {
+ //------------------------------------------------------------------------------------------------------------------
+ // Binding layer: initDetailsExpander / createdRow
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_expandClonesTemplate_marksPanelForTestSelection() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function buildDetailPanel(");
+ var fnBody = functionBody(body, "function expandDetailRow(");
+ assertTrue(fnBody.contains("tpl.content.cloneNode(true)"), fnBody);
assertTrue(fnBody.contains("juneau-view-detail-panel"), fnBody);
assertTrue(fnBody.contains("\"data-testid\", \"detail-panel\""), fnBody);
+ assertFalse(fnBody.contains("innerHTML"), fnBody);
}
- @Test void b03_initDetailsExpander_delegatesOneClickListener_offTheDetailRowMarkerClass() throws Exception {
+ @Test void b02_initDetailsExpander_delegatesOneClickListener_offTheDetailRowMarkerClass() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
var fnBody = functionBody(body, "function initDetailsExpander(");
assertTrue(fnBody.contains("table.addEventListener(\"click\""), fnBody);
assertTrue(fnBody.contains("tr.juneau-view-detail-row"), fnBody);
}
- @Test void b04_initDetailsExpander_usesDataTablesNativeChildRowApi_notItsOwnDom() throws Exception {
+ @Test void b03_initDetailsExpander_usesDataTablesNativeChildRowApi() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function initDetailsExpander(");
- assertTrue(fnBody.contains("row.child.isShown()"), fnBody);
- assertTrue(fnBody.contains("row.child(buildDetailPanel(buildDetailFields(viewDef.details, row.data()))).show()"), fnBody);
- assertTrue(fnBody.contains("row.child.hide()"), fnBody);
+ var initFn = functionBody(body, "function initDetailsExpander(");
+ var expandFn = functionBody(body, "function expandDetailRow(");
+ assertTrue(initFn.contains("row.child.isShown()"), initFn);
+ assertTrue(initFn.contains("row.child.hide()"), initFn);
+ assertTrue(expandFn.contains("row.child(panel).show()"), expandFn);
+ assertTrue(initFn.contains("ctx.dataTable"), initFn);
+ assertTrue(expandFn.contains("encodeURIComponent") || body.contains("substituteDetailUrl"), expandFn);
}
- /** Expanding a row does not survive a redraw - a toggle-open marker class is used purely for the CSS glyph,
- * not to re-open the panel across a draw; DataTables' own child-row lifecycle (not this file) is what
- * actually drops the panel on the next draw.dt. */
- @Test void b05_initDetailsExpander_togglesAnOpenMarkerClass_forTheCssGlyphOnly() throws Exception {
+ @Test void b04_initDetailsExpander_togglesAnOpenMarkerClass_forTheCssGlyphOnly() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var fnBody = functionBody(body, "function initDetailsExpander(");
- assertTrue(fnBody.contains("tr.classList.add(\"juneau-view-detail-open\")"), fnBody);
- assertTrue(fnBody.contains("tr.classList.remove(\"juneau-view-detail-open\")"), fnBody);
+ var initFn = functionBody(body, "function initDetailsExpander(");
+ var expandFn = functionBody(body, "function expandDetailRow(");
+ assertTrue(expandFn.contains("tr.classList.add(\"juneau-view-detail-open\")"), expandFn);
+ assertTrue(initFn.contains("tr.classList.remove(\"juneau-view-detail-open\")")
+ || initFn.contains("parentTr.classList.remove(\"juneau-view-detail-open\")"), initFn);
}
- @Test void b06_createdRow_marksExpandableRows_onlyWhenViewDeclaresNonEmptyDetails() throws Exception {
+ @Test void b05_createdRow_marksExpandableRows_whenTemplatePresent() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
var fnBody = functionBody(body, "function buildOptions(");
- assertTrue(fnBody.contains("if (viewDef.details && viewDef.details.length)"), fnBody);
+ assertTrue(fnBody.contains("if (deps.hasRowDetail)"), fnBody);
assertTrue(fnBody.contains("juneau-view-detail-row"), fnBody);
}
- @Test void b07_initTable_onlyWiresTheExpanderWhenViewDeclaresNonEmptyDetails() throws Exception {
+ @Test void b06_initTable_onlyWiresTheExpanderWhenTemplatePresent() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var initBody = functionBody(body, "function beginInitTable(");
+ assertTrue(initBody.contains("if (findRowDetailTemplate(table))"), initBody);
+ assertTrue(initBody.contains("initDetailsExpander(table, ctx, viewDef)"), initBody);
+ }
+
+ @Test void b07_actionSubmitUsesParentTr_notJson() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initDetailsExpander(");
+ assertTrue(fnBody.contains("submitRowAction(action, table, parentTr, ctx)"), fnBody);
+ assertTrue(fnBody.contains("panel._juneauParentTr"), fnBody);
+ }
+
+ @Test void b08_constructTable_clearsCoalesceMapOnDraw() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var initBody = functionBody(body, "function initTable(");
- assertTrue(initBody.contains("if (viewDef.details && viewDef.details.length)"), initBody);
- assertTrue(initBody.contains("initDetailsExpander(table, dt, viewDef)"), initBody);
+ var fn = functionBody(body, "function constructTable(");
+ assertTrue(fn.contains("draw.dt"), fn);
+ assertTrue(fn.contains("_detailInflight"), fn);
}
//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SavedViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SavedViewsMixin_Serving_Test.java
new file mode 100644
index 0000000000..f9a951fe1c
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/SavedViewsMixin_Serving_Test.java
@@ -0,0 +1,317 @@
+/*
+ * 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 java.security.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.http.classic.header.*;
+import org.apache.juneau.marshall.marshaller.*;
+import org.apache.juneau.rest.client.classic.RestResponse;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Serving/security-matrix test for {@link SavedViewsMixin} — the saved-views REST endpoints (TODO-444 §3.3).
+ *
+ * <p>
+ * Drives the fixed-mount endpoints through a {@link MockRestClient} and exercises the security matrix the plan
+ * requires: unauthenticated (incl. the safe {@code GET list}) is {@code 401}; user A cannot read/write/delete user
+ * B's views; a non-JSON write is {@code 415}; blank/reserved/over-length names and a missing {@code view} are
+ * {@code 400}; the per-blob size cap is {@code 413} and the count quotas (per-scope and per-user aggregate) are
+ * {@code 507}; {@code saveAndActivate} is atomic; set/clear active and dangling-active→Default resolve; and
+ * delete works.
+ *
+ * <h5 class='section'>CSRF / Origin / Host are intentionally NOT tested here</h5>
+ * <p>
+ * Those checks are enforced by {@link org.apache.juneau.rest.server.filter.LoopbackBoundaryFilter} (a {@code /*}
+ * servlet filter that runs BEFORE the mixin), which a {@code MockRestClient} does not run — and the mixin
+ * deliberately does not re-implement CSRF (see the mixin javadoc). Their coverage lives in
+ * {@code LoopbackBoundary_Test} / {@code LoopbackBoundaryFilter_Test}. The one write-time transport check the mixin
+ * DOES own — {@code Content-Type: application/json} (415) — is exercised below.
+ */
+class SavedViewsMixin_Serving_Test extends TestBase {
+
+ @Rest
+ public static class R extends BasicRestServlet implements SavedViewsMixin {
+ private static final long serialVersionUID = 1L;
+
+ /** Swappable so each test gets a clean store (and the quota tests can install a small-cap one). */
+ static volatile SavedViewStore store = new InMemorySavedViewStore();
+
+ @Override public SavedViewStore savedViewStore() { return store; }
+ }
+
+ private static final MockRestClient c = MockRestClient.buildLax(R.class);
+
+ private static final Principal ALICE = () -> "alice";
+ private static final Principal BOB = () -> "bob";
+ private static final Principal BLANK = () -> " ";
+
+ private static final String BLOB = "{\"schemaVersion\":1}";
+ private static final String BASE = "/juneau-saved-views";
+ private static final String ITEM = BASE + "/item";
+ private static final String ACTIVE = BASE + "/active";
+
+ @BeforeEach void reset() {
+ R.store = new InMemorySavedViewStore();
+ }
+
+ // Helpers -----------------------------------------------------------------------------------------------------------
+
+ private static RestResponse getReq(Principal p, String url) throws Exception {
+ var req = c.get(url);
+ if (p != null)
+ req.userPrincipal(p);
+ req.header("Accept", "application/json");
+ return req.run();
+ }
+
+ private static RestResponse putJson(Principal p, String url, String body) throws Exception {
+ var req = c.put(url, body, ContentType.APPLICATION_JSON);
+ if (p != null)
+ req.userPrincipal(p);
+ req.header("Accept", "application/json");
+ return req.run();
+ }
+
+ private static RestResponse deleteJson(Principal p, String url) throws Exception {
+ // The classic client refuses a content entity on DELETE, so the JSON content type travels as a bare header
+ // (no body) - the mixin's delete reads 'name' from the query and never consumes a body anyway.
+ var req = c.delete(url);
+ if (p != null)
+ req.userPrincipal(p);
+ req.header("Content-Type", "application/json");
+ req.header("Accept", "application/json");
+ return req.run();
+ }
+
+ private static Map<?,?> asMap(RestResponse r) throws Exception {
+ return Json.to(r.getContent().asString(), Map.class);
+ }
+
+ private static void assertWriteOk(RestResponse r) {
+ var code = r.getStatusCode();
+ assertTrue(code == 200 || code == 204, "expected a 2xx-no-error write status but got " + code);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Authentication: 401 on absent/blank principal for EVERY op, including GET list
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_listUnauthenticatedIs401() throws Exception {
+ getReq(null, BASE + "?view=grid").assertStatus(401);
+ }
+
+ @Test void a02_blankPrincipalIs401() throws Exception {
+ getReq(BLANK, BASE + "?view=grid").assertStatus(401);
+ }
+
+ @Test void a03_everyWriteUnauthenticatedIs401() throws Exception {
+ putJson(null, ITEM + "?view=grid&name=v1", BLOB).assertStatus(401);
+ putJson(null, ACTIVE + "?view=grid", "{\"name\":\"v1\"}").assertStatus(401);
+ deleteJson(null, ITEM + "?view=grid&name=v1").assertStatus(401);
+ getReq(null, ITEM + "?view=grid&name=v1").assertStatus(401);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Happy path: save, list, load, save+activate, set/clear active, delete
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_saveThenListThenLoad() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&page=orders&name=v1", BLOB));
+ var listing = asMap(getReq(ALICE, BASE + "?view=grid&page=orders").assertStatus(200));
+ assertNull(listing.get("active"));
+ var views = (List<?>) listing.get("views");
+ assertEquals(1, views.size());
+ assertEquals("v1", ((Map<?,?>) views.get(0)).get("name"));
+ var blob = asMap(getReq(ALICE, ITEM + "?view=grid&page=orders&name=v1").assertStatus(200));
+ assertEquals(1, ((Number) blob.get("schemaVersion")).intValue());
+ }
+
+ @Test void b02_saveAndActivateFlipsActive() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1&activate=1", BLOB));
+ var listing = asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200));
+ assertEquals("v1", listing.get("active"));
+ }
+
+ @Test void b03_plainSaveDoesNotActivate() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ var listing = asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200));
+ assertNull(listing.get("active"));
+ }
+
+ @Test void b04_setActiveThenClear() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ assertWriteOk(putJson(ALICE, ACTIVE + "?view=grid", "{\"name\":\"v1\"}"));
+ assertEquals("v1", asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200)).get("active"));
+ assertWriteOk(putJson(ALICE, ACTIVE + "?view=grid", "{}"));
+ assertNull(asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200)).get("active"));
+ }
+
+ @Test void b05_danglingActiveResolvesToDefault() throws Exception {
+ // Activate a name that was never saved -> list() resolves it to Default (null), never an error.
+ assertWriteOk(putJson(ALICE, ACTIVE + "?view=grid", "{\"name\":\"ghost\"}"));
+ assertNull(asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200)).get("active"));
+ }
+
+ @Test void b06_deleteRemovesAndClearsActive() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1&activate=1", BLOB));
+ assertWriteOk(deleteJson(ALICE, ITEM + "?view=grid&name=v1"));
+ var listing = asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200));
+ assertTrue(((List<?>) listing.get("views")).isEmpty());
+ assertNull(listing.get("active"));
+ getReq(ALICE, ITEM + "?view=grid&name=v1").assertStatus(404);
+ }
+
+ @Test void b07_nameWithSpecialCharsRoundTrips() throws Exception {
+ // Wire-encoded space + slash; the container decodes, the structured-tuple store accepts them verbatim.
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=My%20View%2F1", BLOB));
+ var views = (List<?>) asMap(getReq(ALICE, BASE + "?view=grid").assertStatus(200)).get("views");
+ assertEquals("My View/1", ((Map<?,?>) views.get(0)).get("name"));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) Cross-user isolation: A cannot read/write/delete B's views (and vice versa)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_userBCannotSeeUserAViews() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ var bList = asMap(getReq(BOB, BASE + "?view=grid").assertStatus(200));
+ assertTrue(((List<?>) bList.get("views")).isEmpty());
+ getReq(BOB, ITEM + "?view=grid&name=v1").assertStatus(404);
+ }
+
+ @Test void c02_userBWriteDoesNotDisturbUserA() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ assertWriteOk(putJson(BOB, ITEM + "?view=grid&name=v1", "{\"schemaVersion\":1,\"who\":\"bob\"}"));
+ var aBlob = asMap(getReq(ALICE, ITEM + "?view=grid&name=v1").assertStatus(200));
+ assertNull(aBlob.get("who")); // A still sees A's blob, not B's
+ }
+
+ @Test void c03_userBDeleteDoesNotRemoveUserAView() throws Exception {
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ assertWriteOk(deleteJson(BOB, ITEM + "?view=grid&name=v1")); // no-op in B's namespace
+ getReq(ALICE, ITEM + "?view=grid&name=v1").assertStatus(200); // A's view survives
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Content-Type: a non-JSON write is 415 (mixin defense-in-depth; the filter also enforces it)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_nonJsonSaveIs415() throws Exception {
+ c.put(ITEM + "?view=grid&name=v1", BLOB, ContentType.TEXT_PLAIN).userPrincipal(ALICE).run().assertStatus(415);
+ }
+
+ @Test void d02_nonJsonSetActiveIs415() throws Exception {
+ c.put(ACTIVE + "?view=grid", "{\"name\":\"v1\"}", ContentType.TEXT_PLAIN).userPrincipal(ALICE).run().assertStatus(415);
+ }
+
+ @Test void d03_deleteWithoutJsonContentTypeIs415() throws Exception {
+ c.delete(ITEM + "?view=grid&name=v1").userPrincipal(ALICE).header("Content-Type", "text/plain").run().assertStatus(415);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Validation: 400 on missing view / blank / reserved / over-length name / non-JSON body; 404 on missing load
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_missingViewParamIs400() throws Exception {
+ getReq(ALICE, BASE).assertStatus(400);
+ }
+
+ @Test void e02_blankNameIs400() throws Exception {
+ putJson(ALICE, ITEM + "?view=grid&name=", BLOB).assertStatus(400);
+ getReq(ALICE, ITEM + "?view=grid&name=").assertStatus(400);
+ deleteJson(ALICE, ITEM + "?view=grid&name=").assertStatus(400);
+ }
+
+ @Test void e03_reservedDefaultNameIs400() throws Exception {
+ putJson(ALICE, ITEM + "?view=grid&name=Default", BLOB).assertStatus(400);
+ putJson(ALICE, ITEM + "?view=grid&name=default", BLOB).assertStatus(400); // case-insensitive
+ putJson(ALICE, ITEM + "?view=grid&name=%20DEFAULT%20", BLOB).assertStatus(400); // trimmed
+ }
+
+ @Test void e04_overLengthNameIs400() throws Exception {
+ var longName = "x".repeat(SavedViewsMixin.MAX_NAME_LEN + 1);
+ putJson(ALICE, ITEM + "?view=grid&name=" + longName, BLOB).assertStatus(400);
+ }
+
+ @Test void e05_nonJsonBodyIs400() throws Exception {
+ // Correct content type (passes 415), but the body is not a JSON object -> 400 (not a quota/415).
+ putJson(ALICE, ITEM + "?view=grid&name=v1", "not json").assertStatus(400);
+ }
+
+ @Test void e06_emptyBodyIs400() throws Exception {
+ putJson(ALICE, ITEM + "?view=grid&name=v1", "").assertStatus(400);
+ }
+
+ @Test void e07_loadMissingIs404() throws Exception {
+ getReq(ALICE, ITEM + "?view=grid&name=nope").assertStatus(404);
+ }
+
+ @Test void e08_setActiveBlankNameIs400() throws Exception {
+ // A present-but-blank name in the set-active body is a malformed name -> 400 (clearing uses {} instead).
+ putJson(ALICE, ACTIVE + "?view=grid", "{\"name\":\"\"}").assertStatus(400);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) Quotas: per-blob size 413; per-scope count 507; per-user aggregate 507
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_oversizeBlobIs413() throws Exception {
+ var big = "{\"schemaVersion\":1,\"pad\":\"" + "x".repeat((int) SavedViewsMixin.MAX_BLOB_BYTES) + "\"}";
+ putJson(ALICE, ITEM + "?view=grid&name=big", big).assertStatus(413);
+ }
+
+ @Test void f02_perScopeCountQuotaIs507() throws Exception {
+ R.store = new InMemorySavedViewStore(2, 100);
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v0", BLOB));
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ putJson(ALICE, ITEM + "?view=grid&name=v2", BLOB).assertStatus(507);
+ }
+
+ @Test void f03_replaceAtScopeCapSucceeds() throws Exception {
+ R.store = new InMemorySavedViewStore(2, 100);
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v0", BLOB));
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v1", BLOB));
+ // Overwriting an existing name at the cap is a replace, not a create -> not a quota rejection.
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&name=v0", "{\"schemaVersion\":1,\"z\":9}"));
+ }
+
+ @Test void f04_perUserAggregateQuotaIs507() throws Exception {
+ R.store = new InMemorySavedViewStore(100, 2);
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&page=p1&name=v", BLOB));
+ assertWriteOk(putJson(ALICE, ITEM + "?view=grid&page=p2&name=v", BLOB));
+ // Third create in yet another scope trips the aggregate ceiling, not the per-scope one.
+ putJson(ALICE, ITEM + "?view=grid&page=p3&name=v", BLOB).assertStatus(507);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) The mount is the fixed prefix
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_fixedMountConstants() {
+ assertEquals("/juneau-saved-views", SavedViewsMixin.SAVED_VIEWS_PREFIX);
+ assertEquals("/juneau-saved-views/item", SavedViewsMixin.ITEM_PATH);
+ assertEquals("/juneau-saved-views/active", SavedViewsMixin.ACTIVE_PATH);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
index be7184bebb..5d8a766916 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
@@ -143,18 +143,26 @@ class TablePolling_Wiring_Test extends TestBase {
assertTrue(fnBody.contains("state.failed = false"), fnBody);
}
- @Test void b07_initTable_onlyWiresPollingWhenViewDeclaresAPollInterval() throws Exception {
+ @Test void b07_constructTable_onlyWiresPollingWhenViewDeclaresAPollInterval() throws Exception {
var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
- var initBody = functionBody(body, "function initTable(");
+ var initBody = functionBody(body, "function constructTable(");
assertTrue(initBody.contains("if (viewDef.pollIntervalMs && toolbarRow)"), initBody);
assertTrue(initBody.contains("buildStalenessIndicator()"), initBody);
- assertTrue(initBody.contains("initPolling(table, dt, viewDef, staleness)"), initBody);
- // Inserted into the RIGHT toolbar cluster, ahead of search/ribbon - buildToolbarRow's own signature/tests
- // are untouched (this insertion happens as a follow-up DOM step, not a buildToolbarRow(...) parameter).
+ assertTrue(initBody.contains("initPolling(table, ctx.dataTable, viewDef, staleness, ctx)"), initBody);
assertTrue(initBody.contains(".juneau-view-toolbar-right"), initBody);
assertTrue(initBody.contains("insertBefore(staleness, rightCluster.firstChild)"), initBody);
}
+ @Test void b08_initPolling_storesIntervalIds_andClearsLeftovers() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initPolling(");
+ assertTrue(fnBody.contains("clearInterval(id)"), fnBody);
+ assertTrue(fnBody.contains("ctx._pollTimers = [pollId, renderId]"), fnBody);
+ var teardown = functionBody(body, "function teardownTable(");
+ assertTrue(teardown.contains("clearInterval(id)"), teardown);
+ assertTrue(teardown.contains("ctx._pollTimers"), teardown);
+ }
+
//------------------------------------------------------------------------------------------------------------------
// CSS shape (neutral, no palette color; distinct error-state emphasis)
//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
index 662a54d517..bbd8157728 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewMeta_Contract_Test.java
@@ -53,7 +53,7 @@ class ViewMeta_Contract_Test extends TestBase {
*/
private static final String EXPECTED_VIEW_META = """
{
- "contractVersion": "3",
+ "contractVersion": "4",
"id": "releases",
"rowType": "Release",
"dataMode": "server",
@@ -123,14 +123,17 @@ class ViewMeta_Contract_Test extends TestBase {
new ArrayList<>(actual.keySet()));
}
- @Test void a03_reservedFieldsOmittedNotNull() {
+ @Test void a03_reservedOrUnsetFieldsOmittedNotNull() {
var json = Json.of(releasesView());
- // "details" and "rowActions" are implemented - see f01-f04 (details) and g01-g05 (rowActions) below for
- // their POSITIVE assertions. The remaining reserved C/D/E stubs must still be omitted entirely (not
- // serialized as null keys).
- for (var k : List.of("catalog", "format", "description", "pinned", "defaultVisible")) {
+ // "rowActions" is a VIEW_META wire field - see g01-g05. "details" left the sidecar (Java-only
+ // <template>); it is in a03's omitted list. "catalog"/"format"/"description" remain reserved stubs.
+ // (design doc §6.4/§6.10) not exposed by this builder at all. "pinned"/"defaultVisible" are no longer
+ // reserved stubs as of contract v4 - they are REAL nullable-wrapper fields (see h01-h04 below for their
+ // positive assertions) that are simply omitted-when-unset by this non-configurable fixture, same as
+ // "columnConfig" (the chooser opt-in this fixture never sets).
+ for (var k : List.of("catalog", "format", "description", "pinned", "defaultVisible", "columnConfig", "details")) {
var key = "\"" + k + "\"";
- assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
+ assertFalse(json.contains(key), () -> "Reserved or unset field leaked into VIEW_META: " + k + "\n" + json);
}
}
@@ -391,42 +394,42 @@ class ViewMeta_Contract_Test extends TestBase {
}
//------------------------------------------------------------------------------------------------------------------
- // f) Row-details expander: the positive assertion for the "details" key (the same positive-assertion-plus-
- // remaining-negatives split used elsewhere for "rowActions", applied here - see a03 above for the remaining
- // negative list with "details" now removed from it).
+ // f) Row-details expander: details is Java-only (never VIEW_META JSON), even when set.
//------------------------------------------------------------------------------------------------------------------
@Test void f01_details_omittedWhenUnset() {
- // The golden fixture view never calls .details(...) - confirms the new field doesn't leak when absent,
- // and that a01/a02's frozen contract is untouched by this additive field.
var json = Json.of(releasesView());
assertFalse(json.contains("\"details\""), json);
}
- @Test void f02_details_emittedInPinnedOrder_withExpectedShape() {
+ @Test void f02_details_neverEmittedEvenWhenSet() {
var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
- .details(ViewDef.DetailDef.of("owner").title("Owner"), ViewDef.DetailDef.of("createdAt").title("Created"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("info", "Info").fields(DetailField.of("owner").title("Owner"))))
.build();
var json = Json.of(v);
- assertTrue(json.contains(
- "\"details\":[{\"data\":\"owner\",\"title\":\"Owner\"},{\"data\":\"createdAt\",\"title\":\"Created\"}]"),
- json);
+ assertFalse(json.contains("\"details\""), json);
+ assertFalse(json.contains("\"endpoint\""), json);
+ assertEquals("4", v.contractVersion);
}
- @Test void f03_details_isBeforePollIntervalMs_andAfterRowClassRules_inTopLevelKeyOrder() {
+ @Test void f03_details_doesNotAffectTopLevelKeyOrder() {
var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
.rowClassRule("error", Op.PRESENT, "row-flagged")
- .details(ViewDef.DetailDef.of("owner").title("Owner"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("info", "Info").fields(DetailField.of("owner").title("Owner"))))
.poll(60_000L)
.build();
Map<?,?> actual = Json.to(Json.of(v), Map.class);
assertEquals(
- List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "details", "pollIntervalMs"),
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "pollIntervalMs"),
new ArrayList<>(actual.keySet()));
}
- @Test void f04_detailDefTitleDefaultsToNullWhenUnset() {
- var d = ViewDef.DetailDef.of("owner");
+ @Test void f04_detailFieldTitleDefaultsToNullWhenUnset() {
+ var d = DetailField.of("owner");
assertEquals("owner", d.data);
assertNull(d.title);
}
@@ -472,16 +475,18 @@ class ViewMeta_Contract_Test extends TestBase {
assertFalse(json.contains("\"onSuccess\""), json);
}
- @Test void g04_rowActions_isAfterRowClassRules_andBeforeDetails_inTopLevelKeyOrder() {
+ @Test void g04_rowActions_isAfterRowClassRules_inTopLevelKeyOrder() {
var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
.rowClassRule("error", Op.PRESENT, "row-flagged")
.rowActions(RowAction.create("go").endpoint("u").method(RowAction.Method.POST))
- .details(ViewDef.DetailDef.of("owner").title("Owner"))
+ .details(RowDetailDef.create()
+ .endpoint("/data/{id}")
+ .sections(DetailSection.create("info", "Info").fields(DetailField.of("owner").title("Owner"))))
.poll(60_000L)
.build();
Map<?,?> actual = Json.to(Json.of(v), Map.class);
assertEquals(
- List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "rowActions", "details", "pollIntervalMs"),
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "rowActions", "pollIntervalMs"),
new ArrayList<>(actual.keySet()));
}
@@ -507,4 +512,62 @@ class ViewMeta_Contract_Test extends TestBase {
assertEquals("mergeRow", RowAction.OnSuccess.MERGE_ROW.wire());
assertEquals("navigate", RowAction.OnSuccess.NAVIGATE.wire());
}
+
+ //------------------------------------------------------------------------------------------------------------------
+ // h) Column-configurator opt-in (contract v4): ViewDef.columnConfig + Column.pinned/defaultVisible/formats -
+ // each omitted from the wire when unset (a03 above pins the non-configurable negative); this section pins the
+ // POSITIVE configurable shape and proves the nullable-wrapper omit-when-unset behavior field-by-field.
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_columnConfig_omittedWhenUnset() {
+ // The golden fixture view never calls .columnConfig(...) - confirms the new field doesn't leak when absent,
+ // and that a01/a02's frozen contract is untouched by this additive field.
+ var json = Json.of(releasesView());
+ assertFalse(json.contains("\"columnConfig\""), json);
+ }
+
+ @Test void h02_configurableView_serializesColumnConfigAndColumnFlags() {
+ // Each column sets exactly ONE of the three new nullable fields, proving they're independently omitted
+ // (not a package-deal) - and none of them ever emits as a primitive "false" when unset on a sibling column.
+ var v = ViewDef.create("releases")
+ .rowType(Release.class)
+ .dataMode(DataMode.SERVER)
+ .dataUrl("servlet:/releases/data")
+ .columns(
+ Column.of("name").title("Name").pinned(true),
+ Column.of("status").title("Status").defaultVisible(false),
+ Column.of("date").title("Date").formats("date", "ts-zulu"))
+ .columnConfig(ColumnConfig.create())
+ .build();
+ var json = Json.of(v);
+ assertTrue(json.contains(
+ "\"columns\":["
+ + "{\"data\":\"name\",\"title\":\"Name\",\"orderable\":true,\"searchable\":true,\"pinned\":true},"
+ + "{\"data\":\"status\",\"title\":\"Status\",\"orderable\":true,\"searchable\":true,\"defaultVisible\":false},"
+ + "{\"data\":\"date\",\"title\":\"Date\",\"orderable\":true,\"searchable\":true,\"formats\":[\"date\",\"ts-zulu\"]}"
+ + "]"),
+ json);
+ assertTrue(json.contains("\"columnConfig\":{}"), json);
+ }
+
+ @Test void h03_columnConfig_isTheLastWireKey_afterPollIntervalMs() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+ .poll(60_000L)
+ .columnConfig(ColumnConfig.create())
+ .build();
+ Map<?,?> actual = Json.to(Json.of(v), Map.class);
+ assertEquals(
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "pollIntervalMs", "columnConfig"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void h04_columnFlags_areNullableWrappers_neverEmitPrimitiveFalseWhenUnset() {
+ // pinned/defaultVisible/formats must be wrapper types left null, not primitives - a primitive boolean
+ // would emit "pinned":false/"defaultVisible":false on every column and break the omit-when-unset invariant
+ // the way the always-emitting primitive orderable/searchable fields do (contrast with c/d sections above).
+ var json = Json.of(Column.of("plain").title("Plain"));
+ assertFalse(json.contains("\"pinned\""), json);
+ assertFalse(json.contains("\"defaultVisible\""), json);
+ assertFalse(json.contains("\"formats\""), json);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
index 11b1ccc30f..e2b94c0c7a 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_Emit_Test.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.apache.juneau.*;
+import org.apache.juneau.marshall.*;
import org.apache.juneau.marshall.marshaller.*;
import org.apache.juneau.rest.server.views.ViewDef.DataMode;
import org.junit.jupiter.api.*;
@@ -205,4 +206,23 @@ class ViewTable_Emit_Test extends TestBase {
assertTrue(html.contains("released"), html);
assertTrue(html.contains("2.0"), html);
}
+
+ //------------------------------------------------------------------------------------------------------------------
+ // data-juneau-saved-views stamp (slice 6) — wrapper <div>, not the <table>
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_resolvedBase_stampsWrapperDivNotTable() {
+ var html = Html.of(ViewTable.of(MarshallingContext.DEFAULT, view(), null, null, null, null,
+ "/myapp/juneau-saved-views"));
+ assertTrue(html.contains("data-juneau-saved-views="), html);
+ assertTrue(html.contains("/myapp/juneau-saved-views"), html);
+ assertEquals("data-juneau-saved-views", ViewTable.SAVED_VIEWS_ATTR);
+ // The table itself must NOT carry the attribute (JS reads it via closest() from the wrapper/page shell).
+ assertFalse(html.contains("<table id=\"releases\" data-juneau-saved-views"), html);
+ }
+
+ @Test void e02_absentBase_doesNotStamp() {
+ var html = Html.of(ViewTable.of(view()));
+ assertFalse(html.contains("data-juneau-saved-views"), html);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
new file mode 100644
index 0000000000..94e8c6017a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_RowDetail_Emit_Test.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+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.apache.juneau.rest.server.views.ViewDef.DataMode;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@link ViewTable} emits one {@code <template data-juneau-row-detail>} whose section/field/action ids match the
+ * bean, and never pours field values into the template.
+ */
+class ViewTable_RowDetail_Emit_Test extends TestBase {
+
+ private static ViewDef view() {
+ return ViewDef.create("alerts")
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data/alerts")
+ .columns(Column.of("id").title("Id"))
+ .rowActions(
+ RowAction.create("ack").label("Acknowledge").endpoint("/data/alerts/{id}/ack").method(RowAction.Method.POST),
+ RowAction.create("esc").label("Escalate").endpoint("/data/alerts/{id}/esc").method(RowAction.Method.POST))
+ .details(RowDetailDef.create()
+ .endpoint("/data/alerts/{id}")
+ .sections(
+ DetailSection.create("overview", "Overview")
+ .columns(2)
+ .fields(DetailField.of("severity").title("Severity"), DetailField.of("title").title("Title"))
+ .actions(ActionBar.create().items(ActionRef.of("ack"), SafeAction.COLLAPSE)),
+ DetailSection.create("detail", "Detail")
+ .fields(DetailField.of("summary").title("Summary"))
+ .actions(ActionBar.create().items(ActionRef.of("esc")))))
+ .build();
+ }
+
+ @Test void a01_emitsOneTemplate_withStampedUrlAndContract() {
+ var html = Html.of(ViewTable.of(view()));
+ assertTrue(html.contains("data-juneau-row-detail"), html);
+ assertTrue(html.contains("data-juneau-detail-contract=\"1\""), html);
+ assertTrue(html.contains("data-juneau-detail-url=\"/data/alerts/{id}\""), html);
+ assertEquals(html.indexOf("data-juneau-row-detail"), html.lastIndexOf("data-juneau-row-detail"));
+ }
+
+ @Test void a02_sectionFieldActionIdsMatchBean() {
+ var html = Html.of(ViewTable.of(view()));
+ assertTrue(html.contains("data-juneau-detail-section=\"overview\""), html);
+ assertTrue(html.contains("data-juneau-detail-section=\"detail\""), html);
+ assertTrue(html.contains("data-juneau-field=\"severity\""), html);
+ assertTrue(html.contains("data-juneau-field=\"title\""), html);
+ assertTrue(html.contains("data-juneau-field=\"summary\""), html);
+ assertTrue(html.contains("data-juneau-action=\"ack\""), html);
+ assertTrue(html.contains("data-juneau-action=\"esc\""), html);
+ assertTrue(html.contains("data-juneau-safe=\"collapse\""), html);
+ assertTrue(html.contains("Acknowledge"), html);
+ assertTrue(html.contains("Escalate"), html);
+ assertTrue(html.contains("Collapse"), html);
+ }
+
+ @Test void a03_actionRefStartsDisabled_collapseDoesNot() {
+ var html = Html.of(ViewTable.of(view()));
+ assertTrue(html.contains("data-juneau-action=\"ack\""), html);
+ assertTrue(html.contains("disabled=\"disabled\"") || html.contains(" disabled"), html);
+ // COLLAPSE button must not carry disabled.
+ var collapseAt = html.indexOf("data-juneau-safe=\"collapse\"");
+ assertTrue(collapseAt >= 0, html);
+ var collapseTagEnd = html.indexOf('>', collapseAt);
+ var collapseTagStart = html.lastIndexOf('<', collapseAt);
+ var collapseTag = html.substring(collapseTagStart, collapseTagEnd);
+ assertFalse(collapseTag.contains("disabled"), collapseTag);
+ }
+
+ @Test void a04_fieldSlotsAreEmpty_noValuesInTemplate() {
+ var html = Html.of(ViewTable.of(view()));
+ assertFalse(html.contains("innerHTML"), html);
+ var fieldAt = html.indexOf("data-juneau-field=\"severity\"");
+ var fieldTagEnd = html.indexOf('>', fieldAt);
+ var close = html.indexOf('<', fieldTagEnd + 1);
+ var inner = html.substring(fieldTagEnd + 1, close);
+ assertTrue(inner.isBlank(), inner);
+ }
+
+ @Test void a05_noTemplateWhenDetailsUnset() {
+ var v = ViewDef.create("plain").dataMode(DataMode.CLIENT).dataUrl("/u").columns(Column.of("a")).build();
+ var html = Html.of(ViewTable.of(v));
+ assertFalse(html.contains("data-juneau-row-detail"), html);
+ }
+
+ @Test void a06_gridTemplateColumns_fromValidatedColumns() {
+ var html = Html.of(ViewTable.of(view()));
+ assertTrue(html.contains("grid-template-columns:repeat(2,minmax(0,1fr))"), html);
+ }
+
+ @Test void a07_pageTable_alsoEmitsChildTemplate() {
+ var html = Html.of(PageTable.of(PageDef.create("p").tabs(Tab.create("t", "T").view(view())).build()));
+ assertTrue(html.contains("data-juneau-row-detail"), html);
+ assertTrue(html.contains("data-juneau-detail-section=\"overview\""), html);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
index 4dc0107e96..2ed19e4395 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewTable_SelectionBulk_Test.java
@@ -168,7 +168,8 @@ class ViewTable_SelectionBulk_Test extends TestBase {
}
@Test void d02_viewDefContractVersionUnchanged() {
- // Sanity: this wave must not have bumped the existing VIEW_META contract version.
- assertEquals("3", ViewDef.CONTRACT_VERSION);
+ // Sanity: the selection/bulk-mutation opt-ins themselves never bump VIEW_META's contract version - pin
+ // whatever the current value is so a future bump elsewhere doesn't silently drift this fixture's assumption.
+ assertEquals("4", ViewDef.CONTRACT_VERSION);
}
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigApplication_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigApplication_Test.java
new file mode 100644
index 0000000000..8b2b3d13fc
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigApplication_Test.java
@@ -0,0 +1,330 @@
+/*
+ * 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.apache.juneau.test.bct.BctAssertions.*;
+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 {@code juneau-config.js} pure config-application layer (TODO-444, slice 4):
+ * {@code computeEffectiveColumns} / {@code validateView} / saved-view (de)serialization / {@code dtIndex}.
+ *
+ * <p>
+ * Two layers, matching the slice-2 split between {@link ViewsJs_ConfigPersistence_Test} and its browser canary:
+ * <ul>
+ * <li><b>Source-shape</b> (always runs, no Node) — pins that the shipped script defines and exports the
+ * §4.3 / §4.2-index-model functions.</li>
+ * <li><b>Behavioral</b> (runs when {@code node} is on {@code PATH}) — drives
+ * {@code config-application.cjs} against the real classpath {@code juneau-config.js} so the hardening rules
+ * and the load-bearing {@code dtIndex} fixture ({@code [sel, A, B(hidden), C, actions] → C is 3}) are
+ * actually executed, not merely grepped. Absent Node, the behavioral tests
+ * {@link Assumptions#assumeTrue(boolean) assumeTrue}-skip so an offline source-tarball build never
+ * hard-depends on Node (same posture as the {@code js-tests} profile comment in this module's pom).</li>
+ * </ul>
+ *
+ * <p>
+ * Deliberately does <b>not</b> require the {@code js-tests} / Playwright profile — the harness is pure Node
+ * {@code vm}, no Chromium.
+ */
+class ViewsJs_ConfigApplication_Test extends TestBase {
+
+ /** Classpath location of the shipped config script (shared with {@link ViewsJs_ConfigPersistence_Test}). */
+ static final String CONFIG_JS_RESOURCE = ViewsJs_ConfigPersistence_Test.CONFIG_JS_RESOURCE;
+
+ private static String configJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(CONFIG_JS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + CONFIG_JS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ /** Balanced-brace function-body extractor (same algorithm as {@link ViewsJs_ConfigPersistence_Test}). */
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var i = body.indexOf('{', start);
+ assertTrue(i >= 0, () -> "'" + signature + "' has no opening brace:\n" + body);
+ var depth = 0;
+ var j = i;
+ for (; j < body.length(); j++) {
+ var c = body.charAt(j);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) { j++; break; }
+ } else if (c == '"' || c == '\'' || c == '`') {
+ var quote = c;
+ j++;
+ while (j < body.length() && body.charAt(j) != quote) { if (body.charAt(j) == '\\') j++; j++; }
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '/') {
+ while (j < body.length() && body.charAt(j) != '\n') j++;
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '*') {
+ j = body.indexOf("*/", j) + 1;
+ }
+ }
+ return body.substring(start, j);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Source-shape — functions exist and are exported on NS.config
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_validateView_enforcesPinnedAndAtLeastOneVisible() throws Exception {
+ var fn = functionBody(configJs(), "function validateView(");
+ assertTrue(fn.contains("c.pinned"), fn);
+ assertTrue(fn.contains("visible.length === 0"), fn);
+ }
+
+ @Test void a02_validateView_dropsUnknownIds_rejectsDuplicates() throws Exception {
+ var fn = functionBody(configJs(), "function validateView(");
+ assertTrue(fn.contains("hasDuplicateEntries"), fn);
+ assertTrue(fn.contains("byData[id] != null"), fn);
+ }
+
+ @Test void a03_validateView_constrainsFormatsToDeclaredList() throws Exception {
+ var fn = functionBody(configJs(), "function validateView(");
+ assertTrue(fn.contains("allowed.indexOf(fmt)"), fn);
+ }
+
+ @Test void a04_computeEffectiveColumns_preservesRenderMetaAndHref_blankLabelReverts() throws Exception {
+ var body = configJs();
+ var compute = functionBody(body, "function computeEffectiveColumns(");
+ assertTrue(compute.contains("swapRenderId("), compute);
+ assertTrue(compute.contains("String(override).trim()"), compute);
+ var swap = functionBody(body, "function swapRenderId(");
+ assertTrue(swap.contains("render.meta"), swap);
+ }
+
+ @Test void a05_dtIndex_isIndexIntoActualOptsColumns_notVisibleOffset() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function dtIndex(");
+ assertTrue(fn.contains("optsColumns[i].data === dataKey"), fn);
+ assertTrue(body.contains("function buildOptsColumnSpace("), body);
+ assertTrue(body.contains("hasSelection"), body);
+ assertTrue(body.contains("hasActions"), body);
+ }
+
+ @Test void a06_serializeDeserializeSavedView_schemaVersionAndBlankLabelOmit() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("function serializeSavedView("), body);
+ assertTrue(body.contains("function deserializeSavedView("), body);
+ var ser = functionBody(body, "function serializeSavedView(");
+ assertTrue(ser.contains("CURRENT_SCHEMA_VERSION"), ser);
+ assertTrue(ser.contains("String(v).trim()"), ser);
+ }
+
+ @Test void a07_pureLayerExportedOnNsConfig() throws Exception {
+ var body = configJs();
+ for (var name : new String[]{
+ "NS.config.validateView = validateView",
+ "NS.config.computeEffectiveColumns = computeEffectiveColumns",
+ "NS.config.deserializeSavedView = deserializeSavedView",
+ "NS.config.serializeSavedView = serializeSavedView",
+ "NS.config.buildOptsColumnSpace = buildOptsColumnSpace",
+ "NS.config.dtIndex = dtIndex"
+ })
+ assertTrue(body.contains(name), () -> "missing export '" + name + "':\n" + body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b–l) Behavioral — real Node execution of the pure layer (skipped when node is absent)
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var configFile = Files.createTempFile("juneau-config-", ".js");
+ try {
+ Files.writeString(configFile, configJs(), UTF_8);
+ report = Json.to(runNode(harness, configFile), Map.class);
+ } finally {
+ Files.deleteIfExists(configFile);
+ }
+ }
+
+ 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/config-application.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/config-application.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/config-application.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path configJs) throws Exception {
+ var stdout = Files.createTempFile("config-application-stdout-", ".json");
+ var stderr = Files.createTempFile("config-application-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of("node", harness.toString(), configJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("config-application.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("config-application.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 config-application.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ @Test void b01_defaultLayering_catalogOrderAndDefaultVisible() {
+ var r = report();
+ assertEquals(List.of("A", "B", "C"), r.get("a_defaultOrder"));
+ assertEquals(List.of("A", "B", "C"), r.get("a_defaultVisible"));
+ }
+
+ @Test void b02_pinnedAlwaysForcedVisible() {
+ var r = report();
+ assertEquals(true, r.get("b_pinnedOk"));
+ assertEquals(true, r.get("b_pinnedForcedVisible"));
+ }
+
+ @Test void b03_atLeastOneVisible_repairsAllHiddenBlob() {
+ var r = report();
+ assertEquals(true, r.get("c_atLeastOneVisible"));
+ assertSize(1, r.get("c_repairedVisible"));
+ }
+
+ @Test void b04_unknownColumnIdsDropped() {
+ var r = report();
+ assertEquals(true, r.get("d_ok"));
+ assertEquals(List.of("C", "A", "B"), r.get("d_order"));
+ assertFalse(((List<?>)r.get("d_visible")).contains("GONE"));
+ assertEquals(Map.of("C", "See"), r.get("d_labels"));
+ assertEquals(Map.of("C", "ts-zulu"), r.get("d_formats"));
+ }
+
+ @Test void b05_duplicateOrderAndVisibleRejected() {
+ var r = report();
+ assertEquals(false, ((Map<?,?>)r.get("e_dupOrder")).get("ok"));
+ assertEquals(false, ((Map<?,?>)r.get("e_dupVisible")).get("ok"));
+ }
+
+ @Test void b06_formatOverrideConstrainedToDeclaredList() {
+ var r = report();
+ assertEquals(Map.of(), r.get("f_formatsAfterConstraint"));
+ }
+
+ @Test void b07_formatSwap_preservesRenderMetaAndHref() {
+ var r = report();
+ assertEquals("ts-zulu", r.get("g_renderId"));
+ assertEquals(Map.of("tz", "UTC"), r.get("g_renderMeta"));
+ assertEquals("/c/{id}", r.get("g_href"));
+ }
+
+ @Test void b08_blankLabelRevertsToCatalogTitle() {
+ var r = report();
+ assertEquals("Col B", r.get("h_blankReverts"));
+ assertEquals("Custom C", r.get("h_customKept"));
+ }
+
+ @Test void b09_reorderAndHide() {
+ var r = report();
+ assertEquals(List.of("C", "A", "B"), r.get("i_order"));
+ assertEquals(List.of(true, true, false), r.get("i_visibility"));
+ }
+
+ @Test void b10_newCatalogColumnAppearsPerDefaultVisible() {
+ var r = report();
+ assertEquals(List.of("A", "B", "C", "D"), r.get("j_orderIncludesD"));
+ assertEquals(false, r.get("j_D_visible"));
+ }
+
+ @Test void b11_serializeDeserializeRoundTrip_blankLabelOmitted() {
+ var r = report();
+ assertEquals(true, r.get("k_blankLabelOmitted"));
+ var ser = (Map<?,?>)r.get("k_serialized");
+ assertEquals(1, ((Number)ser.get("schemaVersion")).intValue());
+ assertEquals(List.of("C", "A", "B"), ser.get("order"));
+ assertEquals(Map.of("C", "See"), ser.get("labels"));
+ assertEquals(Map.of("C", "ts-zulu"), ser.get("formats"));
+ }
+
+ /**
+ * Load-bearing §4.2 / §6.10 fixture: {@code opts.columns = [sel, A, B(hidden), C, actions]} → C's
+ * {@code dtIndex} is <b>3</b>, not 2 (the "visible+offset" self-contradiction).
+ */
+ @Test void b12_dtIndex_selectionPlusHiddenBetweenTarget_Cis3() {
+ var r = report();
+ // List.of forbids nulls — selection/actions cells legitimately carry data:null.
+ assertEquals(Arrays.asList(null, "A", "B", "C", null), r.get("l_optsDataKeys"));
+ assertEquals(5, ((Number)r.get("l_optsLength")).intValue());
+ assertEquals(1, ((Number)r.get("l_dtIndex_A")).intValue());
+ assertEquals(2, ((Number)r.get("l_dtIndex_B")).intValue());
+ assertEquals(3, ((Number)r.get("l_dtIndex_C")).intValue());
+ assertEquals(-1, ((Number)r.get("l_dtIndex_missing")).intValue());
+ }
+
+ @Test void b13_defaultVisibleFalse_hiddenOnFirstVisit() {
+ var r = report();
+ assertEquals(List.of("A", "C"), r.get("m_firstVisitVisible"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigChooser_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigChooser_Test.java
new file mode 100644
index 0000000000..15033bafc4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigChooser_Test.java
@@ -0,0 +1,276 @@
+/*
+ * 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 View-tab chooser in {@code juneau-config.js} (slice 6): source-shape pins
+ * for the XSS {@code textContent} mandate, last-column refusal, pinned-disabled, and the
+ * {@code mountChooser} seam, plus a Node behavioral harness when {@code node} is on PATH.
+ */
+class ViewsJs_ConfigChooser_Test extends TestBase {
+
+ private static String configJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.CONFIG_JS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + ViewsMixin.CONFIG_JS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String viewsJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.VIEWS_JS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + ViewsMixin.VIEWS_JS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var i = body.indexOf('{', start);
+ assertTrue(i >= 0, () -> "'" + signature + "' has no opening brace:\n" + body);
+ var depth = 0;
+ var j = i;
+ for (; j < body.length(); j++) {
+ var c = body.charAt(j);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) { j++; break; }
+ } else if (c == '"' || c == '\'' || c == '`') {
+ var quote = c;
+ j++;
+ while (j < body.length() && body.charAt(j) != quote) { if (body.charAt(j) == '\\') j++; j++; }
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '/') {
+ while (j < body.length() && body.charAt(j) != '\n') j++;
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '*') {
+ j = body.indexOf("*/", j) + 1;
+ }
+ }
+ return body.substring(start, j);
+ }
+
+ @Test void a01_paintUserText_usesTextContentOnly() throws Exception {
+ var fn = functionBody(configJs(), "function paintUserText(");
+ assertTrue(fn.contains("el.textContent"), fn);
+ assertFalse(fn.contains("innerHTML"), fn);
+ assertFalse(fn.contains(".html("), fn);
+ }
+
+ @Test void a02_paintUserInput_usesValueOnly() throws Exception {
+ var fn = functionBody(configJs(), "function paintUserInput(");
+ assertTrue(fn.contains("el.value"), fn);
+ assertFalse(fn.contains("innerHTML"), fn);
+ }
+
+ @Test void a03_canHideColumn_refusesPinnedAndLastVisible() throws Exception {
+ var fn = functionBody(configJs(), "function canHideColumn(");
+ assertTrue(fn.contains("col.pinned"), fn);
+ assertTrue(fn.contains("visibleCount(draft) > 1"), fn);
+ }
+
+ @Test void a04_sanitizeAndPaintHeaders_neverHandTitleToDataTables() throws Exception {
+ var body = configJs();
+ var san = functionBody(body, "function sanitizeColumnTitlesForDataTables(");
+ assertTrue(san.contains("c.title = \"\""), san);
+ var paint = functionBody(body, "function paintHeaderTitles(");
+ assertTrue(paint.contains("paintUserText(th, label)"), paint);
+ }
+
+ @Test void a05_chooserExportedOnNsConfig() throws Exception {
+ var body = configJs();
+ for (var name : new String[]{
+ "NS.config.mountChooser = mountChooser",
+ "NS.config.paintUserText = paintUserText",
+ "NS.config.canHideColumn = canHideColumn",
+ "NS.config.sanitizeColumnTitlesForDataTables = sanitizeColumnTitlesForDataTables",
+ "NS.config.paintHeaderTitles = paintHeaderTitles",
+ "NS.config.applyDraft = applyDraft"
+ })
+ assertTrue(body.contains(name), () -> "missing export '" + name + "':\n" + body);
+ }
+
+ @Test void a06_viewsJsSeam_mountsChooserWhenColumnConfigAndConfigJsPresent() throws Exception {
+ var fn = functionBody(viewsJs(), "function constructTable(");
+ assertTrue(fn.contains("viewDef.columnConfig"), fn);
+ assertTrue(fn.contains("NS.config.mountChooser"), fn);
+ assertTrue(fn.contains("sanitizeColumnTitlesForDataTables"), fn);
+ assertTrue(fn.contains("paintHeaderTitles"), fn);
+ }
+
+ @Test void a07_saveAsDefault_goesThroughValidateNameBasic() throws Exception {
+ var fn = functionBody(configJs(), "function openChooser(");
+ assertTrue(fn.contains("validateNameBasic(name)"), fn);
+ assertTrue(fn.contains("Save as"), fn);
+ assertTrue(fn.contains("paintUserText"), fn);
+ }
+
+ @Test void a08_configJs_containsNoInnerHtmlAssignment() throws Exception {
+ var body = configJs();
+ assertFalse(body.contains(".innerHTML ="), body);
+ assertFalse(body.contains(".html("), body);
+ assertTrue(body.contains(".textContent"), body);
+ }
+
+ @Test void a09_applyDraft_callsApplyView() throws Exception {
+ var fn = functionBody(configJs(), "function applyDraft(");
+ assertTrue(fn.contains("applyView(table, saved)"), fn);
+ assertTrue(fn.contains("in-flight"), fn);
+ }
+
+ @Test void a10_chooserBackdrop_distinctFromActionDialogTeardownClass() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("CHOOSER_BACKDROP_CLASS = \"juneau-config-dialog-backdrop\""), body);
+ assertFalse(body.contains("CHOOSER_BACKDROP_CLASS = \"juneau-view-dialog-backdrop\""), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Behavioral — Node harness (skipped when node is absent)
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var configFile = Files.createTempFile("juneau-config-", ".js");
+ try {
+ Files.writeString(configFile, configJs(), UTF_8);
+ report = Json.to(runNode(harness, configFile), Map.class);
+ } finally {
+ Files.deleteIfExists(configFile);
+ }
+ }
+
+ 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/config-chooser.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/config-chooser.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/config-chooser.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path configJs) throws Exception {
+ var stdout = Files.createTempFile("config-chooser-stdout-", ".json");
+ var stderr = Files.createTempFile("config-chooser-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of("node", harness.toString(), configJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("config-chooser.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("config-chooser.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 config-chooser.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ @Test void b01_xssPaint_setsTextContent_leavesInnerHtmlUntouched() {
+ var r = report();
+ assertEquals("<img src=x onerror=alert(1)>", r.get("xssTextContent"));
+ assertEquals(true, r.get("xssInnerHtmlUntouched"));
+ assertEquals("<img src=x onerror=alert(1)>", r.get("xssInputValue"));
+ assertEquals(true, r.get("xssInputInnerHtmlUntouched"));
+ }
+
+ @Test void b02_lastVisibleAndPinned_cannotHide() {
+ var r = report();
+ assertEquals(true, r.get("lastCannotHideA"));
+ assertEquals(true, r.get("pinnedCannotHide"));
+ assertEquals(true, r.get("unpinnedCanHide"));
+ assertEquals(true, r.get("alreadyHiddenCanShow"));
+ }
+
+ @Test void b03_saveAsDefault_refused() {
+ var r = report();
+ assertEquals(true, r.get("defaultReserved"));
+ assertEquals(true, r.get("defaultReservedCase"));
+ assertEquals(true, r.get("saveAsDefaultRefused"));
+ }
+
+ @Test void b04_sanitizeTitles_andPaintHeadersViaTextContent() {
+ var r = report();
+ assertEquals(true, r.get("sanitizedDataTitleBlank"));
+ assertEquals(true, r.get("sanitizedSelectionUntouched"));
+ assertEquals("<img src=x onerror=alert(1)>", r.get("headerAText"));
+ assertEquals(true, r.get("headerAInnerHtmlUntouched"));
+ assertEquals("Col B", r.get("headerBText"));
+ }
+
+ @Test void b05_defaultDraftAndReorder() {
+ var r = report();
+ assertEquals(List.of("A", "B", "C"), r.get("defaultDraftOrder"));
+ assertEquals(List.of("A", "B", "C"), r.get("defaultDraftVisible"));
+ assertEquals(true, r.get("moved"));
+ assertEquals(List.of("A", "C", "B"), r.get("orderAfterMove"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigPersistence_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigPersistence_Test.java
new file mode 100644
index 0000000000..05bda722a2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_ConfigPersistence_Test.java
@@ -0,0 +1,356 @@
+/*
+ * 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 java.io.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Always-on source-shape coverage for {@code juneau-config.js} (TODO-444, slice 2): the client-side async
+ * persistence SPI, the strict localStorage key codec, and the two first-party persistence providers.
+ *
+ * <p>
+ * Mirrors {@link ViewsJs_RowActions_Test}'s served-script substring style, but reads the asset straight off the
+ * classpath ({@link #CONFIG_JS_RESOURCE}) so the persistence SPI can be pinned without going through a mixin route.
+ * {@link ViewsMixin} now serves the same bytes at {@link ViewsMixin#CONFIG_JS_PATH} (slice 6). Behavioral
+ * (real-DOM/localStorage/fetch) proof of this contract lives in the opt-in {@code ConfigPersistence_BrowserTest}
+ * canary.
+ */
+class ViewsJs_ConfigPersistence_Test extends TestBase {
+
+ /** Classpath location of the shipped slice-2 persistence layer (mirrors {@code ViewsMixin}'s *_JS_RESOURCE constants). */
+ static final String CONFIG_JS_RESOURCE = "/org/apache/juneau/views/juneau-config.js";
+
+ private static String configJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(CONFIG_JS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + CONFIG_JS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ /**
+ * Extracts a named function/method's body via BALANCED brace matching (from the first {@code {} after
+ * {@code signature} to its true matching close), skipping braces inside string/template literals and
+ * comments. A plain {@code indexOf("\n\t}", start)} (the sibling {@code ViewsJs_RowActions_Test}/
+ * {@code ViewsMixin_Serving_Test} helper) only works for functions indented exactly one tab; several of this
+ * file's functions/methods are nested two or three tabs deep inside a provider factory, where that shortcut
+ * would overshoot into unrelated trailing code.
+ */
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var i = body.indexOf('{', start);
+ assertTrue(i >= 0, () -> "'" + signature + "' has no opening brace:\n" + body);
+ var depth = 0;
+ var j = i;
+ for (; j < body.length(); j++) {
+ var c = body.charAt(j);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) { j++; break; }
+ } else if (c == '"' || c == '\'' || c == '`') {
+ var quote = c;
+ j++;
+ while (j < body.length() && body.charAt(j) != quote) { if (body.charAt(j) == '\\') j++; j++; }
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '/') {
+ while (j < body.length() && body.charAt(j) != '\n') j++;
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '*') {
+ j = body.indexOf("*/", j) + 1;
+ }
+ }
+ return body.substring(start, j);
+ }
+
+ /**
+ * Extracts an ENTIRE top-level factory function's body (from `function <name>(` to the next `// ====` section
+ * divider) - needed because both provider factories define same-named object-literal methods
+ * (e.g. both have a {@code saveAndActivate:} method), so a plain {@link String#indexOf} for a nested method
+ * signature would silently match the FIRST provider rather than the intended one.
+ */
+ private static String factoryBody(String body, String factoryName) {
+ var start = body.indexOf("function " + factoryName + "(");
+ assertTrue(start >= 0, () -> "'" + factoryName + "' not found:\n" + body);
+ var end = body.indexOf("\n\t// ====", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Namespace attachment - extends window.JuneauViews, never replaces it
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_extendsExistingJuneauViewsNamespace_neverReplacesIt() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("window.JuneauViews = window.JuneauViews || {}"), body);
+ assertTrue(body.contains("NS.config = NS.config || {}"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Key grammar + enc/dec codec (§3.1)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_encSegment_usesUppercasePercentEncoding_notEncodeURIComponent() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function encSegment(");
+ assertTrue(fn.contains("hex2Upper(b)"), fn);
+ assertFalse(fn.contains("encodeURIComponent"), fn);
+ }
+
+ @Test void b02_decSegment_isStrict_rejectsNonCanonicalInput() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function decSegment(");
+ // Lowercase hex, a truncated escape, and a raw unsafe char must all throw 'malformed' - never a lenient decode.
+ assertTrue(fn.contains("[0-9A-F]{2}"), fn);
+ assertTrue(fn.contains("malformedError("), fn);
+ }
+
+ @Test void b03_safeAlphabetIsExactly_A_Za_z0_9_dash_underscore() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function isSafeSegmentChar(");
+ assertTrue(fn.contains("ch === \"_\""), fn);
+ assertTrue(fn.contains("ch === \"-\""), fn);
+ }
+
+ @Test void b04_scopeKey_isPageQualified_soTwoPagesSharingAViewIdCannotCollide() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function scopeKey(");
+ assertTrue(fn.contains("\"~\""), fn);
+ assertTrue(fn.contains("encSegment(pageId)"), fn);
+ assertTrue(fn.contains("encSegment(viewId)"), fn);
+ }
+
+ @Test void b05_keyRootAndViewsActiveKeyShape() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("const KEY_ROOT = \"juneau.view.\";"), body);
+ assertTrue(body.contains(".columns.views."), body);
+ assertTrue(body.contains(".columns.active"), body);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) pageId/viewId/base-URL resolution (§3.1/§3.3) - cross-checked against the server-side marker constants
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_pageIdAttr_matchesPageTableMarkerAttr() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("const PAGE_ID_ATTR = \"" + PageTable.MARKER_ATTR + "\";"), body);
+ }
+
+ @Test void c02_viewIdAttr_matchesViewTableMarkerAttr() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("const VIEW_ID_ATTR = \"" + ViewTable.MARKER_ATTR + "\";"), body);
+ }
+
+ @Test void c03_resolvePageId_usesClosestOnThePageIdAttr() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function resolvePageId(");
+ assertTrue(fn.contains("table.closest(\"[\" + PAGE_ID_ATTR + \"]\")"), fn);
+ }
+
+ @Test void c04_resolveSavedViewsBase_failsClosedWhenShellAbsent() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("const SAVED_VIEWS_BASE_ATTR = \"data-juneau-saved-views\";"), body);
+ var fn = functionBody(body, "function resolveSavedViewsBase(");
+ assertTrue(fn.contains("table.closest(\"[\" + SAVED_VIEWS_BASE_ATTR + \"]\")"), fn);
+ assertTrue(fn.contains("isBlank(v) ? null : v"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) localStorage bounds - a SEPARATE, independently-named copy of the server defaults
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_localStorageBounds_areSeparatelyNamedFromAnyServerConstant() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("const LOCALSTORAGE_MAX_VIEWS_PER_SCOPE = 50;"), body);
+ assertTrue(body.contains("const LOCALSTORAGE_MAX_BLOB_BYTES = 64 * 1024;"), body);
+ assertTrue(body.contains("const LOCALSTORAGE_MAX_VIEWS_PER_USER = 500;"), body);
+ assertTrue(body.contains("const MAX_NAME_LEN = 128;"), body);
+ assertTrue(body.contains("const MAX_ENCODED_SEGMENT_LEN = 512;"), body);
+ }
+
+ @Test void d02_localStorageProvider_enforcesPerScopeAndAggregateCaps() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function enforceBounds(");
+ assertTrue(fn.contains("quotaError("), fn);
+ assertTrue(fn.contains("LOCALSTORAGE_MAX_VIEWS_PER_SCOPE"), fn);
+ assertTrue(fn.contains("countAllViewsForThisUser()"), fn);
+ assertTrue(fn.contains("LOCALSTORAGE_MAX_VIEWS_PER_USER"), fn);
+ }
+
+ @Test void d03_localStorageProvider_handlesDanglingActiveAsDefaultPlusNotice() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function resolveActiveAgainstViews(");
+ assertTrue(fn.contains("dangling: true"), fn);
+ assertTrue(fn.contains("name: null"), fn);
+ }
+
+ @Test void d04_localStorageProvider_watchesStorageEventForCrossTabReconcile() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "watchExternalChanges: function (table, onChange) {");
+ assertTrue(fn.contains("window.addEventListener(\"storage\""), fn);
+ assertTrue(fn.contains("function unwatch()"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Server-persisted provider transport envelope (§3.3)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_writeRequest_usesJsonContentTypeAndTheSharedCsrfHelpers_failsClosedOnBlankToken() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function writeRequest(");
+ assertTrue(fn.contains("\"Content-Type\": \"application/json\""), fn);
+ assertTrue(fn.contains("init.resolveCsrfToken(table)"), fn);
+ assertTrue(fn.contains("init.resolveCsrfHeaderName(table)"), fn);
+ assertTrue(fn.contains("init.isBlankToken(token)"), fn);
+ assertTrue(fn.contains("refuse: true"), fn);
+ }
+
+ @Test void e02_writesUseCredentialsSameOrigin() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("credentials: \"same-origin\""), body);
+ }
+
+ @Test void e03_getRequests_arePlainCsrfFreeFetch() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function get(table, path, extraParams) {");
+ assertTrue(fn.contains("{ method: \"GET\", credentials: \"same-origin\" }"), fn);
+ assertFalse(fn.contains("CsrfToken"), fn);
+ }
+
+ @Test void e04_saveAndActivate_usesActivateQueryFlag_neverAFieldInsideTheBlob() throws Exception {
+ var region = factoryBody(configJs(), "createServerProvider");
+ assertTrue(region.contains("{ name: name, activate: 1 }"), region);
+ }
+
+ @Test void e05_setActive_alwaysSendsARealJsonBody_neverEmpty() throws Exception {
+ var region = factoryBody(configJs(), "createServerProvider");
+ assertTrue(region.contains("name == null ? {} : { name: name }"), region);
+ }
+
+ @Test void e06_delete_sendsNameAsQueryParam_neverAsAPathSegment() throws Exception {
+ var region = factoryBody(configJs(), "createServerProvider");
+ assertTrue(region.contains("write(table, \"DELETE\", \"/item\", { name: name }, {})"), region);
+ }
+
+ @Test void e07_baseUrl_resolvedFromClosestShellAttribute_failsClosedWhenAbsent() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function baseFor(table) {");
+ assertTrue(fn.contains("resolveSavedViewsBase(table)"), fn);
+ assertTrue(fn.contains("unavailableError("), fn);
+ }
+
+ @Test void e08_serverProvider_neverEmitsHttpForCrossTabReconcile() throws Exception {
+ // The server provider's own object literal must not DEFINE watchExternalChanges (that method-def token,
+ // not a mere substring match, since the region's own comment explaining the omission legitimately
+ // contains the plain word "watchExternalChanges").
+ var region = factoryBody(configJs(), "createServerProvider");
+ assertFalse(region.contains("watchExternalChanges: function"), region);
+ assertTrue(region.contains("storage` event never fires for an HTTP write"), region);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // f) The seven-method async SPI + provider-selection seam (§3.2/§5)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_persistenceFacadeExposesTheSevenMethodContract() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("NS.persistence = {"), body);
+ for (var method : new String[]{"list:", "load:", "save:", "saveAndActivate:", "setActive:", "\"delete\":", "getActive:"})
+ assertTrue(body.contains(method), () -> "missing SPI method '" + method + "':\n" + body);
+ }
+
+ @Test void f02_bothProviderFactoriesImplementAllSevenMethods() throws Exception {
+ var body = configJs();
+ for (var factory : new String[]{"createLocalStorageProvider", "createServerProvider"}) {
+ var region = factoryBody(body, factory);
+ for (var method : new String[]{"list:", "load:", "save:", "saveAndActivate:", "setActive:", "\"delete\":"})
+ assertTrue(region.contains(method), () -> factory + " missing '" + method + "':\n" + region);
+ }
+ }
+
+ @Test void f03_providerSelectionSeam_defaultsToLocalStorage_swappableViaSetPersistenceProvider() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("NS.setPersistenceProvider = function (provider) {"), body);
+ assertTrue(body.contains("NS.persistenceProviders = {"), body);
+ assertTrue(body.contains("localStorage: createLocalStorageProvider"), body);
+ assertTrue(body.contains("server: createServerProvider"), body);
+ var fn = functionBody(body, "function activeProvider() {");
+ assertTrue(fn.contains("createLocalStorageProvider()"), fn);
+ }
+
+ @Test void f04_getActive_isDerivedUniformlyFromListRatherThanDuplicatedPerProvider() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "getActive: function (table) {");
+ assertTrue(fn.contains("NS.persistence.list(table)"), fn);
+ }
+
+ @Test void f05_syncGetItemSetItem_areSynchronous_notPromises() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("NS.persistence.getItem = function (key)"), body);
+ assertTrue(body.contains("NS.persistence.setItem = function (key, value)"), body);
+ var getFn = functionBody(body, "NS.persistence.getItem = function (key) {");
+ assertTrue(getFn.contains("window.localStorage.getItem(key)"), getFn);
+ assertFalse(getFn.contains("Promise"), getFn);
+ assertFalse(getFn.contains(".then("), getFn);
+ var setFn = functionBody(body, "NS.persistence.setItem = function (key, value) {");
+ assertTrue(setFn.contains("window.localStorage.setItem(key, String(value))"), setFn);
+ assertFalse(setFn.contains("Promise"), setFn);
+ assertFalse(setFn.contains(".then("), setFn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // g) Typed error shapes (§3.2) - 'conflict' was dropped in round-2; must not resurface here
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void g01_typedErrorCodes_areExactlyTheFourFrozenCodes_conflictNeverResurfaces() throws Exception {
+ var body = configJs();
+ for (var code : new String[]{"\"malformed\"", "\"quota\"", "\"unavailable\"", "\"network\""})
+ assertTrue(body.contains(code), () -> "missing typed error code " + code + ":\n" + body);
+ assertFalse(body.contains("\"conflict\""), body);
+ }
+
+ @Test void g02_schemaVersionMismatch_rejectsAsMalformed_neverCrashes() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function assertSupportedSchema(");
+ assertTrue(fn.contains("malformedError("), fn);
+ assertTrue(fn.contains("CURRENT_SCHEMA_VERSION"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // h) Reserved-name rule (shared by both providers, so slice 3's mixin must enforce the identical rule)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void h01_defaultIsReservedCaseInsensitively() throws Exception {
+ var body = configJs();
+ var fn = functionBody(body, "function isReservedName(");
+ assertTrue(fn.contains(".toLowerCase() === \"default\""), fn);
+ }
+
+ @Test void h02_validateNameBasic_isSharedByBothProviders_notJustLocalStorage() throws Exception {
+ // The server provider's load/save/saveAndActivate/setActive/delete all call validateNameBasic (not the
+ // localStorage-only validateNameForLocalStorage) - proves the two providers share ONE name-validity rule.
+ var region = factoryBody(configJs(), "createServerProvider");
+ var occurrences = region.split("validateNameBasic\\(name\\)", -1).length - 1;
+ assertTrue(occurrences >= 4, () -> "expected validateNameBasic(name) called at least 4x in the server provider, found " + occurrences + ":\n" + region);
+ assertFalse(region.contains("validateNameForLocalStorage"), region);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Reinit_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Reinit_Test.java
new file mode 100644
index 0000000000..9fa7dcd1c7
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Reinit_Test.java
@@ -0,0 +1,342 @@
+/*
+ * 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 destroy/reinit transaction and async {@code initTable} handshake: source-shape
+ * pins on {@code juneau-views.js}/{@code juneau-config.js}/{@code juneau-pages.js}/{@code juneau-ribbon.js},
+ * plus a Node behavioral check that {@code resolveOrder} uses the live {@code dtIndex} (selection + hidden
+ * column → C is 3).
+ *
+ * <p>Chooser-dialog browser canaries are out of scope here (they land with the View-tab UI).
+ */
+class ViewsJs_Reinit_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 configJs() throws IOException {
+ return resource(ViewsJs_ConfigPersistence_Test.CONFIG_JS_RESOURCE);
+ }
+
+ private static String pagesJs() throws IOException {
+ return resource(ViewsMixin.PAGES_JS_RESOURCE);
+ }
+
+ private static String ribbonJs() throws IOException {
+ return resource(ViewsMixin.RIBBON_JS_RESOURCE);
+ }
+
+ /** Balanced-brace extractor (nested functions / object literals would fool a plain {@code \\n\\t}} scan). */
+ private static String functionBody(String body, String signature) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var i = body.indexOf('{', start);
+ assertTrue(i >= 0, () -> "'" + signature + "' has no opening brace:\n" + body);
+ var depth = 0;
+ var j = i;
+ for (; j < body.length(); j++) {
+ var c = body.charAt(j);
+ if (c == '{') {
+ depth++;
+ } else if (c == '}') {
+ depth--;
+ if (depth == 0) { j++; break; }
+ } else if (c == '"' || c == '\'' || c == '`') {
+ var quote = c;
+ j++;
+ while (j < body.length() && body.charAt(j) != quote) { if (body.charAt(j) == '\\') j++; j++; }
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '/') {
+ while (j < body.length() && body.charAt(j) != '\n') j++;
+ } else if (c == '/' && j + 1 < body.length() && body.charAt(j + 1) == '*') {
+ j = body.indexOf("*/", j) + 1;
+ }
+ }
+ return body.substring(start, j);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a) Async initTable handshake
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_initTable_alwaysReturnsAThenable_andCoalescesInFlight() throws Exception {
+ var fn = functionBody(viewsJs(), "function initTable(");
+ assertTrue(fn.contains("data-juneau-init-pending"), fn);
+ assertTrue(fn.contains("table.__juneauInitPromise"), fn);
+ assertTrue(fn.contains("if (table.__juneauInitPromise) return table.__juneauInitPromise"), fn);
+ assertTrue(fn.contains("return Promise.resolve()"), fn);
+ assertTrue(fn.contains("return p"), fn);
+ assertTrue(fn.contains("removeAttribute(\"data-juneau-init-pending\")"), fn);
+ }
+
+ @Test void a02_beginInitTable_awaitsResolveActiveViewWhenColumnConfigPresent() throws Exception {
+ var fn = functionBody(viewsJs(), "function beginInitTable(");
+ assertTrue(fn.contains("viewDef.columnConfig"), fn);
+ assertTrue(fn.contains("NS.config.resolveActiveView"), fn);
+ assertTrue(fn.contains("buildTable(table, viewDef, effective, ctx)"), fn);
+ }
+
+ @Test void a03_pagesJs_activatePanelViews_awaitsInitTableThenAdjustsIffDataTable() throws Exception {
+ var fn = functionBody(pagesJs(), "function activatePanelViews(");
+ assertTrue(fn.contains("Promise.resolve(NS.init.initTable(t))"), fn);
+ assertTrue(fn.contains("isDataTable(t)"), fn);
+ assertTrue(fn.contains("columns.adjust()"), fn);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // b) Teardown protocol + mutex + selection survival
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_buildTable_refusesWhileInFlightOrJob_noQueue() throws Exception {
+ var fn = functionBody(viewsJs(), "function buildTable(");
+ assertTrue(fn.contains("hasInFlightRow(table)"), fn);
+ assertTrue(fn.contains("hasJobRow(table)"), fn);
+ assertTrue(fn.contains("Finish the in-progress action first."), fn);
+ assertFalse(fn.contains("run-when"), fn);
+ }
+
+ @Test void b02_teardown_clearsPollTimers_closesEveryJobSource_thenDestroys() throws Exception {
+ var fn = functionBody(viewsJs(), "function teardownTable(");
+ var clearIdx = fn.indexOf("clearInterval");
+ var jobIdx = fn.indexOf("_jobSources");
+ var destroyIdx = fn.indexOf("ctx.dataTable.destroy()");
+ assertTrue(clearIdx >= 0, fn);
+ assertTrue(jobIdx > clearIdx, fn);
+ assertTrue(destroyIdx > jobIdx, fn);
+ assertTrue(fn.contains("closeActionDialog(ctx)"), fn);
+ assertTrue(fn.contains("closeRowActionMenus(table)"), fn);
+ assertTrue(fn.contains("stripGeneratedDom(table)"), fn);
+ }
+
+ @Test void b03_reinitMutex_latestWins_neverConcurrentTeardown() throws Exception {
+ var fn = functionBody(viewsJs(), "function buildTable(");
+ assertTrue(fn.contains("ctx._reinitRunning"), fn);
+ assertTrue(fn.contains("ctx._reinitPending"), fn);
+ assertTrue(fn.contains("coalesced: true"), fn);
+ }
+
+ @Test void b04_selectionStateLivesOnCtx_notRebuilt() throws Exception {
+ var fn = functionBody(viewsJs(), "function beginInitTable(");
+ assertTrue(fn.contains("selected: new Set()"), fn);
+ assertTrue(fn.contains("selectionState: selectionState"), fn);
+ var assemble = functionBody(viewsJs(), "function assembleFullColumnArray(");
+ assertTrue(assemble.contains("buildSelectionColumnDef(ctx.selectionState)"), assemble);
+ }
+
+ @Test void b05_jobSourcesTrackedAsSet_notLastSlot() throws Exception {
+ var views = viewsJs();
+ assertTrue(views.contains("ctx._jobSources = new Set()"), views);
+ assertTrue(views.contains("ctx._jobSources.add(es)"), views);
+ assertFalse(views.contains("ctx._jobSource = es"), views);
+ }
+
+ @Test void b06_restoreHeaderShell_recreatesSelectionThThenActions() throws Exception {
+ var fn = functionBody(viewsJs(), "function restoreHeaderShell(");
+ assertTrue(fn.contains("juneau-view-select-th"), fn);
+ assertTrue(fn.contains("appendActionsHeaderCell(table)"), fn);
+ var strip = functionBody(viewsJs(), "function stripGeneratedDom(");
+ assertTrue(strip.contains("juneau-view-toolbar-row"), strip);
+ assertTrue(strip.contains("juneau-view-columnsearch-row"), strip);
+ assertTrue(strip.contains("juneau-view-actions-th"), strip);
+ assertTrue(strip.contains("juneau-view-select-th"), strip);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // c) dtIndex rebind + export :visible + programmatic Apply
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_assembleFullColumnArray_computesColumnsFirstThenOrderViaDtIndex() throws Exception {
+ var fn = functionBody(viewsJs(), "function assembleFullColumnArray(");
+ assertTrue(fn.contains("cols.push(sel)"), fn);
+ assertTrue(fn.contains("_juneau: \"actions\""), fn);
+ assertTrue(fn.contains("opts.order = resolveOrder(viewDef, opts.columns)"), fn);
+ assertFalse(fn.contains("unshift"), fn);
+ }
+
+ @Test void c02_buildColumnSearchRow_emitsOneThPerOptsColumn_hidesHidden() throws Exception {
+ var fn = functionBody(viewsJs(), "function buildColumnSearchRow(");
+ assertTrue(fn.contains("(optsColumns || []).forEach"), fn);
+ assertTrue(fn.contains("col.visible === false"), fn);
+ assertTrue(fn.contains("th.style.display = \"none\""), fn);
+ assertTrue(fn.contains("dt.column(idx).search"), fn);
+ }
+
+ @Test void c03_ribbon_exportOptionsVisible_onButtonsConstructor() throws Exception {
+ var fn = functionBody(ribbonJs(), "function buildRibbon(");
+ assertTrue(fn.contains("exportOptions: { columns: \":visible\" }"), fn);
+ assertTrue(fn.contains("new $.fn.dataTable.Buttons(ctx.dataTable"), fn);
+ }
+
+ @Test void c04_ribbonToQueryParams_usesLiveOptsColumns() throws Exception {
+ var fn = functionBody(ribbonJs(), "function ribbonToQueryParams(");
+ assertTrue(fn.contains("optsColumns"), fn);
+ assertTrue(fn.contains("optionParam(viewDef, a, optsColumns)"), fn);
+ }
+
+ @Test void c05_config_exportsApplyViewAndResolveActiveView() throws Exception {
+ var body = configJs();
+ assertTrue(body.contains("function applyView("), body);
+ assertTrue(body.contains("function resolveActiveView("), body);
+ assertTrue(body.contains("NS.config.applyView = applyView"), body);
+ assertTrue(body.contains("NS.config.resolveActiveView = resolveActiveView"), body);
+ assertTrue(body.contains("NS.init.buildTable"), body);
+ }
+
+ @Test void c06_listenersBoundOnceInBeginInit_notInBuildTable() throws Exception {
+ var begin = functionBody(viewsJs(), "function beginInitTable(");
+ assertTrue(begin.contains("initDetailsExpander(table, ctx, viewDef)"), begin);
+ assertTrue(begin.contains("initRowActions(table, viewDef, ctx)"), begin);
+ assertTrue(begin.contains("initSelection(table, ctx)"), begin);
+ var construct = functionBody(viewsJs(), "function constructTable(");
+ assertFalse(construct.contains("initDetailsExpander("), construct);
+ assertFalse(construct.contains("initRowActions("), construct);
+ assertFalse(construct.contains("initSelection("), construct);
+ assertTrue(construct.contains("bindSelectionPrune("), construct);
+ assertTrue(construct.contains("initPolling("), construct);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // d) Behavioral — resolveOrder via live dtIndex (skipped when node is absent)
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static Map<?,?> report;
+
+ @BeforeAll
+ static void probeIfNodeAvailable() throws Exception {
+ if (!nodeAvailable())
+ return;
+ var harness = locateHarness();
+ if (harness == null)
+ return;
+ var configFile = Files.createTempFile("juneau-config-", ".js");
+ var viewsFile = Files.createTempFile("juneau-views-", ".js");
+ try {
+ Files.writeString(configFile, configJs(), UTF_8);
+ Files.writeString(viewsFile, viewsJs(), UTF_8);
+ report = Json.to(runNode(harness, configFile, viewsFile), Map.class);
+ } finally {
+ Files.deleteIfExists(configFile);
+ 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/config-reinit.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/config-reinit.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/config-reinit.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path configJs, Path viewsJs) throws Exception {
+ var stdout = Files.createTempFile("config-reinit-stdout-", ".json");
+ var stderr = Files.createTempFile("config-reinit-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of(
+ "node", harness.toString(), configJs.toString(), viewsJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("config-reinit.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("config-reinit.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 config-reinit.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ @Test void d01_resolveOrder_usesDtIndex_Cis3_not2() {
+ var r = report();
+ assertEquals(true, r.get("hasDtIndex"));
+ assertEquals(true, r.get("hasBuildTable"));
+ assertEquals(true, r.get("hasApplyView"));
+ assertEquals(3, ((Number)r.get("dtIndexC")).intValue());
+ assertEquals(3, ((Number)r.get("orderIndex")).intValue());
+ assertEquals("asc", r.get("orderDir"));
+ }
+
+ @Test void d02_hiddenOrderedColumn_fallsBackToFirstVisibleOrderable() {
+ var r = report();
+ assertEquals("A", r.get("hiddenFallbackData"));
+ assertEquals(1, ((Number)r.get("hiddenFallbackIndex")).intValue());
+ }
+
+ @Test void d03_applyView_withoutCtx_returnsNotInitialized() {
+ var r = report();
+ var v = (Map<?,?>)r.get("applyViewNotInit");
+ assertEquals(false, v.get("ok"));
+ assertEquals("not-initialized", v.get("reason"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
index 77c4308b88..63a22ccede 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowActions_Test.java
@@ -54,10 +54,10 @@ class ViewsJs_RowActions_Test extends TestBase {
return c.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
}
- @Test void a01_contractVersionBumpedToThree() throws Exception {
+ @Test void a01_contractVersionPinnedToCurrent() throws Exception {
var body = viewsJs();
- assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION = \"3\""), body);
- assertEquals("3", ViewDef.CONTRACT_VERSION);
+ assertTrue(body.contains("JUNEAU_VIEW_CONTRACT_VERSION = \"4\""), body);
+ assertEquals("4", ViewDef.CONTRACT_VERSION);
}
@Test void a02_defaultCsrfHeaderMatchesTheServerBoundary() throws Exception {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
new file mode 100644
index 0000000000..f270630f7e
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_RowDetail_Test.java
@@ -0,0 +1,198 @@
+/*
+ * 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 row-detail JS helpers. Source-shape always runs; behavioral Node harness runs
+ * when {@code node} is on {@code PATH} (skipped otherwise — no {@code -Pjs-tests} required).
+ */
+class ViewsJs_RowDetail_Test extends TestBase {
+
+ private static String viewsJs() throws IOException {
+ try (var in = ViewsMixin.class.getResourceAsStream(ViewsMixin.VIEWS_JS_RESOURCE)) {
+ assertNotNull(in, () -> "missing classpath resource: " + ViewsMixin.VIEWS_JS_RESOURCE);
+ return new String(in.readAllBytes(), UTF_8);
+ }
+ }
+
+ @Test void a01_helpersExportedOnNsInit() throws Exception {
+ var body = viewsJs();
+ for (var name : new String[]{
+ "isSafeDetailUrl: isSafeDetailUrl",
+ "substituteDetailUrl: substituteDetailUrl",
+ "scalarFieldValue: scalarFieldValue",
+ "fillDetailSlots: fillDetailSlots",
+ "findRowDetailTemplate: findRowDetailTemplate",
+ "JUNEAU_ROW_DETAIL_CONTRACT_VERSION: JUNEAU_ROW_DETAIL_CONTRACT_VERSION"
+ })
+ assertTrue(body.contains(name), () -> "missing export '" + name + "'");
+ assertFalse(body.contains("function buildDetailFields("), body);
+ assertFalse(body.contains("function buildDetailPanel("), body);
+ assertTrue(body.contains("submitRowAction(action, table, parentTr"),
+ "write path must target the expanded DataTables row, not expand JSON");
+ }
+
+ 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");
+ try {
+ Files.writeString(viewsFile, viewsJs(), UTF_8);
+ report = Json.to(runNode(harness, viewsFile), Map.class);
+ } finally {
+ 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/row-detail.cjs");
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ for (var rel : List.of(
+ "src/test/js/row-detail.cjs",
+ "juneau-rest/juneau-rest-server-views/src/test/js/row-detail.cjs"
+ )) {
+ var p = Path.of(rel);
+ if (Files.isRegularFile(p)) return p.toAbsolutePath().normalize();
+ }
+ return null;
+ }
+
+ private static String runNode(Path harness, Path viewsJs) throws Exception {
+ var stdout = Files.createTempFile("row-detail-stdout-", ".json");
+ var stderr = Files.createTempFile("row-detail-stderr-", ".txt");
+ try {
+ var pb = new ProcessBuilder(List.of("node", harness.toString(), viewsJs.toString()))
+ .redirectOutput(stdout.toFile())
+ .redirectError(stderr.toFile());
+ var p = pb.start();
+ if (!p.waitFor(30, TimeUnit.SECONDS)) {
+ p.destroyForcibly();
+ fail("row-detail.cjs did not finish within 30s; stderr:\n" + quietRead(stderr));
+ }
+ if (p.exitValue() != 0)
+ fail("row-detail.cjs exited " + p.exitValue() + "; stderr:\n" + quietRead(stderr)
+ + "\nstdout:\n" + quietRead(stdout));
+ return Files.readString(stdout, UTF_8);
+ } finally {
+ Files.deleteIfExists(stdout);
+ Files.deleteIfExists(stderr);
+ }
+ }
+
+ private static String quietRead(Path p) {
+ try { return Files.readString(p, UTF_8); }
+ catch (IOException e) { return "(unreadable: " + e.getMessage() + ")"; }
+ }
+
+ private static Map<?,?> report() {
+ assumeTrue(report != null, "node not available or row-detail.cjs not found — behavioral layer skipped");
+ return report;
+ }
+
+ @Test void b01_urlSafety() {
+ var r = report();
+ assertEquals(true, r.get("url_pathOk"));
+ assertEquals(true, r.get("url_relativeOk"));
+ assertEquals(false, r.get("url_absolute"));
+ assertEquals(false, r.get("url_protoRel"));
+ assertEquals(false, r.get("url_scheme"));
+ assertEquals(false, r.get("url_dotdot"));
+ }
+
+ @Test void b02_hostileId_isEncoded_notPathTraversal() {
+ var r = report();
+ assertEquals(true, r.get("sub_hostileEncoded"));
+ assertNull(r.get("sub_absoluteTpl"));
+ assertEquals("/data/alerts/a1", r.get("sub_plain"));
+ var hostileUrl = String.valueOf(r.get("sub_hostile"));
+ assertFalse(hostileUrl.contains("../"), hostileUrl);
+ assertTrue(hostileUrl.startsWith("/data/alerts/"), hostileUrl);
+ }
+
+ @Test void b03_scalarAndXssFill_usesTextContent() {
+ var r = report();
+ assertEquals("hi", r.get("scalar_str"));
+ assertEquals("7", r.get("scalar_num"));
+ assertEquals("true", r.get("scalar_bool"));
+ assertEquals("", r.get("scalar_null"));
+ assertEquals("", r.get("scalar_obj"));
+ assertEquals("", r.get("scalar_arr"));
+ assertEquals(true, r.get("fill_xssNotInterpreted"));
+ assertTrue(String.valueOf(r.get("fill_xss")).contains("<img"));
+ assertEquals("42", r.get("fill_num"));
+ assertEquals("", r.get("fill_missing"));
+ }
+
+ @Test void b04_404500_actionRefButtonless_collapseRemains() {
+ var r = report();
+ assertEquals(true, r.get("fail_ackDisabled"));
+ assertEquals(true, r.get("fail_ackHidden"));
+ assertEquals(true, r.get("fail_escHidden"));
+ assertEquals(true, r.get("fail_collapseEnabled"));
+ }
+
+ @Test void b05_coalesceKeyAndDropIfOrphaned() {
+ var r = report();
+ assertEquals("a1:3", r.get("key_a1"));
+ assertEquals(true, r.get("drop_gone"));
+ assertEquals(true, r.get("drop_gen"));
+ assertEquals(false, r.get("drop_keep"));
+ }
+
+ @Test void b06_loudContractMismatch() {
+ var r = report();
+ assertEquals("1", r.get("contractVersion"));
+ assertEquals(true, r.get("contract_ok"));
+ assertEquals(false, r.get("contract_bad"));
+ assertEquals(false, r.get("contract_missing"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
index 14447d8504..f96a27de97 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_Selection_Test.java
@@ -111,20 +111,27 @@ class ViewsJs_Selection_Test extends TestBase {
assertTrue(fn.contains("Object.hasOwn(present, String(id))"), fn);
}
- @Test void d02_initSelection_prunesOnEveryDraw() throws Exception {
+ @Test void d02_initSelection_listenerLifetimeSplit_pruneIsPerInstance() throws Exception {
var body = viewsJs();
- var fn = functionBody(body, "function initSelection(");
- assertTrue(fn.contains("\"draw.dt\""), fn);
- assertTrue(fn.contains("pruneSelection(Array.from(selectionState.selected), currentRowIds())"), fn);
- assertTrue(fn.contains("selectionState.selected = new Set(pruned)"), fn);
+ var nativeFn = functionBody(body, "function initSelection(");
+ assertTrue(nativeFn.contains("table.addEventListener(\"change\""), nativeFn);
+ assertTrue(nativeFn.contains(".juneau-view-select-checkbox"), nativeFn);
+ assertFalse(nativeFn.contains("\"draw.dt\""), nativeFn);
+
+ var pruneFn = functionBody(body, "function bindSelectionPrune(");
+ assertTrue(pruneFn.contains("\"draw.dt\""), pruneFn);
+ assertTrue(pruneFn.contains("pruneSelection(Array.from(selectionState.selected), ids)"), pruneFn);
+ assertTrue(pruneFn.contains("selectionState.selected = new Set(pruned)"), pruneFn);
}
@Test void e01_selectAll_isScopedToTheCurrentDrawsRowsOnly() throws Exception {
var body = viewsJs();
var fn = functionBody(body, "function initSelection(");
// The select-all header checkbox only ever iterates rows currently in the tbody - never an off-screen page.
- assertTrue(fn.contains("SELECT_ALL_ATTR"), fn);
assertTrue(fn.contains("tbody tr[\" + ROW_ID_ATTR + \"]"), fn);
+ assertTrue(fn.contains(".juneau-view-select-all-checkbox"), fn);
+ var ensure = functionBody(body, "function ensureSelectAllCheckbox(");
+ assertTrue(ensure.contains("SELECT_ALL_ATTR"), ensure);
}
//------------------------------------------------------------------------------------------------------------------
@@ -134,29 +141,32 @@ class ViewsJs_Selection_Test extends TestBase {
@Test void f01_initTable_bulkIsOnlyEverConsultedInsideTheSelectionBranch() throws Exception {
var body = viewsJs();
- var fn = functionBody(body, "function initTable(");
+ var fn = functionBody(body, "function beginInitTable(");
var selectionIdx = fn.indexOf("const selectionState = hasSelection(table)");
var selectionBranchIdx = fn.indexOf("if (selectionState) {");
var bulkCheckIdx = fn.indexOf("hasBulk(table)");
- var bulkToolbarIdx = fn.indexOf("buildBulkToolbar(bulkDef, table, ctx, selectionState)");
assertTrue(selectionIdx >= 0, fn);
assertTrue(selectionBranchIdx > selectionIdx, fn);
assertTrue(bulkCheckIdx > selectionBranchIdx, fn);
- assertTrue(bulkToolbarIdx > bulkCheckIdx, fn);
+ var construct = functionBody(body, "function constructTable(");
+ assertTrue(construct.contains("buildBulkToolbar(ctx._bulkDef, table, ctx, ctx.selectionState)"), construct);
}
- @Test void f02_initTable_unshiftsASyntheticLeadingSelectionColumn() throws Exception {
+ @Test void f02_buildTable_prependsASyntheticLeadingSelectionColumn_beforeResolveOrder() throws Exception {
var body = viewsJs();
- var fn = functionBody(body, "function initTable(");
- assertTrue(fn.contains("opts.columns.unshift(buildSelectionColumnDef(selectionState))"), fn);
+ var fn = functionBody(body, "function assembleFullColumnArray(");
+ assertTrue(fn.contains("buildSelectionColumnDef(ctx.selectionState)"), fn);
+ assertTrue(fn.contains("cols.push(sel)"), fn);
+ assertTrue(fn.contains("opts.order = resolveOrder(viewDef, opts.columns)"), fn);
+ assertFalse(fn.contains("opts.columns.unshift"), fn);
}
@Test void f03_initTable_selectionWiringIsUnconditionalOnBulkHealth() throws Exception {
// initSelection(...) must run whenever selection was declared, REGARDLESS of whether the bulk sidecar is
// present/healthy - selection (e.g. for export) must keep working even if bulk mutation is withheld.
var body = viewsJs();
- var fn = functionBody(body, "function initTable(");
- var initSelectionIdx = fn.indexOf("initSelection(table, dt, selectionState, ctx)");
+ var fn = functionBody(body, "function beginInitTable(");
+ var initSelectionIdx = fn.indexOf("initSelection(table, ctx)");
var bulkCheckIdx = fn.indexOf("hasBulk(table)");
assertTrue(initSelectionIdx >= 0 && initSelectionIdx < bulkCheckIdx, fn);
}
@@ -199,7 +209,7 @@ class ViewsJs_Selection_Test extends TestBase {
@Test void i01_bulkContractMismatch_isLoggedAndWithheld_selectionSurvives() throws Exception {
var body = viewsJs();
- var fn = functionBody(body, "function initTable(");
+ var fn = functionBody(body, "function beginInitTable(");
assertTrue(fn.contains("bulkDef.contractVersion !== JUNEAU_BULK_CONTRACT_VERSION"), fn);
assertTrue(fn.contains("bulk mutation withheld"), fn);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index dfd2af8b78..c4731ee94d 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -116,10 +116,29 @@ class ViewsMixin_Serving_Test extends TestBase {
}
}
+ /**
+ * Request-bearing {@link ViewTable}/{@link PageTable} {@code of(req, ...)} host so the
+ * {@code data-juneau-saved-views} stamp is resolved through a real {@link RestRequest} URI resolver
+ * (the emit tests only exercise the already-resolved-base overload).
+ */
+ @Rest(mixins=ViewsMixin.class)
+ public static class StampHost extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/releases") public Div releases(RestRequest req) {
+ return ViewTable.of(req, releasesView());
+ }
+ @RestGet(path="/admin") public Div admin(RestRequest req) {
+ return PageTable.of(req, PageDef.create("admin")
+ .tabs(Tab.create("releases", "Releases").view(releasesView()))
+ .build());
+ }
+ }
+
private static final MockRestClient cNoMixin = MockRestClient.buildLax(NoMixin.class);
private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.class);
private static final MockRestClient cViewHost = MockRestClient.buildLax(ViewHost.class);
private static final MockRestClient cViewBeanHost = MockRestClient.buildLax(ViewBeanHost.class);
+ private static final MockRestClient cStampHost = MockRestClient.buildLax(StampHost.class);
/** Extracts a named function's body: from `function <name>(` to the next top-level `\n\t}`. */
private static String functionBody(String body, String signature) {
@@ -147,12 +166,19 @@ class ViewsMixin_Serving_Test extends TestBase {
cNoMixin.get(ViewsMixin.RIBBON_JS_PATH).run().assertStatus(404);
cNoMixin.get(ViewsMixin.RENDERS_JS_PATH).run().assertStatus(404);
cNoMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.CONFIG_JS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.CONFIG_CSS_PATH).run().assertStatus(404);
}
@Test void h01_hostWithoutMixin_iconsJsRouteIs404() throws Exception {
cNoMixin.get(ViewsMixin.ICONS_JS_PATH).run().assertStatus(404);
}
+ @Test void h02_hostWithoutMixin_configAssetsAre404() throws Exception {
+ cNoMixin.get(ViewsMixin.CONFIG_JS_PATH).run().assertStatus(404);
+ cNoMixin.get(ViewsMixin.CONFIG_CSS_PATH).run().assertStatus(404);
+ }
+
@Test void a02_hostExistingRoute_unaffectedByMixin() throws Exception {
cWithMixin.get("/items").accept("application/json").run().assertStatus(200).assertContent().asString().isContains("items");
}
@@ -198,6 +224,22 @@ class ViewsMixin_Serving_Test extends TestBase {
cWithMixin.get(ViewsMixin.VIEWS_JS_PATH + "?v=whatever").run().assertStatus(200);
}
+ @Test void b06_configJs_served() throws Exception {
+ cWithMixin.get(ViewsMixin.CONFIG_JS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/javascript")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-config.js");
+ }
+
+ @Test void b07_configCss_served() throws Exception {
+ cWithMixin.get(ViewsMixin.CONFIG_CSS_PATH).run()
+ .assertStatus(200)
+ .assertHeader("Content-Type").isContains("text/css")
+ .assertHeader("Cache-Control").isContains("max-age")
+ .assertContent().asString().isContains("juneau-config.css");
+ }
+
//------------------------------------------------------------------------------------------------------------------
// c) Versioned asset URL (?v=<buildVersion>) via the viewAssetUrl(...) helper
//------------------------------------------------------------------------------------------------------------------
@@ -211,7 +253,7 @@ class ViewsMixin_Serving_Test extends TestBase {
}
@Test void c02_viewAssetUrl_worksForEveryAssetPath() {
- for (var path : new String[]{ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH, ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH})
+ for (var path : new String[]{ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH, ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH, ViewsMixin.CONFIG_JS_PATH, ViewsMixin.CONFIG_CSS_PATH})
assertTrue(ViewsMixin.viewAssetUrl(path).contains("?v="), path);
}
@@ -234,7 +276,8 @@ class ViewsMixin_Serving_Test extends TestBase {
@Test void c04_viewAssetUrl_contentHash_matchesIndependentlyComputedHash8OfServedBytes() throws Exception {
for (var path : new String[]{
ViewsMixin.VIEWS_JS_PATH, ViewsMixin.RIBBON_JS_PATH, ViewsMixin.RENDERS_JS_PATH,
- ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH, ViewsMixin.PAGES_JS_PATH}) {
+ ViewsMixin.VIEWS_CSS_PATH, ViewsMixin.ICONS_JS_PATH, ViewsMixin.PAGES_JS_PATH,
+ ViewsMixin.CONFIG_JS_PATH, ViewsMixin.CONFIG_CSS_PATH}) {
var servedBytes = cWithMixin.get(path).run().assertStatus(200).getContent().asBytes();
var expectedHash = ChecksumUtils.hash8(servedBytes);
var url = ViewsMixin.viewAssetUrl(path);
@@ -248,7 +291,7 @@ class ViewsMixin_Serving_Test extends TestBase {
@Test void d01_contractVersion_equalsViewDefContractVersion() {
assertEquals(ViewDef.CONTRACT_VERSION, ViewsMixin.CONTRACT_VERSION);
- assertEquals("3", ViewsMixin.CONTRACT_VERSION);
+ assertEquals("4", ViewsMixin.CONTRACT_VERSION);
}
@Test void d02_viewsJs_bakesInContractVersionHandshake() throws Exception {
@@ -597,4 +640,23 @@ class ViewsMixin_Serving_Test extends TestBase {
var end = body.indexOf("}", start);
assertTrue(body.substring(start, end).contains("border: 1px solid"), body);
}
+
+ //------------------------------------------------------------------------------------------------------------------
+ // i) data-juneau-saved-views stamp via a real RestRequest URI resolver
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void i01_requestBearingViewTable_stampsWrapperNotTable() throws Exception {
+ var html = cStampHost.get("/releases").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertTrue(html.contains("data-juneau-saved-views="), html);
+ assertTrue(html.contains(SavedViewsMixin.SAVED_VIEWS_PREFIX), html);
+ assertFalse(html.contains("<table id=\"releases\" data-juneau-saved-views"), html);
+ assertFalse(html.contains("<table id='releases' data-juneau-saved-views"), html);
+ }
+
+ @Test void i02_requestBearingPageTable_stampsPageShell() throws Exception {
+ var html = cStampHost.get("/admin").accept("text/html").run().assertStatus(200).getContent().asString();
+ assertTrue(html.contains("data-juneau-saved-views="), html);
+ assertTrue(html.contains(SavedViewsMixin.SAVED_VIEWS_PREFIX), html);
+ assertTrue(html.contains("data-juneau-page"), html);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/column-config.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/column-config.cjs
new file mode 100644
index 0000000000..80c4fb0e92
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/column-config.cjs
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+/*
+ * column-config.cjs - real-browser XSS canary for the View-tab chooser (TODO-444, slice 6).
+ *
+ * Never runs in a default build. Driven by ColumnConfig_BrowserTest under `mvn -Pjs-tests`.
+ *
+ * Usage: node column-config.cjs <page.html>
+ *
+ * Loads the REAL served juneau-config.js in headless Chromium and asserts that a hostile saved-view
+ * name and a hostile column label are painted with textContent (literal text, no script execution).
+ */
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const PROBE = async function () {
+ const XSS_NAME = '<img src=x onerror="window.__juneauChooserXss=1">';
+ const XSS_LABEL = '<img src=x onerror="window.__juneauChooserLabelXss=1">';
+ const NS = window.JuneauViews;
+ const out = { hasConfig: !!(NS && NS.config) };
+ if (!out.hasConfig) return out;
+
+ window.__juneauChooserXss = 0;
+ window.__juneauChooserLabelXss = 0;
... 976 lines suppressed ...
(juneau) 01/02: TODO-445a: Add juneau-rest-server-widgets module with ActionBar, ActionRef, and SafeAction
Posted by ja...@apache.org.
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
commit 99fc927e9bfb28cc11d777657be453dcccd9a190
Author: James Bognar <ja...@apache.org>
AuthorDate: Thu Aug 20 11:35:18 2026 -0400
TODO-445a: Add juneau-rest-server-widgets module with ActionBar, ActionRef, and SafeAction
Extract a reusable widget toolkit so views can compose row-detail action bars without inlining markup.
---
juneau-bom/pom.xml | 1 +
juneau-rest/juneau-rest-server-widgets/pom.xml | 122 +++++++++++++++++++++
.../juneau/rest/server/widgets/ActionBar.java | 74 +++++++++++++
.../juneau/rest/server/widgets/ActionBarItem.java | 26 +++++
.../juneau/rest/server/widgets/ActionRef.java | 49 +++++++++
.../juneau/rest/server/widgets/SafeAction.java | 59 ++++++++++
.../apache/juneau/rest/server/widgets/Widget.java | 37 +++++++
.../juneau/rest/server/widgets/package-info.java | 35 ++++++
.../juneau/rest/server/widgets/ActionBar_Test.java | 78 +++++++++++++
juneau-rest/pom.xml | 1 +
10 files changed, 482 insertions(+)
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 4c0468308c..8530277776 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -62,6 +62,7 @@
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-tracing-otel</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-management-logging</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-datatables</artifactId><version>${project.version}</version></dependency>
+ <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-widgets</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-views</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-rest-server-console-ui-freemarker</artifactId><version>${project.version}</version></dependency>
diff --git a/juneau-rest/juneau-rest-server-widgets/pom.xml b/juneau-rest/juneau-rest-server-widgets/pom.xml
new file mode 100644
index 0000000000..650890f2ab
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/pom.xml
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest</artifactId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-rest-server-widgets</artifactId>
+ <name>Apache Juneau REST Server Widgets</name>
+ <description>Apache Juneau REST Server - reusable widget primitives (ActionBar and shared Widget contract). Table-specific types stay in juneau-rest-server-views; this module never depends on views.</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-commons</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Test scope -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+ <supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+ <goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBar.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBar.java
new file mode 100644
index 0000000000..4f9f422b3f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBar.java
@@ -0,0 +1,74 @@
+/*
+ * 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.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+/**
+ * An ordered bar of {@link ActionRef} ids and {@link SafeAction}s.
+ *
+ * <p>
+ * Holds ids only — it does <b>not</b> import any views-module write-action type. CSRF, confirm, and
+ * write-result handling stay on the enclosing view's action catalog.
+ *
+ * @since 10.0.0
+ */
+public class ActionBar implements Widget {
+
+ /** The frozen contract version for this widget. */
+ public static final String CONTRACT_VERSION = "1";
+
+ /** The ordered items; omitted / empty means no bar. */
+ public List<ActionBarItem> items;
+
+ /**
+ * Creates an empty action bar.
+ *
+ * @return A new {@link ActionBar}.
+ */
+ public static ActionBar create() {
+ return new ActionBar();
+ }
+
+ /**
+ * Sets the ordered items.
+ *
+ * @param value The items, in display order. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public ActionBar items(ActionBarItem...value) {
+ items = l(value);
+ return this;
+ }
+
+ @Override /* Widget */
+ public void validate() {
+ if (items == null)
+ return;
+ for (var item : items) {
+ if (item == null)
+ throw iaex("ActionBar item must not be null.");
+ if (item instanceof ActionRef ar) {
+ if (ar.id == null || ar.id.isBlank())
+ throw iaex("ActionRef id must not be null or blank.");
+ } else if (!(item instanceof SafeAction))
+ throw iaex("ActionBar item must be ActionRef or SafeAction.");
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBarItem.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBarItem.java
new file mode 100644
index 0000000000..d2cf7c3555
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionBarItem.java
@@ -0,0 +1,26 @@
+/*
+ * 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;
+
+/**
+ * One item in an {@link ActionBar}: either an {@link ActionRef} (opaque id naming a write action on the
+ * enclosing view) or a {@link SafeAction} (client-only, no endpoint).
+ *
+ * @since 10.0.0
+ */
+public interface ActionBarItem {
+}
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionRef.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionRef.java
new file mode 100644
index 0000000000..b0755c6225
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/ActionRef.java
@@ -0,0 +1,49 @@
+/*
+ * 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.apache.juneau.commons.utils.Shorts.*;
+
+/**
+ * An opaque id naming a write action on the enclosing view.
+ *
+ * <p>
+ * This type deliberately does <b>not</b> import any views-module type. Existence of the named action on the
+ * enclosing view's action catalog is checked in views ({@code RowDetailDef}/{@code ViewDef.validate()}), never
+ * here. The bar does not grow its own write protocol (no endpoint / method / csrf fields).
+ *
+ * @since 10.0.0
+ */
+public class ActionRef implements ActionBarItem {
+
+ /** The opaque action id. Must not be blank. */
+ public String id;
+
+ /**
+ * Creates an action reference with the specified id.
+ *
+ * @param id The action id. Must not be <jk>null</jk> or blank.
+ * @return A new {@link ActionRef}.
+ */
+ public static ActionRef of(String id) {
+ if (id == null || id.isBlank())
+ throw iaex("ActionRef id must not be null or blank.");
+ var a = new ActionRef();
+ a.id = id;
+ return a;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/SafeAction.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/SafeAction.java
new file mode 100644
index 0000000000..aa17e10c06
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/SafeAction.java
@@ -0,0 +1,59 @@
+/*
+ * 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;
+
+/**
+ * A client-only {@link ActionBar} item with no endpoint.
+ *
+ * <p>
+ * This slice ships {@link #COLLAPSE} (collapse the child row). Unlike an {@link ActionRef}, a safe action is
+ * enabled immediately — including during an in-flight expand GET and after a failed GET — so the
+ * user can dismiss a loading or error panel.
+ *
+ * @since 10.0.0
+ */
+public enum SafeAction implements ActionBarItem {
+
+ /** Collapse the expanded child row. */
+ COLLAPSE("collapse", "Collapse");
+
+ private final String wire;
+ private final String label;
+
+ SafeAction(String wire, String label) {
+ this.wire = wire;
+ this.label = label;
+ }
+
+ /**
+ * Returns the wire token stamped onto {@code data-juneau-safe}.
+ *
+ * @return The wire token (e.g. {@code "collapse"}).
+ */
+ public String wire() {
+ return wire;
+ }
+
+ /**
+ * Returns the built-in button label, painted with {@code textContent}.
+ *
+ * @return The label.
+ */
+ public String label() {
+ return label;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/Widget.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/Widget.java
new file mode 100644
index 0000000000..e4f5c18736
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/Widget.java
@@ -0,0 +1,37 @@
+/*
+ * 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;
+
+/**
+ * Marker for a reusable widget primitive.
+ *
+ * <p>
+ * Each concrete type owns a {@code public static final String CONTRACT_VERSION} (starting at {@code "1"}) and
+ * implements {@link #validate()} as a fail-closed bean-level check. Serving-path call sites (for example a
+ * table emitter) must invoke {@code validate()} rather than treating it as documentation-only.
+ *
+ * @since 10.0.0
+ */
+public interface Widget {
+
+ /**
+ * Fail-closed bean validation.
+ *
+ * @throws IllegalArgumentException If this widget is not well-formed.
+ */
+ void validate();
+}
diff --git a/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/package-info.java b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/package-info.java
new file mode 100644
index 0000000000..e84bd72f99
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/main/java/org/apache/juneau/rest/server/widgets/package-info.java
@@ -0,0 +1,35 @@
+/*
+ * 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.
+ */
+
+/**
+ * Reusable widget primitives for the Juneau REST server toolkit.
+ *
+ * <p>
+ * This optional module holds the shared {@link org.apache.juneau.rest.server.widgets.Widget} marker and the first
+ * concrete primitive, {@link org.apache.juneau.rest.server.widgets.ActionBar}. Table-specific types
+ * (row-detail defs, row actions) stay in {@code juneau-rest-server-views}; this module has <b>no</b> dependency
+ * on views, so an {@link org.apache.juneau.rest.server.widgets.ActionBar} can never import a row-action type.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.server.widgets.Widget}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.widgets.ActionBar}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.rest.server.widgets;
diff --git a/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ActionBar_Test.java b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ActionBar_Test.java
new file mode 100644
index 0000000000..8ad06c95cf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-widgets/src/test/java/org/apache/juneau/rest/server/widgets/ActionBar_Test.java
@@ -0,0 +1,78 @@
+/*
+ * 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.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * {@link ActionBar} / {@link ActionRef} / {@link SafeAction} bean contract, including the views-module isolation
+ * rule (this module must never import {@code RowAction}).
+ */
+class ActionBar_Test extends TestBase {
+
+ @Test void a01_contractVersion_isOne() {
+ assertEquals("1", ActionBar.CONTRACT_VERSION);
+ }
+
+ @Test void a02_items_roundTrip() {
+ var bar = ActionBar.create().items(ActionRef.of("ack"), SafeAction.COLLAPSE);
+ assertSize(2, bar.items);
+ assertEquals("ack", ((ActionRef) bar.items.get(0)).id);
+ assertEquals(SafeAction.COLLAPSE, bar.items.get(1));
+ bar.validate();
+ }
+
+ @Test void a03_blankActionRef_rejectedAtFactory() {
+ assertThrows(IllegalArgumentException.class, () -> ActionRef.of(null));
+ assertThrows(IllegalArgumentException.class, () -> ActionRef.of(" "));
+ }
+
+ @Test void a04_validate_rejectsBlankActionRefId() {
+ var bar = ActionBar.create();
+ var blank = new ActionRef();
+ blank.id = " ";
+ bar.items = java.util.List.of(blank);
+ var e = assertThrows(IllegalArgumentException.class, bar::validate);
+ assertTrue(e.getMessage().contains("ActionRef"), e::getMessage);
+ }
+
+ @Test void a05_safeAction_collapseWireAndLabel() {
+ assertEquals("collapse", SafeAction.COLLAPSE.wire());
+ assertEquals("Collapse", SafeAction.COLLAPSE.label());
+ }
+
+ @Test void a06_sources_doNotImportRowAction() throws Exception {
+ var root = Path.of("").toAbsolutePath();
+ var src = root;
+ if (!Files.isDirectory(src.resolve("src/main/java")))
+ src = root.resolve("juneau-rest/juneau-rest-server-widgets");
+ assertTrue(Files.isDirectory(src.resolve("src/main/java")), src::toString);
+ try (var walk = Files.walk(src.resolve("src/main/java"))) {
+ for (var f : walk.filter(p -> p.toString().endsWith(".java")).toList()) {
+ var text = Files.readString(f);
+ assertFalse(text.contains("import org.apache.juneau.rest.server.views.RowAction"),
+ () -> "widgets module must not import RowAction: " + f);
+ }
+ }
+ }
+}
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 47781c6716..0d8a964dc3 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -47,6 +47,7 @@
<module>juneau-rest-server-tracing-otel</module>
<module>juneau-rest-server-management-logging</module>
<module>juneau-rest-server-datatables</module>
+ <module>juneau-rest-server-widgets</module>
<module>juneau-rest-server-views</module>
<module>juneau-rest-server-views-markdown</module>
<module>juneau-rest-server-console-ui</module>