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/19 14:01:45 UTC
(juneau) branch master updated: Pre-release cleanup: cache fixes, secret-module rename, markdown view, views toolkit, static-analysis sweep
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 964137ad3a Pre-release cleanup: cache fixes, secret-module rename, markdown view, views toolkit, static-analysis sweep
964137ad3a is described below
commit 964137ad3a9b00b578af3e271adb416b3c5b5104
Author: James Bognar <ja...@apache.org>
AuthorDate: Wed Aug 19 10:01:37 2026 -0400
Pre-release cleanup: cache fixes, secret-module rename, markdown view, views toolkit, static-analysis sweep
- commons: fix WEAK-mode Cache behavior + composite-key docs (Cache2-5, CacheMode); add EscapeForScript security-scan test + scanner
- secret: rename juneau-secret-keychain -> juneau-secret-macos-keychain (module dir, package org.apache.juneau.secret.macos.keychain, ServiceLoader, BOM, consumers, SERVICE_PROPERTY); harden KeychainSecretStore stdin/newline handling + close process pipe streams
- rest: add juneau-rest-server-views-markdown (CommonMark renderer); views toolkit runtime (request-aware asset URLs, table polling, row-details expanders); console-ui chrome/theme/token updates
- static analysis: SonarCloud + Eclipse JDT sweep across console-ui, view-{jsp,freemarker,mustache,thymeleaf}, and views (regex backtracking, lambdas, constants, resource-leak suppressions per house convention, TODO removal); JS asset modernization
- build/ci: sonarcloud.yml + CodeQL workflow alignment; apply-eclipse-prefs script + source-prefs resource-analysis consistency
- examples: add juneau-examples-views
---
.github/workflows/sonarcloud.yml | 15 +-
README.md | 2 +-
juneau-bom/pom.xml | 2 +-
.../apache/juneau/commons/collections/Cache2.java | 23 +-
.../apache/juneau/commons/collections/Cache3.java | 23 +-
.../apache/juneau/commons/collections/Cache4.java | 23 +-
.../apache/juneau/commons/collections/Cache5.java | 23 +-
.../juneau/commons/collections/CacheMode.java | 11 +
.../EscapeForScriptSink_SecurityScan_Test.java | 190 +++++++++++
.../commons/utils/ScriptJsonSinkScanner.java | 271 +++++++++++++++
juneau-examples/juneau-examples-views/pom.xml | 101 ++++++
.../apache/juneau/examples/views/AuditEntry.java | 35 +-
.../juneau/examples/views/ExampleViewsRest.java | 306 +++++++++++++++++
.../juneau/examples/views/ExampleViewsServer.java | 139 ++++++++
.../org/apache/juneau/examples/views/Widget.java | 61 ++++
.../examples/views/ExampleViewsEndToEnd_Test.java | 136 ++++++++
juneau-examples/pom.xml | 1 +
.../ConsoleDataTablesFreemarkerMixin_Test.java | 2 +-
.../console/ConsoleFreemarkerMixin_Test.java | 2 +-
.../rest/server/console/ConsoleChromeMixin.java | 92 +++++-
.../rest/server/console/CssValueEscaper.java | 2 +-
.../rest/server/console/CssValueGrammar.java | 11 +-
.../org/apache/juneau/rest/server/console/Tag.java | 3 +
.../juneau/rest/server/console/TagDomain.java | 17 +-
.../apache/juneau/rest/server/console/Theme.java | 18 +
.../resources/org/apache/juneau/console/chrome.css | 217 ++++++++++--
.../rest/server/console/ChromeCssScanner.java | 2 +-
.../server/console/ConsoleChromeMixin_Test.java | 138 ++++----
.../rest/server/console/CssValueEscaper_Test.java | 20 +-
.../rest/server/console/CssValueGrammar_Test.java | 51 ++-
.../rest/server/console/ModuleGraph_Test.java | 24 +-
.../console/Theme_IdentifierGuards_Test.java | 34 +-
.../view/freemarker/FreemarkerDispatcher_Test.java | 24 +-
.../juneau/rest/server/view/jsp/JspDispatcher.java | 11 +-
.../rest/server/view/jsp/JspViewRenderer.java | 10 +-
.../view/jsp/JspDispatcher_ForwardPaths_Test.java | 7 +-
.../jsp/JspViewRenderer_ForwardPaths_Test.java | 9 +-
.../view/mustache/MustacheDispatcher_Test.java | 23 +-
.../view/thymeleaf/ThymeleafDispatcher_Test.java | 19 +-
.../juneau-rest-server-views-markdown}/pom.xml | 73 +++--
.../views/markdown/CommonmarkMarkdownRenderer.java | 73 +++++
.../server/views/markdown/MarkdownRenderer.java | 89 +++++
.../rest/server/views/markdown/package-info.java | 44 +++
.../markdown/CommonmarkMarkdownRenderer_Test.java | 88 +++++
.../views/markdown/MarkdownRenderer_Test.java | 76 +++++
.../apache/juneau/rest/server/views/Column.java | 1 +
.../apache/juneau/rest/server/views/PageDef.java | 5 +-
.../apache/juneau/rest/server/views/PageTable.java | 10 +-
.../apache/juneau/rest/server/views/Render.java | 1 +
.../juneau/rest/server/views/RibbonAction.java | 1 +
.../apache/juneau/rest/server/views/Subtab.java | 3 +-
.../org/apache/juneau/rest/server/views/Tab.java | 3 +-
.../apache/juneau/rest/server/views/ViewDef.java | 119 ++++++-
.../juneau/rest/server/views/ViewsMixin.java | 37 ++-
.../juneau/rest/server/views/package-info.java | 2 +-
.../org/apache/juneau/views/juneau-pages.js | 66 ++--
.../org/apache/juneau/views/juneau-renders.js | 78 ++---
.../org/apache/juneau/views/juneau-ribbon.js | 88 ++---
.../org/apache/juneau/views/juneau-views.css | 100 +++++-
.../org/apache/juneau/views/juneau-views.js | 362 ++++++++++++++++-----
.../rest/server/views/JuneauPagesJs_Test.java | 5 +-
.../rest/server/views/PageDef_Validation_Test.java | 57 ++--
.../rest/server/views/PageServerWiring_Test.java | 5 +-
.../rest/server/views/PageTable_Emit_Test.java | 2 +-
.../views/PageTable_SubtabPanelContract_Test.java | 3 +
.../rest/server/views/PagingPill_Wiring_Test.java | 3 +
.../server/views/RibbonIcons_Resolution_Test.java | 3 +
.../views/RowDetailsExpander_Wiring_Test.java | 164 ++++++++++
.../server/views/TablePolling_Wiring_Test.java | 171 ++++++++++
.../rest/server/views/ViewMeta_Contract_Test.java | 106 +++++-
.../rest/server/views/ViewServerWiring_Test.java | 3 +
.../rest/server/views/ViewTable_Emit_Test.java | 2 +-
.../rest/server/views/ViewsJs_PageSeam_Test.java | 5 +-
.../rest/server/views/ViewsMixin_Pages_Test.java | 6 +-
.../rest/server/views/ViewsMixin_Serving_Test.java | 50 +++
.../src/test/js/panel-visibility.cjs | 4 +-
juneau-rest/pom.xml | 1 +
.../pom.xml | 4 +-
.../macos}/keychain/KeychainSecretStore.java | 89 ++++-
.../keychain/KeychainSecretStoreProvider.java | 4 +-
.../secret/macos}/keychain/package-info.java | 4 +-
...pache.juneau.commons.secret.SecretStoreProvider | 2 +-
.../keychain/KeychainSecretStoreProvider_Test.java | 2 +-
.../macos}/keychain/KeychainSecretStore_Test.java | 68 +++-
pom.xml | 2 +-
scripts/apply-eclipse-prefs.py | 1 +
.../source-prefs/org.eclipse.jdt.core.prefs | 2 +-
87 files changed, 3606 insertions(+), 579 deletions(-)
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index 1dd8c1c780..55368ab86e 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -21,6 +21,10 @@ on:
pull_request:
types: [opened, synchronize, reopened]
+permissions:
+ contents: read
+ pull-requests: read
+
jobs:
build:
name: Build and analyze
@@ -28,22 +32,23 @@ jobs:
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
steps:
- - uses: actions/checkout@v7
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
+ persist-credentials: false
- name: Set up JDK 17
- uses: actions/setup-java@v4
+ uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: 17
- distribution: 'zulu' # Alternative distribution options are available.
+ distribution: 'temurin' # Alternative distribution options are available.
- name: Cache SonarQube packages
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache Maven packages
- uses: actions/cache@v6
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
diff --git a/README.md b/README.md
index 482576a1ef..7f5583a907 100644
--- a/README.md
+++ b/README.md
@@ -322,7 +322,7 @@ Apache Juneau™ is a single cohesive Java ecosystem consisting of the following
* MCP client/server modules - see [MCP Support](#mcp-model-context-protocol-support) above.
* **juneau-microservice** - **juneau-microservice**, **juneau-microservice-jetty**, **juneau-microservice-tomcat**, **juneau-microservice-test**, **juneau-microservice-examples** - Lightweight standalone microservice APIs (Jetty and Tomcat), plus a JUnit 5 test harness.
* **juneau-sc** - **juneau-sc-server** - Git-backed source-control configuration server integration (`GitControl`, etc.).
-* **juneau-secret-keychain** - Opt-in `SecretStore` implementation backed by the macOS `security` keychain CLI (implements the `SecretStore` SPI in `juneau-commons`).
+* **juneau-secret-macos-keychain** - Opt-in `SecretStore` implementation backed by the macOS `security` keychain CLI (implements the `SecretStore` SPI in `juneau-commons`).
* **juneau-examples** - **juneau-examples-core** - Core code examples; **juneau-examples-mcp** - Runnable MCP example (notes-service demo).
* **juneau-petstore** - **juneau-petstore-core** - Shared petstore domain + REST resources; **juneau-petstore-jetty** - Jetty/Microservice deployment; **juneau-petstore-springboot** - Spring Boot deployment.
* **juneau-shaded** - Shaded (uber) JARs combining multiple Juneau modules for simplified dependency management, especially useful for Bazel builds: **juneau-shaded-core**, **juneau-shaded-rest-client**, **juneau-shaded-rest-server**, **juneau-shaded-rest-server-springboot**, and **juneau-shaded-all**.
diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 64e3373b2b..4c0468308c 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -118,7 +118,7 @@
<!-- ===================================================================================== -->
<!-- juneau-secret -->
<!-- ===================================================================================== -->
- <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-secret-keychain</artifactId><version>${project.version}</version></dependency>
+ <dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-secret-macos-keychain</artifactId><version>${project.version}</version></dependency>
<!-- ===================================================================================== -->
<!-- Curated dependency bundles (transitive module sets per deployment shape) -->
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache2.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache2.java
index b0af614762..a01561714e 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache2.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache2.java
@@ -47,6 +47,17 @@ import org.apache.juneau.commons.function.*;
* <li>Can be disabled entirely via builder or system property
* </ul>
*
+ * <h5 class='section'>WEAK Mode Semantics:</h5>
+ * <p>
+ * {@link CacheMode#WEAK} on this class is <b>best-effort</b>, not retention-guaranteed. Each lookup synthesizes
+ * an internal {@code Tuple2} composite key that is never exposed to the caller, so that key is reachable only
+ * from this cache's own backing {@link WeakHashMap}. Consequently a WEAK-mode entry becomes GC-eligible
+ * <b>immediately</b> after insertion and may be reclaimed at the very next garbage collection - effectively
+ * behaving like {@link CacheMode#NONE} once any GC has occurred. This differs from the single-key {@link Cache},
+ * where WEAK mode keys directly on the caller's own key and therefore retains an entry for as long as that key
+ * remains strongly reachable elsewhere. If guaranteed retention is required, use {@link CacheMode#FULL} (the
+ * default mode) instead of WEAK. See {@link CacheMode#WEAK} for details.
+ *
* <h5 class='section'>Usage:</h5>
* <p class='bjava'>
* <jc>// Create a cache with default supplier</jc>
@@ -199,8 +210,11 @@ public class Cache2<K1,K2,V> {
* Available modes:
* <ul>
* <li>{@link CacheMode#NONE NONE} - No caching (always invoke supplier)
- * <li>{@link CacheMode#WEAK WEAK} - Weak caching (uses {@link WeakHashMap})
- * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default)
+ * <li>{@link CacheMode#WEAK WEAK} - Best-effort weak-key caching (uses {@link WeakHashMap}); on this
+ * composite-key cache an entry may be reclaimed at <b>any</b> GC, not only when the caller's keys
+ * become unreachable - see {@link CacheMode#WEAK} for why
+ * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default); use this
+ * mode for guaranteed retention
* </ul>
*
* @param value The caching mode.
@@ -338,6 +352,11 @@ public class Cache2<K1,K2,V> {
* Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
* garbage collected when keys are no longer strongly referenced elsewhere.
*
+ * <p>
+ * <b>Note:</b> on this composite-key cache, WEAK is best-effort only - the internal composite key is
+ * never caller-visible, so an entry may be reclaimed at any GC. See {@link CacheMode#WEAK} for details,
+ * and use {@link CacheMode#FULL} instead if guaranteed retention is required.
+ *
* @return This object for method chaining.
* @see #cacheMode(CacheMode)
*/
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache3.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache3.java
index 4d247ab0a0..e9150aecd8 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache3.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache3.java
@@ -47,6 +47,17 @@ import org.apache.juneau.commons.function.*;
* <li>Can be disabled entirely via builder or system property
* </ul>
*
+ * <h5 class='section'>WEAK Mode Semantics:</h5>
+ * <p>
+ * {@link CacheMode#WEAK} on this class is <b>best-effort</b>, not retention-guaranteed. Each lookup synthesizes
+ * an internal {@code Tuple3} composite key that is never exposed to the caller, so that key is reachable only
+ * from this cache's own backing {@link WeakHashMap}. Consequently a WEAK-mode entry becomes GC-eligible
+ * <b>immediately</b> after insertion and may be reclaimed at the very next garbage collection - effectively
+ * behaving like {@link CacheMode#NONE} once any GC has occurred. This differs from the single-key {@link Cache},
+ * where WEAK mode keys directly on the caller's own key and therefore retains an entry for as long as that key
+ * remains strongly reachable elsewhere. If guaranteed retention is required, use {@link CacheMode#FULL} (the
+ * default mode) instead of WEAK. See {@link CacheMode#WEAK} for details.
+ *
* <h5 class='section'>Usage:</h5>
* <p class='bjava'>
* <jc>// Create a cache with default supplier</jc>
@@ -130,8 +141,11 @@ public class Cache3<K1,K2,K3,V> {
* Available modes:
* <ul>
* <li>{@link CacheMode#NONE NONE} - No caching (always invoke supplier)
- * <li>{@link CacheMode#WEAK WEAK} - Weak caching (uses {@link WeakHashMap})
- * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default)
+ * <li>{@link CacheMode#WEAK WEAK} - Best-effort weak-key caching (uses {@link WeakHashMap}); on this
+ * composite-key cache an entry may be reclaimed at <b>any</b> GC, not only when the caller's keys
+ * become unreachable - see {@link CacheMode#WEAK} for why
+ * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default); use this
+ * mode for guaranteed retention
* </ul>
*
* @param value The caching mode.
@@ -221,6 +235,11 @@ public class Cache3<K1,K2,K3,V> {
* Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
* garbage collected when keys are no longer strongly referenced elsewhere.
*
+ * <p>
+ * <b>Note:</b> on this composite-key cache, WEAK is best-effort only - the internal composite key is
+ * never caller-visible, so an entry may be reclaimed at any GC. See {@link CacheMode#WEAK} for details,
+ * and use {@link CacheMode#FULL} instead if guaranteed retention is required.
+ *
* @return This object for method chaining.
* @see #cacheMode(CacheMode)
*/
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache4.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache4.java
index 81cf786e39..2416707574 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache4.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache4.java
@@ -47,6 +47,17 @@ import org.apache.juneau.commons.function.*;
* <li>Can be disabled entirely via builder or system property
* </ul>
*
+ * <h5 class='section'>WEAK Mode Semantics:</h5>
+ * <p>
+ * {@link CacheMode#WEAK} on this class is <b>best-effort</b>, not retention-guaranteed. Each lookup synthesizes
+ * an internal {@code Tuple4} composite key that is never exposed to the caller, so that key is reachable only
+ * from this cache's own backing {@link WeakHashMap}. Consequently a WEAK-mode entry becomes GC-eligible
+ * <b>immediately</b> after insertion and may be reclaimed at the very next garbage collection - effectively
+ * behaving like {@link CacheMode#NONE} once any GC has occurred. This differs from the single-key {@link Cache},
+ * where WEAK mode keys directly on the caller's own key and therefore retains an entry for as long as that key
+ * remains strongly reachable elsewhere. If guaranteed retention is required, use {@link CacheMode#FULL} (the
+ * default mode) instead of WEAK. See {@link CacheMode#WEAK} for details.
+ *
* <h5 class='section'>See Also:</h5>
* <ul>
* <li class='jc'>{@link Cache}
@@ -117,8 +128,11 @@ public class Cache4<K1,K2,K3,K4,V> {
* Available modes:
* <ul>
* <li>{@link CacheMode#NONE NONE} - No caching (always invoke supplier)
- * <li>{@link CacheMode#WEAK WEAK} - Weak caching (uses {@link WeakHashMap})
- * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default)
+ * <li>{@link CacheMode#WEAK WEAK} - Best-effort weak-key caching (uses {@link WeakHashMap}); on this
+ * composite-key cache an entry may be reclaimed at <b>any</b> GC, not only when the caller's keys
+ * become unreachable - see {@link CacheMode#WEAK} for why
+ * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default); use this
+ * mode for guaranteed retention
* </ul>
*
* @param value The caching mode.
@@ -208,6 +222,11 @@ public class Cache4<K1,K2,K3,K4,V> {
* Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
* garbage collected when keys are no longer strongly referenced elsewhere.
*
+ * <p>
+ * <b>Note:</b> on this composite-key cache, WEAK is best-effort only - the internal composite key is
+ * never caller-visible, so an entry may be reclaimed at any GC. See {@link CacheMode#WEAK} for details,
+ * and use {@link CacheMode#FULL} instead if guaranteed retention is required.
+ *
* @return This object for method chaining.
* @see #cacheMode(CacheMode)
*/
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache5.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache5.java
index fb51339128..b4e76af9bf 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache5.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/Cache5.java
@@ -47,6 +47,17 @@ import org.apache.juneau.commons.function.*;
* <li>Can be disabled entirely via builder or system property
* </ul>
*
+ * <h5 class='section'>WEAK Mode Semantics:</h5>
+ * <p>
+ * {@link CacheMode#WEAK} on this class is <b>best-effort</b>, not retention-guaranteed. Each lookup synthesizes
+ * an internal {@code Tuple5} composite key that is never exposed to the caller, so that key is reachable only
+ * from this cache's own backing {@link WeakHashMap}. Consequently a WEAK-mode entry becomes GC-eligible
+ * <b>immediately</b> after insertion and may be reclaimed at the very next garbage collection - effectively
+ * behaving like {@link CacheMode#NONE} once any GC has occurred. This differs from the single-key {@link Cache},
+ * where WEAK mode keys directly on the caller's own key and therefore retains an entry for as long as that key
+ * remains strongly reachable elsewhere. If guaranteed retention is required, use {@link CacheMode#FULL} (the
+ * default mode) instead of WEAK. See {@link CacheMode#WEAK} for details.
+ *
* <h5 class='section'>See Also:</h5>
* <ul>
* <li class='jc'>{@link Cache}
@@ -120,8 +131,11 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
* Available modes:
* <ul>
* <li>{@link CacheMode#NONE NONE} - No caching (always invoke supplier)
- * <li>{@link CacheMode#WEAK WEAK} - Weak caching (uses {@link WeakHashMap})
- * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default)
+ * <li>{@link CacheMode#WEAK WEAK} - Best-effort weak-key caching (uses {@link WeakHashMap}); on this
+ * composite-key cache an entry may be reclaimed at <b>any</b> GC, not only when the caller's keys
+ * become unreachable - see {@link CacheMode#WEAK} for why
+ * <li>{@link CacheMode#FULL FULL} - Full caching (uses {@link ConcurrentHashMap}, default); use this
+ * mode for guaranteed retention
* </ul>
*
* @param value The caching mode.
@@ -211,6 +225,11 @@ public class Cache5<K1,K2,K3,K4,K5,V> {
* Weak caching uses {@link WeakHashMap} for storage, allowing cache entries to be
* garbage collected when keys are no longer strongly referenced elsewhere.
*
+ * <p>
+ * <b>Note:</b> on this composite-key cache, WEAK is best-effort only - the internal composite key is
+ * never caller-visible, so an entry may be reclaimed at any GC. See {@link CacheMode#WEAK} for details,
+ * and use {@link CacheMode#FULL} instead if guaranteed retention is required.
+ *
* @return This object for method chaining.
* @see #cacheMode(CacheMode)
*/
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/CacheMode.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/CacheMode.java
index 02d82c5bd8..830c4afa07 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/CacheMode.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/collections/CacheMode.java
@@ -76,6 +76,17 @@ public enum CacheMode {
* <li>Entries may be removed unpredictably by the garbage collector
* <li>Not suitable for high-concurrency scenarios
* </ul>
+ *
+ * <p>
+ * <b>Composite-key caches:</b> {@link Cache2}, {@link Cache3}, {@link Cache4}, and {@link Cache5} synthesize
+ * an internal composite-key object (e.g. a {@code TupleN}) on every lookup, and that object is never exposed
+ * to the caller - it is reachable only from the cache's own backing map. As a result, a WEAK-mode entry in
+ * one of these caches becomes GC-eligible <b>immediately</b> after insertion and may be reclaimed at the very
+ * next garbage collection, i.e. WEAK effectively behaves like {@link #NONE} once any GC has occurred. This is
+ * best-effort, <b>not retention-guaranteed</b> weak-key caching by design (not a bug) and is <i>not</i>
+ * equivalent to WEAK on the single-key {@link Cache}, where retention instead tracks the caller's own key and
+ * an entry survives for as long as that key remains strongly reachable elsewhere. If guaranteed retention is
+ * required, use {@link #FULL} (the default mode) instead of WEAK for the composite-key caches.
*/
WEAK,
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
new file mode 100644
index 0000000000..359492236d
--- /dev/null
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/EscapeForScriptSink_SecurityScan_Test.java
@@ -0,0 +1,190 @@
+/*
+ * 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.commons.utils;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.*;
+
+import org.apache.juneau.commons.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * TODO-431 Phase 3 gate: the framework's {@code <script>}-JSON sinks must route through
+ * {@link StringUtils#escapeForScript(String)}.
+ *
+ * <p>
+ * Pairs a set of unit RED/GREEN checks on {@link ScriptJsonSinkScanner} (proving it flags an unescaped sink, passes
+ * an escaped one, ignores non-{@code <script>} raw text, and is not fooled by a comment mention of the escaper) with
+ * a live scan of the real source tree that both asserts zero violations <i>and</i> asserts the scanner still finds
+ * the known-good {@code ViewTable}/{@code PageTable} sinks — so a green result can never mean "nothing was
+ * examined".
+ */
+class EscapeForScriptSink_SecurityScan_Test extends TestBase {
+
+ // -----------------------------------------------------------------------------------------------------------
+ // Unit RED/GREEN on the scanner itself
+ // -----------------------------------------------------------------------------------------------------------
+
+ /** RED: a serialized payload written into a raw-text {@code <script>} without escapeForScript is a violation. */
+ @Test void a01_unescapedScriptRawText_isFlagged() {
+ var src = """
+ class X {
+ Object of(Object bean) {
+ var json = Json.of(bean);
+ return script().type("application/json").text(rawText(json));
+ }
+ }
+ """;
+ var r = ScriptJsonSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "violations: " + r.violations());
+ assertFalse(r.sinks().get(0).escaped());
+ }
+
+ /** GREEN: the ViewTable/PageTable shape - escaped via a same-source variable assignment - passes. */
+ @Test void a02_escapedViaVariable_passes() {
+ var src = """
+ class X {
+ Object of(Object bean) {
+ var json = escapeForScript(Json.of(bean));
+ return script().type("application/json").text(rawText(json));
+ }
+ }
+ """;
+ var r = ScriptJsonSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(java.util.List.of(), r.violations());
+ assertTrue(r.sinks().get(0).escaped());
+ }
+
+ /** GREEN: inline escapeForScript(...) directly inside rawText(...) passes. */
+ @Test void a03_escapedInline_passes() {
+ var src = """
+ class X {
+ Object of(Object bean) {
+ return script().text(rawText(escapeForScript(Json.of(bean))));
+ }
+ }
+ """;
+ var r = ScriptJsonSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(java.util.List.of(), r.violations());
+ }
+
+ /** A raw-text write that is not into a {@code <script>} element is not a sink (JSON-in/JSON-out scope, not widened). */
+ @Test void a04_nonScriptRawText_isNotASink() {
+ var src = """
+ class X {
+ Object of(Object html) {
+ return div().text(rawText(html));
+ }
+ }
+ """;
+ var r = ScriptJsonSinkScanner.scan(src);
+ assertEquals(java.util.List.of(), r.sinks(), () -> "sinks: " + r.sinks());
+ assertEquals(java.util.List.of(), r.violations());
+ }
+
+ /**
+ * A {@code escapeForScript} mention that lives only in a comment must NOT satisfy the guard - this is the exact
+ * silent-decay failure mode the scanner exists to prevent, so it is asserted directly.
+ */
+ @Test void a05_commentMentionOfEscaper_doesNotSatisfyGuard() {
+ var src = """
+ class X {
+ // We used to call escapeForScript(...) here; the code below no longer does.
+ Object of(Object bean) {
+ var json = Json.of(bean); // escapeForScript would go here
+ return script().type("application/json").text(rawText(json));
+ }
+ }
+ """;
+ var r = ScriptJsonSinkScanner.scan(src);
+ assertEquals(1, r.sinks().size(), () -> "sinks: " + r.sinks());
+ assertEquals(1, r.violations().size(), () -> "comment mention must not satisfy the guard: " + r.violations());
+ }
+
+ /** {@code new RawText(...)} is recognized as the same raw-text primitive as {@code rawText(...)}. */
+ @Test void a06_newRawTextConstructor_isAlsoASink() {
+ var escaped = ScriptJsonSinkScanner.scan("""
+ class X { Object of(Object b) { return script().text(new RawText(escapeForScript(Json.of(b)))); } }
+ """);
+ assertEquals(1, escaped.sinks().size());
+ assertEquals(java.util.List.of(), escaped.violations());
+
+ var unescaped = ScriptJsonSinkScanner.scan("""
+ class X { Object of(Object b) { return script().text(new RawText(Json.of(b))); } }
+ """);
+ assertEquals(1, unescaped.sinks().size());
+ assertEquals(1, unescaped.violations().size());
+ }
+
+ // -----------------------------------------------------------------------------------------------------------
+ // Live gate against the real framework tree
+ // -----------------------------------------------------------------------------------------------------------
+
+ private static final String VIEW_TABLE = "juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewTable.java";
+ private static final String PAGE_TABLE = "juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/PageTable.java";
+
+ /** GREEN: every {@code <script>}-JSON sink in the real framework tree routes through escapeForScript. */
+ @Test void a10_realTree_hasNoViolations() throws Exception {
+ var root = requireRepoRoot();
+ var r = ScriptJsonSinkScanner.scanTree(root);
+ assertEquals(java.util.List.of(), r.violations(),
+ () -> "framework <script> JSON sinks missing escapeForScript:\n " + String.join("\n ", r.violations()));
+ }
+
+ /**
+ * Anti-vacuous: the scan must actually be finding the real, known-good sinks. If this ever drops to zero the
+ * "no violations" result above is meaningless - the scanner would have quietly stopped matching.
+ */
+ @Test void a11_realTree_findsKnownSinks_notVacuous() throws Exception {
+ var root = requireRepoRoot();
+ var r = ScriptJsonSinkScanner.scanTree(root);
+ assertTrue(r.sinks().size() >= 2, () -> "expected >=2 framework <script> sinks, found: " + r.sinks());
+ assertTrue(r.sinks().stream().anyMatch(s -> s.file().replace('\\', '/').equals(VIEW_TABLE)),
+ () -> "ViewTable sink not found; scanner may have stopped matching. sinks: " + r.sinks());
+ assertTrue(r.sinks().stream().anyMatch(s -> s.file().replace('\\', '/').equals(PAGE_TABLE)),
+ () -> "PageTable sink not found; scanner may have stopped matching. sinks: " + r.sinks());
+ }
+
+ /**
+ * Mutation check: the real {@code ViewTable} passes as-is, but removing the escaper turns it into a violation.
+ * This proves the guard is genuinely exercising that sink rather than passing it by accident.
+ */
+ @Test void a12_realViewTable_passesButFailsWhenEscaperRemoved() throws Exception {
+ var root = requireRepoRoot();
+ var source = Files.readString(root.resolve(VIEW_TABLE));
+
+ var clean = ScriptJsonSinkScanner.scan(VIEW_TABLE, source);
+ assertEquals(1, clean.sinks().size(), () -> "sinks: " + clean.sinks());
+ assertEquals(java.util.List.of(), clean.violations());
+
+ var mutated = ScriptJsonSinkScanner.scan(VIEW_TABLE, source.replace("escapeForScript", "noEscape"));
+ assertEquals(1, mutated.sinks().size(), () -> "sinks: " + mutated.sinks());
+ assertEquals(1, mutated.violations().size(),
+ () -> "removing escapeForScript from ViewTable should be flagged: " + mutated.violations());
+ }
+
+ private static Path requireRepoRoot() {
+ var root = ScriptJsonSinkScanner.locateRepoRoot();
+ assertNotNull(root, "could not locate the Juneau repo root from the working directory - the security gate "
+ + "must fail loudly rather than pass vacuously when it cannot find the sources it guards");
+ return root;
+ }
+}
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ScriptJsonSinkScanner.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ScriptJsonSinkScanner.java
new file mode 100644
index 0000000000..27e66e150e
--- /dev/null
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/ScriptJsonSinkScanner.java
@@ -0,0 +1,271 @@
+/*
+ * 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.commons.utils;
+
+import java.io.*;
+import java.nio.file.*;
+import java.util.*;
+import java.util.regex.*;
+
+/**
+ * Test-only, <b>private</b> source scanner enforcing the {@code <script>}-JSON adoption of
+ * {@link StringUtils#escapeForScript(String)} across the Juneau framework's own code (TODO-431 Phase 3).
+ *
+ * <p>
+ * Not shipped and deliberately not public API: it guards the framework's own raw-text {@code <script>} sinks and is
+ * <b>not</b> a tool consumers are expected to run. It is the durable half of the {@code escapeForScript} adoption
+ * sweep — a one-time migration decays, so this fails the build if a framework source ever writes a serialized
+ * JSON payload into a raw-text {@code <script>} element without routing it through {@code escapeForScript} first.
+ *
+ * <h5 class='section'>What counts as a sink</h5>
+ * <p>
+ * The framework's single mechanism for inserting <i>verbatim</i> (non-entity-encoded) content into HTML output is the
+ * {@code juneau-bean-html5} raw-text primitive — {@link org.apache.juneau.bean.html5.HtmlBuilder#rawText(String)}
+ * and {@code new RawText(...)}. A {@code <script type="application/json">} sidecar's body is exactly such content.
+ * A <b>sink</b> is therefore a statement that builds a {@code script(...)} element <i>and</i> inserts a
+ * {@code rawText(...)} / {@code new RawText(...)} payload — which is precisely the shape
+ * {@code ViewTable}/{@code PageTable} use. For each sink the payload argument must resolve to an
+ * {@code escapeForScript(...)} result (directly, or via a same-file {@code var = escapeForScript(...)} assignment).
+ *
+ * <h5 class='section'>Why it must also assert it still finds the known-good sites</h5>
+ * <p>
+ * A scanner that silently stops matching reads as a passing test, which is worse than no scanner. Comments are
+ * therefore stripped before analysis (so a {@code escapeForScript} mention in a javadoc cannot vacuously satisfy the
+ * guard), and the accompanying test asserts the scan still finds the real {@code ViewTable}/{@code PageTable} sinks
+ * and that removing the escaper from one of them turns it into a violation — so "zero violations" can never mean
+ * "zero files examined".
+ *
+ * <h5 class='section'>Contract boundary</h5>
+ * <p>
+ * This enforces the JSON-in/JSON-out contract only; it does not widen {@code escapeForScript} to arbitrary raw-text
+ * payloads and does not attempt to detect hand-built {@code <script>} JSON assembled by raw string concatenation
+ * (the framework does not do that — it goes through the html5 builder). It scans the framework's Java sources on
+ * disk; it does not and cannot see consumer applications.
+ */
+final class ScriptJsonSinkScanner {
+
+ private ScriptJsonSinkScanner() {}
+
+ /** One raw-text {@code <script>} payload site found in a source. */
+ record Sink(String file, boolean escaped, String detail) {}
+
+ /** The outcome of a scan: every sink found, and the subset that are violations. */
+ record Result(List<Sink> sinks, List<String> violations) {}
+
+ /** Matches a {@code rawText(} or {@code new RawText(} call (the html5 raw-text primitive). */
+ private static final Pattern RAWTEXT_CALL = Pattern.compile("(?:new\\s+RawText|\\brawText)\\s*\\(");
+
+ /** An argument that is (directly, possibly fully-qualified) an {@code escapeForScript(...)} call. */
+ private static final Pattern DIRECT_ESCAPE = Pattern.compile("(?s)^[A-Za-z0-9_.]*escapeForScript\\(.*");
+
+ /** A bare Java identifier (the variable-indirection case). */
+ private static final Pattern IDENT = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
+
+ /**
+ * Scans a single Java source string.
+ *
+ * @param file A label for the source (used in violation messages).
+ * @param source The Java source text.
+ * @return The sinks found and any violations.
+ */
+ static Result scan(String file, String source) {
+ var sinks = new ArrayList<Sink>();
+ var violations = new ArrayList<String>();
+ var code = stripCommentsAndStrings(source);
+
+ var m = RAWTEXT_CALL.matcher(code);
+ while (m.find()) {
+ var openParen = m.end() - 1; // the '(' of the rawText/RawText call
+ var stmtStart = statementStart(code, m.start());
+ var stmt = code.substring(stmtStart, m.start());
+ if (! stmt.contains("script("))
+ continue; // a raw-text write that is not into a <script> element - out of scope (JSON-in/JSON-out only)
+
+ var arg = balancedArg(code, openParen).trim();
+ var escaped = isEscaped(arg, code);
+ sinks.add(new Sink(file, escaped, "rawText(" + arg + ")"));
+ if (! escaped)
+ violations.add(file + ": <script> raw-text payload not routed through escapeForScript: rawText(" + arg + ")");
+ }
+ return new Result(sinks, violations);
+ }
+
+ /** Convenience overload for ad-hoc source strings. */
+ static Result scan(String source) {
+ return scan("(source)", source);
+ }
+
+ /**
+ * Walks a repository tree and scans every {@code src/main/java} Java source under it.
+ *
+ * @param repoRoot The repository root.
+ * @return The aggregated sinks and violations across the tree.
+ * @throws IOException If the tree cannot be walked.
+ */
+ static Result scanTree(Path repoRoot) throws IOException {
+ var sinks = new ArrayList<Sink>();
+ var violations = new ArrayList<String>();
+ var mainJava = File.separator + "src" + File.separator + "main" + File.separator + "java" + File.separator;
+ var target = File.separator + "target" + File.separator;
+ try (var stream = Files.walk(repoRoot)) {
+ var files = stream
+ .filter(p -> p.toString().endsWith(".java"))
+ .filter(p -> p.toString().contains(mainJava))
+ .filter(p -> ! p.toString().contains(target))
+ .sorted()
+ .toList();
+ for (var f : files) {
+ var r = scan(repoRoot.relativize(f).toString(), Files.readString(f));
+ sinks.addAll(r.sinks());
+ violations.addAll(r.violations());
+ }
+ }
+ return new Result(sinks, violations);
+ }
+
+ /**
+ * Locates the Juneau repository root by walking up from the working directory until a directory holding both
+ * {@code juneau-core} and {@code juneau-rest} is found.
+ *
+ * @return The repository root, or {@code null} if not found.
+ */
+ static Path locateRepoRoot() {
+ for (var d = Path.of("").toAbsolutePath(); d != null; d = d.getParent())
+ if (Files.isDirectory(d.resolve("juneau-core")) && Files.isDirectory(d.resolve("juneau-rest")))
+ return d;
+ return null;
+ }
+
+ /** True if {@code arg} is an escapeForScript result, directly or via a same-source {@code arg = escapeForScript(...)} assignment. */
+ private static boolean isEscaped(String arg, String code) {
+ if (DIRECT_ESCAPE.matcher(arg).matches())
+ return true;
+ if (IDENT.matcher(arg).matches()) {
+ var p = Pattern.compile("\\b" + Pattern.quote(arg) + "\\s*=\\s*[A-Za-z0-9_.]*escapeForScript\\(");
+ return p.matcher(code).find();
+ }
+ return false;
+ }
+
+ /** Index just past the previous statement/block boundary ({@code ;}, <code>{</code>, or <code>}</code>) before {@code from}. */
+ private static int statementStart(String s, int from) {
+ for (var i = from - 1; i >= 0; i--) {
+ var c = s.charAt(i);
+ if (c == ';' || c == '{' || c == '}')
+ return i + 1;
+ }
+ return 0;
+ }
+
+ /** Returns the (comma-and-nesting-preserving) argument text between the balanced parens starting at {@code openParen}. */
+ private static String balancedArg(String s, int openParen) {
+ var depth = 0;
+ var sb = new StringBuilder();
+ for (var i = openParen; i < s.length(); i++) {
+ var c = s.charAt(i);
+ if (c == '(') {
+ depth++;
+ if (depth == 1)
+ continue;
+ } else if (c == ')') {
+ depth--;
+ if (depth == 0)
+ break;
+ }
+ sb.append(c);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Blanks out comment and string/char-literal <i>content</i> (keeping quote delimiters and newlines) so pattern
+ * matching sees only the code skeleton. Critical for correctness: a {@code escapeForScript} or {@code <script}
+ * mention inside a javadoc or comment must not be mistaken for the code doing the right (or wrong) thing.
+ */
+ private static String stripCommentsAndStrings(String s) {
+ var out = new StringBuilder(s.length());
+ var n = s.length();
+ var i = 0;
+ while (i < n) {
+ var c = s.charAt(i);
+ if (c == '/' && i + 1 < n && s.charAt(i + 1) == '/') {
+ while (i < n && s.charAt(i) != '\n')
+ i++;
+ continue;
+ }
+ if (c == '/' && i + 1 < n && s.charAt(i + 1) == '*') {
+ i += 2;
+ while (i + 1 < n && ! (s.charAt(i) == '*' && s.charAt(i + 1) == '/')) {
+ if (s.charAt(i) == '\n')
+ out.append('\n');
+ i++;
+ }
+ i = Math.min(n, i + 2);
+ continue;
+ }
+ if (c == '"' && i + 2 < n && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"') {
+ out.append("\"\"\"");
+ i += 3;
+ while (i + 2 < n && ! (s.charAt(i) == '"' && s.charAt(i + 1) == '"' && s.charAt(i + 2) == '"')) {
+ if (s.charAt(i) == '\n')
+ out.append('\n');
+ i++;
+ }
+ if (i + 2 < n) {
+ out.append("\"\"\"");
+ i += 3;
+ } else {
+ i = n;
+ }
+ continue;
+ }
+ if (c == '"') {
+ out.append('"');
+ i++;
+ while (i < n && s.charAt(i) != '"') {
+ if (s.charAt(i) == '\\' && i + 1 < n) {
+ i += 2;
+ continue;
+ }
+ if (s.charAt(i) == '\n')
+ out.append('\n');
+ i++;
+ }
+ out.append('"');
+ i++;
+ continue;
+ }
+ if (c == '\'') {
+ out.append('\'');
+ i++;
+ while (i < n && s.charAt(i) != '\'') {
+ if (s.charAt(i) == '\\' && i + 1 < n) {
+ i += 2;
+ continue;
+ }
+ i++;
+ }
+ out.append('\'');
+ i++;
+ continue;
+ }
+ out.append(c);
+ i++;
+ }
+ return out.toString();
+ }
+}
diff --git a/juneau-examples/juneau-examples-views/pom.xml b/juneau-examples/juneau-examples-views/pom.xml
new file mode 100644
index 0000000000..31a9bc35ad
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/pom.xml
@@ -0,0 +1,101 @@
+<?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>
+ <artifactId>juneau-examples</artifactId>
+ <groupId>org.apache.juneau</groupId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-examples-views</artifactId>
+ <name>Apache Juneau Rich-Views Examples</name>
+ <description>Runnable first-party example of the juneau-rest-server-views rich-DataTables toolkit, dogfooding a sub-tabbed PageDef (TODO-430).</description>
+ <packaging>jar</packaging>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <exec.mainClass>org.apache.juneau.examples.views.ExampleViewsServer</exec.mainClass>
+ </properties>
+
+ <dependencies>
+
+ <!-- The rich-view toolkit itself: ViewDef/PageDef/Tab/Subtab/PageTable/ViewsMixin. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-views</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. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-datatables</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Embedded Jetty launcher (Microservice + JettyConfiguration). Brings jetty-server / jetty-ee11-servlet;
+ mirrors juneau-examples-mcp's ExampleServer wiring exactly. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-microservice-jetty</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Test: JUnit Jupiter, per repo test conventions. -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test: TestBase, per repo test conventions. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!--
+ Lets `mvn ... exec:java` run ExampleViewsServer directly. Per .mvn/maven.config's global
+ also-make flag, invoke with `-f juneau-examples/juneau-examples-views/pom.xml` rather than
+ `-pl ...` - `-pl` still resolves the goal against the reactor's root project (which has no
+ exec.mainClass), not this module. See juneau-examples-mcp/pom.xml for the same convention.
+ -->
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <version>3.3.0</version>
+ <configuration>
+ <mainClass>${exec.mainClass}</mainClass>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/AuditEntry.java
similarity index 50%
copy from juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
copy to juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/AuditEntry.java
index aa40ddf9a6..f5515adb0d 100644
--- a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/AuditEntry.java
@@ -14,16 +14,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+package org.apache.juneau.examples.views;
/**
- * An opt-in {@link org.apache.juneau.commons.secret.SecretStore} backed by the macOS <c>security</c> keychain CLI.
- *
- * <p>
- * This is deliberately a separate module rather than part of <c>juneau-commons</c>: it shells out to an external OS
- * process, which the commons zero-runtime-deps / no-OS-integration philosophy keeps out of core. Add this module to
- * the classpath (or wire {@link org.apache.juneau.secret.keychain.KeychainSecretStore} into a
- * {@code BeanStore}) to back secret storage with the user's login keychain on macOS.
+ * A row bean for the sibling plain "Audit Log" tab — deliberately a different row type than {@link Widget},
+ * so the example page composes two distinct {@code ViewDef} row shapes instead of reusing one type everywhere.
*
* @since 10.0.0
*/
-package org.apache.juneau.secret.keychain;
+public class AuditEntry {
+
+ /** An ISO-8601 timestamp; rendered via the {@code date} renderer. */
+ public String timestamp;
+
+ /** Who performed the action. */
+ public String actor;
+
+ /** What happened. */
+ public String action;
+
+ /**
+ * Creates a fully-populated row.
+ *
+ * @param timestamp An ISO-8601 timestamp.
+ * @param actor Who performed the action.
+ * @param action What happened.
+ */
+ public AuditEntry(String timestamp, String actor, String action) {
+ this.timestamp = timestamp;
+ this.actor = actor;
+ this.action = action;
+ }
+}
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
new file mode 100644
index 0000000000..1eecc71550
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsRest.java
@@ -0,0 +1,306 @@
+/*
+ * 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;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.juneau.commons.utils.CollectionUtils.list;
+
+import java.util.*;
+
+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.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.*;
+
+/**
+ * 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}.
+ *
+ * <p>
+ * This is a real caller of {@link PageDef}'s {@link Tab#subtabs(Subtab...) subtabs} outside the views module's own
+ * test sources — see the class-level scope notes below for what it deliberately exercises.
+ *
+ * <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.
+ * <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>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.
+ * </ul>
+ *
+ * <p>
+ * Every panel is a {@link ViewDef} table; non-table prose or form panels are outside this example's scope.
+ *
+ * @since 10.0.0
+ */
+@Rest(mixins=ViewsMixin.class)
+public class ExampleViewsRest extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ /** The stable page id — also the first hash segment of a deep link, e.g. {@code #widgets-demo/catalog/archived}. */
+ public static final String PAGE_ID = "widgets-demo";
+
+ private static final String COL_UPDATED_AT = "updatedAt";
+ private static final String COL_STATUS = "status";
+ private static final String COL_OWNER = "owner";
+ private static final String STATUS_ERROR = "error";
+ private static final String VALUE_ARCHIVED = "archived";
+ private static final String TITLE_OWNER = "Owner";
+
+ private static final List<Widget> ACTIVE_WIDGETS = buildActiveWidgets();
+ private static final List<Widget> ARCHIVED_WIDGETS = buildArchivedWidgets();
+ private static final List<AuditEntry> AUDIT_LOG = buildAuditLog();
+
+ //------------------------------------------------------------------------------------------------------------------
+ // The composed page: Catalog (sub-tabbed: Active/Archived) + a sibling plain Audit Log tab.
+ //------------------------------------------------------------------------------------------------------------------
+
+ static PageDef page() {
+ return PageDef.create(PAGE_ID)
+ .title("Juneau Views Example")
+ .tabs(
+ 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()))
+ .build();
+ }
+
+ /** The sub-tabbed tab's first (default) panel — dogfoods poll/details/ribbon/rowClassRule together. */
+ static ViewDef activeView() {
+ return ViewDef.create("widgets-active")
+ .rowType(Widget.class)
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data/widgets/active")
+ .defaultOrder("name", Dir.ASC)
+ .columns(
+ Column.of("name").title("Name"),
+ Column.of(COL_STATUS).title("Status").render("tag:status"),
+ Column.of(COL_OWNER).title(TITLE_OWNER),
+ Column.of(COL_UPDATED_AT).title("Updated").render("date"))
+ .ribbon(
+ RibbonAction.columnSearchToggle(),
+ RibbonAction.refresh())
+ .rowClassRule(COL_STATUS, Op.EQ, STATUS_ERROR, "row-flagged")
+ // 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"))
+ .build();
+ }
+
+ /** The sub-tabbed tab's second panel — deliberately plain (the "at least one sub-tab has no bells on it" case). */
+ static ViewDef archivedView() {
+ return ViewDef.create("widgets-archived")
+ .rowType(Widget.class)
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data/widgets/archived")
+ .defaultOrder(COL_UPDATED_AT, Dir.DESC)
+ .columns(
+ Column.of("name").title("Name"),
+ Column.of(COL_STATUS).title("Status").render("tag:status"),
+ Column.of(COL_OWNER).title(TITLE_OWNER),
+ Column.of(COL_UPDATED_AT).title("Archived").render("date"))
+ .build();
+ }
+
+ /** The sibling PLAIN leaf tab (no sub-tabs) - the contrast case the blank-panel regression needs. */
+ static ViewDef auditView() {
+ return ViewDef.create("audit-log")
+ .rowType(AuditEntry.class)
+ .dataMode(DataMode.CLIENT)
+ .dataUrl("/data/audit")
+ .defaultOrder("timestamp", Dir.DESC)
+ .columns(
+ Column.of("timestamp").title("When").render("date"),
+ Column.of("actor").title("Actor"),
+ Column.of("action").title("Action"))
+ .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
+ * (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")
+ public HttpResource index(RestRequest req) {
+ var pageMarkup = Html.of(PageTable.of(page()));
+ var deepLink = "#" + PAGE_ID + "/catalog/archived";
+ var html = """
+ <!DOCTYPE html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <title>Apache Juneau - Rich Views Example</title>
+ <link rel="stylesheet" href="%s">
+ <link rel="stylesheet" href="%s">
+ <style>
+ \tbody { font-family: -apple-system, Helvetica, Arial, sans-serif; margin: 2em; }
+ \t.jc-tab-bar, .jc-subtab-bar { margin-bottom: 1em; }
+ </style>
+ </head>
+ <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
+ 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>
+ %s
+ <script src="%s"></script>
+ <script src="%s"></script>
+ <script src="%s"></script>
+ <script src="%s"></script>
+ <script src="%s"></script>
+ <script src="%s"></script>
+ <script src="%s"></script>
+ </body>
+ </html>
+ """.formatted(
+ DataTablesMixin.DATATABLES_CSS_CDN_URL,
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.VIEWS_CSS_PATH),
+ deepLink,
+ pageMarkup,
+ DataTablesMixin.JQUERY_CDN_URL,
+ DataTablesMixin.DATATABLES_JS_CDN_URL,
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.RENDERS_JS_PATH),
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.ICONS_JS_PATH),
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.RIBBON_JS_PATH),
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.VIEWS_JS_PATH),
+ // Must load AFTER juneau-views.js - it calls the public NS.init.initTable to lazy-init a
+ // sub-tab's DataTable on first activation.
+ ViewsMixin.viewAssetUrl(req, ViewsMixin.PAGES_JS_PATH));
+ return HttpResourceBean.of(
+ ByteArrayBody.of(html.getBytes(UTF_8), "text/html;charset=utf-8"),
+ list(ContentType.of("text/html;charset=utf-8")));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // CLIENT-mode data endpoints - each returns its full row list; DataTables paginates in-browser.
+ //------------------------------------------------------------------------------------------------------------------
+
+ /**
+ * [GET /data/widgets/active] — the Active sub-tab's rows.
+ *
+ * @return The active widgets.
+ */
+ @RestGet(path="/data/widgets/active", swagger=@OpSwagger(ignore=true))
+ public List<Widget> activeData() {
+ return ACTIVE_WIDGETS;
+ }
+
+ /**
+ * [GET /data/widgets/archived] — the Archived sub-tab's rows.
+ *
+ * @return The archived widgets.
+ */
+ @RestGet(path="/data/widgets/archived", swagger=@OpSwagger(ignore=true))
+ public List<Widget> archivedData() {
+ return ARCHIVED_WIDGETS;
+ }
+
+ /**
+ * [GET /data/audit] — the Audit Log tab's rows.
+ *
+ * @return The audit log entries.
+ */
+ @RestGet(path="/data/audit", swagger=@OpSwagger(ignore=true))
+ public List<AuditEntry> auditData() {
+ return AUDIT_LOG;
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Row generation - enough rows per panel that a column-sizing regression would be visibly wrong.
+ //------------------------------------------------------------------------------------------------------------------
+
+ private static List<Widget> buildActiveWidgets() {
+ var out = new ArrayList<Widget>();
+ var owners = List.of("Platform", "Storefront", "Billing", "Growth");
+ for (var i = 1; i <= 30; i++) {
+ // Every 7th widget is "error" (rowClassRule target); the rest alternate active/active/active for a
+ // mostly-healthy-looking table with a few flagged rows scattered through it.
+ var status = i % 7 == 0 ? STATUS_ERROR : "active";
+ out.add(new Widget(
+ "widget-" + i,
+ status,
+ owners.get(i % owners.size()),
+ "2026-08-%02dT09:%02d:00Z".formatted((i % 18) + 1, i % 60),
+ status.equals(STATUS_ERROR)
+ ? "Failed health check on 2026-08-%02d; see incident log.".formatted((i % 18) + 1)
+ : "No open issues."));
+ }
+ return List.copyOf(out);
+ }
+
+ private static List<Widget> buildArchivedWidgets() {
+ var out = new ArrayList<Widget>();
+ var owners = List.of("Platform", "Storefront", "Billing");
+ for (var i = 1; i <= 20; i++) {
+ out.add(new Widget(
+ "widget-legacy-" + i,
+ VALUE_ARCHIVED,
+ owners.get(i % owners.size()),
+ "2025-%02d-01T00:00:00Z".formatted((i % 12) + 1),
+ "Archived; superseded by widget-" + (i + 30) + "."));
+ }
+ return List.copyOf(out);
+ }
+
+ private static List<AuditEntry> buildAuditLog() {
+ var out = new ArrayList<AuditEntry>();
+ var actors = List.of("alice", "bob", "carol", "dave");
+ var actions = List.of("created", "updated", VALUE_ARCHIVED, "restored", "deleted");
+ for (var i = 1; i <= 40; i++) {
+ out.add(new AuditEntry(
+ "2026-08-%02dT%02d:00:00Z".formatted((i % 18) + 1, i % 24),
+ actors.get(i % actors.size()),
+ actions.get(i % actions.size()) + " widget-" + ((i % 30) + 1)));
+ }
+ return List.copyOf(out);
+ }
+}
diff --git a/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsServer.java b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsServer.java
new file mode 100644
index 0000000000..64cf51586e
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/ExampleViewsServer.java
@@ -0,0 +1,139 @@
+/*
+ * 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;
+
+import java.net.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.microservice.jetty.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Boots {@link ExampleViewsRest} on an embedded Jetty server.
+ *
+ * <p>
+ * Run {@link #main(String[]) main} to start the server on a fixed port (default {@code 5001}, or pass a
+ * port as the first argument) and leave it running so you can open the page in a browser. The wiring
+ * mirrors {@code juneau-examples-mcp}'s {@code ExampleServer}: a Jetty {@link Server} plus the
+ * {@link ExampleViewsRest} servlet are placed in a {@link BasicBeanStore}, and {@link JettyConfiguration}
+ * (added via {@link org.apache.juneau.microservice.Microservice.Builder#configurations(Class...)})
+ * auto-mounts the {@link org.apache.juneau.rest.server.Rest @Rest} servlet at {@code "/"} and drives the
+ * server lifecycle.
+ */
+@SuppressWarnings({
+ "java:S106" // Example walkthrough intentionally prints to stdout; console output is the demo's deliverable.
+})
+public final class ExampleViewsServer implements AutoCloseable {
+
+ /** Default listen port used by {@link #main(String[])} when none is supplied. */
+ public static final int DEFAULT_PORT = 5001;
+
+ private final Microservice microservice;
+ private final URI rootUrl;
+
+ private ExampleViewsServer(Microservice microservice, URI rootUrl) {
+ this.microservice = microservice;
+ this.rootUrl = rootUrl;
+ }
+
+ /**
+ * Starts the example views server.
+ *
+ * @param port The TCP port to listen on, or {@code 0} to let the OS assign an ephemeral port.
+ * @return A running server handle. Close it (or call {@link #close()}) to stop.
+ * @throws Exception If the server fails to start.
+ */
+ @SuppressWarnings("resource") // The bean store is handed to (and closed by) the Microservice lifecycle.
+ public static ExampleViewsServer start(int port) throws Exception {
+ var jetty = buildServer(port);
+
+ var beanStore = new BasicBeanStore();
+ beanStore.addBean(Server.class, jetty);
+ beanStore.addBean(Servlet.class, new ExampleViewsRest());
+
+ var microservice = Microservice.create()
+ .beanStore(beanStore)
+ .configurations(JettyConfiguration.class)
+ // This example has no interactive commands to offer; skip the Java console entirely so
+ // startup doesn't print "Could not create console command" for whatever default console
+ // commands a discovered config might otherwise register.
+ .consoleEnabled(false)
+ .build();
+ microservice.start();
+
+ return new ExampleViewsServer(microservice, URI.create("http://localhost:" + boundPort(jetty) + "/"));
+ }
+
+ /**
+ * Returns the root URL the server is listening on (e.g. {@code http://localhost:5001/}).
+ *
+ * @return The root URL. Never <jk>null</jk>.
+ */
+ public URI getRootUrl() {
+ return rootUrl;
+ }
+
+ @Override
+ public void close() throws Exception {
+ microservice.stop();
+ }
+
+ /**
+ * Runs the server until the process is killed.
+ *
+ * @param args Optional single argument: the port to listen on (defaults to {@link #DEFAULT_PORT}).
+ * @throws Exception If the server fails to start.
+ */
+ @SuppressWarnings("resource") // example server runs for the JVM lifetime; closed on process exit.
+ public static void main(String[] args) throws Exception {
+ var port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT;
+ var server = start(port);
+ System.out.println("Juneau views example server is listening at " + server.getRootUrl());
+ System.out.println("Open it in a browser to see the Catalog (Active/Archived) and Audit Log tabs.");
+ System.out.println("Press Ctrl-C to stop.");
+ // Microservice.join() defaults to a documented no-op, so it won't actually block here; join the
+ // main thread directly to keep the process (and the Jetty daemon threads it started) alive.
+ Thread.currentThread().join();
+ }
+
+ /** Builds a Jetty server with a single connector on {@code port} and a root servlet context. */
+ @SuppressWarnings("resource") // connector is added to and owned by the server; closed when the server stops.
+ private static Server buildServer(int port) {
+ var server = new Server();
+ var connector = new ServerConnector(server);
+ connector.setPort(port);
+ server.addConnector(connector);
+ var handler = new ServletContextHandler();
+ handler.setContextPath("/");
+ // JettyServerComponent discovers the handler via this attribute (same convention as jetty.xml).
+ server.setAttribute("ServletContextHandler", handler);
+ server.setHandler(handler);
+ return server;
+ }
+
+ /** Reads the actual bound port (meaningful even when the server was started on port 0). */
+ private static int boundPort(Server server) {
+ for (var connector : server.getConnectors())
+ if (connector instanceof ServerConnector sc)
+ return sc.getLocalPort();
+ throw new IllegalStateException("Could not determine the bound port of the Jetty server.");
+ }
+}
diff --git a/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Widget.java b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Widget.java
new file mode 100644
index 0000000000..66c35c9ae1
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/src/main/java/org/apache/juneau/examples/views/Widget.java
@@ -0,0 +1,61 @@
+/*
+ * 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 "Active"/"Archived" sub-tab views.
+ *
+ * <p>
+ * {@code notes} is deliberately NOT one of the table's visible {@code Column}s — it exists only to give the
+ * row-details expander something worth showing that a visible column does not already contain.
+ *
+ * @since 10.0.0
+ */
+public class Widget {
+
+ /** The widget's stable name (also the row's natural sort key). */
+ public String name;
+
+ /** One of {@code "active"}, {@code "error"}, or {@code "archived"} — rendered via the {@code tag} renderer. */
+ public String status;
+
+ /** The team/person responsible for this widget. */
+ public String owner;
+
+ /** An ISO-8601 timestamp; rendered via the {@code date} renderer. */
+ public String updatedAt;
+
+ /** Free-text detail only shown in the row-details expander, never in a table column. */
+ public String notes;
+
+ /**
+ * Creates a fully-populated row.
+ *
+ * @param name The widget name.
+ * @param status The status token.
+ * @param owner The owning team/person.
+ * @param updatedAt An ISO-8601 timestamp.
+ * @param notes Free-text detail (expander-only).
+ */
+ public Widget(String name, String status, String owner, String updatedAt, String notes) {
+ this.name = name;
+ this.status = status;
+ this.owner = owner;
+ this.updatedAt = updatedAt;
+ this.notes = notes;
+ }
+}
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
new file mode 100644
index 0000000000..62b8aadb79
--- /dev/null
+++ b/juneau-examples/juneau-examples-views/src/test/java/org/apache/juneau/examples/views/ExampleViewsEndToEnd_Test.java
@@ -0,0 +1,136 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.http.*;
+import java.net.http.HttpResponse.*;
+
+import org.apache.juneau.TestBase;
+import org.junit.jupiter.api.*;
+
+/**
+ * Proves the example actually works: boots {@link ExampleViewsServer} in-process on an ephemeral port and
+ * drives real HTTP requests at it — the composed page, each of the three data endpoints, and the
+ * first-party JS/CSS assets it links to — asserting that the sub-tabbed {@code PageDef}, request-resolved
+ * asset URLs, and data for every {@code ViewDef} are actually reachable end-to-end, not just compilable.
+ */
+@SuppressWarnings({
+ "resource" // server is opened in @BeforeAll/closed in @AfterAll.
+})
+class ExampleViewsEndToEnd_Test extends TestBase {
+
+ private static ExampleViewsServer server;
+ private static HttpClient http;
+
+ @BeforeAll
+ static void startServer() throws Exception {
+ server = ExampleViewsServer.start(0);
+ http = HttpClient.newHttpClient();
+ }
+
+ @AfterAll
+ static void stopServer() throws Exception {
+ if (server != null)
+ server.close();
+ }
+
+ private HttpResponse<String> get(String path) throws Exception {
+ var uri = server.getRootUrl().resolve(path);
+ var req = HttpRequest.newBuilder(uri).GET().build();
+ return http.send(req, BodyHandlers.ofString());
+ }
+
+ /** Like {@link #get(String)}, but with the {@code Accept} header DataTables' own jQuery ajax call sends. */
+ private HttpResponse<String> getJson(String path) throws Exception {
+ var uri = server.getRootUrl().resolve(path);
+ var req = HttpRequest.newBuilder(uri).header("Accept", "application/json").GET().build();
+ return http.send(req, BodyHandlers.ofString());
+ }
+
+ @Test
+ void a01_indexPage_rendersComposedPage_withBothTabsAndBothSubtabs() throws Exception {
+ var res = get("/");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ 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-subtab-id=\"active\""), "Active sub-tab");
+ assertTrue(body.contains("data-subtab-id=\"archived\""), "Archived sub-tab");
+ }
+
+ @Test
+ void a02_indexPage_assetLinks_areResolvedFetchableUrls_notServletPrefixed() throws Exception {
+ // viewAssetUrl(RestRequest, ...) must resolve "servlet:"-prefixed paths against the request's
+ // own URI resolver into a real, browser-fetchable URL - a literal, unfetchable "servlet:" prefix leaking
+ // into the page would be a defect. (The resolver returns an absolute-*path* URL here
+ // since this example mounts at the server root; c01/c02 below prove the resolved link is actually fetchable.)
+ var body = get("/").body();
+ assertFalse(body.contains("servlet:"), "no unresolved servlet: URIs");
+ assertTrue(body.contains("href=\"/juneau-views.css?v="), "resolved views.css URL");
+ assertTrue(body.contains("src=\"/juneau-views.js?v="), "resolved views.js URL");
+ }
+
+ @Test
+ void a03_indexPage_containsDeepLinkToArchivedSubtab() throws Exception {
+ var body = get("/").body();
+ assertTrue(body.contains("href=\"#widgets-demo/catalog/archived\""), "deep link present");
+ }
+
+ @Test
+ void b01_activeWidgetsData_returnsNonEmptyRows_shapedForTheViewDefsColumns() throws Exception {
+ // Accept: application/json mirrors what DataTables' own jQuery ajax call sends for a CLIENT-mode source.
+ var res = getJson("/data/widgets/active");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ assertTrue(body.contains("\"name\":\"widget-1\""));
+ assertTrue(body.contains("\"status\""));
+ assertTrue(body.contains("\"owner\""));
+ assertTrue(body.contains("\"notes\""), "notes present in data even though it's not a table column");
+ }
+
+ @Test
+ void b02_archivedWidgetsData_returnsNonEmptyRows() throws Exception {
+ var res = getJson("/data/widgets/archived");
+ assertEquals(200, res.statusCode());
+ assertTrue(res.body().contains("\"widget-legacy-1\""));
+ }
+
+ @Test
+ void b03_auditLogData_returnsNonEmptyRows_ofADifferentRowType() throws Exception {
+ var res = getJson("/data/audit");
+ assertEquals(200, res.statusCode());
+ var body = res.body();
+ assertTrue(body.contains("\"actor\""));
+ assertTrue(body.contains("\"action\""));
+ }
+
+ @Test
+ void c01_viewsJsAsset_isReachable_throughTheResolvedUrl() throws Exception {
+ var res = get("/juneau-views.js");
+ assertEquals(200, res.statusCode());
+ assertTrue(res.body().contains("initDetailsExpander"), "ships the details expander this example exercises");
+ }
+
+ @Test
+ void c02_viewsCssAsset_isReachable() throws Exception {
+ var res = get("/juneau-views.css");
+ assertEquals(200, res.statusCode());
+ }
+}
diff --git a/juneau-examples/pom.xml b/juneau-examples/pom.xml
index 9d92f4fb01..f6e0c39dc4 100644
--- a/juneau-examples/pom.xml
+++ b/juneau-examples/pom.xml
@@ -33,6 +33,7 @@
<modules>
<module>juneau-examples-core</module>
<module>juneau-examples-mcp</module>
+ <module>juneau-examples-views</module>
</modules>
<build>
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
index d9aa5c6ce0..49dab351cd 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker-datatables/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/datatables/ConsoleDataTablesFreemarkerMixin_Test.java
@@ -104,7 +104,7 @@ class ConsoleDataTablesFreemarkerMixin_Test extends TestBase {
// String.matches() with unbounded quantifiers at both ends.
assertTrue(Pattern.compile("<table(?=[^>]*class=['\"]jc-table['\"])(?=[^>]*data-juneau-datatable)[^>]*>").matcher(body).find(),
() -> "expected <table class='jc-table' ...data-juneau-datatable...> (attribute order not asserted), body:\n" + body);
- assertTrue(Pattern.compile("<td[^>]*>\\s*<span[^>]*class=['\"]tag status released['\"][^>]*>.*?</td>", Pattern.DOTALL).matcher(body).find(),
+ assertTrue(Pattern.compile("<td[^>]*>\\s*<span(?=[^>]*class=['\"]tag status released['\"])[^>]*>.*?</td>", Pattern.DOTALL).matcher(body).find(),
() -> "expected <span class='tag status released'> nested inside a <td>, body:\n" + body);
assertTrue(body.contains("widget"), () -> "expected the plain property's raw value too, body:\n" + body);
assertFalse(body.contains("<span"), () -> "macro output was HTML-escaped (double-escaped), body:\n" + body);
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin_Test.java
index 119dfdafba..ca7f3e49ca 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ConsoleFreemarkerMixin_Test.java
@@ -171,7 +171,7 @@ class ConsoleFreemarkerMixin_Test extends TestBase {
assertFalse(body.contains("<span"), () -> "macro output was HTML-escaped (double-escaped), body:\n" + body);
// find() on an anchor-free pattern (no wrapping .*) avoids the super-linear backtracking risk of
// String.matches() with unbounded quantifiers at both ends.
- assertTrue(Pattern.compile("<span[^>]*class=['\"]tag status released['\"][^>]*>").matcher(body).find(),
+ assertTrue(Pattern.compile("<span(?=[^>]*class=['\"]tag status released['\"])[^>]*>").matcher(body).find(),
() -> "expected literal <span class='tag status released'> markup, body:\n" + body);
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index 70ac79517c..2ae17522f3 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -121,6 +121,44 @@ public class ConsoleChromeMixin {
/** The image file extensions a configured logo/page-background asset is allowed to use. */
private static final Set<String> ALLOWED_ASSET_EXTS = Set.of("svg", "png", "jpg", "jpeg", "webp", "gif");
+ /**
+ * The framework-authored role-token alias derivations.
+ *
+ * <p>
+ * {@code chrome.css} consumes <i>role</i>-named tokens ({@code --jc-header-bg}, {@code --jc-surface},
+ * {@code --jc-on-accent}, ...); {@link Theme#OPEN} (and every consumer theme) still defines the original
+ * <i>colour</i>-named leaf tokens ({@code --jc-white}, {@code --jc-border-2}, ...). This block bridges the two
+ * by deriving each role token <b>from</b> the legacy token it replaces, so a consumer's existing override of a
+ * legacy token still flows through to every derived role, and a consumer who sets a role token directly
+ * out-ranks the derived default by cascade order (this block is emitted inside the {@code Theme.OPEN} block,
+ * before any active-theme override block).
+ *
+ * <p>
+ * These aliases are <b>permanent</b> (no deprecation window) and are deliberately <i>not</i>
+ * {@link Theme#OPEN} tokens: {@code CssValueGrammar} rejects {@code var()} in every spelling, so a
+ * {@code var(--jc-*)} alias expressed as a {@code Theme} token would throw at class-initialization time. This
+ * is framework-authored literal text and never passes through that grammar (which exists to validate
+ * <i>consumer</i> input, not the framework's own stylesheet). {@code Theme.OPEN} owns leaf values; this block
+ * owns derived values; no token name is declared by both.
+ */
+ static final String OPEN_ROLE_ALIASES = String.join("",
+ "--jc-surface:var(--jc-white);",
+ "--jc-header-bg:var(--jc-surface);",
+ "--jc-nav-bg:var(--jc-header-bg);",
+ "--jc-control-bg:var(--jc-surface);",
+ "--jc-table-bg:var(--jc-surface);",
+ "--jc-table-row-bg:var(--jc-surface);",
+ "--jc-on-accent:var(--jc-white);",
+ "--jc-on-btn-primary:var(--jc-on-accent);",
+ "--jc-table-stripe-bg:var(--jc-card-bg);",
+ "--jc-hover-bg:var(--jc-chrome-bg);",
+ "--jc-table-header-bg:var(--jc-chrome-bg);",
+ "--jc-control-border:var(--jc-border-2);",
+ "--jc-header-icon-text:var(--jc-header-icon-color);",
+ "--jc-btn-primary-bg:var(--jc-btn-primary);",
+ "--jc-btn-primary-bg-hover:var(--jc-btn-primary-hover);"
+ );
+
/** The shipped static chrome.css bytes, read once from the classpath (shared - the static file never varies by theme). */
private static volatile String staticCss;
@@ -274,7 +312,7 @@ public class ConsoleChromeMixin {
private byte[] buildBody(RestRequest req) throws IOException {
buildCount.incrementAndGet();
var sb = new StringBuilder(staticCss());
- sb.append('\n').append(rootBlock(Theme.OPEN));
+ sb.append('\n').append(openRootBlock());
var active = resolveActiveTheme(req);
if (! active.getName().equals(Theme.OPEN.getName()))
sb.append('\n').append(rootBlock(active));
@@ -364,6 +402,24 @@ public class ConsoleChromeMixin {
return sb.toString();
}
+ /**
+ * Renders {@link Theme#OPEN}'s {@code :root{}} block with the framework-authored role-token alias derivations
+ * ({@link #OPEN_ROLE_ALIASES}) appended inside the same block, immediately after the leaf tokens. The aliases
+ * are appended as trusted framework literal text (not routed through {@code CssValueEscaper}, which is for
+ * consumer-supplied values); folding them into {@code Theme.OPEN}'s block keeps the served response at exactly
+ * one {@code :root{}} block for the default theme while still placing every derived default before any
+ * active-theme override block.
+ */
+ private static String openRootBlock() {
+ var sb = new StringBuilder();
+ sb.append(":root{");
+ for (var e : Theme.OPEN.getTokens().entrySet())
+ sb.append(e.getKey()).append(':').append(CssValueEscaper.escape(e.getValue())).append(';');
+ sb.append(OPEN_ROLE_ALIASES);
+ sb.append('}');
+ return sb.toString();
+ }
+
/** Returns the shipped static chrome.css text, reading (and caching, process-wide - the static file never varies) it from the classpath on first call. */
private static String staticCss() throws IOException {
var s = staticCss;
@@ -455,23 +511,23 @@ public class ConsoleChromeMixin {
public ConsoleChromeMixin build() {
return new ConsoleChromeMixin(this);
}
- }
- /**
- * Fail-closed validation for a configured asset's classpath resource path: reject <jk>null</jk>/empty, reject
- * any traversal-shaped path (containing {@code ..} or a {@code %} URI-encoding escape, matching
- * {@code BasicFileFinder.isInvalidPath}), reject an extension outside the image allowlist, and reject a path
- * that does not resolve to an existing classpath resource.
- */
- private static String validateAssetResource(String value, String paramName) {
- if (value == null || value.isEmpty())
- throw iaex("'%s' must not be null or empty.", paramName);
- if (value.contains("..") || value.contains("%"))
- throw iaex("'%s' must not contain '..' or '%%' (path traversal): '%s'.", paramName, value);
- if (! ALLOWED_ASSET_EXTS.contains(FileUtils.getFileExtension(value).toLowerCase(Locale.ROOT)))
- throw iaex("'%s' must end in one of .svg/.png/.jpg/.jpeg/.webp/.gif: '%s'.", paramName, value);
- if (ConsoleChromeMixin.class.getResource(value) == null)
- throw iaex("'%s' classpath resource not found: '%s'.", paramName, value);
- return value;
+ /**
+ * Fail-closed validation for a configured asset's classpath resource path: reject <jk>null</jk>/empty,
+ * reject any traversal-shaped path (containing {@code ..} or a {@code %} URI-encoding escape, matching
+ * {@code BasicFileFinder.isInvalidPath}), reject an extension outside the image allowlist, and reject a
+ * path that does not resolve to an existing classpath resource.
+ */
+ private static String validateAssetResource(String value, String paramName) {
+ if (value == null || value.isEmpty())
+ throw iaex("'%s' must not be null or empty.", paramName);
+ if (value.contains("..") || value.contains("%"))
+ throw iaex("'%s' must not contain '..' or '%%' (path traversal): '%s'.", paramName, value);
+ if (! ALLOWED_ASSET_EXTS.contains(FileUtils.getFileExtension(value).toLowerCase(Locale.ROOT)))
+ throw iaex("'%s' must end in one of .svg/.png/.jpg/.jpeg/.webp/.gif: '%s'.", paramName, value);
+ if (ConsoleChromeMixin.class.getResource(value) == null)
+ throw iaex("'%s' classpath resource not found: '%s'.", paramName, value);
+ return value;
+ }
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueEscaper.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueEscaper.java
index 881cebd507..6bada8e54e 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueEscaper.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueEscaper.java
@@ -61,6 +61,6 @@ final class CssValueEscaper {
}
private static boolean isBreakout(char c) {
- return c == ';' || c == '{' || c == '}' || c == '\\' || c == '<' || c == '>' || (c >= 0x00 && c <= 0x1F);
+ return c == ';' || c == '{' || c == '}' || c == '\\' || c == '<' || c == '>' || c <= 0x1F;
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java
index 2fa88ba666..935c8807dd 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/CssValueGrammar.java
@@ -79,7 +79,7 @@ final class CssValueGrammar {
// leading/trailing C0 control character before this check ever saw it.
for (var i = 0; i < value.length(); i++) {
var c = value.charAt(i);
- if ((c >= 0x00 && c <= 0x1F) || (c >= 0x7F && c <= 0x9F))
+ if (c <= 0x1F || (c >= 0x7F && c <= 0x9F))
throw iaex("Theme token value contains an illegal control character at index %d.", i);
}
@@ -109,6 +109,9 @@ final class CssValueGrammar {
// <number> + allowlisted unit, one or more space-separated (unit optional, e.g. bare "0").
private static final String UNIT = "px|em|rem|%|vh|vw|vmin|vmax|pt|ch|fr|deg|turn|rad|s|ms";
+ @SuppressWarnings({
+ "java:S5998" // Length-list input is a short, author-set theme-token config value (never attacker-controlled request data), so the group-repetition backtracking cannot be driven to a stack overflow; rewriting this security allowlist regex risks altering the set of validated inputs.
+ })
private static final Pattern LENGTH_LIST = Pattern.compile(
"^-?\\d+(?:\\.\\d+)?(?:" + UNIT + ")?(?:\\s+-?\\d+(?:\\.\\d+)?(?:" + UNIT + ")?)*$");
@@ -185,7 +188,11 @@ final class CssValueGrammar {
"seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow",
"springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white",
"whitesmoke", "yellow", "yellowgreen"));
- s.addAll(Set.of("transparent", "currentcolor", "inherit", "initial", "unset"));
+ // "none": lets a theme express a flat, ungradiated page background (background-image: none),
+ // letting html,body's own background-color show through. "none" has no nested-function or url() capability,
+ // so admitting it as a bare keyword does not reopen anything URL_REJECT or the gradient nested-function
+ // allowlist exist to close - it is an orthogonal branch of isAllowedShape, not a loosening of the url() ban.
+ s.addAll(Set.of("transparent", "currentcolor", "inherit", "initial", "unset", "none"));
return Set.copyOf(s);
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Tag.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Tag.java
index c56cf8ade5..0da40fd29f 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Tag.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Tag.java
@@ -65,6 +65,9 @@ public final class Tag {
* @throws IllegalArgumentException If either argument is <jk>null</jk>, empty, or (after lowercasing) not in
* the legal shape.
*/
+ @SuppressWarnings({
+ "java:S1452" // Intended public API: the wildcard return keeps this factory's contract at the HtmlElement abstraction (it currently returns a Span) without committing callers to the concrete type; narrowing it to Span would be a public-signature change.
+ })
public static HtmlElement<?> of(String domain, String value) {
var d = normalize("domain", domain);
var v = normalize("value", value);
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/TagDomain.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/TagDomain.java
index 0bb09e2c2c..0a3fd9fa90 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/TagDomain.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/TagDomain.java
@@ -23,17 +23,12 @@ import java.lang.annotation.*;
* class in the rendered {@code .tag.<domain>.<value>} markup, e.g. {@code .tag.status.released}).
*
* <p>
- * <b>Naming note (deviation from the TODO-361 plan text, recorded here rather than silently "fixed"):</b> the plan
- * and design documents write this annotation as {@code @Tag(domain=...)} — i.e. as if it shared its simple
- * name with the unrelated {@link Tag} <i>class</i> (the {@code Tag.of(domain, value)} static factory). That is not
- * something Java allows: an annotation type ({@code @interface}) cannot declare a static method, so a single type
- * named {@code Tag} cannot simultaneously be the {@code @Tag(domain=...)} annotation <b>and</b> expose
- * {@code Tag.of(...)}. This is a genuine plan-vs-language-semantics contradiction (verified by compiling a
- * `@interface` with a static method — {@code javac} rejects it outright), analogous in kind (though far
- * smaller in scope) to the Phase 5 FreeMarker-seam contradiction the plan's own r3 revision already fixed. Rather
- * than halt the build over an unambiguous, zero-risk naming split, this annotation is named {@code TagDomain}
- * (keeping the plan's heavily-referenced {@code Tag.of(...)} factory call form exactly as documented across
- * Phases 4-7); this deviation is called out in the phase manifest.
+ * <b>Naming note:</b> this annotation is named {@code TagDomain} rather than {@code Tag} because it cannot share
+ * the simple name {@code Tag} with the unrelated {@link Tag} <i>class</i> (the {@code Tag.of(domain, value)} static
+ * factory). An annotation type ({@code @interface}) cannot declare a static method, so a single type named
+ * {@code Tag} cannot simultaneously be the {@code @Tag(domain=...)} annotation <b>and</b> expose
+ * {@code Tag.of(...)}. Naming the annotation {@code TagDomain} keeps the {@code Tag.of(...)} factory form free for
+ * the {@link Tag} class.
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java
index e6d42d9391..f8e058ba44 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/Theme.java
@@ -100,6 +100,24 @@ public final class Theme {
.token("--jc-tag-red-bg", "#f8d7da")
.token("--jc-tag-red-text", "#721c24")
.token("--jc-tag-red-border", "#f5c6cb")
+ // Additive token gaps. Appended after the tag palette so --jc-font stays first and the tag
+ // triads stay contiguous (see Theme_TokenOrdering_Test.a02). All are behaviour-preserving: each default
+ // equals the literal it replaces in chrome.css, so the shipped chrome renders pixel-identically.
+ .token("--jc-header-height", "56px") // (a) keeps .jc-header height and .jc-nav sticky offset in sync
+ .token("--jc-nav-indicator-width", "3px") // (g) nav underline / active-tab indicator thickness
+ .token("--jc-card-shadow", "none") // (c) card elevation seam; flat by default (relies on the "none" keyword)
+ .token("--jc-danger-wash", "#fdeceb") // (d) themeable .jc-btn-danger:hover fill (was a hardcoded hex)
+ .token("--jc-success-wash", "#eaf6ee") // (d) themeable .jc-btn-success:hover fill (was a hardcoded hex)
+ .token("--jc-space-1", "4px") // (e) spacing scale seam (consumed by the .jc-prose rhythm)
+ .token("--jc-space-2", "8px")
+ .token("--jc-space-3", "12px")
+ .token("--jc-space-4", "16px")
+ .token("--jc-space-5", "24px")
+ .token("--jc-space-6", "32px")
+ // Accessibility: a themeable focus-ring colour so the generic :focus-visible rule in chrome.css
+ // gives every interactive control a WCAG 2.4.7-compliant keyboard-focus indicator by default. Defaults to
+ // the Lightning-blue accent so the ring reads as "the same brand colour" out of the box.
+ .token("--jc-focus", "#1589EE")
.build();
private final String name;
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
index 342ba26819..a8af6a1e53 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/resources/org/apache/juneau/console/chrome.css
@@ -64,6 +64,14 @@ body {
a { color: var(--jc-link); text-decoration: none; }
a:hover { text-decoration: underline; }
+/* Accessibility: a single token-driven :focus-visible ring covers every interactive control at once
+ (icon buttons, all .jc-btn variants, nav tabs, links, ribbon/pagingpill controls) so keyboard focus is visible
+ by default - a WCAG 2.4.7 (AA) requirement. :focus-visible (not :focus) so mouse clicks don't show the ring. */
+:focus-visible {
+ outline: 2px solid var(--jc-focus);
+ outline-offset: 2px;
+}
+
/* ==========================================================================
Header
========================================================================== */
@@ -71,8 +79,8 @@ a:hover { text-decoration: underline; }
.jc-header {
display: flex;
align-items: center;
- height: 56px;
- background-color: var(--jc-white);
+ height: var(--jc-header-height);
+ background-color: var(--jc-header-bg);
position: sticky;
top: 0;
z-index: 20;
@@ -91,7 +99,7 @@ a:hover { text-decoration: underline; }
margin: 8px 16px;
flex: 0 0 auto;
border-radius: var(--jc-radius);
- background-color: var(--jc-white);
+ background-color: var(--jc-header-bg);
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCA0OCA0OCc+PHJlY3Qgd2lkdGg9JzQ4JyBoZWlnaHQ9JzQ4JyByeD0nMTAnIGZpbGw9JyMxNTg5RUUnLz48cGF0aCBkPSdNMjQgOGMtNyA2LTExIDEzLTExIDE5YTExIDExIDAgMCAwIDIyIDBjMC02LTQtMTMtMTEtMTl6JyBmaWxsPScjZmZmZmZmJyBmaWxsLW9wYWNpdHk9JzAuOTUnLz48cGF0aCBkPSdNMjQgMTR2MjAnIHN0cm9rZT0nIzE1ODlFRScgc3Ryb2tlLXdpZHRoPScxLjYnIHN0cm9rZS1saW5lY2FwPSdyb3VuZCcvPjwvc3ZnPgo=");
background-repeat: no-repeat;
background-position: center;
@@ -128,13 +136,13 @@ a:hover { text-decoration: underline; }
width: 32px;
height: 32px;
border-radius: 50%;
- color: var(--jc-header-icon-color);
+ color: var(--jc-header-icon-text);
background-color: transparent;
border: none;
cursor: pointer;
}
-.jc-icon-btn:hover { background-color: var(--jc-chrome-bg); }
+.jc-icon-btn:hover { background-color: var(--jc-hover-bg); }
.jc-icon-btn svg { width: 18px; height: 18px; fill: currentColor; }
@@ -143,7 +151,7 @@ a:hover { text-decoration: underline; }
height: 30px;
border-radius: 50%;
background-image: var(--jc-avatar-bg);
- color: var(--jc-white);
+ color: var(--jc-on-accent);
font-size: 0.75rem;
font-weight: 700;
display: flex;
@@ -159,11 +167,11 @@ a:hover { text-decoration: underline; }
.jc-nav {
display: flex;
align-items: stretch;
- background-color: var(--jc-white);
- border-bottom: 3px solid var(--jc-accent);
+ background-color: var(--jc-nav-bg);
+ border-bottom: var(--jc-nav-indicator-width) solid var(--jc-accent);
padding: 0 16px;
position: sticky;
- top: 56px;
+ top: var(--jc-header-height);
z-index: 19;
}
@@ -175,7 +183,7 @@ a:hover { text-decoration: underline; }
color: var(--jc-text-soft);
padding: 9px 12px;
margin-right: 0;
- border-top: 3px solid transparent;
+ border-top: var(--jc-nav-indicator-width) solid transparent;
border-bottom: none;
cursor: pointer;
transition: background-color 0.1s ease-in;
@@ -192,6 +200,40 @@ a:hover { text-decoration: underline; }
color: var(--jc-text);
}
+/* ==========================================================================
+ Page-shell tabs / sub-tabs (.jc-tab / .jc-subtab)
+ The base SHAPE (flex layout, padding, border WIDTH, the display:none-until-
+ .jc-active panel toggle) lives in juneau-views.css with NO palette
+ (currentColor borders); chrome.css paints them HERE so a tab surface reads as
+ belonging next to the .jc-nav-tab bar directly above it - the same
+ base-shape-in-views / colours-in-chrome split as .tag and the ribbon buttons.
+ The active tab's 3px bottom edge (border-bottom-width, set neutrally in
+ -views) becomes the accent, echoing .jc-nav-tab.active's accent top edge.
+ ========================================================================== */
+
+.jc-tab,
+.jc-subtab {
+ color: var(--jc-text-soft);
+ background-color: var(--jc-control-bg);
+ border-color: var(--jc-border);
+ transition: background-color 0.1s ease-in;
+}
+
+.jc-tab:hover,
+.jc-subtab:hover {
+ background-color: var(--jc-accent-wash);
+ color: var(--jc-accent);
+ text-decoration: none;
+}
+
+.jc-tab-active,
+.jc-subtab-active {
+ color: var(--jc-text);
+ background-color: var(--jc-accent-wash);
+ border-color: var(--jc-border);
+ border-bottom-color: var(--jc-accent);
+}
+
/* ==========================================================================
Page scaffolding
========================================================================== */
@@ -243,6 +285,7 @@ a:hover { text-decoration: underline; }
background-color: var(--jc-card-bg);
border: 1px solid var(--jc-border);
border-radius: var(--jc-radius);
+ box-shadow: var(--jc-card-shadow);
padding: 18px 20px 8px;
}
@@ -281,31 +324,31 @@ a:hover { text-decoration: underline; }
}
.jc-btn-primary {
- background-color: var(--jc-btn-primary);
- color: var(--jc-white);
+ background-color: var(--jc-btn-primary-bg);
+ color: var(--jc-on-btn-primary);
}
-.jc-btn-primary:hover { background-color: var(--jc-btn-primary-hover); }
+.jc-btn-primary:hover { background-color: var(--jc-btn-primary-bg-hover); }
.jc-btn-outline {
- background-color: var(--jc-white);
+ background-color: var(--jc-control-bg);
color: var(--jc-text-soft);
- border-color: var(--jc-border-2);
+ border-color: var(--jc-control-border);
}
-.jc-btn-outline:hover { background-color: var(--jc-chrome-bg); }
+.jc-btn-outline:hover { background-color: var(--jc-hover-bg); }
.jc-btn-danger {
- background-color: var(--jc-white);
+ background-color: var(--jc-control-bg);
color: var(--jc-danger);
- border-color: var(--jc-border-2);
+ border-color: var(--jc-control-border);
}
-.jc-btn-danger:hover { background-color: #fdeceb; }
+.jc-btn-danger:hover { background-color: var(--jc-danger-wash); }
.jc-btn-success {
- background-color: var(--jc-white);
+ background-color: var(--jc-control-bg);
color: var(--jc-success);
- border-color: var(--jc-border-2);
+ border-color: var(--jc-control-border);
}
-.jc-btn-success:hover { background-color: #eaf6ee; }
+.jc-btn-success:hover { background-color: var(--jc-success-wash); }
/* ==========================================================================
Tag / pill system
@@ -314,7 +357,7 @@ a:hover { text-decoration: underline; }
.tag {
display: inline-block;
padding: 1px 6px;
- border-radius: 3px;
+ border-radius: var(--jc-radius);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
@@ -358,7 +401,7 @@ a:hover { text-decoration: underline; }
table.jc-table {
width: 100%;
border-collapse: collapse;
- background-color: var(--jc-white);
+ background-color: var(--jc-table-bg);
border: 1px solid var(--jc-border);
border-radius: var(--jc-radius);
overflow: hidden;
@@ -373,7 +416,7 @@ table.jc-table thead th {
text-align: left;
padding: 10px 12px;
border-bottom: 2px solid var(--jc-border);
- background-color: var(--jc-chrome-bg);
+ background-color: var(--jc-table-header-bg);
white-space: nowrap;
}
@@ -425,7 +468,7 @@ table[data-juneau-view] > thead > tr > th,
table[data-juneau-view] > thead > tr > td,
table.dataTable > thead > tr > th,
table.dataTable > thead > tr > td {
- background-color: var(--jc-chrome-bg);
+ background-color: var(--jc-table-header-bg);
color: var(--jc-text-soft);
border-bottom-color: var(--jc-border);
}
@@ -445,7 +488,7 @@ table.dataTable > tbody > tr:nth-child(odd),
table.dataTable > tbody > tr.odd,
table.dataTable.stripe > tbody > tr:nth-child(odd),
table.dataTable.display > tbody > tr:nth-child(odd) {
- background-color: var(--jc-white);
+ background-color: var(--jc-table-row-bg);
}
table[data-juneau-view] > tbody > tr:nth-child(even),
@@ -454,7 +497,7 @@ table.dataTable > tbody > tr:nth-child(even),
table.dataTable > tbody > tr.even,
table.dataTable.stripe > tbody > tr:nth-child(even),
table.dataTable.display > tbody > tr:nth-child(even) {
- background-color: var(--jc-card-bg);
+ background-color: var(--jc-table-stripe-bg);
}
/* Row hover - bare :hover (the mechanism actually in effect today) plus the .hover/.display convenience-class
@@ -477,33 +520,33 @@ table.dataTable.display > tbody > tr:hover {
/* Base thin-neutral-border shape (IRS parity item 1) - -views ships only a currentColor-fallback border, which
inherits the body's near-black text color instead of IRS's subtle gray; themed here with --jc-border, plus a
white face so a button reads as a distinct control against the card's tinted background. */
-.juneau-view-ribbon-btn { border-color: var(--jc-border); background-color: var(--jc-white); color: var(--jc-text-soft); }
+.juneau-view-ribbon-btn { border-color: var(--jc-border); background-color: var(--jc-control-bg); color: var(--jc-text-soft); }
.juneau-view-ribbon-btn:hover { background-color: var(--jc-accent-wash); color: var(--jc-accent); }
-.juneau-view-ribbon-btn:active { background-color: var(--jc-accent); color: var(--jc-white); }
+.juneau-view-ribbon-btn:active { background-color: var(--jc-accent); color: var(--jc-on-accent); }
/* Themed accent for an ACTIVE toggle (option/optionGroup member button's aria-pressed="true" - see -views'
neutral border-width rule for this same attribute selector). Without this, a persisted, still-active filter
(e.g. a column-scoped "option" toggle whose state survived a browser refresh) is visually indistinguishable
from an unpressed button - clicking it once and never again can silently keep filtering out every row. Also
matches the blue "active" export button in the IRS reference ribbon (item 1). */
-.juneau-view-ribbon-btn[aria-pressed="true"] { background-color: var(--jc-accent); color: var(--jc-white); border-color: var(--jc-accent); }
+.juneau-view-ribbon-btn[aria-pressed="true"] { background-color: var(--jc-accent); color: var(--jc-on-accent); border-color: var(--jc-accent); }
/* Unified paging ribbon (nav + page-size menu) - same thin-neutral-border/white-face treatment as the ribbon
buttons. */
-.juneau-view-pagingpill { border-color: var(--jc-border); background-color: var(--jc-white); }
+.juneau-view-pagingpill { border-color: var(--jc-border); background-color: var(--jc-control-bg); }
.juneau-view-pagingpill-btn { border-color: var(--jc-border); color: var(--jc-text-soft); }
.juneau-view-pagingpill-btn:hover:not(:disabled) { background-color: var(--jc-accent-wash); color: var(--jc-accent); }
.juneau-view-pagingpill-menuwrap { border-color: var(--jc-border); }
.juneau-view-pagingpill-menubtn { color: var(--jc-text-soft); }
.juneau-view-pagingpill-menubtn:hover,
.juneau-view-pagingpill-menubtn[aria-expanded="true"] { background-color: var(--jc-accent-wash); color: var(--jc-accent); }
-.juneau-view-pagingpill-menu { border-color: var(--jc-border); background-color: var(--jc-white); }
+.juneau-view-pagingpill-menu { border-color: var(--jc-border); background-color: var(--jc-control-bg); }
.juneau-view-pagingpill-menu-option:hover,
.juneau-view-pagingpill-menu-option:focus { background-color: var(--jc-accent-wash); color: var(--jc-accent); }
.juneau-view-pagingpill-menu-option[aria-selected="true"] { color: var(--jc-accent); }
/* Per-column search row inputs (IRS parity item 4) - same thin-neutral-border treatment, themed accent on focus. */
-.juneau-view-columnsearch-input { border-color: var(--jc-border); background-color: var(--jc-white); color: var(--jc-text); }
+.juneau-view-columnsearch-input { border-color: var(--jc-border); background-color: var(--jc-control-bg); color: var(--jc-text); }
.juneau-view-columnsearch-input:focus { border-color: var(--jc-accent); }
/* ==========================================================================
@@ -516,3 +559,109 @@ table.dataTable.display > tbody > tr:hover {
color: var(--jc-text-muted);
padding: 24px 0 32px;
}
+
+/* ==========================================================================
+ Prose typography (.jc-prose)
+ Opt-in scoping class for rendered documents (runbooks, onboarding docs,
+ "what this surface means" explainers) so prose reads as the console rather
+ than as the browser default. Vertical rhythm consumes the --jc-space-*
+ scale, so a themed density change flows through here too.
+ NOTE: `table.jc-table` is the DATA-GRID rule; a `.jc-prose table` is a
+ static in-document table, styled separately below via a descendant rule
+ (not a new class) since the distinction that matters is
+ data-grid-versus-prose.
+ ========================================================================== */
+
+.jc-prose {
+ color: var(--jc-text);
+ line-height: 1.6;
+}
+
+.jc-prose > :first-child { margin-top: 0; }
+.jc-prose > :last-child { margin-bottom: 0; }
+
+.jc-prose h1,
+.jc-prose h2,
+.jc-prose h3,
+.jc-prose h4,
+.jc-prose h5,
+.jc-prose h6 {
+ color: var(--jc-text);
+ font-weight: 600;
+ line-height: 1.25;
+ margin-bottom: var(--jc-space-3);
+}
+
+.jc-prose h1 { font-size: 2rem; margin-top: var(--jc-space-6); }
+.jc-prose h2 { font-size: 1.5rem; margin-top: var(--jc-space-5); }
+.jc-prose h3 { font-size: 1.25rem; margin-top: var(--jc-space-4); }
+.jc-prose h4 { font-size: 1.125rem; margin-top: var(--jc-space-4); }
+.jc-prose h5 { font-size: 1rem; margin-top: var(--jc-space-3); }
+.jc-prose h6 {
+ font-size: 0.875rem;
+ margin-top: var(--jc-space-3);
+ color: var(--jc-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.jc-prose p { margin: 0 0 var(--jc-space-3); }
+
+.jc-prose ul,
+.jc-prose ol { margin: 0 0 var(--jc-space-3); padding-left: var(--jc-space-5); }
+.jc-prose li { margin-bottom: var(--jc-space-1); }
+.jc-prose li > ul,
+.jc-prose li > ol { margin-bottom: 0; margin-top: var(--jc-space-1); }
+
+.jc-prose a { color: var(--jc-link); }
+.jc-prose a:hover { text-decoration: underline; }
+
+.jc-prose code {
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 0.875em;
+ background-color: var(--jc-chrome-bg);
+ border: 1px solid var(--jc-border);
+ border-radius: var(--jc-radius);
+ padding: var(--jc-space-1) var(--jc-space-2);
+}
+
+.jc-prose pre {
+ background-color: var(--jc-chrome-bg);
+ border: 1px solid var(--jc-border);
+ border-radius: var(--jc-radius);
+ padding: var(--jc-space-3) var(--jc-space-4);
+ overflow: auto;
+ margin: 0 0 var(--jc-space-3);
+}
+
+.jc-prose pre code {
+ background: none;
+ border: none;
+ padding: 0;
+ font-size: 0.8125rem;
+}
+
+.jc-prose blockquote {
+ margin: 0 0 var(--jc-space-3);
+ padding: var(--jc-space-2) var(--jc-space-4);
+ border-left: var(--jc-nav-indicator-width) solid var(--jc-border);
+ color: var(--jc-text-soft);
+}
+
+.jc-prose table {
+ border-collapse: collapse;
+ margin: 0 0 var(--jc-space-3);
+}
+
+.jc-prose table th,
+.jc-prose table td {
+ border: 1px solid var(--jc-border);
+ padding: var(--jc-space-2) var(--jc-space-3);
+ text-align: left;
+}
+
+.jc-prose table th {
+ background-color: var(--jc-table-header-bg);
+ color: var(--jc-text-soft);
+ font-weight: 600;
+}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ChromeCssScanner.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ChromeCssScanner.java
index 890c972633..45296f37c8 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ChromeCssScanner.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ChromeCssScanner.java
@@ -52,7 +52,7 @@ final class ChromeCssScanner {
private static final Pattern COMMENT = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL);
private static final Pattern WHOLE_VALUE_VAR_JC =
- Pattern.compile("([a-zA-Z-]+)\\s*:\\s*var\\((--jc-[a-z0-9-]+)\\)\\s*;");
+ Pattern.compile("([a-zA-Z-]++)\\s*:\\s*var\\((--jc-[a-z0-9-]++)\\)\\s*;");
/**
* Scans the specified CSS source and returns a list of human-readable violation descriptions (empty if the CSS
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
index 7e645b72c8..afcef00020 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin_Test.java
@@ -30,6 +30,8 @@ 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.*;
+import org.junit.jupiter.params.*;
+import org.junit.jupiter.params.provider.*;
/**
* Phase 3 gate: {@link ConsoleChromeMixin} + {@link ThemeSettings} + the dynamic {@code GET /juneau-console/chrome.css}
@@ -138,22 +140,32 @@ class ConsoleChromeMixin_Test extends TestBase {
@Test void c01_openBlock_containsOnlyJcNames() throws Exception {
var body = bodyOf(MockRestClient.buildLax(DefaultHost.class));
var block = firstRootBlock(body);
- var m = Pattern.compile("([a-zA-Z-]+)\\s*:").matcher(block);
+ var m = Pattern.compile("([a-zA-Z-]++)\\s*:").matcher(block);
while (m.find())
assertTrue(m.group(1).startsWith("--jc-"), () -> "non --jc- name in :root{} block: " + m.group(1));
}
- @Test void c02_everyChromeCssVarJc_isDefinedInThemeOpen() throws IOException {
+ @Test void c02_everyChromeCssVarJc_isDefinedInThemeOpenOrAliasBlock() throws IOException {
+ // Three-way check: chrome.css now consumes role-named tokens (--jc-header-bg, --jc-surface, ...)
+ // that are declared by the framework-authored alias block (ConsoleChromeMixin.OPEN_ROLE_ALIASES), not by
+ // Theme.OPEN. Every chrome.css reference must resolve to one of the two framework blocks.
var referenced = referencedTokensInChromeCss();
- var defined = Theme.OPEN.getTokens().keySet();
+ var defined = new LinkedHashSet<>(Theme.OPEN.getTokens().keySet());
+ defined.addAll(aliasDefinedNames());
for (var name : referenced)
- assertTrue(defined.contains(name), () -> "chrome.css references '" + name + "' but Theme.OPEN does not define it");
+ assertTrue(defined.contains(name), () -> "chrome.css references '" + name + "' but neither Theme.OPEN nor the alias block defines it");
}
- @Test void c03_everyThemeOpenToken_isReferencedInChromeCss() throws IOException {
- var referenced = referencedTokensInChromeCss();
- for (var name : Theme.OPEN.getTokens().keySet())
- assertTrue(referenced.contains(name), () -> "Theme.OPEN defines orphan token '" + name + "' not referenced by chrome.css");
+ @Test void c03_everyDefinedToken_isReferencedInChromeCssOrAliasBlock() throws IOException {
+ // Three-way check: a legacy token that chrome.css no longer references directly (e.g. --jc-white)
+ // is not an orphan - it is still consumed by the alias block as the source of a derived role token. A role
+ // token declared by the alias block must in turn be consumed by chrome.css (or by a later alias link).
+ var referenced = new LinkedHashSet<>(referencedTokensInChromeCss());
+ referenced.addAll(aliasReferencedNames());
+ var defined = new LinkedHashSet<>(Theme.OPEN.getTokens().keySet());
+ defined.addAll(aliasDefinedNames());
+ for (var name : defined)
+ assertTrue(referenced.contains(name), () -> "orphan token '" + name + "' defined but never referenced by chrome.css or the alias block");
}
private static Set<String> referencedTokensInChromeCss() throws IOException {
@@ -164,7 +176,25 @@ class ConsoleChromeMixin_Test extends TestBase {
}
var stripped = Pattern.compile("/\\*.*?\\*/", Pattern.DOTALL).matcher(css).replaceAll("");
var out = new LinkedHashSet<String>();
- var m = Pattern.compile("var\\((--jc-[a-z0-9-]+)\\)").matcher(stripped);
+ var m = Pattern.compile("var\\((--jc-[a-z0-9-]++)\\)").matcher(stripped);
+ while (m.find())
+ out.add(m.group(1));
+ return out;
+ }
+
+ /** The role-token names DECLARED (left-hand side) by the framework-authored alias block. */
+ private static Set<String> aliasDefinedNames() {
+ var out = new LinkedHashSet<String>();
+ var m = Pattern.compile("(--jc-[a-z0-9-]++)\\s*:").matcher(ConsoleChromeMixin.OPEN_ROLE_ALIASES);
+ while (m.find())
+ out.add(m.group(1));
+ return out;
+ }
+
+ /** The token names REFERENCED (via var(...)) by the framework-authored alias block. */
+ private static Set<String> aliasReferencedNames() {
+ var out = new LinkedHashSet<String>();
+ var m = Pattern.compile("var\\((--jc-[a-z0-9-]++)\\)").matcher(ConsoleChromeMixin.OPEN_ROLE_ALIASES);
while (m.find())
out.add(m.group(1));
return out;
@@ -229,6 +259,9 @@ class ConsoleChromeMixin_Test extends TestBase {
// e) url-sink gate, end-to-end
//-----------------------------------------------------------------------------------------------------------------
+ @SuppressWarnings({
+ "java:S5778" // End-to-end security gate: deliberately asserts the whole mixin-construction chain (theme(Theme.create(x).token(url).build())) rejects a url() bypass vector, so it stays robust if value validation is ever relocated between token()/build()/theme(); isolating a single call would drop that end-to-end coverage.
+ })
@Test void e01_bypassVector_throwsAtMixinConstructionTime_notSilentlySwallowed() {
assertThrows(IllegalArgumentException.class,
() -> ConsoleChromeMixin.create().theme(Theme.create("x").token("--jc-page-bg", "url(https://evil)").build()));
@@ -308,55 +341,41 @@ class ConsoleChromeMixin_Test extends TestBase {
private static final String VALID_LOGO = "/testfiles/console/logo.svg";
private static final String VALID_PAGE_BG = "/testfiles/console/page-bg.png";
- @Test void g01_logo_null_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().logo(null));
- }
-
- @Test void g02_logo_empty_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().logo(""));
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {
+ "", // empty
+ "/testfiles/console/../../../etc/passwd", // path traversal segment
+ "/testfiles/console/bad.txt", // unallowlisted extension
+ "/testfiles/console/nope.svg", // resource does not exist
+ })
+ void g01_logo_invalidInputs_rejected(String value) {
+ var b = ConsoleChromeMixin.create();
+ assertThrows(IllegalArgumentException.class, () -> b.logo(value));
}
- @Test void g03_logo_pathTraversalSegment_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().logo("/testfiles/console/../../../etc/passwd"));
- }
-
- @Test void g04_logo_unallowlistedExtension_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().logo("/testfiles/console/bad.txt"));
- }
-
- @Test void g05_logo_resourceDoesNotExist_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().logo("/testfiles/console/nope.svg"));
- }
-
- @Test void g06_logo_validClasspathResource_accepted() {
+ @Test void g02_logo_validClasspathResource_accepted() {
assertDoesNotThrow(() -> ConsoleChromeMixin.create().logo(VALID_LOGO).build());
}
- @Test void g07_pageBackgroundImage_null_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().pageBackgroundImage(null));
- }
-
- @Test void g08_pageBackgroundImage_empty_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().pageBackgroundImage(""));
- }
-
- @Test void g09_pageBackgroundImage_pathTraversalSegment_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/../../../etc/passwd"));
- }
-
- @Test void g10_pageBackgroundImage_unallowlistedExtension_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/bad.txt"));
- }
-
- @Test void g11_pageBackgroundImage_resourceDoesNotExist_rejected() {
- assertThrows(IllegalArgumentException.class, () -> ConsoleChromeMixin.create().pageBackgroundImage("/testfiles/console/nope.png"));
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = {
+ "", // empty
+ "/testfiles/console/../../../etc/passwd", // path traversal segment
+ "/testfiles/console/bad.txt", // unallowlisted extension
+ "/testfiles/console/nope.png", // resource does not exist
+ })
+ void g03_pageBackgroundImage_invalidInputs_rejected(String value) {
+ var b = ConsoleChromeMixin.create();
+ assertThrows(IllegalArgumentException.class, () -> b.pageBackgroundImage(value));
}
- @Test void g12_pageBackgroundImage_validClasspathResource_accepted() {
+ @Test void g04_pageBackgroundImage_validClasspathResource_accepted() {
assertDoesNotThrow(() -> ConsoleChromeMixin.create().pageBackgroundImage(VALID_PAGE_BG).build());
}
- @Test void g13_allAllowlistedExtensions_accepted() {
+ @Test void g05_allAllowlistedExtensions_accepted() {
// One fixture per allowlisted extension, content irrelevant - only the extension drives validation/content-type.
assertDoesNotThrow(() -> ConsoleChromeMixin.create().logo("/testfiles/console/logo.svg").build());
assertDoesNotThrow(() -> ConsoleChromeMixin.create().logo("/testfiles/console/logo.jpg").build());
@@ -488,6 +507,9 @@ class ConsoleChromeMixin_Test extends TestBase {
assertEquals(List.of(), ChromeCssScanner.scan(body), () -> "violations against assembled body:\n" + body);
}
+ @SuppressWarnings({
+ "java:S5778" // End-to-end security regression: deliberately asserts the whole mixin-construction chain rejects a url() production even with the assets feature present; isolating a single call would drop that end-to-end coverage.
+ })
@Test void j02_themeTokenPath_stillRejectsUrlProduction_evenWithAssetsFeaturePresent() {
assertThrows(IllegalArgumentException.class,
() -> ConsoleChromeMixin.create().theme(Theme.create("x").token("--jc-page-bg", "url(https://evil)").build()));
@@ -498,9 +520,11 @@ class ConsoleChromeMixin_Test extends TestBase {
// NOT part of the Theme token model (finding 4 of the design doc). If this count ever changes, it must be
// a DIFFERENT, deliberate change to Theme.OPEN - not a side effect of the asset feature.
//
- // 35 = the original 32, plus the three-token red tag triad added so a four-state status vocabulary has a
- // fourth colour. Bumping this number is only ever correct alongside a reviewed edit to Theme.OPEN itself.
- assertEquals(35, Theme.OPEN.getTokens().size());
+ // 47 = the original 32, plus the three-token red tag triad, plus the eleven additive
+ // token gaps (--jc-header-height, --jc-nav-indicator-width, --jc-card-shadow, --jc-danger-wash,
+ // --jc-success-wash, and the six-step --jc-space-1..6 scale), plus the --jc-focus focus-ring colour.
+ // Bumping this number is only ever correct alongside a reviewed edit to Theme.OPEN itself.
+ assertEquals(47, Theme.OPEN.getTokens().size());
assertFalse(Theme.OPEN.getTokens().containsKey("--jc-logo"));
assertFalse(Theme.OPEN.getTokens().containsKey("--jc-page-bg-image"));
}
@@ -647,7 +671,7 @@ class ConsoleChromeMixin_Test extends TestBase {
/**
* The mirror-image regression of (m03): detection inverted in the composed direction, resolving the
* <i>unprefixed</i> constant under a composed mount, yields {@code /rest/assets/logo}. That is not caught by an
- * end-to-end fetch either — {@code TODO-411}'s dual path registration makes {@code /rest/assets/logo} a
+ * end-to-end fetch either — the dual path registration makes {@code /rest/assets/logo} a
* live alias too — nor by the pre-fix root-absolute literal {@code /juneau-console/assets/logo}, which
* still <i>looks</i> like a plausible logo URL. Both wrong answers are pinned out explicitly here.
*/
@@ -808,13 +832,17 @@ class ConsoleChromeMixin_Test extends TestBase {
* itself - that the iteration order is the declaration order and not a per-JVM hash order, which is what makes
* the response byte-stable enough to ever carry an {@code ETag} - is proved in {@code Theme_TokenOrdering_Test}.
*/
- @Test void o01_openBlockDeclarationOrder_matchesThemeOpensDeclarationOrder() throws Exception {
+ @Test void o01_openBlockDeclarationOrder_matchesThemeOpenThenAliasBlockDeclarationOrder() throws Exception {
+ // The OPEN :root{} block emits Theme.OPEN's tokens in declaration order, followed by the framework-authored
+ // role-token alias derivations in their declaration order.
var block = firstRootBlock(bodyOf(MockRestClient.buildLax(DefaultHost.class)));
var emitted = new ArrayList<String>();
- var m = Pattern.compile("(--jc-[a-z0-9-]+)\\s*:").matcher(block);
+ var m = Pattern.compile("(--jc-[a-z0-9-]++)\\s*:").matcher(block);
while (m.find())
emitted.add(m.group(1));
- assertEquals(new ArrayList<>(Theme.OPEN.getTokens().keySet()), emitted);
+ var expected = new ArrayList<>(Theme.OPEN.getTokens().keySet());
+ expected.addAll(aliasDefinedNames());
+ assertEquals(expected, emitted);
}
@Test void o02_twoIndependentlyBuiltMixinsWithTheSameTheme_serveByteIdenticalBodies() throws Exception {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueEscaper_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueEscaper_Test.java
index 1f3f15aac0..113b125e08 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueEscaper_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueEscaper_Test.java
@@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.*;
import org.apache.juneau.*;
import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.*;
+import org.junit.jupiter.params.provider.*;
/**
* Phase 2 gate: {@code CssValueEscaper} unit tests.
@@ -45,17 +47,13 @@ class CssValueEscaper_Test extends TestBase {
assertFalse(escaped.contains(">"));
}
- @Test void a03_legitimateGradient_passesThroughByteForByte() {
- var value = "linear-gradient(180deg, #eef3f8, #dde6ef)";
- assertEquals(value, CssValueEscaper.escape(value));
- }
-
- @Test void a04_legitimateHexColor_passesThroughByteForByte() {
- assertEquals("#1589EE", CssValueEscaper.escape("#1589EE"));
- }
-
- @Test void a05_legitimateFontFamilyList_passesThroughByteForByte() {
- var value = "'Salesforce Sans', Inter, sans-serif";
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "linear-gradient(180deg, #eef3f8, #dde6ef)",
+ "#1589EE",
+ "'Salesforce Sans', Inter, sans-serif",
+ })
+ void a03_legitimateValues_passThroughByteForByte(String value) {
assertEquals(value, CssValueEscaper.escape(value));
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java
index 09e4df6726..57cde15f82 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/CssValueGrammar_Test.java
@@ -48,26 +48,37 @@ class CssValueGrammar_Test extends TestBase {
"url/**/(https://evil)", // comment
" url(x)", // leading whitespace
"linear-gradient(url(evil))", // nested url() inside an otherwise-shaped gradient - grammar-layer reject
+ // The "none"-interaction vectors below (folded in from the former a12, merged to close out S4144 - these
+ // share a01's exact shape and target, so splitting them into a second method was pure duplication):
+ "url(none)", // "none" as a url() argument must not smuggle the url() production through
+ "URL(none.png)",
+ "url\t(none)", // tab (C0) between url and paren
+ "none url(evil)", // "none" prefix must not whitelist a trailing url()
+ "linear-gradient(none, url(evil))",
})
void a01_bypassVectorSweep_throws(String payload) {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-page-bg", payload));
+ var b = Theme.create("x");
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-page-bg", payload));
}
@Test void a02_cssHexEscapedUrl_throws() {
// CSS-hex-escaped letters spelling "url(" - the grammar has no production admitting a literal backslash,
// so these die in the allowlist grammar (not the normalization url-regex, which does not match "\75rl(").
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-page-bg", "\\75rl(x)"));
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-page-bg", "url\\3C(x)"));
+ var b = Theme.create("x");
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-page-bg", "\\75rl(x)"));
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-page-bg", "url\\3C(x)"));
}
@Test void a03_rawC0ControlChar_anywhere_throws() {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-accent", "#1589EE\u0000"));
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-accent", "\u0007red"));
+ var b = Theme.create("x");
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-accent", "#1589EE\u0000"));
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-accent", "\u0007red"));
}
@Test void a04_rawC1DelControlChar_throws() {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-accent", "red\u007F"));
- assertThrows(IllegalArgumentException.class, () -> Theme.create("x").token("--jc-accent", "red\u0085"));
+ var b = Theme.create("x");
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-accent", "red\u007F"));
+ assertThrows(IllegalArgumentException.class, () -> b.token("--jc-accent", "red\u0085"));
}
//-----------------------------------------------------------------------------------------------------------------
@@ -82,14 +93,16 @@ class CssValueGrammar_Test extends TestBase {
// the (?i)url\s*\( belt would NOT catch it either, and the grammar would ACCEPT it (whitespace is in the
// rgb() charset). It must still throw - and for a DIFFERENT reason (control character) than an
// out-of-grammar value.
+ var b = Theme.create("x");
var ex = assertThrows(IllegalArgumentException.class,
- () -> Theme.create("x").token("--jc-accent", "rgb(21,\n137,238)"));
+ () -> b.token("--jc-accent", "rgb(21,\n137,238)"));
assertTrue(ex.getMessage().contains("control character"), () -> "unexpected message: " + ex.getMessage());
}
@Test void a06_outOfGrammarValue_throwsWithDistinctMessage_notControlCharacter() {
+ var b = Theme.create("x");
var ex = assertThrows(IllegalArgumentException.class,
- () -> Theme.create("x").token("--jc-accent", "not-a-css-value!!!"));
+ () -> b.token("--jc-accent", "not-a-css-value!!!"));
assertFalse(ex.getMessage().contains("control character"), () -> "unexpected message: " + ex.getMessage());
}
@@ -123,7 +136,25 @@ class CssValueGrammar_Test extends TestBase {
}
@Test void a09_gradientWithDisallowedNestedFunction_rejected() {
+ var b = Theme.create("x");
assertThrows(IllegalArgumentException.class,
- () -> Theme.create("x").token("--jc-avatar-bg", "linear-gradient(135deg, image(evil))"));
+ () -> b.token("--jc-avatar-bg", "linear-gradient(135deg, image(evil))"));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // The bare keyword "none" is admitted so a theme can express a flat page background
+ // (background-image: none), but admitting it must NOT reopen the url() ban - "none" is an orthogonal branch of
+ // isAllowedShape, not a loosening of URL_REJECT or the gradient nested-function allowlist. The "must not reopen
+ // the url() ban" vectors are covered by the "none"-interaction entries folded into (a01) above.
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a10_bareNoneKeyword_acceptedVerbatim() {
+ var theme = Theme.create("x").token("--jc-page-bg", "none").build();
+ assertEquals("none", theme.getTokens().get("--jc-page-bg"));
+ }
+
+ @Test void a11_noneKeyword_isCaseInsensitive() {
+ assertEquals("NONE", Theme.create("x").token("--jc-page-bg", "NONE").build().getTokens().get("--jc-page-bg"));
+ assertEquals("None", Theme.create("x").token("--jc-page-bg", "None").build().getTokens().get("--jc-page-bg"));
}
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
index 4bc3f9d462..49bded6111 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/ModuleGraph_Test.java
@@ -23,9 +23,11 @@ import java.nio.file.*;
import org.apache.juneau.*;
import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.*;
+import org.junit.jupiter.params.provider.*;
/**
- * Mechanical, {@code basedir}-relative assertion of the TODO-361 three-module dependency graph.
+ * Mechanical, {@code basedir}-relative assertion of the three-module dependency graph.
*
* <p>
* This is an <b>invariant/regression assertion, not a shown-failing-first TDD RED</b>: the
@@ -59,18 +61,14 @@ class ModuleGraph_Test extends TestBase {
return pomXml.contains("<artifactId>" + artifactId + "</artifactId>");
}
- @Test void a01_viewFreemarker_doesNotDependOnDatatables() throws IOException {
- var pom = pomOf("juneau-rest-server-view-freemarker");
- assertFalse(hasDependency(pom, "juneau-rest-server-datatables"));
- }
-
- @Test void a02_consoleUiFreemarker_doesNotDependOnDatatables() throws IOException {
- var pom = pomOf("juneau-rest-server-console-ui-freemarker");
- assertFalse(hasDependency(pom, "juneau-rest-server-datatables"));
- }
-
- @Test void a03_consoleUi_doesNotDependOnDatatables() throws IOException {
- var pom = pomOf("juneau-rest-server-console-ui");
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "juneau-rest-server-view-freemarker",
+ "juneau-rest-server-console-ui-freemarker",
+ "juneau-rest-server-console-ui",
+ })
+ void a01_moduleDoesNotDependOnDatatables(String moduleArtifactId) throws IOException {
+ var pom = pomOf(moduleArtifactId);
assertFalse(hasDependency(pom, "juneau-rest-server-datatables"));
}
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_IdentifierGuards_Test.java b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_IdentifierGuards_Test.java
index 29025d27be..7623a2ebc0 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_IdentifierGuards_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/test/java/org/apache/juneau/rest/server/console/Theme_IdentifierGuards_Test.java
@@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.*;
import org.apache.juneau.*;
import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.*;
+import org.junit.jupiter.params.provider.*;
/**
* Phase 1 gate: {@link Theme#create(String)} / {@link Theme.Builder#token(String, String)} identifier REJECT
@@ -37,36 +39,36 @@ class Theme_IdentifierGuards_Test extends TestBase {
// Token name REJECT
//-----------------------------------------------------------------------------------------------------------------
- @Test void a01_tokenName_noJcPrefix_rejects() {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("salesforce").token("rm-accent", "red"));
- }
-
- @Test void a02_tokenName_withSpace_rejects() {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("salesforce").token("--jc-bad name", "red"));
- }
-
/**
- * The anchoring proof: an unanchored {@code find()}-based regex would incorrectly ACCEPT this because
- * {@code --jc-foo} matches as a substring; only a full-string {@code matches("^--jc-[a-z0-9-]+$")} rejects it.
+ * The third vector is the anchoring proof: an unanchored {@code find()}-based regex would incorrectly ACCEPT
+ * {@code --jc-foo;--bar} because {@code --jc-foo} matches as a substring; only a full-string
+ * {@code matches("^--jc-[a-z0-9-]+$")} rejects it.
*/
- @Test void a03_tokenName_trailingIllegalSuffix_rejects_anchoringProof() {
- assertThrows(IllegalArgumentException.class, () -> Theme.create("salesforce").token("--jc-foo;--bar", "red"));
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "rm-accent", // no --jc- prefix
+ "--jc-bad name", // interior space
+ "--jc-foo;--bar", // anchoring proof (see javadoc)
+ })
+ void a01_tokenName_invalidNames_rejects(String name) {
+ var b = Theme.create("salesforce");
+ assertThrows(IllegalArgumentException.class, () -> b.token(name, "red"));
}
//-----------------------------------------------------------------------------------------------------------------
// Theme name REJECT
//-----------------------------------------------------------------------------------------------------------------
- @Test void a04_themeName_uppercase_rejects() {
+ @Test void a02_themeName_uppercase_rejects() {
assertThrows(IllegalArgumentException.class, () -> Theme.create("Salesforce"));
}
/** Path-shaped names are rejected here so a later fast-follow that interpolates the name into an asset path doesn't need to re-gate. */
- @Test void a05_themeName_pathShaped_rejects() {
+ @Test void a03_themeName_pathShaped_rejects() {
assertThrows(IllegalArgumentException.class, () -> Theme.create("../evil"));
}
- @Test void a06_themeName_empty_rejects() {
+ @Test void a04_themeName_empty_rejects() {
assertThrows(IllegalArgumentException.class, () -> Theme.create(""));
}
@@ -74,7 +76,7 @@ class Theme_IdentifierGuards_Test extends TestBase {
// Positive control
//-----------------------------------------------------------------------------------------------------------------
- @Test void a07_positiveControl_roundTrips() {
+ @Test void a05_positiveControl_roundTrips() {
var theme = Theme.create("salesforce").token("--jc-accent", "#1589EE").build();
assertEquals("salesforce", theme.getName());
assertEquals("#1589EE", theme.getTokens().get("--jc-accent"));
diff --git a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
index a80458fa3a..15f1714e22 100644
--- a/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
+++ b/juneau-rest/juneau-rest-server-view-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/FreemarkerDispatcher_Test.java
@@ -74,7 +74,7 @@ class FreemarkerDispatcher_Test extends TestBase {
* Section A: resolveConfiguration
* ---------------------------------------------------------------------------------------- */
- @Test void a01_resolveConfiguration_prefersRegisteredBean() throws Exception {
+ @Test void a01_resolveConfiguration_prefersRegisteredBean() {
var dispatcher = FreemarkerDispatcher.create().build();
var beanStore = new BasicBeanStore();
var registered = stringLoaderConfig("hello", "Hi!");
@@ -83,7 +83,7 @@ class FreemarkerDispatcher_Test extends TestBase {
assertSame(registered, dispatcher.resolveConfiguration(mockRequest(beanStore)));
}
- @Test void a02_resolveConfiguration_secondCallReusesCachedDefault() throws Exception {
+ @Test void a02_resolveConfiguration_secondCallReusesCachedDefault() {
var dispatcher = FreemarkerDispatcher.create().build();
var req = mockRequest(new BasicBeanStore());
@@ -129,8 +129,9 @@ class FreemarkerDispatcher_Test extends TestBase {
// an IOException subclass, which render()'s "catch (IOException | BasicHttpException ex)" rethrows as-is.
beanStore.addBean(Configuration.class, stringLoaderConfig("hello", "Hi!"));
- assertThrows(IOException.class,
- () -> dispatcher.render("does-not-exist", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ assertThrows(IOException.class, () -> dispatcher.render("does-not-exist", req, res));
}
@Test void b04_render_linkageErrorFromEngine_wrapsWithNoEngineDiagnostic() throws Exception {
@@ -140,8 +141,9 @@ class FreemarkerDispatcher_Test extends TestBase {
when(cfg.getTemplate("hello")).thenThrow(new NoClassDefFoundError("freemarker.core.Environment"));
beanStore.addBean(Configuration.class, cfg);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("FreeMarker engine"));
}
@@ -152,8 +154,9 @@ class FreemarkerDispatcher_Test extends TestBase {
when(cfg.getTemplate("hello")).thenThrow(new RuntimeException("boom"));
beanStore.addBean(Configuration.class, cfg);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("FreeMarker render failed"));
}
@@ -161,8 +164,9 @@ class FreemarkerDispatcher_Test extends TestBase {
var dispatcher = FreemarkerDispatcher.create().basePath("/templates/").build();
var beanStore = new BasicBeanStore();
- assertThrows(Forbidden.class,
- () -> dispatcher.render("../../../etc/passwd", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ assertThrows(Forbidden.class, () -> dispatcher.render("../../../etc/passwd", req, res));
}
/* ---------------------------------------------------------------------------------------- *
diff --git a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher.java b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher.java
index f829667178..07735fce3f 100644
--- a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher.java
+++ b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher.java
@@ -108,15 +108,14 @@ public class JspDispatcher implements RawTemplateDispatcher {
throw new InternalServerError("Could not resolve RequestDispatcher for '%s'. %s",
target, JspViewRenderer.NO_ENGINE_DIAGNOSTIC);
rd.forward(req.getHttpServletRequest(), res.getHttpServletResponse());
- } catch (InternalServerError ex) {
- // Re-surface the specific NO_ENGINE diagnostic constructed above as-is -- without this
- // clause, the generic `catch (Exception ex)` below would re-wrap it into a generic
- // "JSP render failed for '%s'" message, discarding the diagnostic text from the response body.
+ } catch (InternalServerError | IOException | NotFound ex) {
+ // InternalServerError re-surfaces the specific NO_ENGINE diagnostic constructed above as-is;
+ // IOException / NotFound propagate unchanged. Without this clause the generic `catch (Exception ex)`
+ // below would re-wrap them into a generic "JSP render failed for '%s'" message, discarding the
+ // diagnostic text from the response body.
throw ex;
} catch (NoClassDefFoundError ex) {
throw new InternalServerError(ex, JspViewRenderer.NO_ENGINE_DIAGNOSTIC);
- } catch (IOException | NotFound ex) {
- throw ex;
} catch (Exception ex) {
throw new InternalServerError(ex, "JSP render failed for '%s'", target);
}
diff --git a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer.java b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer.java
index 828c036db1..a5ff3edd86 100644
--- a/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer.java
+++ b/juneau-rest/juneau-rest-server-view-jsp/src/main/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer.java
@@ -147,15 +147,13 @@ public class JspViewRenderer implements ViewRenderer {
target, NO_ENGINE_DIAGNOSTIC);
rd.forward(req.getHttpServletRequest(), res.getHttpServletResponse());
return FINISHED;
- } catch (InternalServerError ex) {
- // Re-surface the specific NO_ENGINE diagnostic constructed above as-is -- without this
- // clause, the generic `catch (Exception ex)` below would re-wrap it into a generic
- // "JSP render failed for '%s'" message, discarding the diagnostic text from the response body.
+ } catch (InternalServerError | IOException ex) {
+ // InternalServerError re-surfaces the specific NO_ENGINE diagnostic constructed above as-is;
+ // IOException propagates unchanged. Without this clause the generic `catch (Exception ex)` below
+ // would re-wrap them into a generic "JSP render failed for '%s'" message, discarding the diagnostic.
throw ex;
} catch (NoClassDefFoundError ex) {
throw new InternalServerError(ex, NO_ENGINE_DIAGNOSTIC);
- } catch (IOException ex) {
- throw ex;
} catch (Exception ex) {
throw new InternalServerError(ex, "JSP render failed for '%s'", target);
}
diff --git a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher_ForwardPaths_Test.java b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher_ForwardPaths_Test.java
index bf36f04c09..b36222a193 100644
--- a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher_ForwardPaths_Test.java
+++ b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspDispatcher_ForwardPaths_Test.java
@@ -91,10 +91,9 @@ class JspDispatcher_ForwardPaths_Test extends TestBase {
@Test void a00_contextPresentButDispatcherNull_reportsNoEngineDiagnostic() throws Exception {
// A real (non-null) ServletContext that itself resolves the target to no dispatcher -- the
// documented rd == null branch, distinct from JspMixin_MockRest_Test's NPE-driven 500 (see class
- // javadoc above). render(...) now has a dedicated `catch (InternalServerError ex) { throw ex; }`
- // clause ahead of the generic catch, so this branch's specific "Could not resolve
- // RequestDispatcher... NO_ENGINE_DIAGNOSTIC" message reaches the response body intact instead of
- // being re-wrapped into the generic "JSP render failed for" message.
+ // javadoc above). render(...) re-throws InternalServerError ahead of the generic catch, so this
+ // branch's specific "Could not resolve RequestDispatcher... NO_ENGINE_DIAGNOSTIC" message reaches
+ // the response body intact instead of being re-wrapped into the generic "JSP render failed for" message.
var ctx = fakeServletContext(null);
var res = c.get("/jsp/hello.jsp").servletContext(ctx).run();
res.assertStatus(500);
diff --git a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
index 678f755422..213e62df82 100644
--- a/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
+++ b/juneau-rest/juneau-rest-server-view-jsp/src/test/java/org/apache/juneau/rest/server/view/jsp/JspViewRenderer_ForwardPaths_Test.java
@@ -210,11 +210,10 @@ class JspViewRenderer_ForwardPaths_Test extends TestBase {
@Test void a00_contextPresentButDispatcherNull_reportsNoEngineDiagnostic() throws Exception {
// The rd == null branch itself (a non-null ServletContext that resolves the target to no
// dispatcher), as distinct from a null ServletContext (which never reaches this class's process()
- // call at all under MockRest's defaults). process(...) now has a dedicated
- // `catch (InternalServerError ex) { throw ex; }` clause ahead of the generic catch, so this
- // branch's specific "Could not resolve RequestDispatcher... NO_ENGINE_DIAGNOSTIC" message reaches
- // the response body intact instead of being re-wrapped into the generic "JSP render failed for"
- // message.
+ // call at all under MockRest's defaults). process(...) re-throws InternalServerError ahead of the
+ // generic catch, so this branch's specific "Could not resolve RequestDispatcher...
+ // NO_ENGINE_DIAGNOSTIC" message reaches the response body intact instead of being re-wrapped into
+ // the generic "JSP render failed for" message.
var ctx = fakeServletContext(null);
var res = c.get("/view").servletContext(ctx).run();
res.assertStatus(500);
diff --git a/juneau-rest/juneau-rest-server-view-mustache/src/test/java/org/apache/juneau/rest/server/view/mustache/MustacheDispatcher_Test.java b/juneau-rest/juneau-rest-server-view-mustache/src/test/java/org/apache/juneau/rest/server/view/mustache/MustacheDispatcher_Test.java
index c7f644ec59..ae1ced5b79 100644
--- a/juneau-rest/juneau-rest-server-view-mustache/src/test/java/org/apache/juneau/rest/server/view/mustache/MustacheDispatcher_Test.java
+++ b/juneau-rest/juneau-rest-server-view-mustache/src/test/java/org/apache/juneau/rest/server/view/mustache/MustacheDispatcher_Test.java
@@ -76,7 +76,7 @@ class MustacheDispatcher_Test extends TestBase {
* Section A: resolveMustacheFactory
* ---------------------------------------------------------------------------------------- */
- @Test void a01_resolveMustacheFactory_prefersRegisteredBean() throws Exception {
+ @Test void a01_resolveMustacheFactory_prefersRegisteredBean() {
var dispatcher = MustacheDispatcher.create().build();
var beanStore = new BasicBeanStore();
var registered = mockFactory(noopMustache());
@@ -109,7 +109,7 @@ class MustacheDispatcher_Test extends TestBase {
"cacheTemplates(false) must bypass the lazy singleton and rebuild a fresh factory (with an empty compile-cache) on every call");
}
- @Test void a04_resolveMustacheFactory_registeredBeanIgnoresCacheTemplatesFlag() throws Exception {
+ @Test void a04_resolveMustacheFactory_registeredBeanIgnoresCacheTemplatesFlag() {
var dispatcher = MustacheDispatcher.create().cacheTemplates(false).build();
var beanStore = new BasicBeanStore();
var registered = mockFactory(noopMustache());
@@ -156,8 +156,8 @@ class MustacheDispatcher_Test extends TestBase {
var res = mock(RestResponse.class);
when(res.getWriter()).thenThrow(new IOException("broken pipe"));
- var ex = assertThrows(IOException.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), res));
+ var req = mockRequest(beanStore);
+ var ex = assertThrows(IOException.class, () -> dispatcher.render("hello", req, res));
assertEquals("broken pipe", ex.getMessage());
}
@@ -168,8 +168,9 @@ class MustacheDispatcher_Test extends TestBase {
when(factory.compile(anyString())).thenThrow(new NoClassDefFoundError("com.github.mustachejava.Mustache"));
beanStore.addBean(MustacheFactory.class, factory);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("Mustache engine"));
}
@@ -180,8 +181,9 @@ class MustacheDispatcher_Test extends TestBase {
when(factory.compile(anyString())).thenThrow(new RuntimeException("boom"));
beanStore.addBean(MustacheFactory.class, factory);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("Mustache render failed"));
}
@@ -189,7 +191,8 @@ class MustacheDispatcher_Test extends TestBase {
var dispatcher = MustacheDispatcher.create().basePath("/templates/").build();
var beanStore = new BasicBeanStore();
- assertThrows(Forbidden.class,
- () -> dispatcher.render("../../../etc/passwd", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ assertThrows(Forbidden.class, () -> dispatcher.render("../../../etc/passwd", req, res));
}
}
diff --git a/juneau-rest/juneau-rest-server-view-thymeleaf/src/test/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafDispatcher_Test.java b/juneau-rest/juneau-rest-server-view-thymeleaf/src/test/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafDispatcher_Test.java
index 3f131b26ce..bbf08a9d07 100644
--- a/juneau-rest/juneau-rest-server-view-thymeleaf/src/test/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafDispatcher_Test.java
+++ b/juneau-rest/juneau-rest-server-view-thymeleaf/src/test/java/org/apache/juneau/rest/server/view/thymeleaf/ThymeleafDispatcher_Test.java
@@ -121,8 +121,8 @@ class ThymeleafDispatcher_Test extends TestBase {
var res = mock(RestResponse.class);
when(res.getWriter()).thenThrow(new IOException("broken pipe"));
- var ex = assertThrows(IOException.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), res));
+ var req = mockRequest(beanStore);
+ var ex = assertThrows(IOException.class, () -> dispatcher.render("hello", req, res));
assertEquals("broken pipe", ex.getMessage());
}
@@ -134,8 +134,9 @@ class ThymeleafDispatcher_Test extends TestBase {
.process(anyString(), any(IContext.class), any(Writer.class));
beanStore.addBean(TemplateEngine.class, engine);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("Thymeleaf engine"));
}
@@ -147,8 +148,9 @@ class ThymeleafDispatcher_Test extends TestBase {
.process(anyString(), any(IContext.class), any(Writer.class));
beanStore.addBean(TemplateEngine.class, engine);
- var ex = assertThrows(InternalServerError.class,
- () -> dispatcher.render("hello", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ var ex = assertThrows(InternalServerError.class, () -> dispatcher.render("hello", req, res));
assertTrue(ex.getMessage().contains("Thymeleaf render failed"));
}
@@ -156,8 +158,9 @@ class ThymeleafDispatcher_Test extends TestBase {
var dispatcher = ThymeleafDispatcher.create().basePath("/templates/").build();
var beanStore = new BasicBeanStore();
- assertThrows(Forbidden.class,
- () -> dispatcher.render("../../../etc/passwd", mockRequest(beanStore), mockResponse()));
+ var req = mockRequest(beanStore);
+ var res = mockResponse();
+ assertThrows(Forbidden.class, () -> dispatcher.render("../../../etc/passwd", req, res));
}
/* ---------------------------------------------------------------------------------------- *
diff --git a/juneau-secret-keychain/pom.xml b/juneau-rest/juneau-rest-server-views-markdown/pom.xml
similarity index 64%
copy from juneau-secret-keychain/pom.xml
copy to juneau-rest/juneau-rest-server-views-markdown/pom.xml
index 2e86a44a8c..ceff212a1b 100644
--- a/juneau-secret-keychain/pom.xml
+++ b/juneau-rest/juneau-rest-server-views-markdown/pom.xml
@@ -21,35 +21,78 @@
<parent>
<groupId>org.apache.juneau</groupId>
- <artifactId>juneau</artifactId>
+ <artifactId>juneau-rest</artifactId>
<version>10.0.0-SNAPSHOT</version>
</parent>
- <artifactId>juneau-secret-keychain</artifactId>
- <name>Apache Juneau Secret Store - OS Keychain</name>
- <description>Opt-in SecretStore implementation backed by the macOS 'security' keychain CLI.</description>
+ <artifactId>juneau-rest-server-views-markdown</artifactId>
+ <name>Apache Juneau REST Server Views Markdown</name>
+ <description>Apache Juneau REST Server - Optional markdown-to-HTML rendering for the views toolkit: a pluggable parser SPI with a commonmark-java default (GFM tables enabled)</description>
<packaging>bundle</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <!-- Latest release on Maven Central (0.30.0 exists only in an Eclipse staging repo as of this writing). -->
+ <commonmark.version>0.28.0</commonmark.version>
</properties>
<dependencies>
+ <!--
+ juneau-commons supplies the Shorts exception factories; it has no compile-scope transitive dependencies of
+ its own, so it does not weigh this optional add-on module down.
+ -->
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-commons</artifactId>
<version>${project.version}</version>
</dependency>
+ <!--
+ commonmark-java is the shipped DEFAULT parser behind the MarkdownRenderer SPI (BSD-2-Clause = ASF
+ Category A). compile scope (NOT provided) so a consumer that opts into this module gets a working
+ renderer out of the box without having to independently discover, add, and version the parser.
+ -->
+ <dependency>
+ <groupId>org.commonmark</groupId>
+ <artifactId>commonmark</artifactId>
+ <version>${commonmark.version}</version>
+ </dependency>
+ <!-- GFM pipe-table extension, enabled by default (real documents use pipe tables; core CommonMark renders them as literal pipes). -->
+ <dependency>
+ <groupId>org.commonmark</groupId>
+ <artifactId>commonmark-ext-gfm-tables</artifactId>
+ <version>${commonmark.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>
</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>
@@ -67,19 +110,6 @@
</execution>
</executions>
</plugin>
- <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.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
@@ -103,15 +133,6 @@
</execution>
</executions>
</plugin>
- <plugin>
- <groupId>org.apache.maven.plugins</groupId>
- <artifactId>maven-surefire-plugin</artifactId>
- <configuration>
- <includes>
- <include>**/*Test.class</include>
- </includes>
- </configuration>
- </plugin>
</plugins>
</build>
</project>
diff --git a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
new file mode 100644
index 0000000000..7617a4fadb
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer.java
@@ -0,0 +1,73 @@
+/*
+ * 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.markdown;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.commonmark.Extension;
+import org.commonmark.ext.gfm.tables.TablesExtension;
+import org.commonmark.parser.Parser;
+import org.commonmark.renderer.html.HtmlRenderer;
+
+/**
+ * The default {@link MarkdownRenderer} implementation: a thin wrapper around
+ * <a class="doclink" href="https://github.com/commonmark/commonmark-java">commonmark-java</a> with the GFM tables
+ * extension enabled.
+ *
+ * <p>
+ * The GFM tables extension is on by default because real-world documents (runbooks, onboarding guides) routinely
+ * use pipe tables, and core CommonMark renders them as paragraphs of literal pipes — shipping core-only would
+ * reproduce inside the framework exactly the discovery cost this module exists to remove.
+ *
+ * <p>
+ * commonmark-java's {@link Parser} and {@link HtmlRenderer} are immutable and thread-safe once built, so a single
+ * instance of this class is safe to share across threads and reuse for every render.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link MarkdownRenderer}
+ * <li class='link'><a class="doclink" href="https://github.github.com/gfm/#tables-extension-">GFM tables extension</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public class CommonmarkMarkdownRenderer implements MarkdownRenderer {
+
+ private final Parser parser;
+ private final HtmlRenderer renderer;
+
+ /**
+ * Constructor.
+ *
+ * <p>
+ * Builds a parser/renderer pair with the GFM tables extension enabled on both.
+ */
+ public CommonmarkMarkdownRenderer() {
+ List<Extension> extensions = List.of(TablesExtension.create());
+ parser = Parser.builder().extensions(extensions).build();
+ renderer = HtmlRenderer.builder().extensions(extensions).build();
+ }
+
+ @Override /* MarkdownRenderer */
+ public String toHtml(String markdown) {
+ if (markdown == null)
+ throw iaex("Markdown source must not be null.");
+ return renderer.render(parser.parse(markdown));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
new file mode 100644
index 0000000000..e62bfd4dfe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer.java
@@ -0,0 +1,89 @@
+/*
+ * 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.markdown;
+
+import java.util.*;
+
+/**
+ * A pluggable markdown-to-HTML renderer: hand it a markdown string, get back an HTML fragment.
+ *
+ * <p>
+ * This is the extension point of the optional markdown module — the toolkit's answer to "the way
+ * {@code ViewDef} turns a list of beans into a themed table, hand me a markdown string and give me back HTML".
+ * The interface is deliberately library-neutral: a single {@link #toHtml(String)} method, so any parser can sit
+ * behind it. The shipped default is {@link CommonmarkMarkdownRenderer} (commonmark-java with the GFM tables
+ * extension enabled), but a consumer can swap in their own parser without the toolkit knowing which library won.
+ *
+ * <h5 class='section'>Selecting an implementation:</h5>
+ * <p>
+ * {@link #resolve()} returns the active renderer via the standard {@link ServiceLoader} SPI mechanism: if a
+ * consumer registers their own provider (a {@code META-INF/services/}
+ * {@code org.apache.juneau.rest.server.views.markdown.MarkdownRenderer} file naming their implementation class),
+ * that provider wins; otherwise the built-in commonmark-java default is used. The default is intentionally
+ * <b>not</b> registered as a service, so a single consumer-supplied provider is always unambiguous.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <jc>// Render markdown to an HTML fragment using whichever renderer is active.</jc>
+ * MarkdownRenderer <jv>renderer</jv> = MarkdownRenderer.<jsm>resolve</jsm>();
+ * String <jv>html</jv> = <jv>renderer</jv>.toHtml(<js>"# Runbook\n\nStep one."</js>);
+ * </p>
+ *
+ * <p>
+ * The rendered HTML is intended to be dropped inside a <c>.jc-prose</c> container so it picks up the console's
+ * prose typography.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link CommonmarkMarkdownRenderer}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface MarkdownRenderer {
+
+ /**
+ * Renders the specified markdown source to an HTML fragment.
+ *
+ * @param markdown The markdown source. Must not be <jk>null</jk> (an empty string renders to an empty fragment).
+ * @return The rendered HTML fragment.
+ * @throws IllegalArgumentException If <jv>markdown</jv> is <jk>null</jk>.
+ */
+ String toHtml(String markdown);
+
+ /**
+ * Resolves the active renderer: the first {@link ServiceLoader}-registered provider if one exists, otherwise the
+ * built-in commonmark-java default.
+ *
+ * @return The active renderer. Never <jk>null</jk>.
+ */
+ static MarkdownRenderer resolve() {
+ return resolve(ServiceLoader.load(MarkdownRenderer.class));
+ }
+
+ /**
+ * Resolution seam used by {@link #resolve()}, exposed package-private so both the provider-found and
+ * default-fallback branches can be exercised without touching the JVM-wide {@link ServiceLoader} registry.
+ *
+ * @param providers The candidate providers (a live {@link ServiceLoader} in production).
+ * @return The first provider if any, otherwise a new {@link CommonmarkMarkdownRenderer}.
+ */
+ static MarkdownRenderer resolve(Iterable<MarkdownRenderer> providers) {
+ var i = providers.iterator();
+ return i.hasNext() ? i.next() : new CommonmarkMarkdownRenderer();
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/package-info.java b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/package-info.java
new file mode 100644
index 0000000000..1ff5a16d8f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views-markdown/src/main/java/org/apache/juneau/rest/server/views/markdown/package-info.java
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+/**
+ * An optional markdown-to-HTML rendering story for the views/console toolkit.
+ *
+ * <p>
+ * The framework itself renders data tables ({@code juneau-rest-server-views}) and chrome
+ * ({@code juneau-rest-server-console-ui}); neither has any concept of a rendered <i>document</i>. This optional
+ * module fills that gap with a single, library-neutral extension point —
+ * {@link org.apache.juneau.rest.server.views.markdown.MarkdownRenderer} — plus a shipped default
+ * ({@link org.apache.juneau.rest.server.views.markdown.CommonmarkMarkdownRenderer}) that wraps commonmark-java with
+ * the GFM tables extension enabled. Because it is a separate module, consumers that do not need markdown pay
+ * nothing for it.
+ *
+ * <p>
+ * The rendered HTML is meant to be dropped inside a <c>.jc-prose</c> container so it inherits the console's prose
+ * typography. Wiring the rendered output into a {@code Tab}/{@code Subtab} panel's content property is a deferred
+ * follow-on integration point, not part of this module.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.server.views.markdown.MarkdownRenderer}
+ * <li class='jc'>{@link org.apache.juneau.rest.server.views.markdown.CommonmarkMarkdownRenderer}
+ * <li class='link'><a class="doclink" href="https://github.com/commonmark/commonmark-java">commonmark-java</a>
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.rest.server.views.markdown;
diff --git a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
new file mode 100644
index 0000000000..4db081af06
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/CommonmarkMarkdownRenderer_Test.java
@@ -0,0 +1,88 @@
+/*
+ * 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.markdown;
+
+import static org.apache.juneau.BasicTestUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Behavioural tests for {@link CommonmarkMarkdownRenderer} - the shipped commonmark-java default with the GFM
+ * tables extension enabled.
+ */
+class CommonmarkMarkdownRenderer_Test extends TestBase {
+
+ private static final CommonmarkMarkdownRenderer RENDERER = new CommonmarkMarkdownRenderer();
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // a) Core CommonMark rendering
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_paragraph() {
+ assertEquals("<p>Hello world.</p>\n", RENDERER.toHtml("Hello world."));
+ }
+
+ @Test void a02_heading() {
+ assertEquals("<h1>Runbook</h1>\n", RENDERER.toHtml("# Runbook"));
+ }
+
+ @Test void a03_unorderedList() {
+ assertEquals("<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n", RENDERER.toHtml("- one\n- two"));
+ }
+
+ @Test void a04_fencedCodeBlock() {
+ var html = RENDERER.toHtml("```\ncode();\n```");
+ assertTrue(html.contains("<pre><code>"), () -> "expected a code block, got:\n" + html);
+ assertTrue(html.contains("code();"), () -> "expected code content, got:\n" + html);
+ }
+
+ @Test void a05_inlineEmphasisAndLink() {
+ assertEquals("<p><em>hi</em> <a href=\"https://x\">y</a></p>\n", RENDERER.toHtml("*hi* [y](https://x)"));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // b) GFM tables extension - the reason this ships enabled by default
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_pipeTable_rendersAsTable_notLiteralPipes() {
+ var md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
+ var html = RENDERER.toHtml(md);
+ assertTrue(html.contains("<table>"), () -> "GFM tables extension not enabled - got:\n" + html);
+ assertTrue(html.contains("<th>A</th>"), () -> "expected header cell, got:\n" + html);
+ assertTrue(html.contains("<td>1</td>"), () -> "expected body cell, got:\n" + html);
+ assertFalse(html.contains("| A | B |"), () -> "table rendered as literal pipes, got:\n" + html);
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // c) Edge cases
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_emptyString_rendersEmpty() {
+ assertEquals("", RENDERER.toHtml(""));
+ }
+
+ @Test void c02_null_throws() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Markdown source must not be null.", () -> RENDERER.toHtml(null));
+ }
+
+ @Test void c03_rendererIsReusableAcrossCalls() {
+ assertEquals("<p>one</p>\n", RENDERER.toHtml("one"));
+ assertEquals("<p>two</p>\n", RENDERER.toHtml("two"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer_Test.java b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer_Test.java
new file mode 100644
index 0000000000..10ab80622e
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views-markdown/src/test/java/org/apache/juneau/rest/server/views/markdown/MarkdownRenderer_Test.java
@@ -0,0 +1,76 @@
+/*
+ * 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.markdown;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * SPI-resolution tests for {@link MarkdownRenderer#resolve()} and its testable {@code resolve(Iterable)} seam:
+ * a registered provider wins, and the commonmark-java default is the fallback when none is registered.
+ */
+class MarkdownRenderer_Test extends TestBase {
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // a) The SPI is a simple, swappable functional contract
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_interfaceIsTriviallyImplementable() {
+ MarkdownRenderer custom = md -> "<custom>" + md + "</custom>";
+ assertEquals("<custom>x</custom>", custom.toHtml("x"));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // b) resolve(Iterable) - both branches without touching the JVM-wide ServiceLoader registry
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_registeredProviderWins() {
+ MarkdownRenderer provider = md -> "SENTINEL";
+ var resolved = MarkdownRenderer.resolve(List.of(provider));
+ assertSame(provider, resolved);
+ assertEquals("SENTINEL", resolved.toHtml("anything"));
+ }
+
+ @Test void b02_firstProviderWinsWhenSeveralRegistered() {
+ MarkdownRenderer first = md -> "FIRST";
+ MarkdownRenderer second = md -> "SECOND";
+ assertEquals("FIRST", MarkdownRenderer.resolve(List.of(first, second)).toHtml("x"));
+ }
+
+ @Test void b03_fallsBackToCommonmarkDefaultWhenNoProvider() {
+ var resolved = MarkdownRenderer.resolve(List.<MarkdownRenderer>of());
+ assertInstanceOf(CommonmarkMarkdownRenderer.class, resolved);
+ assertEquals("<p>hi</p>\n", resolved.toHtml("hi"));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // c) resolve() - the production ServiceLoader path
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_resolve_returnsWorkingRenderer() {
+ // No provider is registered on the test classpath, so this exercises the commonmark-java fallback end-to-end.
+ var resolved = MarkdownRenderer.resolve();
+ assertNotNull(resolved);
+ assertInstanceOf(CommonmarkMarkdownRenderer.class, resolved);
+ var html = resolved.toHtml("| A |\n| --- |\n| 1 |");
+ assertTrue(html.contains("<table>"), () -> "expected GFM table from the default, got:\n" + html);
+ }
+}
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 eb28fa822d..e3e30a2576 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
@@ -41,6 +41,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="data,name,title,orderable,searchable,render,href,className")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class Column {
/** The bean-property / JSON key this column reads from each row. */
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 9d9ce5ea3c..a7f9a1d727 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
@@ -23,8 +23,8 @@ import java.util.*;
import org.apache.juneau.commons.bean.*;
/**
- * The top-level, declarative "tabs / sub-tabs page" definition (TODO-399 Phase C, design doc §"Bean model") —
- * composes multiple existing {@link ViewDef} views into one multi-tab / sub-tab admin page.
+ * The top-level, declarative "tabs / sub-tabs page" definition (design doc §"Bean model") — composes
+ * multiple existing {@link ViewDef} views into one multi-tab / sub-tab admin page.
*
* <p>
* A {@link PageDef} is an ordinary Juneau bean built via a small fluent builder ({@link #create(String)} + chained
@@ -61,6 +61,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="contractVersion,id,title,tabs")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class PageDef {
/** The frozen contract version, reusing {@link ViewDef#CONTRACT_VERSION} as the single source of truth. */
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 ff2e756442..7e0dbb56dd 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
@@ -29,9 +29,9 @@ import org.apache.juneau.marshall.marshaller.*;
/**
* Builds the HTML delivery shell for a {@link PageDef} — the self-contained, class-based tab/sub-tab shell
- * (TODO-399 Phase C, design doc §"Client page runtime" + Decision 1(A)) wrapping one independent
- * {@code data-juneau-view} table per referenced {@link ViewDef}, plus the {@code <script type="application/json">}
- * PAGE_META sidecar the {@code juneau-pages.js} runtime consumes for hash routing.
+ * (design doc §"Client page runtime" + Decision 1(A)) wrapping one independent {@code data-juneau-view} table per
+ * referenced {@link ViewDef}, plus the {@code <script type="application/json">} PAGE_META sidecar the
+ * {@code juneau-pages.js} runtime consumes for hash routing.
*
* <p>
* Mirrors the sibling {@link ViewTable} emitter pattern exactly, including its escaping contract (see that class's
@@ -64,8 +64,8 @@ import org.apache.juneau.marshall.marshaller.*;
* <p>
* The emitted shell carries only the neutral {@code .jc-*} classes ({@link #PAGE_CLASS}, {@link #TAB_CLASS},
* {@link #SUBTAB_CLASS}, etc.) — no inline {@code style="..."} and no {@code url(...)}. This module takes no
- * dependency on {@code juneau-rest-server-console-ui}; TODO-361's {@code chrome.css} may theme the same classes when
- * present, mirroring the already-agreed {@code .tag.<domain>.<value>} chip reconciliation.
+ * dependency on {@code juneau-rest-server-console-ui}; that module's {@code chrome.css} may theme the same classes
+ * when present, mirroring the already-agreed {@code .tag.<domain>.<value>} chip reconciliation.
*
* <h5 class='section'>Child views stay independent (no wrapping/absorption):</h5>
* <p>
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
index 546ebc4874..00e04a2e7b 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
@@ -43,6 +43,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="id,meta")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class Render {
/** The renderer id (registry key in {@code juneau-renders.js}). */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
index aa1894d049..33956645e4 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/RibbonAction.java
@@ -39,6 +39,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="type,buttons,optional,id,title,group,column,value,param,persist,symbol,color,deselectable,options")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class RibbonAction {
/** The discriminator: {@code export}/{@code refresh}/{@code columnSearchToggle}/{@code option}/{@code optionGroup}/{@code divider}. */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
index e005247ad4..ec43b576f9 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Subtab.java
@@ -22,7 +22,7 @@ import org.apache.juneau.commons.bean.*;
/**
* A single sub-tab within a {@link Tab} that has {@link Tab#subtabs}, referencing exactly one child {@link ViewDef}
- * (TODO-399 Phase C, design doc §"Bean model").
+ * (design doc §"Bean model").
*
* <p>
* A {@link Subtab} carries a stable {@code id} (the third hash segment, {@code #pageId/tabId/<subtabId>}) and
@@ -38,6 +38,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="id,label,view")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class Subtab {
/** The stable sub-tab id (the third hash segment). */
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
index 3b83864577..b2271b4b76 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Tab.java
@@ -24,7 +24,7 @@ import org.apache.juneau.commons.bean.*;
/**
* A single top-level tab within a {@link PageDef}, referencing either one child {@link ViewDef} directly or an
- * ordered list of {@link Subtab} (TODO-399 Phase C, design doc §"Bean model").
+ * ordered list of {@link Subtab} (design doc §"Bean model").
*
* <p>
* A {@link Tab} carries a stable {@code id} (the second hash segment, {@code #pageId/<tabId>/...}) and {@code label}
@@ -40,6 +40,7 @@ import org.apache.juneau.commons.bean.*;
* @since 10.0.0
*/
@BeanType(properties="id,label,view,subtabs")
+@SuppressWarnings("java:S1845") // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
public class Tab {
/** The stable tab id (the second hash segment), unique across the page. */
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 08966b2aba..b096a36cc5 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,9 +35,10 @@ import org.apache.juneau.rest.server.datatables.*;
* sidecar the server writes and the {@code juneau-views.js} runtime consumes stay wire-stable.
*
* <p>
- * The reserved detail/row-action/catalog fields ({@code details}, {@code rowActions}, {@code catalog}) 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 details} (the row-details expander's field list) is implemented; see {@link #details(DetailDef...)}.
+ * The remaining reserved row-action/catalog fields ({@code rowActions}, {@code catalog}) 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'>
@@ -59,12 +60,25 @@ import org.apache.juneau.rest.server.datatables.*;
*
* @since 10.0.0
*/
-@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules")
+@BeanType(properties="contractVersion,id,rowType,dataMode,dataUrl,defaultOrder,columns,ribbon,rowClassRules,details,pollIntervalMs")
+@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 = "2";
+ /**
+ * The minimum honored polling interval, in milliseconds.
+ *
+ * <p>
+ * A declared {@link #poll(long)} interval below this floor is clamped up to it rather than honored as
+ * configured — a declarable interval with no floor lets a consumer configure a self-inflicted load
+ * problem on a server-side-query table. Enforced here (server-side) rather than only in
+ * {@code juneau-views.js} so the clamp is a single, easily-tested source of truth and a stale/cached client
+ * script can't be tricked into honoring a sub-floor value the server never actually declared.
+ */
+ public static final long MIN_POLL_INTERVAL_MS = 5_000L;
+
/**
* How the table sources its rows.
*
@@ -154,6 +168,49 @@ 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;
@@ -181,11 +238,27 @@ public class ViewDef {
/** The declarative row-decorator rules. */
public List<RowClassRule> rowClassRules;
+ /**
+ * 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.
+ */
+ public List<DetailDef> details;
+
+ /**
+ * The declared table-refresh polling interval, in milliseconds; omitted from the wire when unset (no polling).
+ * Never below {@link #MIN_POLL_INTERVAL_MS} — see {@link #poll(long)}.
+ */
+ public Long pollIntervalMs;
+
/**
* 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.
*/
- private transient Class<?> rowTypeClass;
+ private Class<?> rowTypeClass;
/**
* Starts a new {@link ViewDef} builder with the specified stable view id.
@@ -269,6 +342,27 @@ public class ViewDef {
return this;
}
+ /**
+ * Declares that this table's data should be polled (re-fetched) on the given interval.
+ *
+ * <p>
+ * A value below {@link #MIN_POLL_INTERVAL_MS} is silently clamped up to the floor rather than rejected, so a
+ * consumer that fat-fingers a too-aggressive interval gets a safe table rather than a build-time failure.
+ * The client ({@code juneau-views.js}) re-fetches on this interval via a plain interval timer — a
+ * separate mechanism from any streaming/SSE transport — pauses while the tab or page is not visible,
+ * shows a per-table last-refreshed age, and never overwrites a row that is currently in-flight from a write
+ * (that row is left stale until the write's own result repaints it).
+ *
+ * @param intervalMs The desired polling interval, in milliseconds. Must be positive.
+ * @return This object.
+ */
+ public ViewDef poll(long intervalMs) {
+ if (intervalMs <= 0)
+ throw iaex("ViewDef.poll(...) interval must be positive.");
+ pollIntervalMs = Math.max(intervalMs, MIN_POLL_INTERVAL_MS);
+ return this;
+ }
+
/**
* Adds a value-based row-decorator rule ({@link RowClassRule.Op#EQ eq}/{@link RowClassRule.Op#NE ne}).
*
@@ -301,6 +395,21 @@ public class ViewDef {
return this;
}
+ /**
+ * Declares the row-details expander's field list.
+ *
+ * <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.
+ *
+ * @param value The detail fields, in display order.
+ * @return This object.
+ */
+ public ViewDef details(DetailDef...value) {
+ details = l(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/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index 79ae6088a4..6b3a594455 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
@@ -33,8 +33,7 @@ import org.apache.juneau.rest.server.*;
/**
* 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 (TODO-399 Phase C) — each at its stable path (design doc
- * §6.1).
+ * {@code juneau-pages.js} tabs/sub-tabs page 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
@@ -54,7 +53,9 @@ import org.apache.juneau.rest.server.*;
* <h5 class='section'>Cache-busting + versioned URLs:</h5>
* <p>
* Each asset is served with a one-day {@code Cache-Control} and referenced from a page's {@code head=} block with a
- * {@code ?v=<buildVersion>-<hash8>} cache-buster (see {@link #viewAssetUrl(String)}), where {@code hash8} is an
+ * {@code ?v=<buildVersion>-<hash8>} cache-buster (see {@link #viewAssetUrl(String)}, or
+ * {@link #viewAssetUrl(RestRequest, String)} for a template-rendered consumer that needs the URL already resolved
+ * to an absolute, browser-fetchable form), where {@code hash8} is an
* 8-hex-char CRC32 of that asset's own served bytes, computed once and cached (classpath resources never change
* within a running JVM). Keying the buster off content rather than {@code buildVersion} alone matters for
* {@code -SNAPSHOT} builds: that version string is stable across dev rebuilds, so a version-only buster would keep
@@ -103,8 +104,8 @@ public class ViewsMixin {
public static final String ICONS_JS_PATH = "/juneau-icons.js";
/**
- * The URL path at which the opt-in tabs/sub-tabs page runtime is served (relative to the host mount) —
- * TODO-399 Phase C. A separate, opt-in asset (Decision 2(A)): single-view pages never load it.
+ * The URL path at which the opt-in tabs/sub-tabs page runtime is served (relative to the host mount). A
+ * separate, opt-in asset (Decision 2(A)): single-view pages never load it.
*/
public static final String PAGES_JS_PATH = "/juneau-pages.js";
@@ -129,7 +130,7 @@ public class ViewsMixin {
/** Classpath location of the shipped icon registry. */
static final String ICONS_JS_RESOURCE = "/org/apache/juneau/views/juneau-icons.js";
- /** Classpath location of the shipped page runtime (TODO-399 Phase C). */
+ /** Classpath location of the shipped page runtime. */
static final String PAGES_JS_RESOURCE = "/org/apache/juneau/views/juneau-pages.js";
/** Content type emitted for the JavaScript assets. */
@@ -223,7 +224,7 @@ public class ViewsMixin {
}
/**
- * [GET /juneau-pages.js] — serve the opt-in tabs/sub-tabs page runtime (TODO-399 Phase C).
+ * [GET /juneau-pages.js] — serve the opt-in tabs/sub-tabs page runtime.
*
* @return The page runtime as a JavaScript {@link HttpResource}.
*/
@@ -250,6 +251,28 @@ public class ViewsMixin {
return "servlet:" + path + "?v=" + buildVersion() + "-" + contentHash(path);
}
+ /**
+ * Returns a real, browser-fetchable <b>absolute</b> URL for a served asset, resolved against the given
+ * request's context path and mount — carrying the same {@code ?v=<buildVersion>-<hash8>} content-
+ * sensitive cache-buster as {@link #viewAssetUrl(String)} (see the class Javadoc's cache-busting section).
+ *
+ * <p>
+ * {@link #viewAssetUrl(String)} returns a {@code servlet:}-prefixed URL that only Juneau's own HTML serializer
+ * resolves (it rewrites {@code servlet:} against the request at render time). A template-rendering consumer
+ * (e.g. {@code juneau-rest-server-view-freemarker}) sits downstream of that serializer, so it never sees the
+ * rewrite and would otherwise receive the literal, unfetchable string. This overload resolves the URL itself,
+ * per-request, the same way {@code ConsoleChromeMixin.assetUrl(RestRequest, ...)} does — via
+ * {@link RestRequest#getUriResolver()} — so it is usable from any consumer, template-rendered or not.
+ *
+ * @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}).
+ * @return The absolute asset URL with the version+content-hash cache-buster appended.
+ */
+ public static String viewAssetUrl(RestRequest req, String path) {
+ return req.getUriResolver().resolve("servlet:" + path) + "?v=" + buildVersion() + "-" + contentHash(path);
+ }
+
/** Reads (and caches) the classpath asset and wraps it as a cacheable {@link HttpResource}. */
private static HttpResource serve(String resource, String contentType) {
var bytes = CACHE.computeIfAbsent(resource, ViewsMixin::load);
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
index 94833efff6..2c28502413 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/package-info.java
@@ -20,7 +20,7 @@
* contract consumed by the first-party client runtime.
*
* <p>
- * This optional module builds on {@code juneau-rest-server-datatables} (the TODO-355 server-side query protocol):
+ * This optional module builds on {@code juneau-rest-server-datatables} (the server-side query protocol):
* an app author declares one {@link org.apache.juneau.rest.server.views.ViewDef} — columns, ribbon actions,
* row-decorator rules, and named cell renderers — as ordinary Juneau beans, and the model serializes to the
* {@code VIEW_META} JSON contract that the shipped {@code juneau-views.js}/{@code juneau-ribbon.js}/
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 d94cbc1abe..ef61550da6 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
@@ -16,11 +16,11 @@
*/
/*
- * juneau-pages.js - opt-in tabs/sub-tabs page runtime for the Apache Juneau rich-view toolkit (TODO-399 Phase C).
+ * juneau-pages.js - opt-in tabs/sub-tabs page runtime for the Apache Juneau rich-view toolkit.
*
- * Separate, opt-in asset (Decision 2, RESOLVED (A)): a page composing multiple ViewDef views into tabs/sub-tabs
- * loads this file IN ADDITION to juneau-views.js; a plain single-view page never loads it and pays nothing for
- * hash-routing/lazy-init complexity.
+ * Separate, opt-in asset: a page composing multiple ViewDef views into tabs/sub-tabs loads this file IN ADDITION
+ * to juneau-views.js; a plain single-view page never loads it and pays nothing for hash-routing/lazy-init
+ * complexity.
*
* On DOMContentLoaded it owns init for every [data-juneau-page] shell: it reads the shell's id, finds the matching
* <script type="application/json" id="juneau-page:<id>"> PAGE_META sidecar, JSON.parses it, and - like
@@ -54,9 +54,9 @@
// 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.
- var JUNEAU_PAGE_CONTRACT_VERSION = "2";
+ const JUNEAU_PAGE_CONTRACT_VERSION = "2";
- var NS = window.JuneauViews = window.JuneauViews || {};
+ const NS = window.JuneauViews = window.JuneauViews || {};
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM)
@@ -68,9 +68,9 @@
* beyond the third are ignored; a missing trailing segment yields `null` for that field (never `""`).
*/
function parseHash(hash) {
- var h = String(hash || "").replace(/^#/, "");
+ const h = String(hash || "").replace(/^#/, "");
if (!h) return null;
- var parts = h.split("/");
+ const parts = h.split("/");
return {
pageId: parts[0] || null,
tabId: parts[1] || null,
@@ -81,7 +81,7 @@
/** Finds the entry in `list` whose `.id === id`, or `null` when absent/list is empty. */
function findById(list, id) {
if (!list) return null;
- for (var i = 0; i < list.length; i++)
+ for (let i = 0; i < list.length; i++)
if (list[i].id === id) return list[i];
return null;
}
@@ -93,18 +93,18 @@
* `{tabId, subtabId}` (`subtabId` is `null` for a leaf tab) or `null` when `pageMeta` has no tabs at all.
*/
function resolveInitial(pageMeta, hash) {
- var tabs = (pageMeta && pageMeta.tabs) || [];
+ const tabs = (pageMeta && pageMeta.tabs) || [];
if (!tabs.length) return null;
- var parsed = parseHash(hash);
- var tab = null;
+ const parsed = parseHash(hash);
+ let tab = null;
if (parsed && parsed.pageId === pageMeta.id && parsed.tabId)
tab = findById(tabs, parsed.tabId);
if (!tab) tab = tabs[0];
- var result = { tabId: tab.id, subtabId: null };
+ const result = { tabId: tab.id, subtabId: null };
if (tab.subtabs && tab.subtabs.length) {
- var sub = (parsed && parsed.subtabId) ? findById(tab.subtabs, parsed.subtabId) : null;
+ let sub = (parsed && parsed.subtabId) ? findById(tab.subtabs, parsed.subtabId) : null;
if (!sub) sub = tab.subtabs[0];
result.subtabId = sub.id;
}
@@ -113,7 +113,7 @@
/** Builds the deep-linkable hash for a (pageId, tabId, subtabId) triple; `subtabId` may be null/omitted. */
function hashFor(pageId, tabId, subtabId) {
- var h = "#" + pageId + "/" + tabId;
+ let h = "#" + pageId + "/" + tabId;
if (subtabId != null) h += "/" + subtabId;
return h;
}
@@ -127,14 +127,14 @@
/** Renders the fail-loud, visible banner used on a PAGE_META contract-version mismatch (or a parse failure). */
function renderBanner(root, message) {
- var b = document.createElement("div");
+ const b = document.createElement("div");
b.className = "jc-page-error";
b.textContent = message;
root.insertBefore(b, root.firstChild);
}
// Every node whose visibility this runtime owns: a top-level tab panel, or a sub-tab panel nested inside one.
- var PANEL_SELECTOR = ".jc-panel, .jc-subpanel";
+ const PANEL_SELECTOR = ".jc-panel, .jc-subpanel";
/*
* Whether `panel` should be visible for the resolved active (tabId, subtabId).
@@ -155,7 +155,7 @@
*/
function panelMatches(panel, tabId, subtabId) {
if (panel.getAttribute("data-panel-tab") !== tabId) return false;
- var panelSubtabId = panel.getAttribute("data-panel-subtab");
+ const panelSubtabId = panel.getAttribute("data-panel-subtab");
if (!panelSubtabId) return true; // sub-tab-agnostic panel: tab match is sufficient
return panelSubtabId === subtabId;
}
@@ -170,13 +170,13 @@
* avoid.
*/
function activatePanelViews(panel) {
- var tables = panel.querySelectorAll("table[data-juneau-view]");
+ const tables = panel.querySelectorAll("table[data-juneau-view]");
Array.prototype.forEach.call(tables, function (t) {
if (t.closest(PANEL_SELECTOR) !== panel) return;
- var $ = window.jQuery;
- if ($ && $.fn && $.fn.dataTable && $.fn.dataTable.isDataTable(t)) {
+ const $ = window.jQuery;
+ if ($?.fn?.dataTable?.isDataTable(t)) {
$(t).DataTable().columns.adjust();
- } else if (NS.init && NS.init.initTable) {
+ } else if (NS.init?.initTable) {
NS.init.initTable(t);
} else {
warn("Juneau page: juneau-views.js not loaded (or too old to expose initTable); cannot lazy-init view '" +
@@ -187,33 +187,33 @@
/** Applies the resolved (tabId, subtabId) to `root`: toggles active classes, shows/hides panels, lazy-inits. */
function showActive(root, tabId, subtabId) {
- var tabs = root.querySelectorAll(".jc-tab");
+ const tabs = root.querySelectorAll(".jc-tab");
Array.prototype.forEach.call(tabs, function (el) {
el.classList.toggle("jc-tab-active", el.getAttribute("data-tab-id") === tabId);
});
- var subtabs = root.querySelectorAll(".jc-subtab");
+ const subtabs = root.querySelectorAll(".jc-subtab");
Array.prototype.forEach.call(subtabs, function (el) {
- var active = subtabId != null &&
+ const active = subtabId != null &&
el.getAttribute("data-subtab-id") === subtabId &&
el.getAttribute("data-parent-tab") === tabId;
el.classList.toggle("jc-subtab-active", active);
});
- var panels = root.querySelectorAll(PANEL_SELECTOR);
+ const panels = root.querySelectorAll(PANEL_SELECTOR);
Array.prototype.forEach.call(panels, function (p) {
- var active = panelMatches(p, tabId, subtabId);
+ const active = panelMatches(p, tabId, subtabId);
p.classList.toggle("jc-active", active);
if (active) activatePanelViews(p);
});
}
function initPage(root) {
- var id = root.getAttribute("data-juneau-page");
- var sidecar = document.getElementById("juneau-page:" + id);
+ const id = root.getAttribute("data-juneau-page");
+ const sidecar = document.getElementById("juneau-page:" + id);
if (!sidecar) { error("Juneau page '" + id + "': missing PAGE_META sidecar; refusing to init."); return; }
- var pageMeta;
+ let pageMeta;
try {
pageMeta = JSON.parse(sidecar.textContent);
} catch (e) {
@@ -223,7 +223,7 @@
}
if (pageMeta.contractVersion !== JUNEAU_PAGE_CONTRACT_VERSION) {
- var m = "Juneau page '" + id + "': contract version mismatch (page='" + pageMeta.contractVersion +
+ const m = "Juneau page '" + id + "': contract version mismatch (page='" + pageMeta.contractVersion +
"', runtime='" + JUNEAU_PAGE_CONTRACT_VERSION + "'). Refusing to init - reload to clear a stale cached script.";
error(m);
renderBanner(root, m);
@@ -231,7 +231,7 @@
}
function apply() {
- var active = resolveInitial(pageMeta, window.location.hash);
+ const active = resolveInitial(pageMeta, window.location.hash);
if (active) showActive(root, active.tabId, active.subtabId);
}
@@ -240,7 +240,7 @@
}
function initAllPages() {
- var pages = document.querySelectorAll("[data-juneau-page]");
+ const pages = document.querySelectorAll("[data-juneau-page]");
Array.prototype.forEach.call(pages, function (p) { initPage(p); });
}
diff --git a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
index 80e6c409dc..3ce24bdd02 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
+++ b/juneau-rest/juneau-rest-server-views/src/main/resources/org/apache/juneau/views/juneau-renders.js
@@ -16,7 +16,7 @@
*/
/*
- * juneau-renders.js - dependency-free cell-renderer registry for the Apache Juneau rich-view toolkit (Task B.6).
+ * juneau-renders.js - dependency-free cell-renderer registry for the Apache Juneau rich-view toolkit.
*
* A pure, library-free registry mapping render ids (per the VIEW_META §6.6 grammar) to cell-render functions. Each
* renderer is an object with up to five orthogonal facets DataTables asks a column for:
@@ -39,13 +39,13 @@
(function () {
"use strict";
- var NS = window.JuneauViews = window.JuneauViews || {};
+ const NS = window.JuneauViews = window.JuneauViews || {};
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
// ==================================================================================================================
- var registry = NS._renderers = NS._renderers || {};
+ const registry = NS._renderers = NS._renderers || {};
/**
* Registers (or overrides) a renderer under `id`. `def` is `{display?, sort?, filter?, type?, class?}`; a bare
@@ -58,7 +58,7 @@
/** Looks up a renderer by id; returns null when unknown (callers warn + fall back to the raw value). */
function resolveRenderer(id) {
- return Object.prototype.hasOwnProperty.call(registry, id) ? registry[id] : null;
+ return Object.hasOwn(registry, id) ? registry[id] : null;
}
/**
@@ -68,7 +68,7 @@
function parseRenderId(spec) {
if (spec == null) return null;
if (typeof spec === "object") return spec;
- var i = spec.indexOf(":");
+ const i = spec.indexOf(":");
if (i < 0) return { id: spec };
return { id: spec.substring(0, i), meta: { field: spec.substring(i + 1) } };
}
@@ -77,11 +77,11 @@
function escHtml(s) {
return String(s)
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
}
function escAttr(s) {
@@ -91,7 +91,7 @@
/** Interpolates a `{property}` URL template against a row, URL-encoding each substituted value. */
function interpolateHref(template, rowData) {
return String(template).replace(/\{([^}]+)\}/g, function (m, key) {
- var v = rowData ? rowData[key] : undefined;
+ const v = rowData ? rowData[key] : undefined;
return v == null ? "" : encodeURIComponent(String(v));
});
}
@@ -116,12 +116,12 @@
/** Coerces a cell value to a Date, accepting epoch millis, numeric strings, and ISO/parseable date strings. */
function toDate(cellData) {
if (cellData == null || cellData === "") return null;
- if (cellData instanceof Date) return isNaN(cellData.getTime()) ? null : cellData;
- if (typeof cellData === "number") { var dn = new Date(cellData); return isNaN(dn.getTime()) ? null : dn; }
- var s = String(cellData);
- if (/^\d+$/.test(s)) { var de = new Date(Number(s)); return isNaN(de.getTime()) ? null : de; }
- var d = new Date(s);
- return isNaN(d.getTime()) ? null : d;
+ if (cellData instanceof Date) return Number.isNaN(cellData.getTime()) ? null : cellData;
+ if (typeof cellData === "number") { const dn = new Date(cellData); return Number.isNaN(dn.getTime()) ? null : dn; }
+ const s = String(cellData);
+ if (/^\d+$/.test(s)) { const de = new Date(Number(s)); return Number.isNaN(de.getTime()) ? null : de; }
+ const d = new Date(s);
+ return Number.isNaN(d.getTime()) ? null : d;
}
// --- generic renderers (pure) --------------------------------------------------------------------------------
@@ -129,38 +129,38 @@
registerRenderer("date", {
display: function (cellData) {
- var d = toDate(cellData);
+ const d = toDate(cellData);
if (!d) return cellData == null ? "" : escHtml(cellData);
return escHtml(new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "2-digit" }).format(d));
},
- sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+ sort: function (cellData) { const d = toDate(cellData); return d ? d.getTime() : cellData; }
});
registerRenderer("datetime", {
display: function (cellData) {
- var d = toDate(cellData);
+ const d = toDate(cellData);
if (!d) return cellData == null ? "" : escHtml(cellData);
return escHtml(new Intl.DateTimeFormat(undefined, {
year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit"
}).format(d));
},
- sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+ sort: function (cellData) { const d = toDate(cellData); return d ? d.getTime() : cellData; }
});
registerRenderer("ts-zulu", {
// Absolute UTC ("Zulu") ISO-8601 timestamp - stable, locale-independent, no date library.
display: function (cellData) {
- var d = toDate(cellData);
+ const d = toDate(cellData);
if (!d) return cellData == null ? "" : escHtml(cellData);
return escHtml(d.toISOString().replace(/\.\d{3}Z$/, "Z"));
},
- sort: function (cellData) { var d = toDate(cellData); return d ? d.getTime() : cellData; }
+ sort: function (cellData) { const d = toDate(cellData); return d ? d.getTime() : cellData; }
});
registerRenderer("bool", {
display: function (cellData) {
if (cellData == null || cellData === "") return "";
- var truthy = (cellData === true || cellData === 1 || cellData === "true" || cellData === "1" || cellData === "yes");
+ const truthy = (cellData === true || cellData === 1 || cellData === "true" || cellData === "1" || cellData === "yes");
return truthy ? "Yes" : "No";
}
});
@@ -169,8 +169,8 @@
// Consumes the column's `href` {property} template (merged into meta by the initializer); no template -> text.
display: function (cellData, rowData, meta) {
if (cellData == null) return "";
- var text = String(cellData);
- var href = meta && meta.href;
+ const text = String(cellData);
+ const href = meta && meta.href;
if (!href) return escHtml(text);
return '<a href="' + escAttr(interpolateHref(href, rowData)) + '">' + escHtml(text) + "</a>";
}
@@ -179,9 +179,9 @@
registerRenderer("truncate", {
display: function (cellData, rowData, meta) {
if (cellData == null) return "";
- var s = String(cellData);
- var max = meta && meta.length ? parseInt(meta.length, 10) : 64;
- if (!(max > 0) || s.length <= max) return escHtml(s);
+ const s = String(cellData);
+ const max = meta && meta.length ? Number.parseInt(meta.length, 10) : 64;
+ if (max <= 0 || s.length <= max) return escHtml(s);
return '<span title="' + escAttr(s) + '">' + escHtml(s.substring(0, max)) + "\u2026</span>";
}
});
@@ -189,7 +189,7 @@
registerRenderer("json", {
display: function (cellData) {
if (cellData == null) return "";
- var s;
+ let s;
try { s = JSON.stringify(cellData); } catch (e) { s = String(cellData); }
return "<code>" + escHtml(s) + "</code>";
}
@@ -198,12 +198,12 @@
registerRenderer("decimal", {
display: function (cellData, rowData, meta) {
if (cellData == null || cellData === "") return "";
- var n = Number(cellData);
- if (isNaN(n)) return escHtml(cellData);
- var places = meta && meta.places != null ? parseInt(meta.places, 10) : 2;
+ const n = Number(cellData);
+ if (Number.isNaN(n)) return escHtml(cellData);
+ const places = meta && meta.places != null ? Number.parseInt(meta.places, 10) : 2;
return escHtml(n.toFixed(places >= 0 ? places : 2));
},
- sort: function (cellData) { var n = Number(cellData); return isNaN(n) ? cellData : n; }
+ sort: function (cellData) { const n = Number(cellData); return Number.isNaN(n) ? cellData : n; }
});
registerRenderer("tag", {
@@ -213,11 +213,11 @@
// chrome.css rules (e.g. `.tag.status.released`) match; the RAW value is kept as the display text.
display: function (cellData, rowData, meta) {
if (cellData == null || cellData === "") return "";
- var value = String(cellData);
- var domain = meta && meta.field ? String(meta.field) : "";
- var domainToken = normalizeTagToken(domain);
- var valueToken = normalizeTagToken(value);
- var cls = "tag" + (domainToken ? " " + domainToken : "") + (valueToken ? " " + valueToken : "");
+ const value = String(cellData);
+ const domain = meta && meta.field ? String(meta.field) : "";
+ const domainToken = normalizeTagToken(domain);
+ const valueToken = normalizeTagToken(value);
+ const cls = "tag" + (domainToken ? " " + domainToken : "") + (valueToken ? " " + valueToken : "");
return '<span class="' + escAttr(cls) + '">' + escHtml(value) + "</span>";
},
"class": function () { return "tag-cell"; }
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 fa0cb7b529..03de76562c 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
@@ -16,7 +16,7 @@
*/
/*
- * juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit (Task B.7).
+ * juneau-ribbon.js - ribbon/toolbar runtime for the Apache Juneau rich-view toolkit.
*
* Builds the toolbar from viewDef.ribbon: export (feature-detected copy/csv via DataTables Buttons, with excel/pdf
* lit up only when JSZip/pdfMake are present), refresh, columnSearchToggle, option/optionGroup server-query toggles
@@ -36,7 +36,7 @@
(function () {
"use strict";
- var NS = window.JuneauViews = window.JuneauViews || {};
+ const NS = window.JuneauViews = window.JuneauViews || {};
// ==================================================================================================================
// PURE LOGIC LAYER (no DOM, no jQuery, no DataTables)
@@ -44,8 +44,8 @@
/** Resolves a column `data` key to its zero-based index in the view (mirrors RibbonAction.columnIndex). */
function columnIndex(viewDef, columnKey) {
- var cols = viewDef.columns || [];
- for (var i = 0; i < cols.length; i++)
+ const cols = viewDef.columns || [];
+ for (let i = 0; i < cols.length; i++)
if (cols[i].data === columnKey) return i;
return -1;
}
@@ -58,7 +58,7 @@
function optionParam(viewDef, opt) {
if (opt == null || opt.value == null) return null;
if (opt.column != null) {
- var idx = columnIndex(viewDef, opt.column);
+ const idx = columnIndex(viewDef, opt.column);
if (idx < 0) return null;
return { name: "columns[" + idx + "][search][value]", value: opt.value };
}
@@ -74,19 +74,19 @@
* refresh/columnSearchToggle/divider/export are not query-contributing and are skipped.
*/
function ribbonToQueryParams(viewDef, activeState) {
- var out = {};
- var state = activeState || {};
+ const out = {};
+ const state = activeState || {};
(viewDef.ribbon || []).forEach(function (a) {
if (a.type === "option") {
if (state[a.id]) {
- var p = optionParam(viewDef, a);
+ const p = optionParam(viewDef, a);
if (p) out[p.name] = p.value;
}
} else if (a.type === "optionGroup" && a.options) {
- var selected = state[a.id];
+ const selected = state[a.id];
a.options.forEach(function (o) {
if (o.id === selected) {
- var p = optionParam(viewDef, o);
+ const p = optionParam(viewDef, o);
if (p) out[p.name] = p.value;
}
});
@@ -102,8 +102,8 @@
*/
function detectExportFeatures(win) {
win = win || (typeof window !== "undefined" ? window : {});
- var $ = win.jQuery;
- var hasButtons = !!($ && $.fn && $.fn.dataTable && $.fn.dataTable.Buttons);
+ const $ = win.jQuery;
+ const hasButtons = !!$?.fn?.dataTable?.Buttons;
return { buttons: hasButtons, jszip: !!win.JSZip, pdfmake: !!win.pdfMake };
}
@@ -113,12 +113,12 @@
* dep is present, otherwise omitted.
*/
function resolveExportButtons(action, features) {
- var out = [];
- if (!features || !features.buttons) return out; // degrade gracefully - no export cluster at all
+ const out = [];
+ if (!features?.buttons) return out; // degrade gracefully - no export cluster at all
(action.buttons || []).forEach(function (b) { out.push(b); });
(action.optional || []).forEach(function (b) {
- if (b === "excel" && features.jszip) out.push(b);
- else if (b === "pdf" && features.pdfmake) out.push(b);
+ // Both branches merely gate on their own extra dep being present - combined rather than duplicated.
+ if ((b === "excel" && features.jszip) || (b === "pdf" && features.pdfmake)) out.push(b);
// else: dep absent - omit/grey (feature-detected)
});
return out;
@@ -133,10 +133,10 @@
* Default built-in id -> icon-name lookup (visual-parity design doc §4.A). Keyed by *button id* for the export
* cluster (one export action renders one button per resolved id) and by *action type* for refresh/
* columnSearchToggle (each renders exactly one button). `collapse` is not wired to any RibbonAction.type today -
- * it ships here purely for forward-compatibility with TODO-399 P4's (deferred) row-expander "collapse all"
+ * it ships here purely for forward-compatibility with a possible future (deferred) row-expander "collapse all"
* affordance; inert until that action type exists.
*/
- var DEFAULT_ICONS = {
+ const DEFAULT_ICONS = {
copy: "content_copy", csv: "csv", excel: "table", pdf: "picture_as_pdf",
refresh: "refresh", columnSearchToggle: "manage_search", collapse: "unfold_less"
};
@@ -146,12 +146,12 @@
* action/Opt always wins; otherwise a `defaultKey` (the export button id, or the action `type`) resolves from
* DEFAULT_ICONS; a custom option/optionGroup member with neither falls back to the neutral "tune" glyph, never
* blank/unset. Markup resolution (NS.icons.resolveIcon(name)) and the "unregistered name -> render title as
- * text" fallback both happen in the DOM binding layer (Task 3), since this pure layer has no access to the
- * registry's runtime contents (apps can register icons after page load).
+ * text" fallback both happen in the DOM binding layer, since this pure layer has no access to the registry's
+ * runtime contents (apps can register icons after page load).
*/
function resolveButtonIcon(actionOrOpt, defaultKey) {
if (actionOrOpt && actionOrOpt.symbol != null) return actionOrOpt.symbol;
- if (defaultKey != null && Object.prototype.hasOwnProperty.call(DEFAULT_ICONS, defaultKey)) return DEFAULT_ICONS[defaultKey];
+ if (defaultKey != null && Object.hasOwn(DEFAULT_ICONS, defaultKey)) return DEFAULT_ICONS[defaultKey];
return "tune";
}
@@ -164,15 +164,15 @@
}
function loadPersistedState(viewDef) {
- var state = {};
- var store = safeStorage();
+ const state = {};
+ const store = safeStorage();
if (!store) return state;
(viewDef.ribbon || []).forEach(function (a) {
if (a.type === "option" && a.persist) {
- var raw = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+ const raw = store.getItem(ribbonStorageKey(viewDef.id, a.id));
if (raw != null) state[a.id] = (raw === "true");
} else if (a.type === "optionGroup" && a.persist) {
- var sel = store.getItem(ribbonStorageKey(viewDef.id, a.id));
+ const sel = store.getItem(ribbonStorageKey(viewDef.id, a.id));
if (sel != null) state[a.id] = sel;
}
});
@@ -180,7 +180,7 @@
}
function persist(viewDef, id, value) {
- var store = safeStorage();
+ const store = safeStorage();
if (store) store.setItem(ribbonStorageKey(viewDef.id, id), String(value));
}
@@ -196,17 +196,21 @@
* action, one visual cluster - each export action gets its own synthetic per-index id so consecutive distinct
* export actions never merge). A {@code divider} or an ungrouped action always closes any open cluster.
*/
+ // NOSONAR javascript:S3776 -- one dispatch branch per RibbonAction.type (design doc §4.A); each branch is a
+ // few lines and several are pinned verbatim by the wiring canary tests below `functionBody(body, "function
+ // buildRibbon(")`, so splitting them into further helpers would reduce test/code locality without reducing
+ // real complexity.
function buildRibbon(viewDef, ctx) {
- var actions = viewDef.ribbon || [];
+ const actions = viewDef.ribbon || [];
if (!actions.length) return null;
- var $ = window.jQuery;
- var features = detectExportFeatures(window);
- var bar = document.createElement("div");
+ const $ = window.jQuery;
+ const features = detectExportFeatures(window);
+ const bar = document.createElement("div");
bar.className = "juneau-view-ribbon";
bar.setAttribute("data-testid", "ribbon");
- var openGroup = null; // { id, el } - the currently-open adjacent-group wrapper, or null when ungrouped
+ let openGroup = null; // { id, el } - the currently-open adjacent-group wrapper, or null when ungrouped
function place(el, groupId) {
if (groupId == null) {
openGroup = null;
@@ -214,7 +218,7 @@
return;
}
if (!openGroup || openGroup.id !== groupId) {
- var wrap = document.createElement("span");
+ const wrap = document.createElement("span");
wrap.className = "juneau-view-ribbon-group";
bar.appendChild(wrap);
openGroup = { id: groupId, el: wrap };
@@ -225,20 +229,20 @@
actions.forEach(function (a, idx) {
if (a.type === "divider") {
openGroup = null;
- var d = document.createElement("span");
+ const d = document.createElement("span");
d.className = "juneau-view-ribbon-divider";
bar.appendChild(d);
return;
}
if (a.type === "export") {
- var ids = resolveExportButtons(a, features);
- if (ids.length && $ && ctx.dataTable && $.fn.dataTable.Buttons) {
+ const ids = resolveExportButtons(a, features);
+ if (ids.length && ctx.dataTable && $?.fn?.dataTable?.Buttons) {
try {
// 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 });
- var exportGroupId = a.group != null ? a.group : ("__export" + idx);
+ const exportGroupId = a.group != null ? a.group : ("__export" + idx);
ids.forEach(function (id) {
place(button(id, resolveButtonIcon(null, id), function () {
ctx.dataTable.button(id).trigger();
@@ -253,7 +257,7 @@
return;
}
if (a.type === "columnSearchToggle") {
- var csBtn = button(a.title || "Column search", resolveButtonIcon(a, "columnSearchToggle"), function () {
+ const csBtn = button(a.title || "Column search", resolveButtonIcon(a, "columnSearchToggle"), function () {
csBtn.setAttribute("aria-pressed", toggleColumnSearch(viewDef, ctx) ? "true" : "false");
});
csBtn.setAttribute("aria-pressed", ctx.columnSearchOn ? "true" : "false");
@@ -282,12 +286,12 @@
* documented for juneau-renders.js).
*/
function button(label, iconName, onClick) {
- var b = document.createElement("button");
+ const b = document.createElement("button");
b.type = "button";
b.className = "juneau-view-ribbon-btn";
b.title = label;
b.setAttribute("aria-label", label);
- var markup = NS.icons && NS.icons.resolveIcon ? NS.icons.resolveIcon(iconName) : null;
+ const markup = NS.icons?.resolveIcon ? NS.icons.resolveIcon(iconName) : null;
if (markup != null) {
b.innerHTML = markup;
} else {
@@ -298,7 +302,7 @@
}
function optionToggle(viewDef, action, ctx) {
- var b = button(action.title || action.id, resolveButtonIcon(action, null), function () {
+ const b = button(action.title || action.id, resolveButtonIcon(action, null), function () {
ctx.activeState[action.id] = !ctx.activeState[action.id];
b.setAttribute("aria-pressed", ctx.activeState[action.id] ? "true" : "false");
if (action.persist) persist(viewDef, action.id, !!ctx.activeState[action.id]);
@@ -309,10 +313,10 @@
}
function optionGroup(viewDef, group, ctx) {
- var wrap = document.createElement("span");
+ const wrap = document.createElement("span");
wrap.className = "juneau-view-ribbon-group";
(group.options || []).forEach(function (o) {
- var b = button(o.title || o.id, resolveButtonIcon(o, null), function () {
+ const b = button(o.title || o.id, resolveButtonIcon(o, null), function () {
ctx.activeState[group.id] = (ctx.activeState[group.id] === o.id && group.deselectable) ? null : o.id;
if (group.persist) persist(viewDef, group.id, ctx.activeState[group.id]);
ctx.redraw();
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 40236a1e11..8494751008 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
@@ -23,12 +23,12 @@
* palette). It exists so a client-rendered `render("tag:status")` cell is legible standalone, even before an app
* adopts console-ui's chrome.
*
- * The ".tag.<domain>.<value>" class-name contract is OWNED by console-ui (TODO-361). When console-ui's chrome.css
- * is present it themes the SAME classes (colors by domain/value); this module takes NO dependency on console-ui and
- * MUST NOT add a color palette here (per the 361/399 reconciliation). Any color layering is console-ui's job.
+ * The ".tag.<domain>.<value>" class-name contract is OWNED by console-ui. When console-ui's chrome.css is present
+ * it themes the SAME classes (colors by domain/value); this module takes NO dependency on console-ui and MUST NOT
+ * add a color palette here. Any color layering is console-ui's job.
*
- * The banner class below is the fail-loud contract-version-mismatch surface rendered by juneau-views.js (B.8); it,
- * too, avoids a color palette (uses currentColor / inherited text color only).
+ * The banner class below is the fail-loud contract-version-mismatch surface rendered by juneau-views.js; it, too,
+ * avoids a color palette (uses currentColor / inherited text color only).
*/
/* Base chip - neutral shape/padding/border-radius, NO colors. */
@@ -43,7 +43,7 @@
vertical-align: baseline;
}
-/* Fail-loud contract-version-mismatch banner (B.8) - neutral, no palette color. */
+/* Fail-loud contract-version-mismatch banner - neutral, no palette color. */
.juneau-view-error {
display: block;
padding: 0.5em 0.75em;
@@ -53,6 +53,21 @@
font-weight: bold;
}
+/*
+ * Accessibility: a NEUTRAL keyboard focus ring for this module's own interactive controls, mirroring console-ui
+ * chrome.css's generic :focus-visible rule but WITHOUT a palette colour (currentColor only, per this file's
+ * no-palette contract) - so a standalone client-rendered view is keyboard-accessible (WCAG 2.4.7) even before an
+ * app adopts the chrome. When chrome.css is present, its own token-driven :focus-visible ring applies to the
+ * same controls with the themed --jc-focus colour.
+ */
+.juneau-view-ribbon-btn:focus-visible,
+.juneau-view-pagingpill-btn:focus-visible,
+.juneau-view-pagingpill-menu-option:focus-visible,
+.juneau-view-columnsearch-input:focus-visible {
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+}
+
/*
* DataTables table density/shape (design doc §4 IRS-parity pass) - compact rows + thin separators. NEUTRAL -
* shape only (font-size, cell padding, border WIDTH/style); no color/background-color/border-color palette here
@@ -284,6 +299,30 @@ table.dataTable > tbody > tr > td {
width: 0.5em; /* pure spacing only; no visible glyph. Stays in the DOM (unchanged JS branch). */
}
+/*
+ * Per-table staleness indicator (per-table, never a single page-level chip) - a small neutral text chip
+ * juneau-views.js inserts at the front of the toolbar row's RIGHT cluster for any table that declares a
+ * `pollIntervalMs`. NEUTRAL - no palette color (per this file's no-color contract); the `data-state="error"`
+ * case ("the last refresh attempt failed" must look visibly distinct from a merely-aging timestamp) gets a
+ * heavier border/bold text instead of a color change, mirroring the
+ * ".juneau-view-ribbon-btn[aria-pressed=\"true\"]" neutral-emphasis convention above; console-ui's chrome.css
+ * MAY layer a themed color onto this same attribute selector when present.
+ */
+.juneau-view-staleness {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.15em 0.6em;
+ border: 1px solid; /* currentColor - no palette color set here */
+ border-radius: 0.75em;
+ font-size: 0.8125rem;
+ white-space: nowrap;
+}
+
+.juneau-view-staleness[data-state="error"] {
+ border-width: 2px;
+ font-weight: bold;
+}
+
/*
* Per-column search row (design doc §4, item 4 - IRS parity fix): one text input per searchable column, inserted
* into <thead> by buildColumnSearchRow(...) (juneau-views.js) and shown/hidden by the columnSearchToggle ribbon
@@ -304,11 +343,11 @@ table.dataTable > tbody > tr > td {
}
/*
- * TODO-399 Phase C - self-contained ".jc-*" tabs/sub-tabs page shell (Decision 1(A)). Folded into this file rather
- * than a separate juneau-pages.css: small enough not to warrant its own served asset/pom touch (see the module's
- * ViewsMixin asset-serving pattern). NEUTRAL - no color palette; console-ui's chrome.css MAY theme these same
- * classes when present, mirroring the ".tag.<domain>.<value>" chip reconciliation above - this module takes NO
- * dependency on console-ui either way.
+ * Self-contained ".jc-*" tabs/sub-tabs page shell (Decision 1(A)). Folded into this file rather than a separate
+ * juneau-pages.css: small enough not to warrant its own served asset/pom touch (see the module's ViewsMixin
+ * asset-serving pattern). NEUTRAL - no color palette; console-ui's chrome.css MAY theme these same classes when
+ * present, mirroring the ".tag.<domain>.<value>" chip reconciliation above - this module takes NO dependency on
+ * console-ui either way.
*/
.jc-tab-bar,
.jc-subtab-bar {
@@ -357,3 +396,42 @@ table.dataTable > tbody > tr > td {
border-radius: 0.25em;
font-weight: bold;
}
+
+/*
+ * 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 {
+ cursor: pointer;
+}
+
+.juneau-view-detail-row > td:first-child::before {
+ content: "\25B8"; /* ▸ */
+ display: inline-block;
+ width: 1em;
+ margin-right: 0.35em;
+}
+
+.juneau-view-detail-open > td:first-child::before {
+ content: "\25BE"; /* ▾ */
+}
+
+/* The expanded detail body itself - DataTables' native child row (juneau-views.js `row.child(...).show()`). */
+.juneau-view-detail-panel {
+ margin: 0;
+ padding: 0.5em 1em;
+}
+
+.juneau-view-detail-panel dt {
+ float: left;
+ clear: left;
+ width: 40%;
+ font-weight: 600;
+}
+
+.juneau-view-detail-panel dd {
+ margin: 0 0 0.35em 42%;
+}
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 020059cd45..12c95177ac 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
@@ -16,7 +16,7 @@
*/
/*
- * juneau-views.js - client initializer for the Apache Juneau rich-view toolkit (Task B.8).
+ * juneau-views.js - client initializer for the Apache Juneau rich-view toolkit.
*
* On DOMContentLoaded it owns init for every table[data-juneau-view] element: it reads the table's id, finds the
* matching <script type="application/json" id="juneau-view:<id>"> VIEW_META sidecar, JSON.parses it, and - CRUCIAL -
@@ -38,9 +38,9 @@
// 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.
- var JUNEAU_VIEW_CONTRACT_VERSION = "2";
+ const JUNEAU_VIEW_CONTRACT_VERSION = "2";
- var NS = window.JuneauViews = window.JuneauViews || {};
+ const NS = window.JuneauViews = window.JuneauViews || {};
NS.CONTRACT_VERSION = JUNEAU_VIEW_CONTRACT_VERSION;
// ==================================================================================================================
@@ -52,7 +52,7 @@
* ViewDef wire field; see the non-goal in design doc §11). Consumed by buildOptions' default pageLength below
* AND by the unified paging ribbon's page-size menu (buildPageSizeMenu, below).
*/
- var PAGE_SIZE_OPTIONS = [
+ const PAGE_SIZE_OPTIONS = [
{ value: 25, label: "25 rows" },
{ value: 100, label: "100 rows" },
{ value: -1, label: "All rows" }
@@ -60,8 +60,8 @@
/** Resolves a column `data` key to its zero-based index in the view (-1 when absent). */
function columnIndexOf(viewDef, dataKey) {
- var cols = viewDef.columns || [];
- for (var i = 0; i < cols.length; i++)
+ const cols = viewDef.columns || [];
+ for (let i = 0; i < cols.length; i++)
if (cols[i].data === dataKey) return i;
return -1;
}
@@ -71,9 +71,9 @@
* indices are not pinned server-side, so client-side column reorder stays correct. Unknown fields are skipped.
*/
function resolveOrder(viewDef) {
- var out = [];
+ const out = [];
(viewDef.defaultOrder || []).forEach(function (e) {
- var idx = columnIndexOf(viewDef, e.data);
+ const idx = columnIndexOf(viewDef, e.data);
if (idx >= 0) out.push([idx, e.dir]);
});
return out;
@@ -89,11 +89,11 @@
* eq/ne compare row[field] to the rule value; present/absent test whether row[field] is non-null/non-empty.
*/
function evaluateRowClassRules(rules, rowData) {
- var out = [];
+ const out = [];
(rules || []).forEach(function (r) {
- var v = rowData ? rowData[r.field] : undefined;
- var present = (v != null && v !== "");
- var match = false;
+ const v = rowData ? rowData[r.field] : undefined;
+ const present = (v != null && v !== "");
+ let match = false;
switch (r.op) {
case "eq": match = valuesEqual(v, r.value); break;
case "ne": match = !valuesEqual(v, r.value); break;
@@ -106,11 +106,56 @@
return out;
}
+ /**
+ * 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.
+ */
+ 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;
+ }
+
+ /**
+ * The minimum honored polling interval, in milliseconds - mirrors {@code ViewDef.MIN_POLL_INTERVAL_MS}. The
+ * server is the authoritative clamp (a `pollIntervalMs` value already arriving in VIEW_META has already been
+ * floored there); this client-side copy is defense-in-depth only, so a hand-edited or otherwise-malformed
+ * sidecar can't push this runtime below the floor either.
+ */
+ // Deliberately kept as `var` (not `const`): TablePolling_Wiring_Test#a01 pins this exact declaration text as
+ // part of the server/client MIN_POLL_INTERVAL_MS parity contract; see that test before touching this line.
+ var MIN_POLL_INTERVAL_MS = 5000;
+
+ /** Clamps a declared poll interval up to {@link #MIN_POLL_INTERVAL_MS} (mirrors the server-side clamp). */
+ function clampPollInterval(ms) {
+ return Math.max(ms, MIN_POLL_INTERVAL_MS);
+ }
+
+ /**
+ * Formats an elapsed-time duration (milliseconds) as a short staleness-age label ("just now", "5s ago",
+ * "2m ago", "1h ago"). Pure - the caller supplies the already-computed elapsed `ms` rather than this
+ * function reading the clock itself, so it stays independently testable without faking `Date.now()`.
+ */
+ function formatStalenessAge(ms) {
+ if (ms < 1000) return "just now";
+ const s = Math.floor(ms / 1000);
+ if (s < 60) return s + "s ago";
+ const m = Math.floor(s / 60);
+ if (m < 60) return m + "m ago";
+ const h = Math.floor(m / 60);
+ return h + "h ago";
+ }
+
/** Formats a non-negative integer with thousands separators (e.g. 1463 -> "1,463"), matching IRS's paging summary style. */
function formatThousands(n) {
- var s = String(Math.trunc(Math.abs(n)));
- var out = "";
- for (var i = 0; i < s.length; i++) {
+ const s = String(Math.trunc(Math.abs(n)));
+ let out = "";
+ for (let i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 === 0) out += ",";
out += s.charAt(i);
}
@@ -126,7 +171,7 @@
* (`total` falsy) renders "0-0 of 0" rather than "1-0 of 0".
*/
function pagingSummaryText(pageInfo) {
- var total = pageInfo.recordsDisplay != null ? pageInfo.recordsDisplay : pageInfo.recordsTotal;
+ const total = pageInfo.recordsDisplay != null ? pageInfo.recordsDisplay : pageInfo.recordsTotal;
if (!total) return "0-0 of 0";
return (pageInfo.start + 1) + "-" + pageInfo.end + " of " + formatThousands(total);
}
@@ -139,7 +184,7 @@
* treated the same as "already on the last page").
*/
function pillState(pageInfo, pageLength) {
- var page = pageInfo.page, pages = pageInfo.pages;
+ const page = pageInfo.page, pages = pageInfo.pages;
return {
selectedLength: pageLength,
firstDisabled: page === 0,
@@ -155,7 +200,7 @@
* An unknown render id warns once and falls back to the raw value (never throws).
*/
function buildColumnDef(col, deps) {
- var def = {
+ const def = {
data: col.data,
orderable: col.orderable !== false,
searchable: col.searchable !== false,
@@ -172,12 +217,12 @@
if (col.className != null) def.className = col.className;
if (col.render) {
- var spec = deps.parseRenderId(col.render);
- var renderer = deps.resolveRenderer(spec.id);
+ const spec = deps.parseRenderId(col.render);
+ const renderer = deps.resolveRenderer(spec.id);
if (!renderer) {
deps.warn("Juneau view: unknown render id '" + spec.id + "' - falling back to raw value.");
} else if (renderer.display) {
- var meta = mergeMeta(spec.meta, col);
+ const meta = mergeMeta(spec.meta, col);
def.render = function (data, type, rowData) {
if (type && type !== "display") return data; // SERVER mode: sort/filter/type done server-side
try { return renderer.display(data, rowData, meta); }
@@ -190,8 +235,8 @@
/** Merges a column's render.meta with runtime column context (href is needed by the `linked` renderer). */
function mergeMeta(renderMeta, col) {
- var meta = {};
- if (renderMeta) for (var k in renderMeta) if (Object.prototype.hasOwnProperty.call(renderMeta, k)) meta[k] = renderMeta[k];
+ const meta = {};
+ if (renderMeta) for (const k in renderMeta) if (Object.hasOwn(renderMeta, k)) meta[k] = renderMeta[k];
if (col.href != null) meta.href = col.href;
meta.column = col.data;
return meta;
@@ -204,7 +249,7 @@
* merged into the request); client -> serverSide:false + ajax{dataSrc:""}.
*/
function buildOptions(viewDef, deps) {
- var opts = {};
+ const opts = {};
opts.columns = (viewDef.columns || []).map(function (c) { return buildColumnDef(c, deps); });
opts.order = resolveOrder(viewDef);
// Text polish (design doc §4.B): the native search input's label is blanked (searchPlaceholder replaces it
@@ -223,8 +268,8 @@
url: viewDef.dataUrl,
dataSrc: "data",
data: function (d) {
- var extra = deps.ribbonParams ? deps.ribbonParams() : {};
- for (var k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) d[k] = extra[k];
+ const extra = deps.ribbonParams ? deps.ribbonParams() : {};
+ for (const k in extra) if (Object.hasOwn(extra, k)) d[k] = extra[k];
return d;
}
};
@@ -237,6 +282,11 @@
evaluateRowClassRules(viewDef.rowClassRules, rowData).forEach(function (cls) {
if (cls) rowEl.className += (rowEl.className ? " " : "") + cls;
});
+ // 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) {
+ rowEl.className += (rowEl.className ? " " : "") + "juneau-view-detail-row";
+ }
};
return opts;
}
@@ -257,13 +307,13 @@
* rendering the label as text when the glyph isn't registered (same convention as juneau-ribbon.js's button()).
*/
function toolbarButton(className, label, iconName, onClick) {
- var b = document.createElement("button");
+ const b = document.createElement("button");
b.type = "button";
b.className = className;
b.title = label;
b.setAttribute("aria-label", label);
- var icons = window.JuneauViews && window.JuneauViews.icons;
- var markup = icons && icons.resolveIcon ? icons.resolveIcon(iconName) : null;
+ const icons = window.JuneauViews && window.JuneauViews.icons;
+ const markup = icons?.resolveIcon ? icons.resolveIcon(iconName) : null;
if (markup != null) {
b.innerHTML = markup;
} else {
@@ -293,35 +343,35 @@
* of focus leaving the control entirely).
*/
function buildPageSizeMenu(ctx) {
- var wrap = document.createElement("span");
+ const wrap = document.createElement("span");
wrap.className = "juneau-view-pagingpill-menuwrap";
- var btn = document.createElement("button");
+ const btn = document.createElement("button");
btn.type = "button";
btn.className = "juneau-view-pagingpill-menubtn";
btn.title = "Rows per page";
btn.setAttribute("aria-haspopup", "listbox");
btn.setAttribute("aria-expanded", "false");
- var infoEl = document.createElement("span");
+ const infoEl = document.createElement("span");
infoEl.className = "juneau-view-pagingpill-info";
btn.appendChild(infoEl);
- var icons = window.JuneauViews && window.JuneauViews.icons;
- var caretMarkup = icons && icons.resolveIcon ? icons.resolveIcon("expand_more") : null;
- var caretEl = document.createElement("span");
+ const icons = window.JuneauViews && window.JuneauViews.icons;
+ const caretMarkup = icons?.resolveIcon ? icons.resolveIcon("expand_more") : null;
+ const caretEl = document.createElement("span");
caretEl.className = "juneau-view-pagingpill-caret";
caretEl.setAttribute("aria-hidden", "true");
if (caretMarkup != null) caretEl.innerHTML = caretMarkup;
btn.appendChild(caretEl);
- var menuEl = document.createElement("ul");
+ const menuEl = document.createElement("ul");
menuEl.className = "juneau-view-pagingpill-menu";
menuEl.setAttribute("role", "listbox");
menuEl.hidden = true;
- var options = NS.init.PAGE_SIZE_OPTIONS.map(function (o) {
- var optEl = document.createElement("li");
+ const options = NS.init.PAGE_SIZE_OPTIONS.map(function (o) {
+ const optEl = document.createElement("li");
optEl.className = "juneau-view-pagingpill-menu-option";
optEl.setAttribute("role", "option");
optEl.tabIndex = -1;
@@ -332,19 +382,19 @@
});
function indexOfSelected() {
- for (var i = 0; i < options.length; i++) if (options[i].el.getAttribute("aria-selected") === "true") return i;
+ for (let i = 0; i < options.length; i++) if (options[i].el.getAttribute("aria-selected") === "true") return i;
return -1;
}
function indexOfFocused() {
- for (var i = 0; i < options.length; i++) if (options[i].el === document.activeElement) return i;
+ for (let i = 0; i < options.length; i++) if (options[i].el === document.activeElement) return i;
return -1;
}
function openMenu() {
menuEl.hidden = false;
btn.setAttribute("aria-expanded", "true");
- var idx = indexOfSelected();
+ const idx = indexOfSelected();
options[idx >= 0 ? idx : 0].el.focus();
}
@@ -369,7 +419,7 @@
}
});
menuEl.addEventListener("keydown", function (e) {
- var idx = indexOfFocused();
+ const idx = indexOfFocused();
if (e.key === "ArrowDown") {
e.preventDefault();
options[(idx + 1) % options.length].el.focus();
@@ -415,15 +465,15 @@
* buildOptions(...) sets `info: false`, so DataTables never creates that node in the first place.
*/
function buildPagingPill(viewDef, ctx) {
- var pill = document.createElement("div");
+ const pill = document.createElement("div");
pill.className = "juneau-view-pagingpill";
pill.setAttribute("data-testid", "paging");
- var firstBtn = pagingPillButton("First page", "first_page", function () { ctx.dataTable.page("first").draw(); });
- var prevBtn = pagingPillButton("Previous page", "chevron_left", function () { ctx.dataTable.page("previous").draw(); });
- var sizeMenu = buildPageSizeMenu(ctx);
- var nextBtn = pagingPillButton("Next page", "chevron_right", function () { ctx.dataTable.page("next").draw(); });
- var lastBtn = pagingPillButton("Last page", "last_page", function () { ctx.dataTable.page("last").draw(); });
+ const firstBtn = pagingPillButton("First page", "first_page", function () { ctx.dataTable.page("first").draw(); });
+ const prevBtn = pagingPillButton("Previous page", "chevron_left", function () { ctx.dataTable.page("previous").draw(); });
+ const sizeMenu = buildPageSizeMenu(ctx);
+ const nextBtn = pagingPillButton("Next page", "chevron_right", function () { ctx.dataTable.page("next").draw(); });
+ const lastBtn = pagingPillButton("Last page", "last_page", function () { ctx.dataTable.page("last").draw(); });
pill.appendChild(firstBtn);
pill.appendChild(prevBtn);
pill.appendChild(sizeMenu.el);
@@ -431,8 +481,8 @@
pill.appendChild(lastBtn);
function refreshPillState() {
- var info = ctx.dataTable.page.info();
- var st = pillState(info, ctx.dataTable.page.len());
+ const info = ctx.dataTable.page.info();
+ const st = pillState(info, ctx.dataTable.page.len());
firstBtn.disabled = st.firstDisabled;
prevBtn.disabled = st.prevDisabled;
nextBtn.disabled = st.nextDisabled;
@@ -455,19 +505,19 @@
* `<thead>` (defensive; every juneau view table has one).
*/
function buildColumnSearchRow(table, viewDef, dt) {
- var thead = table.querySelector("thead");
+ const thead = table.querySelector("thead");
if (!thead) return null;
- var row = document.createElement("tr");
+ 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) {
- var th = document.createElement("th");
+ const th = document.createElement("th");
if (col.searchable !== false) {
- var input = document.createElement("input");
+ const input = document.createElement("input");
input.type = "text";
input.className = "juneau-view-columnsearch-input";
- var label = "Search " + (col.title || col.data || "column " + idx);
+ const label = "Search " + (col.title || col.data || "column " + idx);
input.placeholder = label;
input.setAttribute("aria-label", label);
input.addEventListener("input", function () { dt.column(idx).search(input.value).draw(); });
@@ -481,7 +531,7 @@
/** Renders the fail-loud, visible in-table banner used on a contract-version mismatch (or a parse failure). */
function renderBanner(table, message) {
- var caption = table.createCaption ? table.createCaption() : null;
+ let caption = table.createCaption ? table.createCaption() : null;
if (!caption) {
caption = document.createElement("caption");
table.insertBefore(caption, table.firstChild);
@@ -504,15 +554,15 @@
* disabled).
*/
function buildToolbarRow(wrapper, pill, bar) {
- var filterEl = wrapper.querySelector(".dataTables_filter, .dt-search");
- var row = document.createElement("div");
+ const filterEl = wrapper.querySelector(".dataTables_filter, .dt-search");
+ const row = document.createElement("div");
row.className = "juneau-view-toolbar-row";
- var left = document.createElement("div");
+ const left = document.createElement("div");
left.className = "juneau-view-toolbar-left";
if (pill) left.appendChild(pill);
- var right = document.createElement("div");
+ const right = document.createElement("div");
right.className = "juneau-view-toolbar-right";
if (filterEl) right.appendChild(filterEl);
if (bar) right.appendChild(bar);
@@ -523,13 +573,128 @@
return row;
}
+ /**
+ * Whether `table` currently has a row marked in-flight - a row-action implementation is expected to set
+ * `data-juneau-inflight` (any truthy attribute value) on a `<tr>` while its write is pending. A poll landing
+ * mid-write must not overwrite that row with the pre-write server view, or the UI would appear to undo the
+ * user's action and then redo it once the write's own result repaints it (design doc §9.1 B5) - so a poll
+ * tick skips its ENTIRE redraw whenever ANY row in this table carries the marker, leaving the table exactly
+ * as-is (stale, but honestly so) until that write settles.
+ */
+ function hasInFlightRow(table) {
+ return !!table.querySelector("tbody tr[data-juneau-inflight]");
+ }
+
+ /**
+ * 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.
+ */
+ function buildStalenessIndicator() {
+ const el = document.createElement("span");
+ el.className = "juneau-view-staleness";
+ el.setAttribute("data-testid", "staleness");
+ el.setAttribute("data-state", "fresh");
+ return el;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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.
+ *
+ * <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.
+ */
+ function initDetailsExpander(table, dt, viewDef) {
+ table.addEventListener("click", function (e) {
+ const tr = e.target && e.target.closest ? e.target.closest("tr.juneau-view-detail-row") : null;
+ if (!tr) return;
+ const row = dt.row(tr);
+ if (!row || !row.length) return;
+ 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");
+ }
+ });
+ }
+
+ /**
+ * Wires a table's poll timer + its staleness indicator. A plain interval fetch - deliberately not a
+ * streaming/SSE transport, which would be an independent mechanism.
+ *
+ * <p>Any successful DataTables draw resets the "last refreshed" clock - whether it was triggered by this
+ * timer, the refresh ribbon button, paging, or a search - because each one really did just complete a fresh
+ * server round trip. Only the timer's OWN tick additionally (a) skips entirely while the tab/page is hidden
+ * (Page Visibility API - no fetch, no cost, while backgrounded) and (b) skips entirely while
+ * {@link #hasInFlightRow} is true. A failed round trip (`error.dt`) flips the indicator to a distinct
+ * "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) {
+ const intervalMs = clampPollInterval(viewDef.pollIntervalMs);
+ const state = { lastSuccessAt: Date.now(), failed: false };
+
+ function render() {
+ const age = formatStalenessAge(Date.now() - state.lastSuccessAt);
+ indicator.setAttribute("data-state", state.failed ? "error" : "fresh");
+ indicator.textContent = state.failed ? "Refresh failed - last updated " + age : "Updated " + age;
+ }
+
+ dt.on("draw.dt", function () { state.lastSuccessAt = Date.now(); state.failed = false; render(); });
+ dt.on("error.dt", function () { state.failed = true; render(); });
+
+ function poll() {
+ if (document.hidden) return;
+ if (hasInFlightRow(table)) return;
+ dt.ajax.reload(null, false);
+ }
+
+ 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);
+ render();
+ }
+
+ // 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) {
- var $ = window.jQuery;
- var id = table.getAttribute("data-juneau-view");
- var sidecar = document.getElementById("juneau-view:" + id);
+ const $ = window.jQuery;
+ const id = table.getAttribute("data-juneau-view");
+ const sidecar = document.getElementById("juneau-view:" + id);
if (!sidecar) { error("Juneau view '" + id + "': missing JSON sidecar; refusing to init."); return; }
- var viewDef;
+ let viewDef;
try {
viewDef = JSON.parse(sidecar.textContent);
} catch (e) {
@@ -540,40 +705,45 @@
// 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) {
- var m = "Juneau view '" + id + "': contract version mismatch (page='" + viewDef.contractVersion +
+ 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
}
- if (!$ || !$.fn || !$.fn.DataTable) {
+ if (!$?.fn?.DataTable) {
warn("Juneau view '" + id + "': jQuery/DataTables not present; cannot bind.");
return;
}
if ($.fn.dataTable.isDataTable(table)) return; // idempotent
- var activeState = (NS.ribbon && NS.ribbon.loadPersistedState) ? NS.ribbon.loadPersistedState(viewDef) : {};
+ const activeState = NS.ribbon?.loadPersistedState ? NS.ribbon.loadPersistedState(viewDef) : {};
- var deps = {
+ const deps = {
parseRenderId: NS.parseRenderId,
resolveRenderer: NS.resolveRenderer,
warn: warn,
ribbonParams: function () {
- return (NS.ribbon && NS.ribbon.ribbonToQueryParams) ? NS.ribbon.ribbonToQueryParams(viewDef, activeState) : {};
+ return NS.ribbon?.ribbonToQueryParams ? NS.ribbon.ribbonToQueryParams(viewDef, activeState) : {};
}
};
- var opts = buildOptions(viewDef, deps);
- var dt = $(table).DataTable(opts);
+ const opts = buildOptions(viewDef, deps);
+ const dt = $(table).DataTable(opts);
- var pill = buildPagingPill(viewDef, { table: table, dataTable: dt });
+ // 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.
- var ctx = {
+ const ctx = {
table: table,
dataTable: dt,
activeState: activeState,
@@ -581,9 +751,9 @@
redraw: function () { dt.ajax ? dt.ajax.reload() : dt.draw(); }
};
- var bar = (NS.ribbon && NS.ribbon.build) ? NS.ribbon.build(viewDef, ctx) : null;
+ const bar = NS.ribbon?.build ? NS.ribbon.build(viewDef, ctx) : null;
- var columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
+ const columnSearchRow = buildColumnSearchRow(table, viewDef, dt);
ctx.onColumnSearchToggle = function (on) {
if (!columnSearchRow) return;
columnSearchRow.style.display = on ? "" : "none";
@@ -596,19 +766,28 @@
// 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.
- var wrapper = table.parentNode;
- if (wrapper) buildToolbarRow(wrapper, pill, bar);
+ const wrapper = table.parentNode;
+ const toolbarRow = wrapper ? buildToolbarRow(wrapper, pill, bar) : null;
+
+ // 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);
+ }
}
/**
- * Inits every table[data-juneau-view] on the page, EXCEPT one scoped inside a [data-juneau-page] shell (TODO-399
- * Phase C seam): a page shell's juneau-pages.js runtime owns first-init for its own panels (lazy, on first tab
- * activation - DataTables mis-sizes columns initialized inside a display:none panel), rather than the eager
+ * Inits every table[data-juneau-view] on the page, EXCEPT one scoped inside a [data-juneau-page] shell: a
+ * page shell's juneau-pages.js runtime owns first-init for its own panels (lazy, on first tab activation -
+ * DataTables mis-sizes columns initialized inside a display:none panel), rather than the eager
* DOMContentLoaded scan below. A standalone page with no page shell is unaffected - every one of its tables is
* still inited exactly as before.
*/
function initAll() {
- var tables = document.querySelectorAll("table[data-juneau-view]");
+ const tables = document.querySelectorAll("table[data-juneau-view]");
Array.prototype.forEach.call(tables, function (t) {
if (t.closest && t.closest("[data-juneau-page]")) return;
initTable(t);
@@ -632,15 +811,26 @@
mergeMeta: mergeMeta,
buildOptions: buildOptions,
initAll: initAll,
- // TODO-399 Phase C seam: 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 runtime after the DOMContentLoaded scan has already run is always safe.
+ // 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
+ // runtime after the DOMContentLoaded scan has already run is always safe.
initTable: initTable,
- // visual-parity pass: exposed for Option-A/manual verification.
+ // visual-parity pass: exposed for manual verification.
buildPagingPill: buildPagingPill,
buildPageSizeMenu: buildPageSizeMenu,
buildColumnSearchRow: buildColumnSearchRow,
- buildToolbarRow: buildToolbarRow
+ buildToolbarRow: buildToolbarRow,
+ // Table polling + visible staleness indicator - exposed for manual verification.
+ MIN_POLL_INTERVAL_MS: MIN_POLL_INTERVAL_MS,
+ clampPollInterval: clampPollInterval,
+ formatStalenessAge: formatStalenessAge,
+ hasInFlightRow: hasInFlightRow,
+ buildStalenessIndicator: buildStalenessIndicator,
+ initPolling: initPolling,
+ // Row-details expander - exposed for manual verification.
+ buildDetailFields: buildDetailFields,
+ buildDetailPanel: buildDetailPanel,
+ initDetailsExpander: initDetailsExpander
};
if (document.readyState === "loading") {
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 1eecde1098..b4b3c6c9eb 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
@@ -24,7 +24,7 @@ import org.apache.juneau.rest.server.*;
import org.junit.jupiter.api.*;
/**
- * {@code juneau-pages.js} pure hash-routing logic + DOM binding shim tests (TODO-399 Phase C, Tasks 7-8).
+ * {@code juneau-pages.js} pure hash-routing logic + DOM binding shim tests.
*
* <p>
* <b>Scope:</b> these tests extract each pure function's source body from the served script and assert on its
@@ -36,6 +36,9 @@ import org.junit.jupiter.api.*;
* The functions remain written as a DOM-free pure layer (see the class-header comment in {@code juneau-pages.js}),
* so the harness could also drive them directly should these particular assertions ever need that depth.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class JuneauPagesJs_Test extends TestBase {
@Rest(mixins=ViewsMixin.class)
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
index a8ea648b87..6c69a110dd 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageDef_Validation_Test.java
@@ -58,23 +58,22 @@ class PageDef_Validation_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void a01_tabWithBothViewAndSubtabs_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin")
- .tabs(Tab.create("t", "T").view(view("v")).subtabs(Subtab.create("s", "S").view(view("s"))))
- .build());
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("t", "T").view(view("v")).subtabs(Subtab.create("s", "S").view(view("s"))));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
}
@Test void a02_tabWithNeitherViewNorSubtabs_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin").tabs(Tab.create("t", "T")).build());
+ var page = PageDef.create("admin").tabs(Tab.create("t", "T"));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
}
@Test void a03_tabWithEmptySubtabsListAndNoView_rejected() {
// An explicitly-empty subtabs() call is equivalent to "no subtabs" -> still neither.
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin").tabs(Tab.create("t", "T").subtabs()).build());
+ var page = PageDef.create("admin").tabs(Tab.create("t", "T").subtabs());
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("exactly one"), e::getMessage);
}
@@ -83,10 +82,9 @@ class PageDef_Validation_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void b01_duplicateTabId_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin")
- .tabs(Tab.create("releases", "R1").view(view("r1")), Tab.create("releases", "R2").view(view("r2")))
- .build());
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("releases", "R1").view(view("r1")), Tab.create("releases", "R2").view(view("r2")));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("releases"), e::getMessage);
}
@@ -95,12 +93,11 @@ class PageDef_Validation_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void c01_duplicateSubtabIdWithinSameTab_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin")
- .tabs(Tab.create("catalog", "Catalog").subtabs(
- Subtab.create("packages", "P1").view(view("p1")),
- Subtab.create("packages", "P2").view(view("p2"))))
- .build());
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("catalog", "Catalog").subtabs(
+ Subtab.create("packages", "P1").view(view("p1")),
+ Subtab.create("packages", "P2").view(view("p2"))));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("packages"), e::getMessage);
}
@@ -120,20 +117,18 @@ class PageDef_Validation_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void d01_duplicateViewIdAcrossTabs_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin")
- .tabs(Tab.create("a", "A").view(view("shared")), Tab.create("b", "B").view(view("shared")))
- .build());
+ var page = PageDef.create("admin")
+ .tabs(Tab.create("a", "A").view(view("shared")), Tab.create("b", "B").view(view("shared")));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("shared"), e::getMessage);
}
@Test void d02_duplicateViewIdBetweenTabAndSubtab_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () ->
- PageDef.create("admin")
- .tabs(
- Tab.create("a", "A").view(view("shared")),
- Tab.create("b", "B").subtabs(Subtab.create("s", "S").view(view("shared"))))
- .build());
+ var page = PageDef.create("admin")
+ .tabs(
+ Tab.create("a", "A").view(view("shared")),
+ Tab.create("b", "B").subtabs(Subtab.create("s", "S").view(view("shared"))));
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("shared"), e::getMessage);
}
@@ -149,12 +144,14 @@ class PageDef_Validation_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void e01_noTabs_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").build());
+ var page = PageDef.create("admin");
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("tab"), e::getMessage);
}
@Test void e02_emptyTabsList_rejected() {
- var e = assertThrows(IllegalArgumentException.class, () -> PageDef.create("admin").tabs().build());
+ var page = PageDef.create("admin").tabs();
+ var e = assertThrows(IllegalArgumentException.class, page::build);
assertTrue(e.getMessage().contains("tab"), e::getMessage);
}
}
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 40fbf53078..3dd18c8bf9 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
@@ -34,7 +34,7 @@ import org.apache.juneau.rest.server.views.ViewDef.DataMode;
import org.junit.jupiter.api.*;
/**
- * End-to-end server wiring smoke test for {@link PageTable} + {@link ViewsMixin} (TODO-399 Phase C, Task 9).
+ * End-to-end server wiring smoke test for {@link PageTable} + {@link ViewsMixin}.
*
* <p>
* Proves a REST resource composing {@link ViewsMixin} and returning a {@code PageTable.of(...)} response emits a
@@ -44,6 +44,9 @@ import org.junit.jupiter.api.*;
* {@code juneau-renders.js}/{@code juneau-views.css} (design doc §"Client page runtime"; mirrors
* {@code ViewServerWiring_Test}/{@code ViewsMixin_Serving_Test}'s end-to-end coverage style).
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class PageServerWiring_Test extends TestBase {
public static class Release {
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 7a0a36b4a7..d98e45580e 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
@@ -26,7 +26,7 @@ import org.apache.juneau.rest.server.views.ViewDef.DataMode;
import org.junit.jupiter.api.*;
/**
- * Markup + escaping tests for the {@link PageTable} emitter (TODO-399 Phase C, Tasks 3-4).
+ * Markup + escaping tests for the {@link PageTable} emitter.
*
* <p>
* Proves the emitter produces the {@code data-juneau-page} shell, a tab-bar entry per {@link Tab} (+ a sub-tab bar
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
index 63b6fe3031..f975834d2b 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/PageTable_SubtabPanelContract_Test.java
@@ -68,6 +68,9 @@ import org.junit.jupiter.api.*;
* never produces. They are still a two-artifact contract — {@code juneau-pages.js} sets them and
* {@code juneau-views.css} styles them — so they get the same protection a different way, in {@code c03}.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class PageTable_SubtabPanelContract_Test extends TestBase {
@Rest(mixins=ViewsMixin.class)
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 5a99fff2ff..07a93ef877 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
@@ -30,6 +30,9 @@ import org.junit.jupiter.api.*;
* coverage, mirroring {@code ViewsMixin_Serving_Test}'s established idiom - the pill's DataTables-API-surface
* wiring (`page.len`, `page(...)`, `draw.dt`) and the cross-version native-control-hide CSS selector list.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class PagingPill_Wiring_Test extends TestBase {
@Rest(mixins=ViewsMixin.class)
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 49f70e5a9c..c08df7cf11 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
@@ -29,6 +29,9 @@ import org.junit.jupiter.api.*;
* (DataTables ribbon visual-parity design doc §4.A, plan Task 2). Option-A (content-substring +
* function-body-extraction) coverage, mirroring {@code ViewsMixin_Serving_Test}'s established idiom.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class RibbonIcons_Resolution_Test extends TestBase {
@Rest(mixins=ViewsMixin.class)
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
new file mode 100644
index 0000000000..99bf730e38
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/RowDetailsExpander_Wiring_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.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+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.*;
+
+/**
+ * Row-details expander: Option-A (content-substring + function-body-extraction) coverage for
+ * {@code juneau-views.js}/{@code juneau-views.css}, mirroring {@code TablePolling_Wiring_Test}'s established idiom.
+ */
+@SuppressWarnings({
+ "resource", // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+ "java:S5976" // Each test targets a distinct function/behavior via the shared functionBody(...) idiom; collapsing
+ // into one @ParameterizedTest would obscure which specific behavior failed.
+})
+class RowDetailsExpander_Wiring_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.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) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Pure layer: buildDetailFields
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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 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 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);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // 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);
+ assertFalse(fnBody.contains("innerHTML"), fnBody);
+ }
+
+ @Test void b02_buildDetailPanel_marksItselfForTestSelection() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildDetailPanel(");
+ assertTrue(fnBody.contains("juneau-view-detail-panel"), fnBody);
+ assertTrue(fnBody.contains("\"data-testid\", \"detail-panel\""), fnBody);
+ }
+
+ @Test void b03_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 {
+ 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);
+ }
+
+ /** 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 {
+ 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);
+ }
+
+ @Test void b06_createdRow_marksExpandableRows_onlyWhenViewDeclaresNonEmptyDetails() 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("juneau-view-detail-row"), fnBody);
+ }
+
+ @Test void b07_initTable_onlyWiresTheExpanderWhenViewDeclaresNonEmptyDetails() 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);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // CSS shape (neutral, no palette color; open/closed glyph is a bare content-string, not an icon dependency)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_viewsCss_detailRowIsMarkedClickable() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-detail-row {"), body);
+ var start = body.indexOf(".juneau-view-detail-row {");
+ var end = body.indexOf("}", start);
+ assertTrue(body.substring(start, end).contains("cursor: pointer"), body.substring(start, end));
+ }
+
+ @Test void c02_viewsCss_openStateFlipsGlyph_withoutAnIconDependency() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-detail-row > td:first-child::before {"), body);
+ assertTrue(body.contains(".juneau-view-detail-open > td:first-child::before {"), body);
+ assertFalse(body.contains("url("), body);
+ }
+
+ @Test void c03_viewsCss_detailPanelShapeIsNeutral_noPaletteColor() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-detail-panel {"), body);
+ var start = body.indexOf(".juneau-view-detail-panel {");
+ var end = body.indexOf("}", start);
+ var rule = body.substring(start, end);
+ assertFalse(rule.contains("color:"), rule);
+ assertFalse(rule.contains("background"), rule);
+ }
+}
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
new file mode 100644
index 0000000000..67b70d4c08
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/TablePolling_Wiring_Test.java
@@ -0,0 +1,171 @@
+/*
+ * 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.rest.mock.classic.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Table polling + visible staleness indicator: Option-A (content-substring + function-body-extraction) coverage
+ * for {@code juneau-views.js}/{@code juneau-views.css}, mirroring {@code PagingPill_Wiring_Test}'s established
+ * idiom.
+ */
+@SuppressWarnings({
+ "resource", // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+ "java:S5976" // Each test targets a distinct function/behavior via the shared functionBody(...) idiom; collapsing
+ // into one @ParameterizedTest would obscure which specific behavior failed.
+})
+class TablePolling_Wiring_Test extends TestBase {
+
+ @Rest(mixins=ViewsMixin.class)
+ public static class WithMixin extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ }
+
+ private static final MockRestClient cWithMixin = MockRestClient.buildLax(WithMixin.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) {
+ var start = body.indexOf(signature);
+ assertTrue(start >= 0, () -> "'" + signature + "' not found:\n" + body);
+ var end = body.indexOf("\n\t}", start);
+ return body.substring(start, end < 0 ? body.length() : end);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Pure layer: MIN_POLL_INTERVAL_MS / clampPollInterval / formatStalenessAge
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_minPollIntervalMs_mirrorsServerFloor() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains("var MIN_POLL_INTERVAL_MS = 5000;"), body);
+ assertEquals(5000L, ViewDef.MIN_POLL_INTERVAL_MS);
+ }
+
+ @Test void a02_clampPollInterval_flooredNotRejected() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function clampPollInterval(");
+ assertTrue(fnBody.contains("Math.max(ms, MIN_POLL_INTERVAL_MS)"), fnBody);
+ }
+
+ @Test void a03_formatStalenessAge_boundaries() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function formatStalenessAge(");
+ assertTrue(fnBody.contains("\"just now\""), fnBody);
+ assertTrue(fnBody.contains("\"s ago\""), fnBody);
+ assertTrue(fnBody.contains("\"m ago\""), fnBody);
+ assertTrue(fnBody.contains("\"h ago\""), fnBody);
+ // Pure - reads no Date.now()/clock itself; the caller supplies the elapsed ms.
+ assertFalse(fnBody.contains("Date.now()"), fnBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // Binding layer: hasInFlightRow / buildStalenessIndicator / initPolling
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_hasInFlightRow_queriesTheDataJuneauInflightMarker() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function hasInFlightRow(");
+ assertTrue(fnBody.contains("tbody tr[data-juneau-inflight]"), fnBody);
+ }
+
+ @Test void b02_buildStalenessIndicator_startsInTheNeutralFreshState() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function buildStalenessIndicator(");
+ assertTrue(fnBody.contains("juneau-view-staleness"), fnBody);
+ assertTrue(fnBody.contains("\"data-testid\", \"staleness\""), fnBody);
+ assertTrue(fnBody.contains("\"data-state\", \"fresh\""), fnBody);
+ }
+
+ /**
+ * Never overwrite an in-flight row: the poll tick itself must check {@code hasInFlightRow} and skip its
+ * redraw entirely rather than partially/fully overwrite it.
+ */
+ @Test void b03_initPolling_pollTickSkipsWhenAnyRowIsInFlight() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initPolling(");
+ assertTrue(fnBody.contains("if (hasInFlightRow(table)) return;"), fnBody);
+ }
+
+ /** Polling pauses while the tab/page is not visible - no fetch cost while hidden. */
+ @Test void b04_initPolling_pollTickSkipsWhileTabIsHidden() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initPolling(");
+ assertTrue(fnBody.contains("if (document.hidden) return;"), fnBody);
+ }
+
+ /** A plain interval fetch - not a streaming/SSE transport. */
+ @Test void b05_initPolling_isAPlainIntervalFetch_notAStreamingTransport() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initPolling(");
+ assertTrue(fnBody.contains("setInterval(poll, intervalMs)"), fnBody);
+ assertTrue(fnBody.contains("dt.ajax.reload(null, false)"), fnBody);
+ assertFalse(body.toLowerCase().contains("eventsource"), body);
+ assertFalse(body.contains("text/event-stream"), body);
+ }
+
+ /** A failed round trip must flip a DISTINCT visible state, not just leave a frozen "fresh" timestamp. */
+ @Test void b06_initPolling_ajaxErrorFlipsADistinctFailedState_withoutTouchingLastSuccess() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_JS_PATH).run().assertStatus(200).getContent().asString();
+ var fnBody = functionBody(body, "function initPolling(");
+ assertTrue(fnBody.contains("\"error.dt\""), fnBody);
+ assertTrue(fnBody.contains("state.failed = true"), fnBody);
+ assertTrue(fnBody.contains("\"draw.dt\""), fnBody);
+ assertTrue(fnBody.contains("state.lastSuccessAt = Date.now()"), fnBody);
+ assertTrue(fnBody.contains("state.failed = false"), fnBody);
+ }
+
+ @Test void b07_initTable_onlyWiresPollingWhenViewDeclaresAPollInterval() 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.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(".juneau-view-toolbar-right"), initBody);
+ assertTrue(initBody.contains("insertBefore(staleness, rightCluster.firstChild)"), initBody);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // CSS shape (neutral, no palette color; distinct error-state emphasis)
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_viewsCss_hasNeutralStalenessChipShape() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-staleness {"), body);
+ var start = body.indexOf(".juneau-view-staleness {");
+ var end = body.indexOf("}", start);
+ var rule = body.substring(start, end);
+ assertFalse(rule.contains("color:"), rule);
+ assertFalse(rule.contains("background"), rule);
+ }
+
+ @Test void c02_viewsCss_errorStateIsVisiblyDistinctWithoutColor() throws Exception {
+ var body = cWithMixin.get(ViewsMixin.VIEWS_CSS_PATH).run().assertStatus(200).getContent().asString();
+ assertTrue(body.contains(".juneau-view-staleness[data-state=\"error\"] {"), body);
+ var start = body.indexOf(".juneau-view-staleness[data-state=\"error\"] {");
+ var end = body.indexOf("}", start);
+ var rule = body.substring(start, end);
+ assertTrue(rule.contains("border-width: 2px") || rule.contains("font-weight: bold"), rule);
+ }
+}
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 ce36a61ab1..3ef8f2687c 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
@@ -35,7 +35,8 @@ import org.junit.jupiter.api.*;
* This is the load-bearing seam of the toolkit: it pins the {@link ViewDef} JSON serialization field-by-field
* against the frozen §6.10 example. The serializer is the repo's canonical compact JSON marshaller
* ({@link Json#of(Object)} → {@code JsonSerializer.DEFAULT}) — the same config that serializes the
- * {@code DataTablesResults} envelope in the TODO-355 tests, so the emitted sidecar and this fixture agree.
+ * {@code DataTablesResults} envelope in the server-side query protocol tests, so the emitted sidecar and this
+ * fixture agree.
*/
class ViewMeta_Contract_Test extends TestBase {
@@ -116,7 +117,7 @@ class ViewMeta_Contract_Test extends TestBase {
}
@Test void a02_topLevelKeyOrderMatchesContract() {
- var actual = Json.to(Json.of(releasesView()), Map.class);
+ Map<?,?> actual = Json.to(Json.of(releasesView()), Map.class);
assertEquals(
List.of("contractVersion", "id", "rowType", "dataMode", "dataUrl", "defaultOrder", "columns", "ribbon", "rowClassRules"),
new ArrayList<>(actual.keySet()));
@@ -124,8 +125,9 @@ class ViewMeta_Contract_Test extends TestBase {
@Test void a03_reservedFieldsOmittedNotNull() {
var json = Json.of(releasesView());
- // Reserved C/D/E stubs must be omitted entirely (not serialized as null keys).
- for (var k : List.of("details", "rowActions", "catalog", "format", "description", "pinned", "defaultVisible")) {
+ // "details" is implemented - see e07/e08 below for its POSITIVE assertion. The remaining reserved C/D/E
+ // stubs must still be omitted entirely (not serialized as null keys).
+ for (var k : List.of("rowActions", "catalog", "format", "description", "pinned", "defaultVisible")) {
var key = "\"" + k + "\"";
assertFalse(json.contains(key), () -> "Reserved field leaked into VIEW_META: " + k + "\n" + json);
}
@@ -136,8 +138,9 @@ class ViewMeta_Contract_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@Test void b01_rowClassRuleEqRequiresValue() {
+ var view = ViewDef.create("x");
var e = assertThrows(IllegalArgumentException.class,
- () -> ViewDef.create("x").rowClassRule("deleted", Op.EQ, null, "row-deleted"));
+ () -> view.rowClassRule("deleted", Op.EQ, null, "row-deleted"));
assertTrue(e.getMessage().contains("value"), e::getMessage);
}
@@ -156,7 +159,8 @@ class ViewMeta_Contract_Test extends TestBase {
}
//------------------------------------------------------------------------------------------------------------------
- // B.2: render-id string sugar (§6.6) - "id:field" -> {id, meta:{field}}
+ // B.2: render-id string sugar (§6.6): everything after the first colon of an "id:field"-shaped string becomes
+ // the parsed renderer's meta.field.
//------------------------------------------------------------------------------------------------------------------
@Test void c01_renderSugarTagField() {
@@ -296,8 +300,8 @@ class ViewMeta_Contract_Test extends TestBase {
}
@Test void d11_queryableSettingsWithoutRowTypeThrows() {
- var e = assertThrows(IllegalArgumentException.class,
- () -> ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build().queryableSettings());
+ var view = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").build();
+ var e = assertThrows(IllegalArgumentException.class, view::queryableSettings);
assertTrue(e.getMessage().contains("rowType"), e::getMessage);
}
@@ -339,4 +343,90 @@ class ViewMeta_Contract_Test extends TestBase {
assertNull(ungrouped.group);
assertFalse(Json.of(ungrouped).contains("\"group\""), Json.of(ungrouped));
}
+
+ //------------------------------------------------------------------------------------------------------------------
+ // e) Declarable poll interval: floor clamp + wire presence/omission
+ //------------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_pollIntervalMs_omittedWhenUnset() {
+ // The golden fixture view never calls .poll(...) - 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("\"pollIntervalMs\""), json);
+ }
+
+ @Test void e02_pollAboveFloor_isHonoredAsDeclared() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a")).poll(30_000L).build();
+ assertEquals(30_000L, v.pollIntervalMs);
+ assertTrue(Json.of(v).contains("\"pollIntervalMs\":30000"), Json.of(v));
+ }
+
+ @Test void e03_pollBelowFloor_isClampedUpToFloor() {
+ // By design: a declared interval below the floor is clamped to it, not rejected.
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a")).poll(1_000L).build();
+ assertEquals(ViewDef.MIN_POLL_INTERVAL_MS, v.pollIntervalMs);
+ }
+
+ @Test void e04_pollAtExactlyTheFloor_isUnchanged() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a"))
+ .poll(ViewDef.MIN_POLL_INTERVAL_MS).build();
+ assertEquals(ViewDef.MIN_POLL_INTERVAL_MS, v.pollIntervalMs);
+ }
+
+ @Test void e05_pollNonPositiveThrows() {
+ var view = ViewDef.create("x");
+ var e = assertThrows(IllegalArgumentException.class, () -> view.poll(0L));
+ assertTrue(e.getMessage().contains("positive"), e::getMessage);
+ var e2 = assertThrows(IllegalArgumentException.class, () -> view.poll(-500L));
+ assertTrue(e2.getMessage().contains("positive"), e2::getMessage);
+ }
+
+ @Test void e06_pollIntervalMs_isTheLastWireKey_afterRowClassRules() {
+ var v = ViewDef.create("x").dataMode(DataMode.SERVER).dataUrl("u").columns(Column.of("a")).poll(60_000L).build();
+ Map<?,?> actual = Json.to(Json.of(v), Map.class);
+ assertEquals(
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "pollIntervalMs"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // 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).
+ //------------------------------------------------------------------------------------------------------------------
+
+ @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() {
+ 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"))
+ .build();
+ var json = Json.of(v);
+ assertTrue(json.contains(
+ "\"details\":[{\"data\":\"owner\",\"title\":\"Owner\"},{\"data\":\"createdAt\",\"title\":\"Created\"}]"),
+ json);
+ }
+
+ @Test void f03_details_isBeforePollIntervalMs_andAfterRowClassRules_inTopLevelKeyOrder() {
+ 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"))
+ .poll(60_000L)
+ .build();
+ Map<?,?> actual = Json.to(Json.of(v), Map.class);
+ assertEquals(
+ List.of("contractVersion", "id", "dataMode", "dataUrl", "columns", "rowClassRules", "details", "pollIntervalMs"),
+ new ArrayList<>(actual.keySet()));
+ }
+
+ @Test void f04_detailDefTitleDefaultsToNullWhenUnset() {
+ var d = ViewDef.DetailDef.of("owner");
+ assertEquals("owner", d.data);
+ assertNull(d.title);
+ }
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
index c098256f30..50ef6d8e85 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewServerWiring_Test.java
@@ -47,6 +47,9 @@ import org.junit.jupiter.api.*;
* {@code columns[1][search][value]} key the pure mapping emits.
* </ul>
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class ViewServerWiring_Test extends TestBase {
/** Row bean: {@code name} (col 0), {@code status} (col 1), {@code date} (col 2). */
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 f89ceaf305..11b1ccc30f 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
@@ -78,7 +78,7 @@ class ViewTable_Emit_Test extends TestBase {
var html = Html.of(ViewTable.of(view()));
// Distinct marker: must NOT collide with the plain-DataTables path (data-juneau-datatable).
assertFalse(html.contains("data-juneau-datatable"), html);
- assertEquals(ViewTable.MARKER_ATTR, "data-juneau-view");
+ assertEquals("data-juneau-view", ViewTable.MARKER_ATTR);
}
@Test void a03_sidecarCarriesTheSerializedViewMeta() {
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
index 1fe880c6c2..072389ed18 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsJs_PageSeam_Test.java
@@ -24,7 +24,7 @@ import org.apache.juneau.rest.server.*;
import org.junit.jupiter.api.*;
/**
- * Lazy-init seam test for {@code juneau-views.js} (TODO-399 Phase C, Task 6, design doc §"Client page runtime").
+ * Lazy-init seam test for {@code juneau-views.js} (design doc §"Client page runtime").
*
* <p>
* Asserts on the served script text: {@code initTable} must be reachable off the public {@code NS.init} namespace
@@ -36,6 +36,9 @@ import org.junit.jupiter.api.*;
* views it is called for. These text assertions remain as always-on tripwires for the seam's <i>shape</i>, since
* that harness is opt-in.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class ViewsJs_PageSeam_Test extends TestBase {
@Rest(mixins=ViewsMixin.class)
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java
index 04f61f89df..5d065d40c1 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Pages_Test.java
@@ -24,14 +24,16 @@ import org.apache.juneau.rest.server.*;
import org.junit.jupiter.api.*;
/**
- * Serving + versioned-URL tests for the {@code juneau-pages.js} asset on {@link ViewsMixin} (TODO-399 Phase C,
- * Task 5).
+ * Serving + versioned-URL tests for the {@code juneau-pages.js} asset on {@link ViewsMixin}.
*
* <p>
* Extends the existing {@code ViewsMixin_Serving_Test} Option-A serving-smoke pattern to the new opt-in page-runtime
* asset: 200 + correct content-type + {@code Cache-Control}, a 404 when the mixin is absent, and the
* {@code ?v=<buildVersion>} cache-buster via {@link ViewsMixin#viewAssetUrl(String)}.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class ViewsMixin_Pages_Test extends TestBase {
public static class NoMixin extends org.apache.juneau.rest.server.servlet.BasicRestServlet {
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 c9f59c647b..d1887ae3a7 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
@@ -44,6 +44,9 @@ import org.junit.jupiter.api.*;
* {@code Cache-Control}; a host without the mixin {@code 404}s the same paths. The {@code ?v=<buildVersion>}
* cache-buster and the {@code CONTRACT_VERSION} handshake constant are asserted directly.
*/
+@SuppressWarnings({
+ "resource" // Closeable test fixtures held in static fields; lifecycle managed by the test/framework, not a real leak.
+})
class ViewsMixin_Serving_Test extends TestBase {
//------------------------------------------------------------------------------------------------------------------
@@ -440,6 +443,53 @@ class ViewsMixin_Serving_Test extends TestBase {
assertTrue(body.contains("\"" + name + "\""), () -> "missing bundled glyph '" + name + "':\n" + body);
}
+ //------------------------------------------------------------------------------------------------------------------
+ // i) viewAssetUrl(RestRequest, path): a real, resolved, browser-fetchable URL (not "servlet:...")
+ //------------------------------------------------------------------------------------------------------------------
+
+ /** Echoes the request-aware overload's resolved URL as a plain-text response, for assertion below. */
+ @Rest(mixins=ViewsMixin.class)
+ public static class AssetUrlHost extends BasicRestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet(path="/echo-asset-url") public String echoAssetUrl(RestRequest req) {
+ return ViewsMixin.viewAssetUrl(req, ViewsMixin.VIEWS_JS_PATH);
+ }
+ }
+
+ @Test void i01_requestAwareOverload_resolvesToARealUrl_notAServletPrefixedLiteral() throws Exception {
+ var url = MockRestClient.buildLax(AssetUrlHost.class).get("/echo-asset-url").accept("text/plain").run()
+ .assertStatus(200).getContent().asString();
+ // The whole point of FG-4: the literal "servlet:" scheme must NOT leak into the resolved result - a
+ // template-rendering consumer downstream of Juneau's own HTML serializer would otherwise receive it
+ // verbatim and fetch nothing.
+ assertFalse(url.startsWith("servlet:"), url);
+ assertTrue(url.endsWith(ViewsMixin.VIEWS_JS_PATH.substring(1)) || url.contains(ViewsMixin.VIEWS_JS_PATH + "?v="), url);
+ }
+
+ @Test void i02_requestAwareOverload_carriesTheSameVersionAndContentHashCacheBuster() throws Exception {
+ var url = MockRestClient.buildLax(AssetUrlHost.class).get("/echo-asset-url").accept("text/plain").run()
+ .assertStatus(200).getContent().asString();
+ var staticForm = ViewsMixin.viewAssetUrl(ViewsMixin.VIEWS_JS_PATH);
+ var expectedSuffix = staticForm.substring(staticForm.indexOf("?v="));
+ assertTrue(url.endsWith(expectedSuffix), url);
+ }
+
+ @Test void i03_requestAwareOverload_resolvesUnderANonRootHostMount() throws Exception {
+ var c = MockRestClient.createLax(AssetUrlHost.class).servletPath("/rest/admin").build();
+ var url = c.get("/echo-asset-url").accept("text/plain").run().assertStatus(200).getContent().asString();
+ assertTrue(url.startsWith("/rest/admin" + ViewsMixin.VIEWS_JS_PATH), url);
+ }
+
+ @Test void i04_requestAwareOverload_resolvedUrlIsActuallyFetchableAtThatMount() throws Exception {
+ // Round-trip proof: the URL this overload hands a template is not just shaped right - fetching it (minus
+ // the query string) against the SAME mount actually serves the asset.
+ var c = MockRestClient.createLax(AssetUrlHost.class).servletPath("/rest/admin").build();
+ var url = c.get("/echo-asset-url").accept("text/plain").run().assertStatus(200).getContent().asString();
+ var pathOnly = url.substring(0, url.indexOf('?'));
+ var relative = pathOnly.substring("/rest/admin".length());
+ c.get(relative).run().assertStatus(200);
+ }
+
//------------------------------------------------------------------------------------------------------------------
// n) Ribbon visual-parity Task 12/13 - responsive cleanup (tab/sub-tab scroll, control-row wrap, toolbar wrapper)
//------------------------------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs
index 649b9b21f4..67d3bb7d25 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/panel-visibility.cjs
@@ -50,8 +50,8 @@
*/
'use strict';
-const fs = require('fs');
-const path = require('path');
+const fs = require('node:fs');
+const path = require('node:path');
const { chromium } = require('playwright');
/*
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index a1ae6e6770..47781c6716 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -48,6 +48,7 @@
<module>juneau-rest-server-management-logging</module>
<module>juneau-rest-server-datatables</module>
<module>juneau-rest-server-views</module>
+ <module>juneau-rest-server-views-markdown</module>
<module>juneau-rest-server-console-ui</module>
<module>juneau-rest-server-console-ui-freemarker</module>
<module>juneau-rest-server-console-ui-freemarker-datatables</module>
diff --git a/juneau-secret-keychain/pom.xml b/juneau-secret-macos-keychain/pom.xml
similarity index 96%
rename from juneau-secret-keychain/pom.xml
rename to juneau-secret-macos-keychain/pom.xml
index 2e86a44a8c..f1aad0c82b 100644
--- a/juneau-secret-keychain/pom.xml
+++ b/juneau-secret-macos-keychain/pom.xml
@@ -25,8 +25,8 @@
<version>10.0.0-SNAPSHOT</version>
</parent>
- <artifactId>juneau-secret-keychain</artifactId>
- <name>Apache Juneau Secret Store - OS Keychain</name>
+ <artifactId>juneau-secret-macos-keychain</artifactId>
+ <name>Apache Juneau Secret Store - macOS Keychain</name>
<description>Opt-in SecretStore implementation backed by the macOS 'security' keychain CLI.</description>
<packaging>bundle</packaging>
diff --git a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore.java
similarity index 65%
rename from juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java
rename to juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore.java
index a1449b1941..786f0001ed 100644
--- a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStore.java
+++ b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore.java
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.juneau.secret.keychain;
+package org.apache.juneau.secret.macos.keychain;
import static java.nio.charset.StandardCharsets.*;
import static java.util.concurrent.TimeUnit.*;
@@ -53,10 +53,15 @@ import org.apache.juneau.commons.secret.*;
* absent/no-op, while {@link FailMode#FAIL_CLOSED} (the default) throws.
*
* <p>
- * <b>Exposure caveats (inherent to shelling out to a CLI).</b> {@link #store} passes the secret as a
- * <c>-w <secret></c> command-line argument, so it is briefly visible in the process table and is materialized
- * as a {@link String}; {@link #find} decodes the retrieved bytes into a {@code char[]} without an intermediate
- * {@link String}. The value is never logged or {@code toString()}'d by this class.
+ * <b>Secret handling.</b> {@link #store} never places the secret on the {@code security} process's argv: it invokes
+ * <c>add-generic-password</c> with a bare, valueless <c>-w</c> (which makes the CLI prompt) and writes the secret,
+ * followed by a newline, twice to the child process's stdin — once for the value and once for the CLI's own
+ * confirmation re-entry — so the secret is never visible in the process table (e.g. to {@code ps}). Neither
+ * {@link #store} nor {@link #find} materializes the secret as a {@link String}: {@code store} encodes the
+ * {@code char[]} directly to the stdin byte payload, and {@code find} decodes the retrieved bytes into a
+ * {@code char[]}. The value is never logged or {@code toString()}'d by this class. Because the confirmation
+ * prompt splits input on newlines, a secret containing {@code '\n'} or {@code '\r'} cannot be delivered this way
+ * and is rejected by {@link #store} before any process is started.
*
* @since 10.0.0
*/
@@ -69,6 +74,7 @@ public class KeychainSecretStore implements SecretStore {
public static final long DEFAULT_TIMEOUT_SECONDS = 15L;
private static final String SECURITY = "/usr/bin/security";
+ private static final String FIND_GENERIC_PASSWORD = "find-generic-password";
private final String service;
private final FailMode failMode;
@@ -117,14 +123,20 @@ public class KeychainSecretStore implements SecretStore {
public void store(String key, char[] secret) {
assertArgNotNull("key", key);
assertArgNotNull("secret", secret);
+ assertArg(! containsNewline(secret), "Argument 'secret' must not contain a newline or carriage return character.");
+ var payload = stdinConfirmationPayload(secret);
try {
- var r = run("add-generic-password", "-U", "-a", key, "-s", service, "-w", new String(secret));
+ // A bare, valueless "-w" makes the CLI prompt (twice, for confirmation) on stdin/stderr instead
+ // of accepting the password as an argv element, so the secret never appears in the process table.
+ var r = run(payload, "add-generic-password", "-U", "-a", key, "-s", service, "-w");
if (r.exit() != 0)
throw backendFailure("add-generic-password", r);
} catch (RuntimeException e) {
if (failMode == FailMode.FAIL_OPEN)
return;
throw e;
+ } finally {
+ Arrays.fill(payload, (byte) 0);
}
}
@@ -132,12 +144,12 @@ public class KeychainSecretStore implements SecretStore {
public Optional<char[]> find(String key) {
assertArgNotNull("key", key);
try {
- var r = run("find-generic-password", "-a", key, "-s", service, "-w");
+ var r = run(FIND_GENERIC_PASSWORD, "-a", key, "-s", service, "-w");
if (r.exit() == 0)
return o(decodeTrimmed(r.stdout()));
if (r.exit() == NOT_FOUND)
return oe();
- throw backendFailure("find-generic-password", r);
+ throw backendFailure(FIND_GENERIC_PASSWORD, r);
} catch (RuntimeException e) {
if (failMode == FailMode.FAIL_OPEN)
return oe();
@@ -149,12 +161,12 @@ public class KeychainSecretStore implements SecretStore {
public boolean exists(String key) {
assertArgNotNull("key", key);
try {
- var r = run("find-generic-password", "-a", key, "-s", service);
+ var r = run(FIND_GENERIC_PASSWORD, "-a", key, "-s", service);
if (r.exit() == 0)
return true;
if (r.exit() == NOT_FOUND)
return false;
- throw backendFailure("find-generic-password", r);
+ throw backendFailure(FIND_GENERIC_PASSWORD, r);
} catch (RuntimeException e) {
if (failMode == FailMode.FAIL_OPEN)
return false;
@@ -180,21 +192,44 @@ public class KeychainSecretStore implements SecretStore {
}
/** The outcome of a single {@code security} invocation. */
+ @SuppressWarnings({
+ "java:S6218" // stdout can hold retrieved SECRET bytes; identity-based equals/hashCode/toString are intentional so a content-based toString can never surface the secret. Result instances are never compared or printed.
+ })
private record Result(int exit, byte[] stdout, String stderr) {}
private Result run(String... args) {
+ return run(null, args);
+ }
+
+ /**
+ * Invokes {@link #binary} with the given arguments, optionally writing {@code stdin} to the child process
+ * before closing its input stream. {@code stdin}, when non-<jk>null</jk>, is written and the stream closed
+ * <i>before</i> waiting on the process so a CLI that blocks on a stdin prompt (as {@code -w} with no value
+ * does) does not deadlock against this method.
+ */
+ private Result run(byte[] stdin, String... args) {
var cmd = new ArrayList<String>(args.length + 1);
cmd.add(binary);
cmd.addAll(Arrays.asList(args));
try {
var p = new ProcessBuilder(cmd).start();
- p.getOutputStream().close();
+ if (stdin != null) {
+ try (var os = p.getOutputStream()) {
+ os.write(stdin);
+ }
+ } else {
+ p.getOutputStream().close();
+ }
if (! p.waitFor(timeoutSeconds, SECONDS)) {
p.destroyForcibly();
throw isex("Timed out invoking '%s' after %s seconds.", binary, timeoutSeconds);
}
- var out = p.getInputStream().readAllBytes();
- var err = new String(p.getErrorStream().readAllBytes(), UTF_8);
+ byte[] out;
+ String err;
+ try (var is = p.getInputStream(); var es = p.getErrorStream()) {
+ out = is.readAllBytes();
+ err = new String(es.readAllBytes(), UTF_8);
+ }
return new Result(p.exitValue(), out, err);
} catch (IOException e) {
throw rex(e, "Unable to invoke '%s' (is this macOS with the keychain CLI available?).", binary);
@@ -208,6 +243,34 @@ public class KeychainSecretStore implements SecretStore {
return isex("Keychain '%s' failed with exit code %s: %s", subcommand, r.exit(), r.stderr().strip());
}
+ private static boolean containsNewline(char[] secret) {
+ for (char c : secret)
+ if (c == '\n' || c == '\r')
+ return true;
+ return false;
+ }
+
+ /**
+ * Encodes {@code secret} as UTF-8 and doubles it with a trailing newline after each copy (<c>secret\nsecret\n</c>),
+ * matching the value-then-confirmation prompt that <c>security ... -w</c> (with no value) issues on stdin.
+ * Built directly from the {@code char[]}, without an intermediate {@link String}.
+ */
+ private static byte[] stdinConfirmationPayload(char[] secret) {
+ var encoded = UTF_8.encode(CharBuffer.wrap(secret));
+ var secretBytes = new byte[encoded.remaining()];
+ encoded.get(secretBytes);
+ try {
+ var payload = new byte[(secretBytes.length + 1) * 2];
+ System.arraycopy(secretBytes, 0, payload, 0, secretBytes.length);
+ payload[secretBytes.length] = '\n';
+ System.arraycopy(secretBytes, 0, payload, secretBytes.length + 1, secretBytes.length);
+ payload[payload.length - 1] = '\n';
+ return payload;
+ } finally {
+ Arrays.fill(secretBytes, (byte) 0);
+ }
+ }
+
/** Decodes UTF-8 stdout bytes into a char[], dropping a single trailing newline, without an intermediate String. */
private static char[] decodeTrimmed(byte[] bytes) {
var len = bytes.length;
diff --git a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider.java
similarity index 93%
rename from juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java
rename to juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider.java
index 3049987f29..7c5b2ae901 100644
--- a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider.java
+++ b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider.java
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.juneau.secret.keychain;
+package org.apache.juneau.secret.macos.keychain;
import org.apache.juneau.commons.secret.*;
@@ -32,7 +32,7 @@ import org.apache.juneau.commons.secret.*;
public class KeychainSecretStoreProvider implements SecretStoreProvider {
/** System property overriding the keychain service name used by the discovered store. */
- public static final String SERVICE_PROPERTY = "juneau.secret.keychain.service";
+ public static final String SERVICE_PROPERTY = "juneau.secret.macos.keychain.service";
/** Default keychain service name when {@link #SERVICE_PROPERTY} is unset. */
public static final String DEFAULT_SERVICE = "org.apache.juneau";
diff --git a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/package-info.java
similarity index 89%
rename from juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
rename to juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/package-info.java
index aa40ddf9a6..abaa909336 100644
--- a/juneau-secret-keychain/src/main/java/org/apache/juneau/secret/keychain/package-info.java
+++ b/juneau-secret-macos-keychain/src/main/java/org/apache/juneau/secret/macos/keychain/package-info.java
@@ -21,9 +21,9 @@
* <p>
* This is deliberately a separate module rather than part of <c>juneau-commons</c>: it shells out to an external OS
* process, which the commons zero-runtime-deps / no-OS-integration philosophy keeps out of core. Add this module to
- * the classpath (or wire {@link org.apache.juneau.secret.keychain.KeychainSecretStore} into a
+ * the classpath (or wire {@link org.apache.juneau.secret.macos.keychain.KeychainSecretStore} into a
* {@code BeanStore}) to back secret storage with the user's login keychain on macOS.
*
* @since 10.0.0
*/
-package org.apache.juneau.secret.keychain;
+package org.apache.juneau.secret.macos.keychain;
diff --git a/juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider b/juneau-secret-macos-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
similarity index 92%
rename from juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
rename to juneau-secret-macos-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
index 83909ec1b9..10a5f52aa1 100644
--- a/juneau-secret-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
+++ b/juneau-secret-macos-keychain/src/main/resources/META-INF/services/org.apache.juneau.commons.secret.SecretStoreProvider
@@ -13,4 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-org.apache.juneau.secret.keychain.KeychainSecretStoreProvider
+org.apache.juneau.secret.macos.keychain.KeychainSecretStoreProvider
diff --git a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java b/juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider_Test.java
similarity index 97%
rename from juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java
rename to juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider_Test.java
index ce88c61364..4396c9c560 100644
--- a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStoreProvider_Test.java
+++ b/juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStoreProvider_Test.java
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.juneau.secret.keychain;
+package org.apache.juneau.secret.macos.keychain;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.*;
diff --git a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java b/juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore_Test.java
similarity index 63%
rename from juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java
rename to juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore_Test.java
index 59bb9d2a1e..34a3601c7a 100644
--- a/juneau-secret-keychain/src/test/java/org/apache/juneau/secret/keychain/KeychainSecretStore_Test.java
+++ b/juneau-secret-macos-keychain/src/test/java/org/apache/juneau/secret/macos/keychain/KeychainSecretStore_Test.java
@@ -14,12 +14,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.juneau.secret.keychain;
+package org.apache.juneau.secret.macos.keychain;
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 org.apache.juneau.commons.secret.*;
@@ -39,6 +40,10 @@ class KeychainSecretStore_Test {
return System.getProperty("os.name", "").toLowerCase().contains("mac") && new File("/usr/bin/security").canExecute();
}
+ private static boolean posixShellAvailable() {
+ return new File("/bin/sh").canExecute();
+ }
+
private static String uniqueService() {
return "org.apache.juneau.test." + UUID.randomUUID();
}
@@ -82,6 +87,46 @@ class KeychainSecretStore_Test {
assertFalse(store.delete("missing"));
}
+ // -----------------------------------------------------------------------------------------------------------------
+ // Secret hygiene: the secret must never appear on the child process's argv.
+ //
+ // Uses a fake "security" shell script (in place of the real binary, via the package-private test constructor)
+ // that dumps its own argv and its stdin to separate files, so the assertion does not depend on being able to
+ // inspect a live process's command line (fragile/racy) or on running against the real keychain.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test void a03_secretNeverAppearsOnArgv() throws IOException {
+ assumeTrue(posixShellAvailable(), "POSIX shell not available");
+ var argvDump = File.createTempFile("juneau-keychain-argv", ".txt");
+ var stdinDump = File.createTempFile("juneau-keychain-stdin", ".txt");
+ var script = File.createTempFile("juneau-keychain-fake-security", ".sh");
+ argvDump.deleteOnExit();
+ stdinDump.deleteOnExit();
+ script.deleteOnExit();
+ try {
+ Files.writeString(script.toPath(), ""
+ + "#!/bin/sh\n"
+ + "printf '%s\\n' \"$@\" > '" + argvDump.getAbsolutePath() + "'\n"
+ + "cat > '" + stdinDump.getAbsolutePath() + "'\n"
+ + "exit 0\n");
+ assertTrue(script.setExecutable(true));
+
+ var secret = "arg\u00eev-h0stile s3cr3t \t \u00fc";
+ var store = new KeychainSecretStore("svc", FailMode.FAIL_CLOSED, 5L, script.getAbsolutePath());
+ store.store("acct", secret.toCharArray());
+
+ var argvContent = Files.readString(argvDump.toPath());
+ assertFalse(argvContent.contains(secret), "secret must not appear in the child process's argv");
+
+ var stdinContent = Files.readString(stdinDump.toPath());
+ assertEquals(secret + "\n" + secret + "\n", stdinContent, "secret should be delivered via stdin, doubled for the CLI's confirmation prompt");
+ } finally {
+ argvDump.delete();
+ stdinDump.delete();
+ script.delete();
+ }
+ }
+
// -----------------------------------------------------------------------------------------------------------------
// FailMode on an unavailable backend (deterministic via a bad binary path).
// -----------------------------------------------------------------------------------------------------------------
@@ -98,7 +143,9 @@ class KeychainSecretStore_Test {
}
@Test void b02_failOpenStoreIsNoOp() {
- assertDoesNotThrow(() -> unavailable(FailMode.FAIL_OPEN).store("k", "v".toCharArray()));
+ var store = unavailable(FailMode.FAIL_OPEN);
+ var secret = "v".toCharArray();
+ assertDoesNotThrow(() -> store.store("k", secret));
}
@Test void b03_failClosedReadsThrow() {
@@ -109,7 +156,9 @@ class KeychainSecretStore_Test {
}
@Test void b04_failClosedStoreThrows() {
- assertThrows(RuntimeException.class, () -> unavailable(FailMode.FAIL_CLOSED).store("k", "v".toCharArray()));
+ var store = unavailable(FailMode.FAIL_CLOSED);
+ var secret = "v".toCharArray();
+ assertThrows(RuntimeException.class, () -> store.store("k", secret));
}
// -----------------------------------------------------------------------------------------------------------------
@@ -131,10 +180,21 @@ class KeychainSecretStore_Test {
@Test void c04_nullKeyRejected() {
var store = unavailable(FailMode.FAIL_OPEN);
+ var secret = "v".toCharArray();
assertThrows(IllegalArgumentException.class, () -> store.find(null));
assertThrows(IllegalArgumentException.class, () -> store.exists(null));
assertThrows(IllegalArgumentException.class, () -> store.delete(null));
- assertThrows(IllegalArgumentException.class, () -> store.store(null, "v".toCharArray()));
+ assertThrows(IllegalArgumentException.class, () -> store.store(null, secret));
assertThrows(IllegalArgumentException.class, () -> store.store("k", null));
}
+
+ @Test void c05_secretWithNewlineOrCarriageReturnRejectedBeforeAnyProcessStarts() {
+ // The stdin-delivered value/confirmation pair (see a03) is newline-delimited, so an embedded '\n' or '\r'
+ // in the secret would desynchronize the CLI's confirmation prompt; reject it up front instead.
+ var store = unavailable(FailMode.FAIL_CLOSED);
+ var withNewline = "line1\nline2".toCharArray();
+ var withCarriageReturn = "line1\rline2".toCharArray();
+ assertThrows(IllegalArgumentException.class, () -> store.store("acct", withNewline));
+ assertThrows(IllegalArgumentException.class, () -> store.store("acct", withCarriageReturn));
+ }
}
diff --git a/pom.xml b/pom.xml
index fb38400fbc..c6b8390fe3 100644
--- a/pom.xml
+++ b/pom.xml
@@ -88,7 +88,7 @@
<module>juneau-rest</module>
<module>juneau-microservice</module>
<module>juneau-sc</module>
- <module>juneau-secret-keychain</module>
+ <module>juneau-secret-macos-keychain</module>
<module>juneau-examples</module>
<module>juneau-petstore</module>
<module>juneau-integration-tests</module>
diff --git a/scripts/apply-eclipse-prefs.py b/scripts/apply-eclipse-prefs.py
index 9d7a245179..b2189eac1a 100755
--- a/scripts/apply-eclipse-prefs.py
+++ b/scripts/apply-eclipse-prefs.py
@@ -65,6 +65,7 @@ REACTOR_GROUPS = [
# Standalone (non-grouped) projects at the repo root.
STANDALONE_PROJECTS = [
"juneau-integration-tests",
+ "juneau-secret-macos-keychain",
]
diff --git a/scripts/eclipse-preferences/source-prefs/org.eclipse.jdt.core.prefs b/scripts/eclipse-preferences/source-prefs/org.eclipse.jdt.core.prefs
index a9811e8051..25c44c2529 100644
--- a/scripts/eclipse-preferences/source-prefs/org.eclipse.jdt.core.prefs
+++ b/scripts/eclipse-preferences/source-prefs/org.eclipse.jdt.core.prefs
@@ -26,7 +26,7 @@ org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
org.eclipse.jdt.core.compiler.problem.emptyStatement=ignore
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
-org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=ignore
+org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
org.eclipse.jdt.core.compiler.problem.fallthroughCase=ignore
org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled
org.eclipse.jdt.core.compiler.problem.fieldHiding=ignore